I built a multiple back stack navigation sample with bottom navigation but when back is clicked from another tab/navigation app navigates to initial destination of graph instead of last one on back stack.
As can be seen on gif Home screen stores states and back stack with save and restore states but when back is clicked it pops to HomeScreen1 while back stack contains HomeScreen3. Changing to another tab and back to home restores states however.
It's obvious it's due to
popUpTo(findStartDestination(nestedNavController.graph).id)
What's proper way to pop or store back stack that it navigates to correct page on back press as well?
Also selected doesn't work with
val selected =
currentDestination?.hierarchy?.any { it.hasRoute(item.route::class) } == true
because second or third page in tab does not contain first screen while checking hierarchy. This another problem with nested navigation, using indices for clicked item works but then when back is clicked it doesn't move selected to correct index.
Added back stack reversed and hierarchy to observe how they change. I tried several combinations with it still haven't found a proper way to save and restore first tabs state properly with save/restore approach yet.
Nav graph
private fun NavGraphBuilder.addBottomNavigationGraph(
nestedNavController: NavHostController,
onGoToProfileScreen: (route: Any, navBackStackEntry: NavBackStackEntry) -> Unit,
onBottomScreenClick: (route: Any, navBackStackEntry: NavBackStackEntry) -> Unit,
) {
navigation<BottomNavigationRoute.HomeGraph>(
startDestination = BottomNavigationRoute.HomeRoute1
) {
composable<BottomNavigationRoute.HomeRoute1> { from: NavBackStackEntry ->
Screen(
text = "Home Screen1",
navController = nestedNavController,
onClick = {
onBottomScreenClick(BottomNavigationRoute.HomeRoute2, from)
}
)
}
composable<BottomNavigationRoute.HomeRoute2> { from: NavBackStackEntry ->
Screen(
text = "Home Screen2",
navController = nestedNavController,
onClick = {
onBottomScreenClick(BottomNavigationRoute.HomeRoute3, from)
}
)
}
composable<BottomNavigationRoute.HomeRoute3> { from: NavBackStackEntry ->
Screen(
text = "Home Screen3",
navController = nestedNavController
)
}
}
navigation<BottomNavigationRoute.SettingsGraph>(
startDestination = BottomNavigationRoute.SettingsRoute1
) {
composable<BottomNavigationRoute.SettingsRoute1> { from: NavBackStackEntry ->
Screen(
text = "Settings Screen",
navController = nestedNavController,
onClick = {
onBottomScreenClick(BottomNavigationRoute.SettingsRoute2, from)
}
)
}
composable<BottomNavigationRoute.SettingsRoute2> { from: NavBackStackEntry ->
Screen(
text = "Settings Screen2",
navController = nestedNavController,
onClick = {
onBottomScreenClick(BottomNavigationRoute.SettingsRoute3, from)
}
)
}
composable<BottomNavigationRoute.SettingsRoute3> { from: NavBackStackEntry ->
Screen(
text = "Settings Screen3",
navController = nestedNavController
)
}
}
composable<BottomNavigationRoute.FavoritesRoute> { from: NavBackStackEntry ->
Screen(
text = "Favorites Screen",
navController = nestedNavController,
onClick = {
onGoToProfileScreen(
Profile("Favorites"),
from
)
}
)
}
composable<BottomNavigationRoute.NotificationRoute> { from: NavBackStackEntry ->
Screen(
text = "Notifications Screen",
navController = nestedNavController,
onClick = {
onGoToProfileScreen(
Profile("Notifications"),
from
)
}
)
}
}
Root and nested NavHost Composables
@SuppressLint("RestrictedApi")
@Preview
@Composable
fun NavigationTest() {
val navController = rememberNavController()
NavHost(
modifier = Modifier.fillMaxSize(),
navController = navController,
startDestination = BottomNavigationRoute.DashboardRoute,
enterTransition = {
slideIntoContainer(
towards = SlideDirection.Start,
animationSpec = tween(700)
)
},
exitTransition = {
slideOutOfContainer(
towards = SlideDirection.End,
animationSpec = tween(700)
)
},
popEnterTransition = {
slideIntoContainer(
towards = SlideDirection.Start,
animationSpec = tween(700)
)
},
popExitTransition = {
slideOutOfContainer(
towards = SlideDirection.End,
animationSpec = tween(700)
)
}
) {
composable<BottomNavigationRoute.DashboardRoute> {
MainContainer { route: Any, navBackStackEntry: NavBackStackEntry ->
// Navigate only when life cycle is resumed for current screen
if (navBackStackEntry.lifecycleIsResumed()) {
navController.navigate(route = route)
}
}
}
composable<Profile> { navBackStackEntry: NavBackStackEntry ->
val profile: Profile = navBackStackEntry.toRoute<Profile>()
Screen(profile.toString(), navController)
}
}
}
@SuppressLint("RestrictedApi")
@Composable
private fun MainContainer(
onGoToProfileScreen: (
route: Any,
navBackStackEntry: NavBackStackEntry,
) -> Unit,
) {
val items = remember {
bottomRouteDataList()
}
val nestedNavController = rememberNavController()
var selectedIndex by remember {
mutableIntStateOf(0)
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = {
Text("TopAppbar")
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.White
)
)
},
bottomBar = {
NavigationBar(
modifier = Modifier.height(56.dp),
tonalElevation = 4.dp
) {
items.forEachIndexed { index, item: BottomRouteData ->
NavigationBarItem(
selected = selectedIndex == index,
icon = {
Icon(
imageVector = item.icon,
contentDescription = null
)
},
onClick = {
selectedIndex = index
nestedNavController.navigate(route = item.route) {
launchSingleTop = true
// 🔥 If restoreState = true and saveState = true are commented
// routes other than Home1 are not saved
restoreState = true
// Pop up backstack to the first destination and save state.
// This makes going back
// to the start destination when pressing back in any other bottom tab.
popUpTo(findStartDestination(nestedNavController.graph).id) {
saveState = true
}
}
}
)
}
}
}
) { paddingValues: PaddingValues ->
NavHost(
modifier = Modifier.padding(paddingValues),
navController = nestedNavController,
startDestination = BottomNavigationRoute.HomeGraph
) {
addBottomNavigationGraph(
nestedNavController = nestedNavController,
onGoToProfileScreen = { route, navBackStackEntry ->
onGoToProfileScreen(route, navBackStackEntry)
},
onBottomScreenClick = { route, navBackStackEntry ->
nestedNavController.navigate(route)
}
)
}
}
}
Screens for displaying and tracking current back stack
@SuppressLint("RestrictedApi")
@Composable
fun Screen(
text: String,
navController: NavController,
onClick: (() -> Unit)? = null,
) {
val packageName = LocalContext.current.packageName
var counter by rememberSaveable {
mutableIntStateOf(0)
}
Column(
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = text,
fontSize = 26.sp,
fontWeight = FontWeight.Bold
)
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
counter++
}
) {
Text("Counter: $counter")
}
onClick?.let {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
onClick()
}
) {
Text("Navigate next screen")
}
}
val currentBackStack: List<NavBackStackEntry> by navController.currentBackStack.collectAsState()
val pagerState = rememberPagerState {
2
}
HorizontalPager(state = pagerState) { page ->
val headerText = if (page == 0) "Current Back stack(reversed)" else "Current hierarchy"
Column {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
text = headerText,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
val destinations = if (page == 0) {
currentBackStack.reversed().map { it.destination }
} else {
navController.currentDestination?.hierarchy?.toList() ?: listOf()
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(items = destinations) { destination: NavDestination ->
if (destination is NavGraph) {
MainText(destination, packageName)
destination.nodes.forEach { _, value ->
SubItemText(value, packageName)
}
} else {
MainText(destination, packageName)
}
}
}
}
}
}
}
@Composable
private fun SubItemText(destination: NavDestination, packageName: String?) {
Row(
modifier = Modifier
.padding(start = 8.dp, bottom = 2.dp)
.shadow(2.dp, RoundedCornerShape(8.dp))
.background(Color.White)
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.Bottom
) {
Text(
text = getDestinationFormattedText(
destination,
packageName
),
fontSize = 12.sp
)
// destination.parent?.let {
// Text(
// text = ", parent: " + getGraphFormattedText(it, packageName),
// fontSize = 10.sp
// )
// }
}
}
@Composable
private fun MainText(destination: NavDestination, packageName: String?) {
Row(
modifier = Modifier
.shadow(4.dp, RoundedCornerShape(8.dp))
.background(Color.White)
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.Bottom
) {
Text(
text = getDestinationFormattedText(
destination,
packageName
),
fontSize = 18.sp
)
// destination.parent?.let {
// Text(
// text = ", parent: " + getGraphFormattedText(it, packageName),
// fontSize = 14.sp
// )
// }
}
}
@SuppressLint("RestrictedApi")
private fun getDestinationFormattedText(
destination: NavDestination,
packageName: String?,
) = (destination.route
?.replace("$packageName.", "")
?.replace("BottomNavigationRoute.", "")
?: (destination.displayName))
@SuppressLint("RestrictedApi")
private fun getGraphFormattedText(
destination: NavGraph,
packageName: String?,
) = (destination.route
?.replace("$packageName.", "")
?.replace("BottomNavigationRoute.", "")
?: (destination.displayName))
Routes and route data
@Serializable
sealed class BottomNavigationRoute {
@Serializable
data object DashboardRoute : BottomNavigationRoute()
@Serializable
data object HomeGraph : BottomNavigationRoute()
@Serializable
data object HomeRoute1 : BottomNavigationRoute()
@Serializable
data object HomeRoute2 : BottomNavigationRoute()
@Serializable
data object HomeRoute3 : BottomNavigationRoute()
@Serializable
data object SettingsGraph : BottomNavigationRoute()
@Serializable
data object SettingsRoute1 : BottomNavigationRoute()
@Serializable
data object SettingsRoute2 : BottomNavigationRoute()
@Serializable
data object SettingsRoute3 : BottomNavigationRoute()
@Serializable
data object FavoritesRoute : BottomNavigationRoute()
@Serializable
data object NotificationRoute : BottomNavigationRoute()
}
internal fun bottomRouteDataList() = listOf(
BottomRouteData(
title = "Home",
icon = Icons.Default.Home,
route = BottomNavigationRoute.HomeRoute1
),
BottomRouteData(
title = "Settings",
icon = Icons.Default.Settings,
route = BottomNavigationRoute.SettingsRoute1
),
BottomRouteData(
title = "Favorites",
icon = Icons.Default.Favorite,
route = BottomNavigationRoute.FavoritesRoute
),
BottomRouteData(
title = "Notifications",
icon = Icons.Default.Notifications,
route = BottomNavigationRoute.NotificationRoute
)
)
data class BottomRouteData(
val title: String,
val icon: ImageVector,
val route: BottomNavigationRoute,
)
Navigation functions from JetSnack
/**
* If the lifecycle is not resumed it means this NavBackStackEntry already processed a nav event.
*
* This is used to de-duplicate navigation events.
*/
internal fun NavBackStackEntry.lifecycleIsResumed() =
this.lifecycle.currentState == Lifecycle.State.RESUMED
private val NavGraph.startDestination: NavDestination?
get() = findNode(startDestinationId)
/**
* Copied from similar function in NavigationUI.kt
*
* https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:navigation/navigation-ui/src/main/java/androidx/navigation/ui/NavigationUI.kt
*/
internal tailrec fun findStartDestination(graph: NavDestination): NavDestination {
return if (graph is NavGraph) findStartDestination(graph.startDestination!!) else graph
}
To make selected
of NavigationBarItem
work, use HomeGraph
instead of HomeRoute1
and SettingsGraph
instead of SettingsRoute1
as the bottom navigation routes:
internal fun bottomRouteDataList() = listOf(
BottomRouteData(
title = "Home",
icon = Icons.Default.Home,
// route = BottomNavigationRoute.HomeRoute1
route = BottomNavigationRoute.HomeGraph
),
BottomRouteData(
title = "Settings",
icon = Icons.Default.Settings,
// route = BottomNavigationRoute.SettingsRoute1
route = BottomNavigationRoute.SettingsGraph
),
//...
Updated NavigationBar
:
NavigationBar(
modifier = Modifier.height(56.dp),
tonalElevation = 4.dp
) {
val navBackStackEntry by nestedNavController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
items.forEach { item: BottomRouteData -> // no need for index anymore
NavigationBarItem(
selected = currentDestination?.hierarchy?.any { it.hasRoute(item.route::class) } == true,
//...
Restoring HomeScreen state on back press is more tricky. NavHost
adds a back handler callback to the local BackPressedDispatcher
and it will only invoke the most recently added callback, so we need to add a BackHandler after NavHost
does. If we manage to do so, we can navigate to the HomeScreen the same way we do in the bottom navigation with restoreState = true
.
Composable with a BackHandler
that navigates to HomeScreen:
@Composable
private fun BackHome(
navController: NavController
) {
BackHandler {
navController.navigate(BottomNavigationRoute.HomeGraph) {
launchSingleTop = true
restoreState = true
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
}
}
}
Add this composable to the navigation destinations that need it - SettingsRoute1
, FavoritesRoute
and NotificationRoute
:
navigation<BottomNavigationRoute.SettingsGraph>(
startDestination = BottomNavigationRoute.SettingsRoute1
) {
composable<BottomNavigationRoute.SettingsRoute1> { from: NavBackStackEntry ->
BackHome(nestedNavController)
Screen(
//...
composable<BottomNavigationRoute.FavoritesRoute> { from: NavBackStackEntry ->
BackHome(nestedNavController)
Screen(
//...
composable<BottomNavigationRoute.NotificationRoute> { from: NavBackStackEntry ->
BackHome(nestedNavController)
Screen(