Assume we are scrolling some shopping list and we open a specific item. While checking details we realize that we probably have something similar in our cart already, so we would like to change tab from listing to cart and check that out. For that, we would like bottom nav to save our progress, so when we come back to a listing tab, we are not starting from the beginning. In basic Android Compose Navigation we don't have that functionality. Let's create our own solution!

Section 1 - Basic bottom navigation

As the very base of our app we will have 2 bottom nav tabs: Home and Settings. Each of them will have 2 screens: Dashboard and Inner. We need at least two of each to properly test our scenario.

@Composable
fun HomeDashboardScreen(goToHomeInnerScreen: () -> Unit) {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center,
        modifier = Modifier.fillMaxSize()
    ) {
        Text(text = "Hello from home screen - dashboard!")
        Button(onClick = goToHomeInnerScreen) {
            Text(text = "Go to home inner screen")
        }
    }
}
@Composable
fun HomeInnerScreen() {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center,
        modifier = Modifier.fillMaxSize()
    ) {
        Text(text = "This is inner home screen!")
    }
}

Besides that, we need 2 screens alike but with just a different name Home -> Settings.

Now we need Destination tree for our routing, so we will know where we can navigate to. It's a nice practice to create separate class for it so we have everything structured.

sealed class Destination(
    val route: String,
    @StringRes val labelId: Int,
    @DrawableRes val icon: Int,
) {
    sealed class Home(route: String) : Destination(
        "${Home.route}/$route",
        R.string.bottom_navigation_home,
        R.drawable.ic_home
    ) {
        data object Dashboard : Home("dashboard")
        data object Inner : Home("inner")

        companion object {
            const val route = "home"
        }
    }

    sealed class Settings(route: String) : Destination(
        "${Settings.route}/$route",
        R.string.bottom_navigation_settings,
        R.drawable.ic_settings
    ) {
        data object Dashboard : Settings("dashboard")
        data object Inner : Settings("inner")

        companion object {
            const val route = "settings"
        }
    }
}

Next, we have to create NavigationBar, so we can inject it into our main application Scaffold that will contain our app's NavHost.

@Composable
fun BottomBar( destinations: List<Destination>, selectedPage: MutableIntState, navController: NavController, ) {
   NavigationBar {
       destinations.forEachIndexed { index, item ->
           val isSelected = index == selectedPage.intValue
           NavigationBarItem(
               selected = isSelected,
               onClick = {
                   selectedPage.intValue = index
                   navController.navigate(item.route) {
                       popUpTo(navController.graph.findStartDestination().id)
                   }
               },
               icon = {
                   Icon(
                       painter = painterResource(id = item.icon),
                       contentDescription = stringResource(id = item.labelId)
                   )
               },
               label = {
                   Text(text = stringResource(id = item.labelId))
               }
           )
       }
   }
}
@Composable
fun Navigation() {
    val navController = rememberNavController()
    val selectedPage = remember { mutableIntStateOf(0) }

    Scaffold(
        bottomBar = {
            BottomBar(
                destinations = listOf(Destination.Home.Dashboard, Destination.Settings.Dashboard),
                selectedPage = selectedPage,
                navController = navController,
            )
        }
    ) {
        NavHost(navController = navController, startDestination = Destination.Home.route) {
            homeNavGraph(navController = navController)
            settingsNavGraph(navController = navController)
        }
    }
}

Last, but not least, we create NavGraphs for both home and settings screens. Here as well I will provide NavGraph for only Home destination to not create unnecessary snippets and keep article cleaner.

fun NavGraphBuilder.homeNavGraph(navController: NavController) {
    navigation(
        route = Destination.Home.route,
        startDestination = Destination.Home.Dashboard.route
    ) {
        composable(route = Destination.Home.Dashboard.route) {
            HomeDashboardScreen(
                goToHomeInnerScreen = {
                    navController.navigate(Destination.Home.Inner.route)
                },
            )
        }

        composable(route = Destination.Home.Inner.route) {
            HomeInnerScreen()
        }
    }
}

Now all we need to do is to invoke our Navigation function in the MainActivity.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            SavingBottomNavTheme {
                Navigation()
            }
        }
    }
}

Preview of default bottom bar navigation behavior

Section 2 - Upgraded BottomBar

We have our base navigation, now we can start upgrading it.

This part of our bottom bar upgrade requires a little bit explanation. We are gonna have multiple NavHostController instances. One for every bottom bar tab. For that, we need some way of differentiating them. The easiest way is just to use simple enum, but befor we create it, we should think of a way of using those controllers in a NavHost, right? Yes, and no. Because 1 NavHost = 1 NavHostController, so we cannot he multiple controllers in there.

If we cannot use more than one controller in one host, we have to create multiple hosts then. Learn how to do it!

Screenshot showing Multiple NavHosts with NavHostControllers.
Multiple NavHosts with NavHostControllers

That one is actually pretty simple. We can achieve that using HorizontalPager. Pager will provide us lazy list behvaior, so we will be able to create nav host controller for each of our bottom destination. So, for some graphic visualization, let's change our Navigation component a bit.

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun Navigation() {
    val bottomBar = rememberBottomBar()

    Scaffold(bottomBar = { bottomBar.createNavComponent() }) {
        HorizontalPager(
            state = bottomBar.pagerState,
            userScrollEnabled = false
        ) { page ->
            val bottomDestination = BottomDestination.getBy(page)
            val navController =
                bottomBar.getNavController(bottomDestination) ?: return@HorizontalPager
            NavHost(
                navController = navController,
                startDestination = Destination.getStartDestinationBy(page)
            ) {
                homeNavGraph(navController = navController)
                settingsNavGraph(navController = navController)
            }
        }
    }
}

For now, it will not compile and IDE will show you few errors, but don't worry, we are gonna resolve them step by step. At first, we need previously mentioned BottomDestination.

enum class BottomDestination(@DrawableRes val icon: Int, @StringRes val nameResId: Int) {
    HOME_FEATURE(R.drawable.ic_home, R.string.bottom_navigation_home),
    SETTINGS_FEATURE(R.drawable.ic_settings, R.string.bottom_navigation_settings);

    companion object {
        fun getBy(position: Int) =
            values().firstOrNull { it.ordinal == position } ?: HOME_FEATURE
    }
}

With that, we can not create our new BottomBar class. It's main purpose will be to handle switching between bottom navigation and every other navigation functionality that will come to our mind. But what will such class need to work? For sure PagerState to switch pages and probably NavHostControllers, so we are gonna have specific instance at hock in one place, but with some way of differentiating it by our bottom destinations so they are not complete strangers to us.

@OptIn(ExperimentalFoundationApi::class)
@Stable
class BottomBar(
    val pagerState: PagerState,
    val navData: Map<BottomDestination, NavHostController>,
)

Nice! We are doing great progress here! But it does not do anything at all...let's change that! What core purpose we would like that class to serve? What do you think about something like that:

Switching pages:

private suspend fun switchPage(page: Int) {
    pagerState.scrollToPage(page)
}

Returning nav controller based on bottom destination:

fun getNavController(key: BottomDestination) = navData[key]
    ?: navData.values.firstOrNull()
    ?: kotlin.run {
        // Any handling method like i.g. FirebaseCrashlytics
        null
    }

Creating bottom nav bar component that will be displayed on the screen:

@SuppressLint("ComposableNaming")
@Composable
fun createNavComponent() {
    val coroutineScope = rememberCoroutineScope()
    BottomNavigation(
        isActive = isBottomBarVisible.value,
        selectedPage = pagerState.currentPage,
        onClick = {
            coroutineScope.launch {
                switchPage(it)
            }
        },
        onCurrentSelectionClick = ::navigateToRoot
    )
}

I think that's pretty decent functionalities. Now lets try and put them together.

@OptIn(ExperimentalFoundationApi::class)
@Stable
class BottomBar(
    val pagerState: PagerState,
    val navData: Map<BottomDestination, NavHostController>,
) {
    @SuppressLint("ComposableNaming")
    @Composable
    fun createNavComponent() {
        val coroutineScope = rememberCoroutineScope()
        BottomNavigation(
            selectedPage = pagerState.currentPage,
            onClick = {
                coroutineScope.launch {
                    switchPage(it)
                }
            },
        )
    }

    fun getNavController(key: BottomDestination) = navData[key]
        ?: navData.values.firstOrNull()
        ?: kotlin.run {
            // Any handling method like i.g. FirebaseCrashlytics
            null
        }

    private suspend fun switchPage(page: Int) {
        pagerState.scrollToPage(page)
    }
}

If you want to now more about @Stable annotation used for our new BottomBar class check official android documentation.

In step 3 we introduced new component, BottomNavigation. This is gonna be our new BottomBar from previous section.

@Composable
private fun BottomNavigation(selectedPage: Int, onClick: (Int) -> Unit) {
    NavigationBar {
        BottomDestination.values().forEachIndexed { index, item ->
            val isSelected = index == selectedPage
            NavigationBarItem(
                selected = isSelected,
                onClick = { onClick(index) },
                icon = {
                    Icon(
                        painter = painterResource(id = item.icon),
                        contentDescription = stringResource(id = item.labelId)
                    )
                },
                label = {
                    Text(text = stringResource(id = item.labelId))
                }
            )
        }
    }
}

As you can see instead of navController, we have onClick as we are gonna change our bottom tab based on BottomDestinations index.

Now that we have all components that we were missing in our class, we can think of how should we create our bottom bar. Sure, we could simply create it just like that val bottomBar = BottomBar(...), but would that really by sufficient? Yea, not so much. Besides, it will be much cleaner to have constructor like method. So, let's quote our bottom bar so we will have clean look at it.

class BottomBar(val pagerState: PagerState, val navData: Map<BottomDestination, NavHostController>)

Last but not least, summarize it. We need pager state, that will be easy, we just need to use rememberPagerState(). What about our map? That's simple as well, we just gonna loop for our BottomDestination values and for each use rememberNavController().

@OptIn(ExperimentalFoundationApi::class)
@SuppressLint("ComposableNaming")
@Composable
fun rememberBottomBar(): BottomBar {
    val navData = buildMap {
        BottomDestination.values().forEach {
            put(it, rememberNavController())
        }
    }

    val pagerState = rememberPagerState { navData.size }

    return remember {
        BottomBar(
            pagerState = pagerState,
            navData = navData,
        )
    }
}

There we go! As as addition I've added returning our BottomBar instance as remember, so it will not change in any recomposition that is gonna occur.

Please note: rememberPagerState is experimental API and can change in the future.

At this point, we have everything created, we already used it in our Navigation, so let's run app! But wait, there is still some error...ouh! We forgot about an important thing, obtaining route for each NavHost as startDestination. This is gonna be similar to getting BottomDestination by page, just add this code before last } in Destination class.

companion object {
    fun getStartDestinationBy(position: Int): String =
        when (BottomDestination.getBy(position)) {
            BottomDestination.HOME_FEATURE -> Home.route
            BottomDestination.SETTINGS_FEATURE -> Settings.route
        }
}

Upgraded bottom bar navigation behavior with switching tabs

Section 3 - Additional functionalities

This section will contain 2 additional functionalities.

1. Going back to tab's initial view

Functionality that I personally like the most is going back to root on currently chosen tab click. Right now, clicking on selected tab does not change anything. But with just a little bit of effort, we can have it. Lets see:

private fun navigateToRoot(page: Int) {
    val bottomDestination = BottomDestination.getBy(page)
    getNavController(bottomDestination)?.run {
        popBackStack(
            destinationId = graph.findStartDestination().id,
            inclusive = false
        )
    }
}

First, add this method to the BottomBar class, so we can use it anywhere we like. Then modify BottomNavigation a bit.

@Composable
private fun BottomNavigation( selectedPage: Int, onClick: (Int) -> Unit, onCurrentSelectionClick: (Int) -> Unit, ) {
    NavigationBar {
        BottomDestination.values().forEachIndexed { index, item ->
            val isSelected = index == selectedPage
            NavigationBarItem(
                selected = isSelected,
                onClick = {
                    if (index == selectedPage) onCurrentSelectionClick(index)
                    else onClick(index)
                },
                icon = {
                    Icon(
                        painter = painterResource(id = item.icon),
                        contentDescription = stringResource(id = item.labelId)
                    )
                },
                label = {
                    Text(text = stringResource(id = item.labelId))
                }
            )
        }
    }
}

The only thing that have changed was adding onCurrentSelectionClick as an argument and it's usage in onClick of NavigationBarItem.

@SuppressLint("ComposableNaming")
@Composable
fun createNavComponent() {
    val coroutineScope = rememberCoroutineScope()
    BottomNavigation(
        selectedPage = pagerState.currentPage,
        onClick = {
            coroutineScope.launch {
                switchPage(it)
            }
        },
        onCurrentSelectionClick = ::navigateToRoot
    )
}

Lastly, we have to add proper method while creating nav component and voila! We can happily use our new functionality!

Changing tab to the root one in upgraded bottom bar

2. Changing bottom bar visibility

It'd be pretty cool to be able to change bottom navigation's visibiity, right? For that, we will need support variable. You can put it right at the beginning of out BottomBar class.

@OptIn(ExperimentalFoundationApi::class)
@Stable
class BottomBar(
    val pagerState: PagerState,
    val navData: Map<BottomDestination, NavHostController>,
) {
    private val isBottomBarVisible: MutableState<Boolean> = mutableStateOf(true)

Then, we're gonna need some function that will handle change of visibility and we will have to make BottomNavigation aware of new variable. So, as follows:

fun changeBottomBarVisibility(visibility: Boolean) {
    isBottomBarVisible.value = visibility
}
@Composable
private fun BottomNavigation( isActive: Boolean, selectedPage: Int, onClick: (Int) -> Unit, onCurrentSelectionClick: (Int) -> Unit, ) {
    if (isActive)
        NavigationBar {
            BottomDestination.values().forEachIndexed { index, item ->
                val isSelected = index == selectedPage
                NavigationBarItem(
                    selected = isSelected,
                    onClick = {
                        if (index == selectedPage) onCurrentSelectionClick(index)
                        else onClick(index)
                    },
                    icon = {
                        Icon(
                            painter = painterResource(id = item.icon),
                            contentDescription = stringResource(id = item.labelId)
                        )
                    },
                    label = {
                        Text(text = stringResource(id = item.labelId))
                    }
                )
            }
        }
}

Of course we have to add isActive = isBottomBarVisible.value to the creation of BottomNavigation instance in createNavComponent function.

Last to add is invokation of this function. Let's say, we do not want to see our bottom navigation in settings inner screen. For that, we have to find our composable for it and simply invoke our new function.

fun NavGraphBuilder.settingsNavGraph( changeBottomBarVisibility: (Boolean) -> Unit, navController: NavController, ) {
    navigation(
        route = Destination.Settings.route,
        startDestination = Destination.Settings.Dashboard.route
    ) {
        composable(route = Destination.Settings.Dashboard.route) {
            changeBottomBarVisibility(true)
            SettingsDashboardScreen(
                goToSettingsInnerScreen = {
                    navController.navigate(Destination.Settings.Inner.route)
                },
            )
        }

        composable(route = Destination.Settings.Inner.route) {
            changeBottomBarVisibility(false)
            SettingsInnerScreen()
        }
    }
}

Changing bottom bar's visibility in upgraded bottom bar

Summary

And thus we created saveable bottom bar navigation that will serve as well in our Compose world. What's even better about it (I didn't try it out, just assuming, but that's next thing to check!), is that this solution probably works with Compose Multiplatform as well! Probably with few changes for specific platform, but still, it should work.

Versions used in this article:

  • kotlin - 1.9.10
  • ComposeBom - 2023.10.01
  • androidx.navigation:navigation-compose:2.7.5
  • androidx.hilt:hilt-navigation-compose:1.1.0
  • androidx.core:core-ktx:1.12.0
  • androidx.lifecycle:lifecycle-runtime-ktx:2.6.2
  • androidx.lifecycle:lifecycle-runtime-compose:2.6.2
  • androidx.activity:activity-compose:1.8.0