44

In all applications there will always be this three scopes of state: States

With Compose, a "Per Screen State" could be achieved by:

NavHost(navController, startDestination = startRoute) {
    ...
    composable(route) {
       ...
       val perScreenViewModel = viewModel()  // This will be different from
    }
    composable(route) {
       ...
       val perScreenViewModel = viewModel()  // this instance
    }
    ...
}

The "App State" could be achieved by:

val appStateViewModel = viewModel()
NavHost(navController, startDestination = startRoute) {
    ...
}

But how about for "Scoped State"? How could we achieve it in Compose?

Archie G. Quiñones
  • 11,638
  • 18
  • 65
  • 107
  • If you need a working solution for it; I currently use compose router [Github](https://github.com/zsoltk/compose-router) for it. – 2jan222 Nov 22 '20 at 19:36

2 Answers2

49

This is precisely what navigation graph scoped view models are used for.

This involves two steps:

  1. Finding the NavBackStackEntry associated with the graph you want to scope the ViewModel to

  2. Pass that to viewModel().

For part 1), you have two options. If you know the route of the navigation graph (which, in general, you should), you can use getBackStackEntry directly:

// Note that you must always use remember with getBackStackEntry
// as this ensures that the graph is always available, even while
// your destination is animated out after a popBackStack()
val navigationGraphEntry = remember {
  navController.getBackStackEntry("graph_route")
}
val navigationGraphScopedViewModel = viewModel(navigationGraphEntry)

However, if you want something more generic, you can retrieve the back stack entry by using the information in the destination itself - its parent:

fun NavBackStackEntry.rememberParentEntry(): NavBackStackEntry {
  // First, get the parent of the current destination
  // This always exists since every destination in your graph has a parent
  val parentId = navBackStackEntry.destination.parent!!.id

  // Now get the NavBackStackEntry associated with the parent
  // making sure to remember it
  return remember {
    navController.getBackStackEntry(parentId)
  }
}

Which allows you to write something like:

val parentEntry = it.rememberParentEntry()
val navigationGraphScopedViewModel = viewModel(parentEntry)

While the parent destination will be equal to the root graph for a simple navigation graph, when you use nested navigation, the parent is one of the intermediate layers of your graph:

NavHost(navController, startDestination = startRoute) {
    ...
  navigation(startDestination = nestedStartRoute, route = nestedRoute) {
    composable(route) {
      // This instance will be the same
      val parentViewModel: YourViewModel = viewModel(it.rememberParentEntry())
    }
    composable(route) {
      // As this instance
      val parentViewModel: YourViewModel = viewModel(it.rememberParentEntry())
    }
  }
  navigation(startDestination = nestedStartRoute, route = secondNestedRoute) {
    composable(route) {
        // But this instance is different
      val parentViewModel: YourViewModel = viewModel(it.rememberParentEntry())
    }
  }
  composable(route) {
     // This is also different (the parent is the root graph)
     // but the root graph has the same scope as the whole NavHost
     // so this isn't particularly helpful
     val parentViewModel: YourViewModel = viewModel(it.rememberParentEntry())
  }
  ...
}

Note that you are not limited to only the direct parent: every parent navigation graph can be used to provide larger scopes.

Tushar Kathuria
  • 645
  • 2
  • 8
  • 22
ianhanniballake
  • 191,609
  • 30
  • 470
  • 443
  • is there a way to know if a `composable` goes to the backstack vs `composable` getting disposed because of configuration changes (like device rotation) vs completely getting disposed as its no longer needed? – Archie G. Quiñones Nov 23 '20 at 14:09
  • 1
    When it comes to specifically the behavior within a `NavHost`, the `onCleared()` of a scoped ViewModel will only be called when that destination (or set of destinations if you are using a navigation graph scoped ViewModel) is popped off the back stack and permanently destroyed. – ianhanniballake Nov 23 '20 at 16:31
  • Hi @ianhanniballake, may I ask another question that is kind of related to this question. I posted it here: https://stackoverflow.com/questions/65075735/properly-scoping-viewmodel-across-screens – Archie G. Quiñones Nov 30 '20 at 14:48
  • Any chance these implementations will make it to the official api? – Jim Ovejera Sep 08 '21 at 16:08
  • @ianhanniballake Will `parentViewModel` be recomposed? The [hilt example](https://developer.android.com/jetpack/compose/libraries#hilt-navigation) for getting a viewModel scoped to a navigation route uses `remember` – clamentjohn Oct 20 '21 at 11:42
  • @clmno - every call to `navController.getBackStackEntry()` should be remembered, yes. That ensures that you still get the same `NavBackStackEntry` while your destination is being animated out (i.e., after you hit the system back button). – ianhanniballake Oct 20 '21 at 17:15
  • 4
    Note that `remember {navController.getBackStackEntry(parentId)}` can cause crashes and now triggers a lint warning ([more here](https://issuetracker.google.com/issues/227382831)). the solution is to use a the `backStackEntry` as a key to `remember` such as. `remember(navBackStackEntry) {navController.getBackStackEntry(parentId)}` – Guerneen4 Oct 06 '22 at 19:56
  • 1
    @Guerneen4 where you getting the `navBackStackEntry` that is being used with remember? – levi Nov 17 '22 at 02:29
  • 1
    @levi it's passed from withing the composable dsl, i.e., `NavHost(...){navigation(....){composable(...){navBackStackEntry -> ...}}}` – Marco Antonio Nov 19 '22 at 17:05
7

From the Compose and other libraries - Hilt doc

To retrieve the instance of a ViewModel scoped to navigation routes, pass the destination root as a parameter:

val loginBackStackEntry = remember { navController.getBackStackEntry("Parent") }
val loginViewModel: LoginViewModel = hiltViewModel(loginBackStackEntry)

The same can be done without Hilt

val loginBackStackEntry = remember { navController.getBackStackEntry("Parent") }
val loginViewModel: LoginViewModel = viewModel(loginBackStackEntry)

This achieves the same thing acheived by @ianhanniballake but lesser code

Note: The navigation graph has its own route = "Parent"

Full code example

Scoped State Example with Jetpack compose and navigation

// import androidx.hilt.navigation.compose.hiltViewModel
// import androidx.navigation.compose.getBackStackEntry

@Composable
fun MyApp() {
    NavHost(navController, startDestination = startRoute) {
        navigation(startDestination = innerStartRoute, route = "Parent") {
            // ...
            composable("exampleWithRoute") { backStackEntry ->
                val parentEntry = remember {navController.getBackStackEntry("Parent")}
                val parentViewModel = hiltViewModel<ParentViewModel>(parentEntry)
                ExampleWithRouteScreen(parentViewModel)
            }
        }
    }
}
clamentjohn
  • 3,417
  • 2
  • 18
  • 42
  • 5
    Note that `remember {navController.getBackStackEntry(parentId)}` can cause crashes and now triggers a lint warning ([more here](https://issuetracker.google.com/issues/227382831)). the solution is to use a the `backStackEntry` as a key to `remember` such as. `remember(backStackEntry) {navController.getBackStackEntry(parentId)}` – Guerneen4 Oct 06 '22 at 19:58