Approach
Recently, we had an opportunity to redesign the sample app for an SDK we're developing. We were only redesigning one screen (home page), so we decided to introduce Jetpack Compose
in the app.
Based on what we read, most migration or view interop posts are about keeping Fragments
and fragment navigation
as they are and moving the content of the fragments into ComposeView
. In fact, official docs also mention that strategy.
For that, we would still have to touch other fragments in the app rather than just the home page. So it felt like more work than what we intended to do. On top of that, the sample app extensively uses PreferenceFragmentCompat
for various setting screens. To move its content to ComposeView
, we would have to re-create a kind of preferences screen from scratch because compose doesn't have a straight replacement for PreferenceFragment
.
So we decided to go with the Fragments in Compose approach. Along with that, we also moved our navigation using compose navigation component.
Fragments in Compose
Layout
We created new layout files for each fragment with FragmentContainerView in each.
<?xml version="1.0" encoding="utf-8"?>
<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/fragment_container_view_events"
android:name="com.example.fragment.analytics.AnalyticsEventsFragment"
android:layout_width="match_parent"
android:layout_height="match_parent">
</androidx.fragment.app.FragmentContainerView>
Composable
We wrote a generic FragmentHolder
composable with AndroidViewBinding. It composes an Android layout resource. The layout files we created above would have their Binding
class generated that we can inflate and pass as BindingFactory
in the AndroidViewBinding
@Composable
fun <T : ViewBinding> FragmentHolderScreen(
topBarTitle: String,
androidViewBindingFactory: (inflater: LayoutInflater, parent: ViewGroup, attachToParent: Boolean) -> T,
onBackPress: () -> Unit = {},
androidViewBindingUpdate: T.() -> Unit = {},
) {
Scaffold(
topBar = {
TopBar(title = topBarTitle, onBackPress = onBackPress)
},
content = { paddingValues ->
AndroidViewBinding(
factory = androidViewBindingFactory,
modifier = Modifier.padding(paddingValues),
update = androidViewBindingUpdate,
)
},
)
}
Navigation
After creating the composable, we defined a navigation component with a Screen
enum.
With above defined layout binding and FragmentHolderScreen
for AnalyticsEventsFragment
, the minimized Scaffold
composables look like this,
Scaffold(
content = { contentPadding ->
NavHost(
navController = navController,
startDestination = Screen.Settings.route,
Modifier.padding(contentPadding),
) {
composable(Screen.Events.route) {
FragmentHolderScreen(
topBarTitle = getString(R.string.toolbar_title_events),
androidViewBindingFactory = ComposeFragmentEventsBinding::inflate,
androidViewBindingUpdate = {
with(fragmentContainerViewEvents.getFragment<AnalyticsEventsFragment>()) {
// Reference of AnalyticsEventsFragment is available here
}
},
onBackPress = {
onBackPress(navController)
},
)
}
}
},
)
Inner/child fragments
Some of our fragments internally had navigation where a user would go to child fragments. While doing the compose-based navigation, we added those child fragments as part of our Screen/route
enum. Then, we set a lambda function (val navigateTo: (Screen) -> Unit
) in the base
fragment class to handle the click actions to move to child fragments.
SharedPreferences
The sample app uses SharedPreferences
, and we didn't have time to move to something like DataStore
. It still worked out well after moving one screen to Compose
. We just made sure to define the reference of SharedPreferences
in onCreate
of the main activity and then pass the reference down to composable. Since we didn't touch existing fragments, they use the SharedPreferences
as defined already.
UI Testing
We have extensive UI testing in the sample app that tests the SDK. After moving the sample app's home screen to Compose
, we broke the entry point of all our tests.
Luckily, compose UI testing and espresso testing work well with each other. You can write a compose test in one-line testing the compose-based screen, and the second line can be an espresso test step looking at the view layer.
The biggest problem we faced around updating unit tests was the delay between the two systems. After clicking on a button on the compose-based screen, the app would go to SDK (a view-based UI). Many tests failed because the view would not be available on the screen when a test executes. After trying out several things, nothing worked well except adding Thread.sleep()
just before and after we handle button clicks on the compose-based screen. The delay probably gave espresso enough time to find the correct view-based screen.
Conclusion
Overall, we're satisfied with how the whole experiment went. We learned a bit about compose-view interop and got the opportunity to share our findings via this blog post.
Thanks for reading! Let me know in the comments if you have questions. Also, you can reach out to me at @shaktiman_droid on Twitter, LinkedIn or Kotlin Slack. And if you find all this interesting, maybe you'd like to work with or work at Touchlab.
Top comments (0)