Bottom Bar Navigation in Android with Compose Navigation

In this article I’ll show you two alternative ways to implement the bottom bar navigation pattern in Android with Jetpack Compose and Compose Navigation library.

Bottom Bar Navigation Pattern

This pattern helps you divide your application into multiple high-level screen groups, and let the users switch between them quickly. That’s how it looks in the tutorial implementation that we’ll implement in the next section:

Standard Implementation of Bottom Bar Navigation

You can find a full working code example of this approach in my open-sourced TechYourChance application. Below I’ll highlight and explain the main parts.

First, define application’s navigation routes:

// Navigation routes for screens
sealed class Route(var route: String, var title: String) {
    data object HomeRoot : Route("home", "Home root screen")
    data object HomeChild : Route("home/{num}", "Home child screen")
    data object SettingsRoot : Route("settings", "Settings root screen")
    data object SettingsChild : Route("settings/{num}", "Settings child screen")
}

Then define bottom tabs:

// Bottom tabs (note how each tab has a root route)
sealed class BottomTab(val title: String, val icon: ImageVector?, val rootRoute: Route) {
    data object Home : BottomTab("Home", Icons.Rounded.Home, Route.HomeRoot)
    data object Settings : BottomTab("Settings", Icons.Rounded.Settings, Route.SettingsRoot)
}

Now we need to establish the navigation hierarchy for screens:

// Navigation hierarchy (i.e. mapping routes to screens)
@Composable
fun MainScreenContent(navController: NavHostController) {
    val navigateToNextScreen: (String) -> Unit =  { destinationRoute ->
        val currentScreenNum = navController.currentBackStackEntry?.arguments?.getString("num") ?: "0"
        val nextScreenNum = currentScreenNum.toInt() + 1
        navController.navigate(destinationRoute.replace("{num}", "$nextScreenNum"))
    }
    NavHost(navController, startDestination = Route.HomeRoot.route) {
        composable(Route.HomeRoot.route) {
            SimpleScreen(
                title = Route.HomeRoot.title,
                onNavigateToNextScreenClicked = { navigateToNextScreen(Route.HomeChild.route) }
            )
        }
        composable(Route.HomeChild.route) { backStackEntry ->
            val screenNum = backStackEntry.arguments?.getString("num") ?: "0"
            SimpleScreen(
                title = "${Route.HomeChild.title} $screenNum",
                onNavigateToNextScreenClicked = { navigateToNextScreen(Route.HomeChild.route) }
            )
        }
        composable(Route.SettingsRoot.route) {
            SimpleScreen(
                title = Route.SettingsRoot.title,
                onNavigateToNextScreenClicked = { navigateToNextScreen(Route.SettingsChild.route) }
            )
        }
        composable(Route.SettingsChild.route) { backStackEntry ->
            val screenNum = backStackEntry.arguments?.getString("num") ?: "0"
            SimpleScreen(
                title = "${Route.SettingsChild.title} $screenNum",
                onNavigateToNextScreenClicked = { navigateToNextScreen(Route.SettingsChild.route) }
            )
        }
    }
}

Lastly, this is the implementation of the bottom bar and the tabs navigation logic:

// Bottom bar UI element and the associated tabs navigation logic
@Composable
fun MyBottomAppBar(
    navController: NavController,
) {
    val currentRoute = navController.currentBackStackEntryFlow.map { backStackEntry ->
        backStackEntry.destination.route
    }.collectAsState(initial = Route.HomeRoot.route)

    val items = listOf(
        BottomTab.Home,
        BottomTab.Settings
    )

    var selectedItem by remember { mutableIntStateOf(0) }

    items.forEachIndexed { index, navigationItem ->
        if (navigationItem.rootRoute.route == currentRoute.value) {
            selectedItem = index
        }
    }

    NavigationBar {
        items.forEachIndexed { index, item ->
            NavigationBarItem(
                alwaysShowLabel = true,
                icon = { Icon(item.icon!!, contentDescription = item.title) },
                label = { Text(item.title) },
                selected = selectedItem == index,
                onClick = {
                    selectedItem = index
                    navController.navigate(item.rootRoute.route) {
                        navController.graph.startDestinationRoute?.let { route ->
                            popUpTo(route) {
                                saveState = true
                            }
                        }
                        launchSingleTop = true
                        restoreState = true
                    }
                }
            )
        }
    }
}

The “magic” of this approach happens inside onClick lambda of NavigationBarItem:

  • When a bottom tab is clicked, pop the entire backstack, up to the start destination of the graph.
  • Before popping the backstack, save its current state.
  • After the backstack is popped, navigate to the desired route and restore its state.

This is basically a clever hack: we store the backstacks associated with the root routes of all the tabs, and automatically restore the respective backstack when the user navigates to a tab.

Limitations of the Standard Implementation

The above implementation works great when you switch tabs from the bottom bar. Unfortunately, I noticed that it breaks when you navigate back using either system’s gesture or a custom button in the top bar.

For example, imagine that you have tabs A and B, and screens A1, A2 shown in tab A, and B1 shown in tab B. If you navigate: A1 -> A2 -> B1 and then invoke back navigation, you’d expect to switch to tab A and see screen A2, but, instead, you’ll see A1. You can reproduce this scenario in my open-source application.

I haven’t found a simple solution for this problem. As far as I can tell, the fundamental root cause here is that you can’t specify restoreState = true for either gesture navigation or navController.popBackStack() calls. Therefore, after the system navigates back to the root route of the previous tab, the saved backstack isn’t restored.

Bottom Bar Navigation with Nested NavHost’s and Multiple Backstacks

The solution that I ended up with involves using multiple NavHost’s.

You can find a working implementation of this approach in the tutorial application for my Android Architecture Masterclass course here. Please note that this link will take you to one of the first commits in the course, before we refactor the app to a clean state, so the code is a bit dirty.

The first core part of this implementation is nesting of NavHost’s:

@Composable
private fun MainScreenContent(
    padding: PaddingValues,
    parentNavController: NavHostController,
    stackoverflowApi: StackoverflowApi,
    favoriteQuestionDao: FavoriteQuestionDao,
    currentNavController: MutableState<NavHostController>,
) {
    Surface(
        modifier = Modifier
            .padding(padding)
            .padding(horizontal = 12.dp),
    ) {
        NavHost(
            modifier = Modifier.fillMaxSize(),
            navController = parentNavController,
            enterTransition = { fadeIn(animationSpec = tween(200)) },
            exitTransition = { fadeOut(animationSpec = tween(200)) },
            startDestination = Route.MainTab.routeName,
        ) {
            composable(route = Route.MainTab.routeName) {
                val nestedNavController = rememberNavController()
                currentNavController.value = nestedNavController
                NavHost(navController = nestedNavController, startDestination = Route.QuestionsListScreen.routeName) {
                    composable(route = Route.QuestionsListScreen.routeName) {
                        QuestionsListScreen(
                            stackoverflowApi = stackoverflowApi,
                            onQuestionClicked = { clickedQuestionId, clickedQuestionTitle ->
                                nestedNavController.navigate(
                                    Route.QuestionDetailsScreen.routeName
                                        .replace("{questionId}", clickedQuestionId)
                                        .replace("{questionTitle}", clickedQuestionTitle)
                                )
                            },
                        )
                    }
                    composable(route = Route.QuestionDetailsScreen.routeName) { backStackEntry ->
                        QuestionDetailsScreen(
                            questionId = backStackEntry.arguments?.getString("questionId")!!,
                            stackoverflowApi = stackoverflowApi,
                            favoriteQuestionDao = favoriteQuestionDao,
                            onError = {
                                nestedNavController.popBackStack()
                            }
                        )
                    }
                }
            }

            composable(route = Route.FavoritesTab.routeName) {
                val nestedNavController = rememberNavController()
                currentNavController.value = nestedNavController
                NavHost(navController = nestedNavController, startDestination = Route.FavoriteQuestionsScreen.routeName) {
                    composable(route = Route.FavoriteQuestionsScreen.routeName) {
                        FavoriteQuestionsScreen(
                            favoriteQuestionDao = favoriteQuestionDao,
                            onQuestionClicked = { favoriteQuestionId, favoriteQuestionTitle ->
                                nestedNavController.navigate(
                                    Route.QuestionDetailsScreen.routeName
                                        .replace("{questionId}", favoriteQuestionId)
                                        .replace("{questionTitle}", favoriteQuestionTitle)
                                )
                            }
                        )
                    }
                    composable(route = Route.QuestionDetailsScreen.routeName) { backStackEntry ->
                        QuestionDetailsScreen(
                            questionId = backStackEntry.arguments?.getString("questionId")!!,
                            stackoverflowApi = stackoverflowApi,
                            favoriteQuestionDao = favoriteQuestionDao,
                            onError = {
                                nestedNavController.popBackStack()
                            }
                        )
                    }
                }
            }
        }
    }
}

Since we have nested NavHost’s, we have nested NavHostController’s as well. Therefore, the code makes an explicit distinction between “parent” and “current” (i.e. current nested) controllers:

val parentNavController = rememberNavController()

val currentNavController = remember {
    mutableStateOf(parentNavController)
}

Parent NavHostController, for example, is used when switching tabs:

BottomAppBar(modifier = Modifier) {
    MyBottomTabsBar(
        bottomTabs = bottomTabsToRootRoutes.keys.toList(),
        currentBottomTab = currentBottomTab,
        onTabClicked = { bottomTab ->
            parentNavController.navigate(bottomTabsToRootRoutes[bottomTab]!!.routeName) {
                parentNavController.graph.startDestinationRoute?.let { startRoute ->
                    popUpTo(startRoute) {
                        saveState = true
                    }
                }
                launchSingleTop = true
                restoreState = true
            }
        }
    )
}

Current nested NavHostController, for example, is used to navigate within the same tab and in the implementation of back navigation:

onBackClicked = {
    if (!currentNavController.value.popBackStack()) {
        parentNavController.popBackStack()
    }
}

This implementation with nested NavHost’s and NavHostController’s is more versatile than the previously described “standard” hack. I believe that you can implement any navigation pattern with it. Unfortunately, it’s also more complex. Said that, you need to deal with most of this complexity just once, when you set up the navigation logic initially. Adding new tabs and screens becomes relatively straightforward afterwards.

In my course about Android Architecture I demonstrate how you can encapsulate the navigation logic in a standalone ScreensNavigation class. This allows you to “hide” most of the complexity behind a single abstraction and expose simple navigation interface, like this:

// Switch tab
screensNavigator.toTab(bottomTab)
// Switch screen and pass arguments
screensNavigator.toRoute(Route.QuestionDetailsScreen(clickedQuestionId, clickedQuestionTitle))

If you need bottom bar navigation in your production Android app, I highly recommend going through this material.

Conclusion

Implementing proper bottom bar navigation pattern with Jetpack Compose and Compose Navigation frameworks is challenging. Hopefully, after reading this article and reviewing my open-sourced examples, you’ll have much easier time integrating it into your own applications.

As always, thank you for reading and please leave your comments and question below.

Check out my premium

Android Development Courses

Leave a Comment