100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Kotlin

Bottom Navigation and Tabs

Building persistent bottom navigation bars and swipeable tab layouts in Compose using NavigationBar, TabRow, and coordinated back stack state.

NavigationIntermediate9 min readJul 8, 2026
Analogies

Bottom Navigation and Tabs

Bottom navigation and tab layouts are two related but distinct patterns for letting users switch between top-level sections of an app. Bottom navigation, built with Material 3's NavigationBar and NavigationBarItem, is meant for 3-5 primary destinations that are always visible at the bottom of the screen and typically each have their own back stack. Tabs, built with TabRow and Tab (often paired with a swipeable HorizontalPager), are meant for switching between closely related views of the same content, like different filters on one screen, and usually share a single scroll or state context rather than separate back stacks.

🏏

Cricket analogy: Bottom navigation is like the scoreboard's fixed panel showing Batting, Bowling, and Fielding stats always visible, while tabs are like switching between 'Powerplay' and 'Death Overs' views of the same innings you're already watching.

Building a NavigationBar

A NavigationBar composable holds a row of NavigationBarItems, each with an icon, a label, and a selected boolean tied to the current destination. It's typically placed in a Scaffold's bottomBar slot so it persists across navigation within the Scaffold's content area, and each item's onClick triggers navigation to its associated route.

🏏

Cricket analogy: A NavigationBar with icons for Live, Scores, and News is like a team's dugout board listing player roles, where tapping a NavigationBarItem for 'Scores' is like a substitute walking onto the field for their assigned position.

kotlin
sealed class BottomDestination(val route: String, val label: String, val icon: ImageVector) {
    object Home : BottomDestination("home", "Home", Icons.Filled.Home)
    object Search : BottomDestination("search", "Search", Icons.Filled.Search)
    object Profile : BottomDestination("profile", "Profile", Icons.Filled.Person)
}

val bottomDestinations = listOf(
    BottomDestination.Home, BottomDestination.Search, BottomDestination.Profile
)

@Composable
fun MainScaffold(navController: NavHostController) {
    val backStackEntry by navController.currentBackStackEntryAsState()
    val currentRoute = backStackEntry?.destination?.route

    Scaffold(
        bottomBar = {
            NavigationBar {
                bottomDestinations.forEach { destination ->
                    NavigationBarItem(
                        selected = currentRoute == destination.route,
                        onClick = {
                            navController.navigate(destination.route) {
                                popUpTo(navController.graph.findStartDestination().id) {
                                    saveState = true
                                }
                                launchSingleTop = true
                                restoreState = true
                            }
                        },
                        icon = { Icon(destination.icon, contentDescription = destination.label) },
                        label = { Text(destination.label) }
                    )
                }
            }
        }
    ) { innerPadding ->
        NavHost(
            navController = navController,
            startDestination = BottomDestination.Home.route,
            modifier = Modifier.padding(innerPadding)
        ) {
            composable(BottomDestination.Home.route) { HomeScreen() }
            composable(BottomDestination.Search.route) { SearchScreen() }
            composable(BottomDestination.Profile.route) { ProfileScreen() }
        }
    }
}

Tracking the current route

navController.currentBackStackEntryAsState() exposes the current back stack entry as observable Compose State, so reading .destination?.route inside the composable automatically recomposes the NavigationBar's selection highlight whenever the active destination changes — no manual listener wiring required, unlike the older View-based addOnDestinationChangedListener approach.

🏏

Cricket analogy: navController.currentBackStackEntryAsState() is like a stadium's automatic scoreboard updating the 'on strike' batsman indicator the instant a run is taken, with no umpire needing to manually signal the change.

The popUpTo/saveState/launchSingleTop/restoreState combination shown above is the standard Google-recommended recipe for bottom navigation: it ensures repeated taps on the same tab don't stack duplicate copies, and switching between tabs preserves each tab's own scroll position and back stack rather than resetting it.

TabRow for content-level tabs

For switching between views of the same content — say, 'Popular' vs 'Recent' posts on one screen — TabRow with a selectedTabIndex and a set of Tab composables is more appropriate than a second NavigationBar. Pairing TabRow with HorizontalPager (from androidx.compose.foundation.pager) lets users both tap a tab and swipe between pages, keeping the selected index and the pager's current page synchronized via a shared PagerState.

🏏

Cricket analogy: TabRow with 'Popular' and 'Recent' posts is like switching between a match's 'Scorecard' and 'Commentary' tabs on the same live game page, both views of the identical match rather than separate matches.

Do not nest a NavigationBar inside content that scrolls or changes per tab in a way that creates a second NavController per tab unless you specifically need isolated back stacks — for simple content switching, plain Compose state (selectedTabIndex) with TabRow/HorizontalPager is simpler and avoids unnecessary navigation-graph complexity.

  • NavigationBar + NavigationBarItem implement Material 3 bottom navigation for 3-5 top-level, independently-back-stacked destinations.
  • currentBackStackEntryAsState() gives reactive access to the current route for driving selection highlighting.
  • The popUpTo(startDestinationId) { saveState = true } + launchSingleTop + restoreState combination is the standard recipe to avoid duplicate stacking and preserve per-tab state.
  • TabRow + Tab (optionally paired with HorizontalPager) suit switching between views of the same content rather than fully independent destinations.
  • PagerState synchronizes a TabRow's selected index with a HorizontalPager's current page.
  • Prefer plain Compose state over a full navigation graph for simple, non-back-stacked tab content switching.

Practice what you learned

Was this page helpful?

Topics covered

#Kotlin#AndroidWithJetpackComposeStudyNotes#MobileDevelopment#BottomNavigationAndTabs#Bottom#Navigation#Tabs#Building#StudyNotes#SkillVeris