Vasiliy
Vasiliy

Reputation: 16228

Jetpack Compose navigation in Android: combine bottom tabs with multi backstack and back button

I implemented bottom tabs with multi backstack in my app using this code:

    NavigationBar {
        bottomTabs.forEachIndexed { _, bottomTab ->
            NavigationBarItem(
                alwaysShowLabel = true,
                icon = { Icon(bottomTab.icon!!, contentDescription = bottomTab.title) },
                label = { Text(bottomTab.title) },
                selected = bottomTab.rootRoute.name == currentRouteName,
                onClick = {
                    currentRouteName = bottomTab.rootRoute.name
                    navController.navigate(bottomTab.rootRoute.name) {
                        navController.graph.startDestinationRoute?.let { startRoute ->
                            popUpTo(startRoute) {
                                saveState = true
                            }
                        }
                        launchSingleTop = true
                        restoreState = true
                    }
                }
            )
        }
    }

This gives me the desired behavior when switching tabs using the bottom bar. It also works OK when using back navigation gesture within the same tab.

However, if I use back navigation gesture (or back button in the top bar) to transition between tabs, the state is lost.

For example, imagine I have tab A and B, and screens A1, A2 and B1. If I navigate: A1 -> A2 -> B1 and then press back, I'd expect the tab to switch to A and land on screen A2, but, instead, I land on A1.

That's how back navigation is implemented:

        navigationIcon = {
            if (!isRootRoute) {
                IconButton(
                    onClick = {
                        navController.popBackStack()
                    }
                ) {
                    Icon(
                        imageVector = Icons.AutoMirrored.Filled.ArrowBack,
                        tint = Color.White,
                        contentDescription = "back"
                    )
                }
            }
        }

My intuition tells me that if I could specify restoreState = true while popping the backstack, similarly to how the bottom tabs navigation works, then it would work. However, there is no popBackStack(restoreState) method available.

So, how can I integrate back gesture and back button with bottom tabs and multi backstack?

Upvotes: 5

Views: 1800

Answers (3)

Ievgen
Ievgen

Reputation: 476

Based on this response https://stackoverflow.com/a/75514499/7848330, I created an example of how to implement the desired behaviour. Here is a video of how it works https://vimeo.com/manage/videos/1034031580.

I used Android Studio Ladybug | 2024.2.1 Patch 2.

I put everything into one file for clarity:

@Serializable
object HomeRoute

@Serializable
object FavoriteRoute

@Serializable
object A1Route

@Serializable
object A2Route

@Serializable
object B1Route

@Serializable
object B2Route

data class NavigationBarDataItem<T : Any>(
    val name: String, val route: T, val icon: ImageVector, val selectedIcon: ImageVector
)

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            Navigation2024Theme { // Auto generated based on the project name
                val navController = rememberNavController()
                Scaffold(modifier = Modifier.fillMaxSize(), bottomBar = {
                    NavigationBar {
                        val navigationBarDataItems = listOf(
                            NavigationBarDataItem(
                                name = "Home",
                                route = HomeRoute,
                                icon = Icons.Outlined.Home,
                                selectedIcon = Icons.Filled.Home
                            ), NavigationBarDataItem(
                                name = "Favorite",
                                route = FavoriteRoute,
                                icon = Icons.Outlined.FavoriteBorder,
                                selectedIcon = Icons.Filled.Favorite
                            )
                        )
                        val navBackStackEntry by navController.currentBackStackEntryAsState()
                        val currentDestination = navBackStackEntry?.destination
                        navigationBarDataItems.forEach { item ->
                            val selected =
                                currentDestination?.hierarchy?.any { it.route == item.route::class.qualifiedName } == true
                            NavigationBarItem(icon = {
                                Icon(
                                    imageVector = if (selected) item.selectedIcon else item.icon,
                                    contentDescription = "${item.name} icon"
                                )
                            }, label = { Text(item.name) }, selected = selected, onClick = {
                                navController.navigate(item.route) {
                                    popUpTo(navigationBarDataItems[0].route /*HomeRoute*/) {
                                        saveState = true
                                    }
                                    launchSingleTop = true
                                    restoreState = true
                                }
                            })
                        }
                    }
                }) { innerPadding ->
                    Content(innerPadding, navController)
                }
            }
        }
    }

    @Composable
    private fun Content(innerPadding: PaddingValues, navController: NavHostController) {
        val navGraph = navController.createGraph(HomeRoute) {
            composable<HomeRoute> { HomeTabContent() }
            composable<FavoriteRoute> { FavoriteTabContent() }
        }
        NavHost(navController, navGraph, modifier = Modifier.padding(innerPadding))
    }

    @Composable
    private fun HomeTabContent() {
        val navController = rememberNavController()
        val navGraph = navController.createGraph(startDestination = A1Route) {
            composable<A1Route> {
                TabScreen("A1", color = Color.Red) {
                    navController.navigate(
                        A2Route
                    )
                }
            }
            composable<A2Route> { TabScreen("A2", color = Color.Red) }
        }
        NavHost(navController, navGraph)
    }


    @Composable
    private fun FavoriteTabContent() {
        val navController = rememberNavController()
        val navGraph = navController.createGraph(startDestination = B1Route) {
            composable<B1Route> {
                TabScreen("B1", color = Color.Blue) {
                    navController.navigate(B2Route)
                }
            }
            composable<B2Route> { TabScreen("B2", color = Color.Blue) }
        }
        NavHost(navController, navGraph)
    }

    @Composable
    private fun TabScreen(
        text: String,
        color: Color,
        onClick: (() -> Unit)? = null
    ) {
        Column(
            modifier = Modifier
                .fillMaxSize()
                .wrapContentSize(),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text(
                text,
                style = TextStyle(fontSize = 64.sp, color = color),
                textAlign = TextAlign.Center
            )
            Button(
                onClick = { if (onClick != null) onClick() },
                enabled = onClick != null
            ) {
                Text("Next screen")
            }
        }
    }
}

Upvotes: 1

Pedro Bilro
Pedro Bilro

Reputation: 1

I'm not sure if I can fully help here, but I've had this problem as well, the "culprit" of the issue seems to be

navController.graph.startDestinationRoute?.let { startRoute ->
    popUpTo(startRoute) {
        saveState = true
    }
}

When you do popUpTo(startRoute), it clears everything from the backstack except the start route.

So in your example, before you navigate to B1, your stack is [A1, A2], once you navigate to B1 in the other tab (assuming A1 is start destination), A2 will be gone from the back stack, your backstack is [A1, B1]. If you press back button, you'll get If you open the bottom navigation tab of A again, you'll get [A1, A2] again. If you log the backstack entries you'll see that behaviour.

If you remove that snippet, all your composables will be in the backstack, so if you do A1 -> A2 -> B1, and press back button, you'll go back to A2. This causes another problem though. You can easily create an endless backstack, if you do A1 -> B1 -> A1 -> B1 -> A1 -> B1 -> A1 -> A2 -> B1 -> B2, etc. you'll have repeated destinations in the backstack. And when you navigate with back button, you'll go back through each of those repeated destinations. So what needs to be done here is to control where you want to pop back to. Youtube app avoids this issue, not fully sure how they do it, but either they don't use Jetpack Navigation (HA), or probably they have some custom behaviour for NavHost to manipulate the back stack.

Maybe this can achieve it - https://vedraj360.medium.com/youtube-like-backstack-in-jetpack-navigation-component-android-2537b446668d (need to modify it to use compose navigation instead of fragments, but the core logic might help)

I'll tinker around with it and update my response if I find anything worthwhile.

Upvotes: 0

Ehsan Setayesh
Ehsan Setayesh

Reputation: 171

Try this

fun handleBackNavigation(navController: NavController) {
    if (!navController.navigateUp()) {
        val currentTab =
            bottomTabs.indexOfFirst { it.rootRoute.name == navController.currentDestination?.route }
        val previousTab = if (currentTab > 0) currentTab - 1 else bottomTabs.size - 1
        val previousTabRoute = bottomTabs[previousTab].rootRoute.name
        navController.navigate(previousTabRoute) {
            navController.graph.findNode(previousTabRoute)?.route?.let { startRoute ->
                popUpTo(startRoute) {
                    saveState = true
                }
            }
            launchSingleTop = true
            restoreState = true
        }
    }
}

    navigationIcon = {
    if (!isRootRoute) {
        IconButton(
            onClick = {
                handleBackNavigation(navController)
            }
        ) {
            Icon(
                imageVector = Icons.AutoMirrored.Filled.ArrowBack,
                tint = Color.White,
                contentDescription = "back"
            )
        }
    }
}

Upvotes: 1

Related Questions