Bottom Navigation
RecipeBox needs more than a linear list-to-detail flow — it needs a persistent bottom bar so the
user can jump straight to Recipes, Search, or their Profile from anywhere in the app. Material 3
provides two composables purpose-built for this: NavigationBar, the bar itself, and
NavigationBarItem, each individual tab inside it. Paired with the
NavController from the last two lessons, they give you a fully working bottom nav in
a few dozen lines.
NavigationBar and NavigationBarItem
NavigationBar is a simple container — you place one NavigationBarItem
inside it per tab. Each item takes an icon, a label, whether it's currently selected,
and an onClick:
NavigationBar {
NavigationBarItem(
selected = true,
onClick = { /* navigate to Recipes */ },
icon = { Icon(Icons.Default.MenuBook, contentDescription = "Recipes") },
label = { Text("Recipes") }
)
NavigationBarItem(
selected = false,
onClick = { /* navigate to Search */ },
icon = { Icon(Icons.Default.Search, contentDescription = "Search") },
label = { Text("Search") }
)
}
On its own that's just static UI — selected is hardcoded and onClick does
nothing yet. The real work is wiring both of those up to the actual navigation state.
Keeping the Selected Tab in Sync With the Back Stack
It's tempting to track "which tab is selected" as its own separate piece of state, but that's a
trap — it can drift out of sync with what's actually on screen (for example, if the user navigates
some other way, or the process is restored). The reliable source of truth is the
NavController's own back stack. You observe the current destination with
currentBackStackEntryAsState(), and compare its route against each tab's route:
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
currentBackStackEntryAsState() returns Compose State, so any composable
reading currentRoute automatically recomposes whenever the current destination
changes — including when the user taps a tab, navigates from within a screen, or presses back.
Worked Example: RecipeBox's 3-Tab Bottom Nav
A complete bottom nav wiring three top-level destinations — Recipes, Search, and Profile — with the selected tab always matching the current route:
private data class BottomNavItem(val route: String, val label: String, val icon: ImageVector)
private val bottomNavItems = listOf(
BottomNavItem("recipes", "Recipes", Icons.Default.MenuBook),
BottomNavItem("search", "Search", Icons.Default.Search),
BottomNavItem("profile", "Profile", Icons.Default.Person)
)
@Composable
fun RecipeBoxScaffold(navController: NavHostController) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
Scaffold(
bottomBar = {
NavigationBar {
bottomNavItems.forEach { item ->
NavigationBarItem(
selected = currentRoute == item.route,
onClick = {
navController.navigate(item.route) {
launchSingleTop = true
}
},
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) }
)
}
}
}
) { innerPadding ->
NavHost(
navController = navController,
startDestination = "recipes",
modifier = Modifier.padding(innerPadding)
) {
composable("recipes") { RecipeListScreen(recipes = recipes, onRecipeClick = { }) }
composable("search") { SearchScreen() }
composable("profile") { ProfileScreen() }
}
}
}
launchSingleTop = true is the detail that keeps this well-behaved: without it,
tapping the "Recipes" tab while already on Recipes would push a second, identical copy of that
destination onto the back stack every time. With it, re-selecting the current tab is a no-op
instead of stacking up duplicate destinations.
NavHost.