Skip to content
Go back

Passing arguments to Android ViewModel

Updated:

I’ll explore two different approaches of using type-safe Jetpack Compose navigation together with Hilt. As an update, ill check it in navigation 3 as well. Generally if you have some details screen, to know which detail to present you need an ID. It’s usually passed along during navigation.

Table of Contents

Open Table of Contents

Desired outcome

The Goal is to have a ViewModel already constructed with the detailId required to identify the data.

class DetailsViewModel @Inject constructor(
    private val detailId: String,
    private val getDetailsUseCase: GetDetailsUseCase
) : ViewModel() { ... }

Route definition:

data class ScreenDetails(val id: String)

Thus the navigation to Details with arguments would be something like:

navigator.goTo(ScreenDetails(id))

Solution 1: Leaking the navigation implementation

Android provides SavedStateHandle that will contain the args. It persists process death1 and doesn’t need much setup to work. It seems to be the easiest way of accessing the navigation arguments in the ViewModel. It’s the android recommendation.

Just declare the SavedStateHandle in the VM constructor. From there you can retrieve the Route object by invoking .toRoute().

@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()

Route definition with the navigation arguments:

@Serializable
data class ScreenDetails(val id: String)

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)
        }
    }
}

The compose entrypoint:

setContent {
    AppTheme {
        val navController = rememberNavController()
        NavHost(
            navController = navController,
            startDestination = DetailsBrowserFeature
        ) {
            navigation(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.

Note that this example additionally leaks the navigation implementation by making the Composable screens aware of NavHostController, but that’s not the focus of this post.

Solution 2: Hiding the navigation implementation

Goal would be making ViewModel unaware of how navigation is implemented. For that just remove the SavedStateHandle.toRoute() call from the ViewModel. Instead of grabbing navigation args from the Route, declare them in the constructor.

@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
    }

This means that you now have to use Hilts assisted injection to pass the argument to ViewModel. And the Composable screen has to have this argument as that is where the ViewModel is created.

@Composable
fun DetailsScreen(navController: NavHostController, navArgs: ScreenDetails) {
    val viewModel: DetailsViewModel =
        hiltViewModel(creationCallback = { factory: DetailsViewModel.Factory ->
            factory.create(navArgs.id)
        })

Nav graph is the new location where you’ll be accessing the Route from. Here you have access to NavBackStackEntry and it too can be converted to Route by invoking toRoute().

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 extra code to make assisted injection work.

Update: Compose navigation 3 2

The first approach doesn’t work anymore as the navigation library no longer adds the “Route” to the SavedStateHandle. Meaning there are no navigation arguments to retrieve. The second approach seems to be the desired direction in navigation 3. As you own the NavBackStack, the navigation arguments are just part of the NavKeys in the stack. You manually pass them to the screens in your graph definition. This is very similar to the second approach above, just done with navigation 3 specifics.

The route must extend from NavKey.

@Serializable
data class ScreenDetails(val id: String) : NavKey

The navigation graph is now defined as entries, i’m using the entryProvider DSL here. Note that the NavKey is an argument in entry() scope. This is the place where we are retrieving the navigation arguments and passing on to the Screen.

In navigation 2 it was similar, but we had to convert the NavBackStackEntry with .toRoute().

fun EntryProviderBuilder<NavKey>.navigation(backStack: NavBackStack) {
    entry<ScreenList> {
        ListScreen(backStack)
    }
    entry<ScreenDetails> { navArgs: ScreenDetails ->
        DetailsScreen(backStack, navArgs)
    }
}

In navigation 3 you use NavDisplay composable instead of NavHost to render your destinations. Also notice that we maintain the backstack now. Under the hood it’s just a List.

In navigation 3, a naive implementation would connect the lifecycle of ViewModel to the parent Activity/Fragment. Meaning if you remove item from backstack, its ViewModel wouldn’t be cleared as the Activity/Fragment is still active. This was being handled for us in navigation 2.

To fix this in navigation 3 you need to add both rememberSavedStateNavEntryDecorator and rememberViewModelStoreNavEntryDecorator. These decorators tie the ViewModels lifecycles to the NavKey entries in the backstack.

setContent {
    NavAppTheme {
        val backStack: NavBackStack = rememberNavBackStack(ScreenList)
        NavDisplay(
            backStack = backStack,
            onBack = { backStack.removeLastOrNull() },
            entryDecorators = listOf(
                // decorators to tie VM lifecycle to backstack instead of activity.
                rememberSavedStateNavEntryDecorator(),
                rememberViewModelStoreNavEntryDecorator()
            ),
            entryProvider = entryProvider {
                navigation(backStack)
            }
        )
    }
}

In the composable screen there are no changes regarding navigation arguments. However we don’t use NavHostController from navigation 2 anymore. To be able to trigger navigation I’ve opted to pass in the whole NavBackStack. You might want to avoid doing so as this exposes your navigation implementation, and may have to be changed when navigation 4 comes out.

@Composable
fun DetailsScreen(backstack: NavBackStack, navArgs: ScreenDetails) {
    val viewModel: DetailsViewModel =
        hiltViewModel(creationCallback = { factory: DetailsViewModel.Factory ->
            factory.create(navArgs.id)
        })

Since we don’t use NavHostController, the actual navigation calls are simple list operations. And as per core idea of Compose: the backstack is a State that is being observed. Thus any changes to it will trigger recomposition and update visible screen accordingly.

backstack.add(ScreenDetails(id))

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. As seen in navigation 3 that is the direction anyway.

If you plan to keep on using navigation 2 and the project is small enough, it’s also fine to use the first approach.

Overall, the frequent changes in navigation provide a great example of why it can be beneficial to keep your concerns separated.


Footnotes

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

  2. As of 1.0.0-alpha02 for androidx.navigation3:navigation3-runtime and androidx.navigation3:navigation3-ui.



Next Post
AI vs Programming as Theory Building