Moving a screen to Compose
Tuesday, March 15, 2022We have a large app. Rewriting everything in Compose at once is not realistic. The plan is to migrate screen by screen, starting with the Search screen which is self-contained and manageable.
Here is what actually happens.
The setup
Compose and Views coexist using ComposeView inside a Fragment:
class SearchFragment : Fragment() {
override fun onCreateView(...): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
setContent {
MaterialTheme {
SearchScreen(viewModel = viewModel())
}
}
}
}
}
The Fragment still exists. Navigation still uses the existing nav graph. Only the UI inside the Fragment is Compose.
State management
Before: the Fragment observes LiveData from a ViewModel and calls adapter methods on a RecyclerView.
After: the ViewModel exposes StateFlow, the Composable collects it:
@Composable
fun SearchScreen(viewModel: SearchViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Column {
SearchBar(
query = uiState.query,
onQueryChange = viewModel::onQueryChange
)
when (val state = uiState) {
is SearchState.Loading -> CircularProgressIndicator()
is SearchState.Results -> ResultsList(state.items)
is SearchState.Empty -> EmptyState()
}
}
}
The when on a sealed class state is clean. Much better than the old visibility juggling.
What breaks
Theming. The app uses an XML theme with custom colors. Compose needs a MaterialTheme with the same colors. We have to map all the theme attributes manually:
val AppTheme = lightColorScheme(
primary = Color(0xFF1976D2),
onPrimary = Color.White,
// ... map everything
)
This takes time. Get it wrong and colors are off or you mix old and new theme values.
Custom views. The Search screen has a custom View for showing highlighted text matches. There is no Compose equivalent yet. We use AndroidView to embed it:
AndroidView(factory = { context ->
HighlightTextView(context)
}, update = { view ->
view.setHighlights(highlights)
})
It works but it feels like a workaround. Eventually we replace it with a proper Compose implementation.
Testing. The old UI tests use Espresso and target RecyclerView item positions. Those tests break because there is no RecyclerView anymore. We have to rewrite them using Compose testing APIs.
What works well
State rendering is so much cleaner. No more if (list.isEmpty()) showEmptyState() else hideEmptyState(). The Composable just renders whatever the state is.
Also the Search screen is now easier to test in isolation. You pass a state directly to the Composable and verify the output. No Fragment, no ViewModel mocking needed for basic rendering tests.
After a month
The Search screen migration takes about two weeks including fixing the theme and rewriting the tests. Longer than expected.
The next screen will be faster because we now know the pitfalls. Theming is done. The patterns are established.
Migrating incrementally is the right strategy. Doing it all at once would have been too risky.