Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 092 / 100

Bottom navigation in Compose in Modern Android & Kotlin

Top-level destinations.

Expona Admin7 min read

Bottom navigation in Compose in Modern Android & Kotlin

Bottom navigation is one of the most common UI patterns in mobile apps—it lets users quickly jump between top-level destinations without losing context. In Jetpack Compose, implementing bottom navigation is straightforward, but getting state management, selection highlighting, and tablet support right requires careful consideration. This guide walks through building a production-grade bottom navigation system that scales from phones to tablets.

Compose provides the NavigationBar composable, which replaces the older BottomNavigationView. It's lightweight, composable-friendly, and integrates seamlessly with your navigation graph. The pattern involves defining your top-level destinations, creating navigation items, and wiring them to your navigation controller.

Here's a practical setup. First, define your destinations as sealed classes or objects:

kotlin
sealed class TopLevelDestination(
    val route: String,
    val label: String,
    val icon: ImageVector
) {
    object Home : TopLevelDestination(
        route = "home",
        label = "Home",
        icon = Icons.Default.Home
    )

    object Orders : TopLevelDestination(
        route = "orders",
        label = "Orders",
        icon = Icons.Default.ShoppingCart
    )

    object Account : TopLevelDestination(
        route = "account",
        label = "Account",
        icon = Icons.Default.AccountCircle
    )
}

val topLevelDestinations = listOf(
    TopLevelDestination.Home,
    TopLevelDestination.Orders,
    TopLevelDestination.Account
)

Now create your navigation bar composable. This should be a pure composable that receives the current route and a callback for navigation:

kotlin
@Composable
fun AppNavigationBar(
    currentRoute: String?,
    onNavigate: (String) -> Unit,
    modifier: Modifier = Modifier
) {
    NavigationBar(modifier = modifier) {
        topLevelDestinations.forEach { destination ->
            NavigationBarItem(
                selected = currentRoute == destination.route,
                onClick = { onNavigate(destination.route) },
                icon = { Icon(destination.icon, contentDescription = destination.label) },
                label = { Text(destination.label) }
            )
        }
    }
}

In your main screen scaffold, place the navigation bar at the bottom:

kotlin
@Composable
fun AppScaffold(navController: NavHostController) {
    val currentBackStackEntry by navController.currentBackStackEntryAsState()
    val currentRoute = currentBackStackEntry?.destination?.route

    Scaffold(
        bottomBar = {
            AppNavigationBar(
                currentRoute = currentRoute,
                onNavigate = { route ->
                    navController.navigate(route) {
                        popUpTo(navController.graph.findStartDestination().id) {
                            saveState = true
                        }
                        lazyRestoreState = true
                        restoreState = true
                    }
                }
            )
        }
    ) { contentPadding ->
        NavHost(
            navController = navController,
            startDestination = TopLevelDestination.Home.route,
            modifier = Modifier.padding(contentPadding)
        ) {
            composable(TopLevelDestination.Home.route) { HomeScreen() }
            composable(TopLevelDestination.Orders.route) { OrdersScreen() }
            composable(TopLevelDestination.Account.route) { AccountScreen() }
        }
    }
}

Notice the navigation flags: popUpTo ensures the back stack stays clean, while saveState and lazyRestoreState preserve scroll positions and form inputs across tab switches.

Save and restore state between tabs

One of the trickiest parts of bottom navigation is maintaining state when users switch between tabs. Without proper configuration, scrolled lists jump back to the top, and form inputs get cleared. Compose and the Navigation library handle this with the flags we used above, but there's more you can do.

When you set saveState = true, the navigation library keeps the back stack state for that destination in memory. When you return to it later with lazyRestoreState = true, the UI rebuilds with the preserved state. However, this only works if your screen composables remember their own state using rememberSaveable.

kotlin
@Composable
fun HomeScreen() {
    val items by rememberSaveable { mutableStateOf(loadHomeItems()) }
    val scrollState = rememberLazyListState()

    LazyColumn(state = scrollState) {
        items(items.size) { index ->
            ItemCard(items[index])
        }
    }
}

For more complex scenarios—like preserving view model state across tab switches—use a scoped view model at the graph level rather than the screen level:

kotlin
@Composable
fun AppNavHost(navController: NavHostController) {
    val sharedViewModel: SharedViewModel = hiltViewModel()

    NavHost(navController = navController, startDestination = "home") {
        composable("home") {
            HomeScreen(viewModel = sharedViewModel)
        }
        composable("orders") {
            OrdersScreen(viewModel = sharedViewModel)
        }
    }
}

The view model stays alive as long as the nav graph is alive, so data persists across tab switches without manual state management.

Highlight the current destination

Your navigation bar should visually indicate which tab is active. The NavigationBarItem composable handles this with the selected parameter, but you need to track the current route correctly.

The safest approach is to track the top-level route, not nested routes. If you have a navigation graph like home/details/123, you still want the Home tab highlighted, not a non-existent "details" tab:

kotlin
@Composable
fun AppNavigationBar(
    currentRoute: String?,
    onNavigate: (String) -> Unit,
    modifier: Modifier = Modifier
) {
    NavigationBar(modifier = modifier) {
        topLevelDestinations.forEach { destination ->
            NavigationBarItem(
                selected = currentRoute?.startsWith(destination.route) == true,
                onClick = { onNavigate(destination.route) },
                icon = { Icon(destination.icon, contentDescription = destination.label) },
                label = { Text(destination.label) },
                alwaysShowLabel = true
            )
        }
    }
}

Use startsWith to match the top-level route even if nested routes exist. Alternatively, extract just the first segment:

kotlin
fun String?.getTopLevelDestination(): String? {
    return this?.split("/")?.firstOrNull()
}

Then compare:

kotlin
selected = currentRoute.getTopLevelDestination() == destination.route

This ensures highlighting works correctly even with deep links or nested navigation.

Use NavigationRail on tablets

Phones and tablets have different affordances. On tablets with landscape orientation or larger screens, a bottom navigation bar wastes space. Jetpack Compose provides NavigationRail for vertical navigation on the side of the screen.

Detect screen size and choose the appropriate navigation component:

kotlin
enum class WindowSizeClass {
    Compact, Medium, Expanded
}

@Composable
fun calculateWindowSizeClass(): WindowSizeClass {
    val configuration = LocalConfiguration.current
    return when {
        configuration.screenWidthDp < 600 -> WindowSizeClass.Compact
        configuration.screenWidthDp < 840 -> WindowSizeClass.Medium
        else -> WindowSizeClass.Expanded
    }
}

Use this to switch between NavigationBar and NavigationRail:

kotlin
@Composable
fun AppScaffold(navController: NavHostController) {
    val currentBackStackEntry by navController.currentBackStackEntryAsState()
    val currentRoute = currentBackStackEntry?.destination?.route
    val windowSizeClass = calculateWindowSizeClass()

    val navigationContent: @Composable () -> Unit = {
        when (windowSizeClass) {
            WindowSizeClass.Compact -> {
                AppNavigationBar(
                    currentRoute = currentRoute,
                    onNavigate = { route -> navigateToTopLevel(navController, route) }
                )
            }
            else -> {
                AppNavigationRail(
                    currentRoute = currentRoute,
                    onNavigate = { route -> navigateToTopLevel(navController, route) }
                )
            }
        }
    }

    Scaffold(
        bottomBar = if (windowSizeClass == WindowSizeClass.Compact) navigationContent else ({})
    ) { contentPadding ->
        Row {
            if (windowSizeClass != WindowSizeClass.Compact) {
                navigationContent()
            }
            NavHost(navController = navController, startDestination = "home")
        }
    }
}

Create the NavigationRail version:

kotlin
@Composable
fun AppNavigationRail(
    currentRoute: String?,
    onNavigate: (String) -> Unit,
    modifier: Modifier = Modifier
) {
    NavigationRail(modifier = modifier) {
        topLevelDestinations.forEach { destination ->
            NavigationRailItem(
                selected = currentRoute?.startsWith(destination.route) == true,
                onClick = { onNavigate(destination.route) },
                icon = { Icon(destination.icon, contentDescription = destination.label) },
                label = { Text(destination.label) }
            )
        }
    }
}

This gives you adaptive navigation that respects the device's form factor and orientation.

Common mistakes

  • Forgetting navigation flags: Omitting saveState = true and lazyRestoreState = true causes state loss and jarring UX when switching tabs.
  • Comparing full routes instead of top-level routes: If your route is home/details/42 but you compare against home using exact equality, nothing highlights. Use startsWith instead.
  • Creating new instances of view models per screen: Always scope shared state to the navigation graph, not individual screens, or you'll lose data on tab switches.
  • Ignoring tablet layouts: Deploying an app with bottom navigation on a 10-inch tablet looks unprofessional. Always provide adaptive navigation using window size classes.
  • Not using rememberSaveable: Local screen state (like scroll position) won't survive process death unless you explicitly mark it with rememberSaveable.

Key takeaways

  • Build bottom navigation with NavigationBar and NavigationBarItem, connecting items to your destination routes via a navigation controller callback.
  • Preserve state across tab switches by setting saveState = true, lazyRestoreState = true, and using rememberSaveable in your screens.
  • Highlight the current destination by matching the top-level portion of the route using startsWith rather than exact equality.
  • Adapt your navigation to device form factor: use NavigationBar on phones and NavigationRail on tablets by detecting window size class.
  • Scope shared view models to the navigation graph level, not individual screens, to preserve data across tab navigation.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles