SHAKIL.DEV

SHAKIL.DEV

Scaffold, Top App Bar and Bottom Navigation in Jetpack Compose
โ† BACK TO TRANSMISSIONS
October 14, 2026

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 a TopAppBar with the screen title.
  • bottomBar: the floor. Usually a NavigationBar with 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 new Scaffold will handle the status bar and the gesture bar space for us.
  • FruitApp() no longer needs a modifier from 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 FruitRow from Part 9. Write once, use twice!
  • isFavorite = true because 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 gives null.
  • The onBack parameter 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, TopAppBar was marked "experimental", and Android Studio showed an error without this line. Newer Material 3 versions made the basic TopAppBar stable. 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.
  • currentRoute is the route name, like "home", "favorites" or "details/{fruitName}". Notice the details route is the pattern with curly braces, not the real fruit name.
  • isDetails is true on the details screen. There we show the back arrow and hide the bottom tabs.
  • goToTab moves between tabs. popUpTo("home") removes extra screens above Home, so tabs don't pile up like plates. launchSingleTop = true means "if I'm already on this tab, don't open it again".
  • TopAppBar has a title and a navigationIcon. The title changes for each screen. The details screen shows the fruit's name.
  • NavigationBar holds the tabs. Each NavigationBarItem has selected (is this tab highlighted?), onClick, an icon and a label.
  • FloatingActionButton shows only on the Fruits tab. It calls shuffleFruits().
  • Modifier.padding(innerPadding) on NavHost keeps 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 each composable("...") { } 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:

  1. You see "Fruit Shop" at the top and two tabs at the bottom. Fruits is highlighted.
  2. Tap ๐Ÿ”€. The list shuffles!
  3. Tap ๐Ÿค on two fruits. Open the Favorites tab. Your fruits are there, and the tab is highlighted. ๐ŸŽ‰
  4. Tap a fruit. The details screen shows its name in the top bar, a โ† arrow, and no bottom tabs.
  5. 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) on NavHost.
  • Double space at the top: You still have the old Scaffold in MainActivity. Remove it (Step 1).
  • currentBackStackEntryAsState is red: Import it from androidx.navigation.compose.
  • Favorites tab is always empty: Make sure you didn't create a second ViewModel. Use the same fruitViewModel.

Try It Yourself

  1. Change the Fruits tab title from "Fruit Shop" to your own shop name, like "Shakil's Fruits".
  2. Show the number of favorites in the tab label: Text("Favorites (${uiState.favorites.size})").
  3. Add a โค๏ธ button to the details screen. Hint: give FruitDetailsScreen two new parameters, isFavorite and onFavoriteClick.

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 Scaffold is the house plan: topBar, bottomBar, floatingActionButton and content.
  • TopAppBar shows the title and a back arrow on the details screen.
  • NavigationBar with NavigationBarItem creates 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 innerPadding to your content.

Our app works well now. Next, let's make it beautiful with our own colors and dark mode!

Part 11: Material 3 Themes, Colors and Dark Mode