androidandroid-jetpack-composejetpack-compose-navigation

Changing back stack with Multiple back stack BottomNavigation with type safety in Jetpack Compose


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.

enter image description here

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
}

Solution

  • 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(
    

    screen capture