I have the following use case:
Activity
which uses Jetpack Compose
+ Compose Navigation
.main
, addresses
(child of main
), and address details
(child of addresses
). addresses
is defined using navigation sub-graph (in reality the navigation graph is much more complex so I want/need sub graphs), but the crash isn't related to it (i.e. it still occurs when I put all the routes 'inline').addresses
creates an AddressesViewModel
scoped to its NavBackStackEntry
and it as well as its children use the view model - children look for the addresses
NavBackStackEntry
to look up the same instance of the model (and it does have to be the same instance). The reason is that I want the view model to be destroyed when the addresses
screen is popped.This works fine when going deeper into the navigation graph; however, when going 'up' (popping the screens), popping the addresses
screen and going to main
causes a crash. The reason is that right after the pop the addresses
@Composable
is recomposed again, which looks up the view model, but at this point the back stack already doesn't have the addresses
route, and looking up the back stack entry for addresses
crashes.
An ugly workaround is to hook the view model to main
screen, but it is too high up, and other modules (siblings of addresses
) will also have it, which is undesired.
Another workaround is to catch the exception and don't render the content of the screen (but still render the Scaffold
with the title etc. to prevent flickering. But this is also ugly.
I have a couple of questions:
Compose Navigation
the same screens are recomposed a few times (like 3, 4 or more) - why is that?viewModel
helper from lifecycle-viewmodel-compose
because it scopes view models to the Activity
/ Fragment
(i.e. a very broad scope I don't want), right? And even if it did scope it to the current route, this would effectively prevent me from sharing the view model for the whole graph (as each nav back stack entry would get its own instance).I created the sample using the latest Android Studio template for Jetpack Compose, here are the dependencies:
dependencies {
implementation 'androidx.core:core-ktx:1.6.0'
implementation 'androidx.appcompat:appcompat:1.3.1'
implementation 'com.google.android.material:material:1.4.0'
implementation 'androidx.compose.ui:ui:1.0.2'
implementation 'androidx.compose.material:material:1.0.2'
implementation 'androidx.compose.ui:ui-tooling-preview:1.0.2'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
implementation 'androidx.activity:activity-compose:1.3.1'
implementation 'androidx.navigation:navigation-compose:2.4.0-alpha09'
debugImplementation 'androidx.compose.ui:ui-tooling:1.0.2'
}
And here is the code (the view model lookup function causing the crash is at the end, its code is based on https://androidx.tech/artifacts/hilt/hilt-navigation-compose/1.0.0-alpha02-source/androidx/hilt/navigation/compose/HiltViewModel.kt.html):
package com.example.nav_sample
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.*
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navigation
import com.example.nav_sample.ui.theme.NavsampleTheme
const val main = "main"
const val addressesModule = "addressesModule"
const val addresses = "addresses"
const val addressDetails = "addressDetails"
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
NavsampleTheme {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = main) {
composable(main) {
ScaffoldScreen(
"Main screen",
onBack = { this@MainActivity.onBackPressed() },
) {
Center {
Button(
onClick = { navController.navigate(addressesModule) },
) {
Text("Addresses")
}
}
}
}
navigation(route = addressesModule, startDestination = addresses) {
composable(addresses) {
val addressesViewModel: AddressesViewModel =
navController.scopedViewModel(route = addresses)
Log.wtf(
"NavSample",
"Route: '$addresses', AddressesViewModel instance: $addressesViewModel",
)
ScaffoldScreen(
"Addresses",
onBack = { navController.popBackStack() },
) {
Center {
Column {
Button(
onClick = { navController.navigate(addressDetails) },
) {
Text("Address #1 details")
}
Button(
onClick = { navController.navigate(addressDetails) },
) {
Text("Address #2 details")
}
}
}
}
}
composable(route = addressDetails) {
val addressesViewModel: AddressesViewModel =
navController.scopedViewModel(route = addresses)
Log.wtf(
"NavSample",
"Route: '$addressDetails', AddressesViewModel instance: $addressesViewModel",
)
ScaffoldScreen(
"Address details",
onBack = { navController.popBackStack() },
) {
Center {
Text("Address details")
}
}
}
}
}
}
}
}
}
@Composable
fun ScaffoldScreen(
topBarTitle: String,
onBack: () -> Unit,
content: @Composable () -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = "back icon",
)
}
},
title = {
Text(text = topBarTitle)
},
)
},
content = { paddingValues ->
Box(
modifier = Modifier
.padding(paddingValues)
) {
content()
}
},
)
}
@Composable
fun Center(
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
content = content,
)
}
class AddressesViewModel : ViewModel()
@Composable
inline fun <reified T : ViewModel> NavController.scopedViewModel(route: String): T {
val viewModelStoreOwner = try {
getBackStackEntry(route)
} catch (e: Exception) {
Log.wtf(
"NavSample",
"Thrown looking up route: '$route'",
e,
)
throw e
}
val provider = ViewModelProvider(viewModelStoreOwner)
return provider[T::class.java]
}
Recomposition happens because of transition animations. There's nothing you can do with this fact, your app should work fine with such recompositions.
Your local problem can be solved quite easily: inside addresses
create a view model by calling viewModel()
and inside addressDetails
you will be able to get it with scopedViewModel
.
View model scope is bind to the navigation route, as declared in documentation.