I’ll explore two different approaches of using type-safe Jetpack Compose navigation together with Hilt. Generally if you have some details screen, to know which detail to present there you need an ID. It’s usually passed along during navigation.
Table of Contents
Open Table of Contents
Desired outcome
ViewModel is already constructed with the detailId
required to identify the data.
class DetailsViewModel @Inject constructor(
private val detailId: String,
private val getDetailsUseCase: GetDetailsUseCase
) : ViewModel() { ... }
Route with navigation arguments definition:
@Serializable
data class ScreenDetails(val id: String)
Thus the navigation to the Details screen would be something like:
navController.navigate(ScreenDetails(id))
Leaking navigation implementation
Android provides SavedStateHandle that will contain the args. It persists process death1. And in general doesn’t need much setup to work. It seems to be the easiest way of transferring navigation arguments to the ViewModel. It’s the android recommendation.
Just declare the SavedStateHandle
in the constructor and the framework handles the rest. From the handle you can retrieve the navigation arguments with .toRoute()
helper.
@HiltViewModel
class DetailsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val getDetailsUseCase: GetDetailsUseCase
) : ViewModel() {
val navArgs = savedStateHandle.toRoute<ScreenDetails>()
val detailId = navArgs.id
ViewModel creation using Hilt
@Composable
fun DetailsScreen(navController: NavHostController) {
val viewModel: DetailsViewModel = hiltViewModel()
Navigation call:
navController.navigate(ScreenDetails(id))
Navigation graph:
fun NavGraphBuilder.navigation(navController: NavHostController) {
navigation<DetailsBrowserFeature>(startDestination = ScreenList) {
composable<ScreenList> {
ListScreen(navController)
}
composable<ScreenDetails> {
DetailsScreen(navController)
}
}
}
This is great, doesn’t require much code. My only concern is that this leaks the route details (ScreenDetails
class) to the ViewModel. Now the VM may have to be changed with navigation changes. Or what about having different routes to same ViewModel?2
Hiding navigation implementation
Lift SavedStateHandle out of ViewModel to break the coupling. ViewModel won’t be able to grab the NavArgs from the handle anymore. It will just declare the required params in the constructor, unrelated to navigation implementation.
@HiltViewModel(assistedFactory = DetailsViewModel.Factory::class)
class DetailsViewModel @AssistedInject constructor(
@Assisted private val detailId: String,
private val getDetailsUseCase: GetDetailsUseCase,
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(detailId: String): DetailsViewModel
}
ViewModel creation: hilt gets callback to factory. NavArgs are resolved upstream and passed to this Screen.
@Composable
fun DetailsScreen(navController: NavHostController, navArgs: ScreenDetails) {
val viewModel: DetailsViewModel =
hiltViewModel(creationCallback = { factory: DetailsViewModel.Factory ->
factory.create(navArgs.id)
})
Navigation graph change: NavArgs (Route) are retrieved from the backstack entry.
composable<ScreenDetails> { entry: NavBackStackEntry ->
DetailsScreen(navController, entry.toRoute())
}
The rest has not changed. If you squint it looks like the desired outcome in the beginning. ViewModel operates on a simple String ID. However the price is a lot of extra code to make assisted injection of Hilt work.
Conclusion
The second option should be doable in any DI framework. All it requires is manual transfer of navigation args to ViewModel, just don’t rely on SavedStateHandle.
Which one to use? I’m a huge fan of decoupling, however at least in case of Hilt, this verbosity is a bit too much. It won’t pay off in smaller projects. Logically the navigation and presentation model are anyway closely related.
In large projects, if you expect the navigation component to change. Or are in middle of refactoring from older styles of navigation, the second option might provide more consistency over the whole codebase and its lifecycle.
Footnotes
-
Except user initiated process death, that’s fine as in this case we do want the app state to clear. ↩
-
This scenario is a bit contrived, as ViewModel may still need the same data, no matter the route. As a workaround if you need different data, you could include it all in the single route definition. So the ViewModel will use whatever is available. However consider that in this case you might be better off splitting to multiple ViewModels. ↩