As part of a recent project, I decided to utilize Jetpack Compose for my view layer entirely. While Google’s Getting Started examples for the UI are fairly simple, you quickly reach a point when you want to navigate between different screens (or Composables
). Although Google also has you covered here with a Compose-component of its navigation library, what Google does not provide is a holistic view, so I want to share some lessons learned with you.
How it worked in ancient times
In the beginning there was l̵i̵g̵h̵t̵ startActivity(AnyActivity::class.java)
whenever we wanted to show a new screen, alternatively you would use fragments with the FragmentManager
and FragmentTransactions
, manage the backstack(s), remember to use the ChildFragmentManager
, too, whenever you had to, remember there is an “old” FragmentManager
and a SupportFragmentManager
that you could mix up etc. Google decided that this sucks and developed the navigation component, giving us navigation graphs and a NavController
that has all the power of previous times combined.
The very basics
Let’s follow the tutorial on the Compose navigation component, use the Compose version of the new NavController
, and we quickly have something like this:
class HomeActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { val navController = rememberNavController() MyTheme { Scaffold { NavigationComponent(navController) } } } } } @Composable fun NavigationComponent(navController: NavHostController) { NavHost( navController = navController, startDestination = "home" ) { composable("home") { HomeScreen(navController) } composable("details") { DetailScreen() } } } @Composable fun HomeScreen(navController: NavController) { Button(onClick = { navController.navigate("detail") }) { Text(text = "Go to detail") } } @Composable fun DetailScreen() { Text(text = "Detail") }
We created a simple NavHost
with two routes, home and detail, where the home screen has a button to go to the detail screen, each consisting of a simple text field.
Introduction of ViewModels
When you reach a certain point, you will want to introduce some more complex logic, and this is typically done with ViewModels
from the Android Architecture package. This is explained in detail here. Fortunately, they also explained how to connect this with the navigation component from earlier, which is described here.
Let’s create a ViewModel
following the tutorials for our detail screen and get the text to display from it, while also providing it from our navigation component:
@Composable fun NavigationComponent(navController: NavHostController) { NavHost( navController = navController, startDestination = "home" ) { composable("home") { HomeScreen(navController) } composable("details") { DetailScreen(viewModel()) } } } @Composable fun DetailScreen(viewModel: DetailViewModel) { Text(text = viewModel.getDetailText()) } class DetailViewModel : ViewModel() { fun getDetailText(): String { // some imaginary backend call return "Detail" } }
In contrast with the “ancient times”, where we retrieved a ViewModel
within an activity or fragment and it was pretty obvious when ViewModel.onCleared()
was called, being that it was tied to the lifecycle of the activity/fragment, when is it now called?
Regardless of whether you use viewModel()
or with Hilt hiltViewModel()
to retrieve your ViewModel
, they both will call onCleared()
when the NavHost
finishes transitioning to a different route. So whenever you navigate to another Composable
, it will be cleaned up. This is achieved by defining a DisposableEffect on the navigation route when the
NavHost is created and you can mimic the behavior even if you’re not using the navigation library and need to clean up the
ViewModel
yourself.
But now my @Preview is broken 🤔
With the introduction of ViewModels
our detail screen now needs a DetailViewModel
instance as an input. So, if you defined a Preview
anywhere, it is broken now.
@Preview @Composable fun DetailScreenPreview() { DetailScreen(viewModel = ??) }
After reading into this problem I found this Slack conversation with Google’s Jim Sproch:
Best practice is probably to avoid referencing AAC ViewModels in your composable functions.
Oh.. so, great, let’s forget all the examples we read in the tutorials and refactor our composable functions:
@Composable fun NavigationComponent(navController: NavHostController) { NavHost( navController = navController, startDestination = "home" ) { composable("home") { HomeScreen(navController) } composable("details") { val viewModel = viewModel<DetailViewModel>() DetailScreen(viewModel::getDetailText) } } } @Composable fun DetailScreen(textProvider: () -> String) { Text(text = textProvider()) } @Preview @Composable fun DetailScreenPreview() { DetailScreen { "Sample text" } }
Job Offers
The idea is that your composable functions only takes low level inputs, like lambdas, LiveData
or a Flow
(which you might need if want to work with a state). This actually also enables us, now, to easily preview different texts 🎉.
Okay, cool, but how to preview the home screen?
Remember our initial screen which we created exactly like shown in the navigation tutorial and passed the NavController
to it so we are able to navigate to the detail screen?
@Composable fun HomeScreen(navController: NavController) { Button(onClick = { navController.navigate("detail") }) { Text(text = "Go to detail") } }
In order to create a preview for this we would need to provide a value for NavController
obviously. You don’t have a mock version handy you say..? How to actually connect this now for the case the ViewModel
asks to navigate to some other screen?
My recommendation is to move any navigation logic out of your composable functions. My suggestion is to create a middle layer for navigation:
class Navigator { private val _sharedFlow = MutableSharedFlow<NavTarget>(extraBufferCapacity = 1) val sharedFlow = _sharedFlow.asSharedFlow() fun navigateTo(navTarget: NavTarget) { _sharedFlow.tryEmit(navTarget) } enum class NavTarget(val label: String) { Home("home"), Detail("detail") } }
Instead of Kotlin’s SharedFlow
you’re of course free to use whatever you’d like. Pass the singleton reference to your ViewModels
and whenever you want to navigate to another screen simply call the navigateTo()
function.
The last step is to actually navigate to a different screen, which will be done inside our composable NavigationComponent
function from the beginning:
@Composable fun NavigationComponent( navController: NavHostController, navigator: Navigator ) { LaunchedEffect("navigation") { navigator.sharedFlow.onEach { navController.navigate(it.label) }.launchIn(this) } NavHost( navController = navController, startDestination = NavTarget.Home.label ) { ... } }
With LaunchedEffect we create a
CoroutineScope
that is started as soon as our composable component is created and canceled as soon as the composition is removed. As a result, whenever Navigator.navigateTo()
is called, this snippet listens to it and performs the actual transition.
Thanks for reading 🙂
Thanks to Mohsen Bahman.