Migrating an Existing App to Jetpack Compose: Where to begin?
With the release of a stable version for Jetpack Compose and Google’s continuous support, more and more apps are beginning to migrate over to Android’s new and powerful UI toolkit. However, the shift to compose can be a daunting one; in order to adopt compose, almost all components of the view need to be refactored. Xml layouts need to be converted, navigation needs to be overhauled, and a new theme system will need to be created (among other things). This can be achieved semi-easily with small codebases, but with larger-scale projects a more incremental approach will likely be needed. The objective of this article is to demonstrate where to begin the compose conversion using an incremental approach, with additional tips for keeping composables modular and testable. For this example, a simple, starter project will be provided via this GitHub repository and converted to compose for reference.
Preliminary Steps
Begin by cloning the starter project; this is a simple app that contains a recyclerView to display space news articles fetched from an API. It follows the MVVM pattern, with the viewModel exposing its state via Kotlin’s stateFlow. This stateFlow is collected by the fragment to display different view states (Loading, Loaded). The fragment displays a Circular Progress Indicator while in the Loading state and a list of article viewHolders in the Loaded state.
Before beginning the conversion to compose, some gradle dependencies are required.
These new dependencies include:
- Compose Foundation- All of compose’s basic components
- Compose Material- Material design components and theming for compose
- Compose Tooling- Includes previews and other useful tooling for compose
- Compose ConstraintLayout- Provides a constraintLayout similar to the xml constraintLayout
- Coil for Compose- For image caching and loading from remote sources
Step One: Theme
The first step in converting an app to compose is to define an App Theme. The theme is perhaps the most important part of an app, as it defines the look and feel of all of the UI components. In the starter app, the theme can be found in themes.xml. In a larger-scale app, this would likely include many more attributes, colors, and typography information, but for the sake of this example just contains basic color items.
When converting an existing app theme to compose, a Material Theme must be created. Compose offers an implementation of Material Design, which is a design system built for creating app interfaces. The Material Theme is comprised of three important pieces: colors, typography, and shapes; each of these will play a role in the design of an app.
To begin implementing a Material Theme, create a new package called “theme” within the app directory and create a new Kotlin file called “Theme.kt” within this new package.
In this file, an implementation of MaterialTheme (in this case SpaceAppTheme) can be defined.
Inspecting the parent class (MaterialTheme) reveals that colors, typography, and shape paramaters are provided by default.
For this example, the default colors will be overridden to demonstrate how an existing app theme can be transferred to a compose app theme. In this project the existing theme can be found in “theme.xml” (shown above) and the corresponding colors can be found in “colors.xml”:
Create a file called “Color.kt” within the theme directory. Here, the desired colors can be added from the existing app’s colors xml file. More information on what each of the colors in the Material palette are used for can be found here. All of the existing colors (and a few added ones) can be transferred from the old colors xml file to here:
These colors can now be separated into dark theme colors and light theme colors in Theme.kt. A conditional statement in conjunction with useDarkTheme
can now be used to provide the new theme-aware color palette to the Theme.
With this code, any composable wrapped in SpaceAppTheme will now leverage these colors rather than the default. In this example, only custom colors are being provided as a parameter, but custom typography and shapes can be provided following the same steps. Additional information on implementing a custom theme can be found here.
Step Two: Composable Views and View States
Now that the Theme is established, the existing xml views can be transitioned to compose. For larger apps, this should ideally be done one at a time, starting with a relatively simple view (containing few UI elements and not too much complex logic) and gradually moving to more complicated ones. This way, each view can be regression tested as it is converted from xml to compose.
Before getting started, a few general tips for converting a view to compose:
- Create a state for each composable- examples of this will be provided, but this is a good strategy to keep things organized; a state can simply be a class containing information for each child composable: strings for text items, colors for backgrounds, lambdas for click listeners, etc.
- Break down views into their individual parts, then make them into composables- by doing this each composable can be individually tested and common composables can be identified for use throughout the app. When creating views, the general approach would be to create the smaller, child composables and then moving onto the parent composables, where they can be encapsulated in a layout.
- Provide a modifier as a parameter for each composable- composables should not be responsible for modifying themselves, this responsibility should be delegated to the parent
- When a view requires a viewModel as a parameter, use a “wrapper” composable- an example of this will be provided, but the idea is to have a composable function that takes the viewModel, and simply observes and provides the necessary state value to a view, allowing the view to be previewed without mocking a viewModel
- Use previews generously- compose previews are very powerful tooling which can be modified and organized to show views in a variety of states
Article ViewHolder
For this example, the first view to be converted will be the “article_view_holder.xml”. (This layout does not require its own viewModel, so there is no need to start with a “wrapper composable” described above.)
As a preliminary step, the state for this view can be created.
By creating a state for each major UI element, each of the components becomes organized and reusable, as each customizable parameter is encapsulated in a single state.
Now that the state is created, the view can be divided into four important elements (as alluded to in the state):
- The article title text
- The news site subtitle text
- The article thumbnail image
- A parent, constraint layout to hold everything together
Note: When migrating each view to compose, a preview can be created for each component; each preview function will need to be marked with the preview
annotation. Any composables within a preview function will be displayed in Android Studio’s preview editor pane. Previews are extremely useful when prototyping composable views; similar to the xml layout previews, they provide insight about the appearance of a UI element without the need to run the app.
Starting with the text items, these can be implemented easily using the Text
composable. These functions will need to use the composable
annotation to mark them as Composable.
Although it might not seem necessary for each of these to have their own composable functions, in the future they might be exposed as “common” ui elements; in this case it might be desirable to have a set textColor and typography defined in a composable that is used for all titles or all subtitles. In addition to reusability, this also makes composables unit testable; this may seem a bit overkill for a simple Text
item, but it is a good practice to follow when designing a UI.
Now that the first two elements have been created, the article thumbnail image is next.
Since article images are being loaded from the network rather than locally, the composable function will take both the image url and the modifier as parameters. Coil’s rememberImagePainter
is used to load the desired image from the url and conveniently provides a way to specify a placeholder to show while an image is loading. Compose’s Image
also includes parameters for modifying the characteristics of the image, just as it is done in xml.
Finally, the parent layout can be defined to tie things together. Compose provides a Constraint Layout implementation which is very similar to the traditional androidx one. Whereas nested rows, columns, and boxes can be used to build layouts, it is recommended to use Constraint Layout for more complex views.
The id’s for each layout can be defined like this:
These id’s can be referenced when constraining the views via a modifier function; this function provides a constraint scope, where constraints can be defined similar to the androidx Constraint Layout.
The constraints for this view are pulled directly from the starter project and used with compose’s linkTo
extension function.
In addition to constraints, the size information for the component can also be included within the constraint scope. This is important for UI elements in constraint layout, as defining the size information with the constraints will help with accurate width and height measurements. More information on compose’s constraint layout can be found here.
The preview function for the viewHolder leverages one of the powerful tools provided by compose: dark and light theming. When creating a preview it is imperative to surround the content with the Theme created for the App. Without this theme, the colors, typography, and other theming information will not be respected by the composable within the preview, and the layout will appear incorrect.
By default, previews will show the view in light mode. To simulate dark mode in a preview, the constant UI_MODE_NIGHT_YES
, can be passed into the annotation as the uiMode parameter, and the dark background can be shown by passing true
for showBackground
.
The resulting viewHolder previews display the colors defined in the SpaceAppTheme
for dark and light modes.
Article List View
After implementing the article viewHolder, the recyclerView containing them can be converted to compose.
This view is simpler than the last view, as it only contains a single UI element; however, it has a few additional complexities to note:
- A ViewModel- this means a container class will be required to observe and provide the state
- Multiple view states- these will require some conditional logic to modify the visibility of components
- Dividers between items- this is a pretty minor detail, but there is a composable for them!
Similar to the last view, a prerequisite for creating this view will be defining a state. Fortunately, the state from the starter project can be reused here with a slight modification. The view will still have “Loading” and “Loaded” states, but Loaded will now use ArticleViewHolderState
instead of the raw API response item.
The child, viewHolder state will now be passed down from the parent composable to the child composable. These new changes will also require some small tweaks in the viewModel to transform the raw API item into the new viewHolder state.
Just as before, the view can now be divided into its individual components.
- The recyclerView, which contains the list of articles
- A circular progress indicator
- A parent, constraint layout to hold everything together
To start, a composable function for the recyclerView will be created. This function will receive a modifier (as usual) and a list of ArticleViewHolderState
, which will be used to create the viewHolder composables contained within the recyclerView.
For this purpose, a lazyColumn
can be leveraged; which is compose’s implementation of recyclerView. Just like the recyclerView, the lazyColumn is optimal for displaying long lists, as it recycles views as they scroll off of the screen. Unlike the recyclerView however, lazyColumn has no need for an adapter, and simply requires a list of composables to display. For this project, the list of viewHolder states that are passed in can be used to construct the composables within the column.
By defining the items within the scope, the lazyColumn will use the list of states to display a composable for each viewHolder state.
The clickable
modifier function is leveraged here so that each item calls the onClick lambda contained within its state. This way, each item will fire its respective event when the user touches an item within the column.
Compose also provides a Divider
composable, which is utilized here to mimic the dividerDrawable
in the starter app.
For the next set of UI components, the circular progress indicator has a compose implementation, so it won’t be necessary to create it from scratch. Instead, a composable function will be created containing the logic for switching whether to show the loading state (containing only the circular progress indicator) or the loaded state (containing the recyclerView with all of the article viewHolders). This composable will take a modifier and an ArticleListViewState
to differentiate between the two view states.
As before, a constraint layout is used as a parent layout, and the references for the child views are defined. For this layout, a conditional statement will define which composables are displayed.
Now that the logic is defined, this composable will need to observe the state, which is emitted from the viewModel. In order to bridge this connection, the viewModel could be passed directly to this composable, but this is not the preferred approach. If the viewModel is a parameter of the view, writing a composable preview would require creation of the viewModel within the preview, which would couple the composable to the viewModel. Instead, a wrapper composable can be written to wrap the composable containing the actual view.
The wrapper will take the viewModel as a parameter and observe the state, which will be passed to the view. In this example, the delegate extension function collectAsState()
is utilized in conjunction with kotlin’s stateFlow
, however, any observable class can be used to emit the state. The important part here is that the viewModel is now hoisted away from the composable containing the view, and can now be previewed without creating an implementation of the viewModel.
The previews defined for this view take advantage of the light and dark mode theming as before, but also utilize the ability for compose previews to be grouped. By passing a string as the group
parameter, preview groups can be viewed separately in the preview editor if desired. This is useful in this particular view, as it has two different states which both have completely different layouts.
For these previews, the loaded state is passed into one and the loading state into the other.
This is more convenient than xml previews, as the composable preview functions can display many different view states without having components overlaid on top of each other.
Step Three: Interoperability
Finally, with the views implemented, it’s time to leverage some interoperability components to make the views usable in the current activity + fragment architecture. Compose provides a handy UI element called a ComposeView
which can be used in xml:
This is useful and can be used to convert individual parts of an xml layout to compose; however, since compose is completely moving away from xml layouts, it would be preferred to use a non-xml solution. Luckily, the ComposeView can be used programmatically directly in the fragment.
Calling setViewCompositionStrategy
with DisposeOnViewTreeLifecycleDestroyed
verifies that the composables within the view won’t stick around when the view is destroyed. Calling setContent
provides the view with the ArticleListScreen composable that was implemented. As discussed earlier, this is surrounded by the SpaceAppTheme
to apply the defined theme to all of the composables contained within the block.
Congratulations! These views are now converted to compose! With this strategy, large apps can be incrementally converted to compose screen by screen.
Future Steps
Following this incremental approach to compose, most views can be safely migrated without disrupting the app architecture. For future migration plans:
- As more views are successfully converted, some thought should be given as to which components can be reused and exported into a “common” components package. These components can be reused throughout the app to cut down on re-writing existing code and to ensure styles remain consistent throughout.
- Once the majority of screens have been converted, the new navigation library and dependency injection processes can be adopted as a final step in the migration. This will involve removing fragments and composeViews from the app in favor of navigating directly with composable screens.
In the meantime, hopefully this guide can assist in getting the app to this point!