Scoping States in Jetpack Compose
Asked Answered
C

2

49

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?

Canoness answered 22/11, 2020 at 15:17 Comment(1)
If you need a working solution for it; I currently use compose router Github for it.Inscrutable
P
53

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.

Potboiler answered 23/11, 2020 at 0:12 Comment(9)
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?Krum
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.Potboiler
Hi @ianhanniballake, may I ask another question that is kind of related to this question. I posted it here: #65076235Krum
Any chance these implementations will make it to the official api?Needlepoint
@Potboiler Will parentViewModel be recomposed? The hilt example for getting a viewModel scoped to a navigation route uses rememberPhosphor
@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).Potboiler
Note that remember {navController.getBackStackEntry(parentId)} can cause crashes and now triggers a lint warning (more here). the solution is to use a the backStackEntry as a key to remember such as. remember(navBackStackEntry) {navController.getBackStackEntry(parentId)}Semolina
@Semolina where you getting the navBackStackEntry that is being used with remember?Riot
@Riot it's passed from withing the composable dsl, i.e., NavHost(...){navigation(....){composable(...){navBackStackEntry -> ...}}}Randi
P
8

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)
            }
        }
    }
}
Phosphor answered 20/10, 2021 at 11:28 Comment(1)
Note that remember {navController.getBackStackEntry(parentId)} can cause crashes and now triggers a lint warning (more here). the solution is to use a the backStackEntry as a key to remember such as. remember(backStackEntry) {navController.getBackStackEntry(parentId)}Semolina

© 2022 - 2024 — McMap. All rights reserved.