Scaffold, Top App Bar and Bottom Navigation in Jetpack Compose
Give your app a real app look. Add a TopAppBar, bottom tabs for Fruits and Favorites, and a floating button using Jetpack Compose Scaffold.
Our fruit app works, but it doesn't look like a "real" app yet. Real apps have a title bar at the top and tabs at the bottom. In this lesson we build a Jetpack Compose Scaffold with bottom navigation: a TopAppBar with a back arrow, a NavigationBar with two tabs (Fruits and Favorites), and a floating button. We build on the ViewModel from Part 9: ViewModel for Beginners, so finish that part first.
What Is a Scaffold?
Think of a house plan. Every house has a roof on top, a floor at the bottom, and rooms in the middle. A Scaffold is that plan for a screen:
topBar: the roof. Usually aTopAppBarwith the screen title.bottomBar: the floor. Usually aNavigationBarwith tabs.floatingActionButton: a round button that "floats" in the corner.- The content: the rooms in the middle. This is where our screens go.
You have actually used Scaffold since your very first app. The Empty Activity template put it in MainActivity the whole time! Today we fill its empty slots.
The best part: Scaffold measures the bars and gives you innerPadding, so your content never hides behind the top bar or bottom tabs.
Step 1: Simplify MainActivity
FruitApp will now have its own Scaffold. So we remove the old one from MainActivity. Two Scaffolds inside each other give you double padding. Update setContent:
setContent {
MyFirstAppTheme {
FruitApp()
}
}
Let's understand it:
enableEdgeToEdge()stays where it is. Our newScaffoldwill handle the status bar and the gesture bar space for us.FruitApp()no longer needs amodifierfrom outside.
Step 2: Add a Shuffle Function to the ViewModel
Our floating button will shuffle the fruit list. Open FruitViewModel.kt and add this function inside FruitViewModel, below toggleFavorite:
fun shuffleFruits() {
_uiState.update { current ->
current.copy(fruits = current.fruits.shuffled())
}
}
Let's understand it:
shuffled()is a Kotlin function that returns the same list in a random order. Like mixing cards.- We follow the same rule as Part 9: only the ViewModel changes data.
Step 3: Create the Favorites Screen
Create a new composable for the second tab. It shows only the fruits the user loved:
@Composable
fun FavoritesScreen(
favoriteFruits: List<Fruit>,
onFavoriteClick: (String) -> Unit,
onFruitClick: (String) -> Unit
) {
if (favoriteFruits.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("No favorites yet. Tap ๐ค on a fruit!")
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(favoriteFruits, key = { it.name }) { fruit ->
FruitRow(
fruit = fruit,
isFavorite = true,
onClick = { onFruitClick(fruit.name) },
onFavoriteClick = { onFavoriteClick(fruit.name) }
)
}
}
}
}
Let's understand it:
- If the list is empty, we show a friendly message in the center. An empty white screen confuses users.
- Otherwise, we reuse our
FruitRowfrom Part 9. Write once, use twice! isFavorite = truebecause every fruit here is a favorite. Tapping the โค๏ธ removes it from the list.
Step 4: Simplify FruitDetailsScreen
The back arrow will now live in the top bar, so the details screen doesn't need its own back button. Replace FruitDetailsScreen with:
@Composable
fun FruitDetailsScreen(fruitName: String) {
val fruit = fruits.find { it.name == fruitName }
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
if (fruit != null) {
Text(text = fruit.emoji, fontSize = 96.sp)
Text(text = fruit.name, style = MaterialTheme.typography.headlineMedium)
Text(text = "เงณ${fruit.price} per kg", style = MaterialTheme.typography.titleMedium)
} else {
Text("Fruit not found")
}
}
}
Let's understand it:
fruits.find { ... }looks in our full list for the fruit with this name. If nothing matches, it givesnull.- The
onBackparameter is gone. The top bar will handle going back.
Step 5: Build the New FruitApp
This is the big one. Replace your whole FruitApp with this:
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FruitApp(fruitViewModel: FruitViewModel = viewModel()) {
val navController = rememberNavController()
val uiState by fruitViewModel.uiState.collectAsStateWithLifecycle()
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
val isDetails = currentRoute == "details/{fruitName}"
val goToTab: (String) -> Unit = { route ->
navController.navigate(route) {
popUpTo("home")
launchSingleTop = true
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
val title = when (currentRoute) {
"favorites" -> "My Favorites"
"details/{fruitName}" ->
backStackEntry?.arguments?.getString("fruitName") ?: "Details"
else -> "Fruit Shop"
}
Text(title)
},
navigationIcon = {
if (isDetails) {
IconButton(onClick = { navController.popBackStack() }) {
Text(text = "โ", fontSize = 24.sp)
}
}
}
)
},
bottomBar = {
if (!isDetails) {
NavigationBar {
NavigationBarItem(
selected = currentRoute == "home",
onClick = { goToTab("home") },
icon = { Text("๐") },
label = { Text("Fruits") }
)
NavigationBarItem(
selected = currentRoute == "favorites",
onClick = { goToTab("favorites") },
icon = { Text("โค๏ธ") },
label = { Text("Favorites") }
)
}
}
},
floatingActionButton = {
if (currentRoute == "home") {
FloatingActionButton(onClick = { fruitViewModel.shuffleFruits() }) {
Text(text = "๐", fontSize = 24.sp)
}
}
}
) { innerPadding ->
NavHost(
navController = navController,
startDestination = "home",
modifier = Modifier.padding(innerPadding)
) {
composable("home") {
FruitListScreen(
uiState = uiState,
onQueryChange = { fruitViewModel.onQueryChange(it) },
onFavoriteClick = { fruitViewModel.toggleFavorite(it) },
onFruitClick = { name -> navController.navigate("details/$name") }
)
}
composable("favorites") {
val favoriteFruits = fruits.filter { it.name in uiState.favorites }
FavoritesScreen(
favoriteFruits = favoriteFruits,
onFavoriteClick = { fruitViewModel.toggleFavorite(it) },
onFruitClick = { name -> navController.navigate("details/$name") }
)
}
composable("details/{fruitName}") { entry ->
val fruitName = entry.arguments?.getString("fruitName") ?: ""
FruitDetailsScreen(fruitName = fruitName)
}
}
}
}
Press Alt+Enter on each red word. The tricky imports are:
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.FloatingActionButton
import androidx.compose.runtime.getValue
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
Let's understand it, piece by piece:
@OptIn(ExperimentalMaterial3Api::class): for a long time,TopAppBarwas marked "experimental", and Android Studio showed an error without this line. Newer Material 3 versions made the basicTopAppBarstable. Keeping this line is harmless, and it saves you from an error if your project uses an older version.currentBackStackEntryAsState()tells us which screen is showing right now. When you move to a new screen, it updates and the bars redraw.currentRouteis the route name, like"home","favorites"or"details/{fruitName}". Notice the details route is the pattern with curly braces, not the real fruit name.isDetailsistrueon the details screen. There we show the back arrow and hide the bottom tabs.goToTabmoves between tabs.popUpTo("home")removes extra screens above Home, so tabs don't pile up like plates.launchSingleTop = truemeans "if I'm already on this tab, don't open it again".TopAppBarhas atitleand anavigationIcon. The title changes for each screen. The details screen shows the fruit's name.NavigationBarholds the tabs. EachNavigationBarItemhasselected(is this tab highlighted?),onClick, aniconand alabel.FloatingActionButtonshows only on the Fruits tab. It callsshuffleFruits().Modifier.padding(innerPadding)onNavHostkeeps our screens between the top bar and the bottom tabs.
Tip: We used emojis (๐, โค๏ธ, โ, ๐) as icons so you don't need any extra library. Real apps often use the Material Icons library (for example
Icons.AutoMirrored.Filled.ArrowBack). You can switch later.
How One ViewModel Feeds Two Tabs
Look closely: fruitViewModel is created once, at the top of FruitApp, outside the NavHost. Both the Fruits tab and the Favorites tab get data from the same uiState.
So when you tap ๐ค on Mango in the Fruits tab, the ViewModel adds "Mango" to favorites. The Favorites tab reads the same favorites, so Mango appears there right away. One kitchen, two waiters, same food.
Tip: If you call
viewModel()inside eachcomposable("...") { }block instead, each screen gets its own ViewModel, and your favorites won't be shared. Create it once, at the top.
Run the App
Press Run and test everything:
- You see "Fruit Shop" at the top and two tabs at the bottom. Fruits is highlighted.
- Tap ๐. The list shuffles!
- Tap ๐ค on two fruits. Open the Favorites tab. Your fruits are there, and the tab is highlighted. ๐
- Tap a fruit. The details screen shows its name in the top bar, a โ arrow, and no bottom tabs.
- Tap โ. You're back where you were.
Common Errors
- "This material API is experimental": Add
@OptIn(ExperimentalMaterial3Api::class)above@Composable, exactly as shown. - Content is hidden under the top bar: You forgot
Modifier.padding(innerPadding)onNavHost. - Double space at the top: You still have the old
ScaffoldinMainActivity. Remove it (Step 1). currentBackStackEntryAsStateis red: Import it fromandroidx.navigation.compose.- Favorites tab is always empty: Make sure you didn't create a second ViewModel. Use the same
fruitViewModel.
Try It Yourself
- Change the Fruits tab title from "Fruit Shop" to your own shop name, like "Shakil's Fruits".
- Show the number of favorites in the tab label:
Text("Favorites (${uiState.favorites.size})"). - Add a โค๏ธ button to the details screen. Hint: give
FruitDetailsScreentwo new parameters,isFavoriteandonFavoriteClick.
Frequently Asked Questions
Why is the shuffle lost when I type in the search box?
Because onQueryChange builds a fresh filtered list from the original order. That's normal. Try making shuffle remember its order as an extra challenge!
Should I use NavigationBar or a TabRow?
NavigationBar sits at the bottom and is for the main sections of your app (3 to 5 sections). TabRow sits at the top and is for switching views inside one section.
My Android Studio offers a newer Navigation version. Should I update?
Yes, you can. At the time of writing, the latest stable navigation-compose is 2.10.2, and this code works the same way. Click Sync Now after changing it.
Quick Recap
- A
Scaffoldis the house plan:topBar,bottomBar,floatingActionButtonand content. TopAppBarshows the title and a back arrow on the details screen.NavigationBarwithNavigationBarItemcreates bottom tabs.currentBackStackEntryAsState()tells us the current route, so we can highlight the right tab.- One
FruitViewModel, created at the top, shares favorites between both tabs. - Always pass
innerPaddingto your content.
Our app works well now. Next, let's make it beautiful with our own colors and dark mode!