Adopt Compose for View-based libraries
Jetpack Compose is designed with View interoperability in mind. This means that existing View-based libraries can readily be used in Compose. However, considering how View-based libraries are used in Compose can improve support for Compose. For library authors, this can inform how you design your APIs, and further, how you may want to provide explicit support for Compose through additional libraries.
In this blog post, I’ll cover how to think about View APIs from the Compose perspective, and how you can wrap existing View-based libraries in Compose.
Levels of support for Compose
The levels of support for Compose define the different options available for View-based library authors to add support for Compose.
Levels of support for Compose include the following:
- No Support: Your View-based library can be consumed in Compose but developers have to think in both Compose and Views at the same time. Developers need to use available interoperability APIs to use Views in your library in their Compose app.
- Compose Support with Wrapper: Your library can be consumed in Compose using composables provided in a Compose library wrapping your View-based library. Your wrapper Compose library handles interoperability APIs so that developers can use composables directly.
- Full Compose Support: Your library can be consumed in Compose using composables provided in an entirely Compose-written library. Any shared code by the View and Compose libraries can be extracted as another dependency that both libraries depend on. As an author, feature development and maintenance of your library takes advantage of the benefits of Compose.
Say you are the author of a library that provides a custom View called DropDownCheckboxMenuView
, which is a UI component that can display a dropdown list of items that can be toggled when clicked.
The API for DropDownCheckboxMenuView
looks something like the following:
- a setter called
setItems
to set the list of items to display on the menu - a getter called
getSelectedItems
to get the list of selected items on the menu - a setter called
setOnItemToggled
to set a lambda to be invoked when an item is toggled between selected and unselected states due to user clicks
Without explicitly adding support for Compose, your consumers would have to use the AndroidView
interop API (or AndroidViewBinding
if view binding is enabled) to use your View in Compose, like so:
On the other hand, by explicitly adding support via wrapper or through full Compose support, consumers can use a composable directly — no need to interact with View or interop code. Much simpler!
Below are some of the benefits of adding explicit support for Compose for your library.
The rest of this post covers how you can improve your library’s support for Compose by rethinking how you design View APIs and how to build a composable wrapper for your View. It will also cover the state management differences between Views and Compose, and how adopting the Compose way of thinking (state as the source of truth) can help both your View and Compose users. Beyond creating a wrapper, it would be desirable to eventually add full support for Compose so that authors can take advantage of the benefits of Compose while continuing to develop their library.
Consuming View-based libraries in Compose
Using View-based libraries in Compose is not any different from using Views in Compose — through the interoperability APIs.
Using the same DropDownCheckboxMenuView
mentioned above, to be able to use this component in Compose, you would use the AndroidView
(or AndroidViewBinding
if you are using view binding) to render this View in Compose. Additionally, to improve the usability of this View throughout your Compose codebase, you can write a wrapper composable called DropDownCheckboxMenu
so that you only have to write interoperability code once — within the implementation of the DropDownCheckboxMenu
composable.
Generally, configurable parameters for the underlying View should be exposed as parameters to the composable wrapper so that those properties can be modified in the underlying View. When parameter values change, you can then update the corresponding View’s properties in the update
lambda of the AndroidView
.
So far, this all looks good. But how can callers retrieve the set of selected items on the DropDownCheckboxMenuView
? That is, how should getSelectedItems
be exposed through the wrapper?
One technique is to use the state holder pattern and expose a read-only state called selectedItems
which updates as items are selected by the user. You can maintain this state within the wrapper and set a different onItemToggled
listener to the View, which will pass through item toggles to the composable-provided onItemToggled
. But before going down this path, let’s cover how state ownership differs between Views and Compose.
State ownership and events in Views vs. Compose
Traditional Views are stateful. They maintain and manage internal states and perform mutations to those states based on interactions. For example, the CheckBox
View widget toggles between check and unchecked states when a user click occurs. Events emitted to listeners from Views correspond to internal state already having changed (i.e. OnCheckedChangeListener
for CheckBox
). In other words, the source of truth of a View’s state can be internal to the component.
In contrast, Compose toolkit-provided composables are stateless. This means that the state provided into composables is the source of truth and entirely controls them. For example, the Checkbox provided in the Material3 library accepts a checked
boolean parameter. By default, user clicks will not toggle the checkbox — this must be programmatically handled by modifying the state directly by listening to events, and modifying state when an event occurs, like so:
Given these different state ownership models, how can View authors think about designing APIs to be supported effectively in Compose?
The answer: design View APIs the Compose way!
Flip state ownership model
Just because traditional Views were designed this way, it doesn’t mean Views need to maintain internal state. The Compose way of thinking can also be applied to designing View APIs.
The Compose way of thinking can also be applied to designing View APIs.
Revisiting the CheckBox
View widget, if we were to design this from scratch it could very well have been written as a stateless component. For the existing CheckBox
View widget, that would mean preventing the check state from toggling when clicked. Implementation-wise, this would mean that CheckBox
should be refactored such that toggleCheck
is not invoked when clicked.
Refactoring Views to be stateless allows composable wrappers to be written idiomatically — the state passed into them entirely controls the various states components can be in.
Going back to the hypothetical DropDownCheckboxMenuView
, following this guidance would mean refactoring the API such that selected items in the menu would have to be explicitly provided. Additionally, the semantics of the onItemToggled
listener changes. The listener would be invoked when an item is clicked, but the View would not internally mutate the set of selected items — that should be up to the caller.
With this change, we can now update the DropDownCheckboxMenu
composable’s API to match how Compose consumers would expect to interact with this component. Another added benefit to this is that a stateless View becomes easier to test as you can provide inputs to it directly and write assertions to verify the expected output.
While refactoring Views should be your first option for improved Compose support, refactoring existing Views may introduce breaking changes and so this strategy needs to be taken into careful consideration. If refactoring is not an option for you, consider using the state holder pattern to synchronize on state changes. With this approach, you can maintain an internal copy of the View’s internal state, and expose an external property for it.
Design your composable wrapper API
View APIs designed with Compose in mind ease consumption in Compose. But, if you choose to release your own wrapper library accompanying your View library, it’s important to design your composable wrapper in an idiomatic way. Designing your API idiomatically also ensures that should you change your implementation from a wrapper to a full Compose implementation, you can elect to keep the same API surface while just replacing the implementation.
For example, your composable should:
- Accept and respect a
Modifier
parameter so that it can be customized with built-in Modifier functions - Provide defaults for optional parameters
- Offer a
content
slot if the component can have a customizable hierarchy
This list of design considerations is not comprehensive. To learn more about designing APIs, see Compose API guidelines.
Support @Preview for your composable
To be able to see your Views and corresponding wrapper with composable previews via @Preview
, ensure that your View at minimum accepts a Context
and an AttributeSet
. Doing so allows Android Studio to create an instance of your View to be used in the design view to preview your composable.
If for any reason your library cannot meet this requirement, or fails to render in preview mode due to known preview limitations, you can use LocalInspectionMode.current in your composable wrapper, or View.isInEditMode in your View code. With both APIs, you can skip executing failing code paths and instead perform the necessary fallback actions; for example, by displaying a placeholder image instead of a network-fetched image.
Release your library
Once you have sufficient coverage of composables for your View-based libraries, you can release it as a separate artifact from the View library. A common naming convention is to suffice the Compose library with -compose
(e.g. my.lib.library-compose
) although that’s not a necessary requirement. Releasing a separate artifact is preferable so that you are not introducing Compose as a transitive dependency to View consumers who might not have migrated to Compose just yet.
Summary
If you are a View-based library author, consider improving the usability of your library in Compose by first evaluating your View API. If possible, modify your Views to be stateless so that they translate well when used via interoperability APIs in Compose.
To further improve your support of Compose so that consumers don’t have to write interop code, consider providing a Compose wrapper around Views in your library. Doing so allows you to continue supporting View consumers by keeping the source of truth in Views, while also providing a much better developer experience for Compose consumers. Down the road, you can transition to providing full Compose support for your library so that you too can take advantage of the benefits of Compose.
Got any questions or feedback? Leave a comment below!
The following post was written based on learnings from developing Maps Compose, a library that provides composables for the Maps SDK for Android. Thanks to Adam Powell, Florina Muntenescu, Jolanda Verhoef, Rebecca Franks, and Lauren Ward for their reviews.