Skip to content
Go back

Passing arguments to Android ViewModel

Published:

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

  1. Except user initiated process death, that’s fine as in this case we do want the app state to clear.

  2. 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.



Next Post
AI vs Programming as Theory Building