RizTech Academy logo
RizTech Academy
Lists and NavigationLesson 5 of 530 min

Bottom bars, drawers and app structure

Mandi Bhav now has a list and a detail screen, and a farmer can only ever be in one of two places. Real apps have a handful of top-level places the user moves between all day, and a structure that says so: a bar along the bottom of a phone, a rail down the side of a tablet. This lesson gives Mandi Bhav three top-level destinations, a separate back stack for each so switching tabs loses nothing, the rail from module 4's 600 dp breakpoint, and the real bug that switching between bar and rail caused on the way. It ends with when a navigation drawer is the wrong choice, which is most of the time.

Choosing the destinations

A top-level destination is somewhere the user goes directly and often, not a step in a task. For Mandi Bhav:

Destination What it is Why it is top-level
Prices Today's prices, searchable: the screen built so far What every user opens the app for
Markets The markets that reported today; a market opens its prices Traders think market-first: "what is Vashi paying?"
Alerts Price alerts, once the notifications module builds them A farmer will come back to it daily

Alerts is a placeholder, and it says so in plain words rather than looking finished:

/**
 * The Alerts destination. Price alerts need storage and notifications, which
 * later modules build, so for now the screen says what it will do and when.
 */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AlertsScreen(modifier: Modifier = Modifier) {
    Scaffold(
        modifier = modifier,
        topBar = { TopAppBar(title = { Text("Alerts") }) },
    ) { innerPadding ->
        Column(
            modifier = Modifier
                .padding(innerPadding)
                .padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            Text(
                text = "Price alerts are not built yet.",
                style = MaterialTheme.typography.titleMedium,
            )
            Text(
                text = "Later versions of Mandi Bhav will tell you when a price crosses a " +
                    "level you choose, such as onion at Lasalgaon above ₹2,500 a quintal.",
                style = MaterialTheme.typography.bodyLarge,
            )
        }
    }
}

That is ui/AlertsScreen.kt, with a preview below it. A disabled tab, or a tab that opens a "Coming soon" image, teaches users that the app is unfinished; a sentence saying what is coming, and why it is not here yet, does not.

Three new keys join navigation/Destinations.kt:

/** Top-level: the markets that reported today. */
@Serializable
data object Markets : NavKey

/** Top-level: price alerts, a placeholder until the notifications module. */
@Serializable
data object Alerts : NavKey

/** One market's prices, by the market's name. */
@Serializable
data class Market(val name: String) : NavKey

ui/MarketsScreen.kt holds two screens: the markets list, a LazyColumn of Material ListItems with how many prices each market has today, and one market's prices, a LazyColumn of PriceCards whose cards open the same PriceDetail key as on the Prices tab. The function that counts prices per market is plain Kotlin, so it gets a test:

/** One market in the markets list: its name and how many prices it has today. */
data class MarketSummary(val name: String, val priceCount: Int)

/** Every market in [prices], each once, in the order it first appears. */
fun marketSummaries(prices: List<CommodityPrice>): List<MarketSummary> =
    prices.groupBy { it.market }.map { (name, atMarket) -> MarketSummary(name, atMarket.size) }

/**
 * The Markets destination: the markets that reported today. The source is
 * in memory and never changes, so this screen reads it once and has no
 * ViewModel yet; module 7 moves it into one, with a shared repository.
 */
@Composable
fun MarketsRoute(onMarketClick: (market: String) -> Unit, modifier: Modifier = Modifier) {
    val markets = remember { marketSummaries(PriceSource().todaysPrices()) }
    MarketsScreen(markets = markets, onMarketClick = onMarketClick, modifier = modifier)
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MarketsScreen(
    markets: List<MarketSummary>,
    onMarketClick: (market: String) -> Unit,
    modifier: Modifier = Modifier,
) {
    Scaffold(
        modifier = modifier,
        topBar = { TopAppBar(title = { Text("Markets") }) },
    ) { innerPadding ->
        LazyColumn(
            modifier = Modifier
                .padding(innerPadding)
                .consumeWindowInsets(innerPadding),
        ) {
            items(items = markets, key = { it.name }) { market ->
                ListItem(
                    headlineContent = { Text(market.name) },
                    supportingContent = {
                        Text(if (market.priceCount == 1) "1 price today" else "${market.priceCount} prices today")
                    },
                    modifier = Modifier.clickable { onMarketClick(market.name) },
                )
                HorizontalDivider()
            }
        }
    }
}

/** One market's prices today. A price opens the same detail screen as on the Prices tab. */
@Composable
fun MarketRoute(
    market: String,
    onPriceClick: (priceId: String) -> Unit,
    onBack: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val prices = remember(market) { PriceSource().pricesAt(market) }
    MarketScreen(market, prices, onPriceClick, onBack, modifier)
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MarketScreen(
    market: String,
    prices: List<CommodityPrice>,
    onPriceClick: (priceId: String) -> Unit,
    onBack: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Scaffold(
        modifier = modifier,
        topBar = {
            TopAppBar(title = { Text(market) }, navigationIcon = { BackButton(onBack) })
        },
    ) { innerPadding ->
        LazyColumn(
            modifier = Modifier
                .padding(innerPadding)
                .consumeWindowInsets(innerPadding),
            contentPadding = PaddingValues(16.dp),
            verticalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            if (prices.isEmpty()) {
                item(key = "empty") {
                    Text(
                        text = "No prices from this market today.",
                        style = MaterialTheme.typography.bodyLarge,
                    )
                }
            }
            items(items = prices, key = { it.id }) { price ->
                PriceCard(
                    price = price,
                    onClick = { onPriceClick(price.id) },
                    modifier = Modifier.fillMaxWidth(),
                )
            }
        }
    }
}

Two previews follow, one for each screen. The routes read the source once with remember, because the sample data never changes; module 7 moves that into ViewModels with a shared repository, as it does for every screen.

Icons, and a promise changed

Module 3 said the app's first icon would arrive with the networking module. The up arrow came in lesson 3, and a navigation bar is built around an icon for each destination with its label underneath; a bar of labels alone reads as a row of buttons. Three more vector drawables go in res/drawable/, of the kind module 3 showed you how to download from Google's icon set: ic_prices.xml (a list), ic_markets.xml (a shop front) and ic_alerts.xml (a bell). Each is a 24 dp vector with one path, like the bell:

<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="24dp"
    android:height="24dp"
    android:viewportWidth="24"
    android:viewportHeight="24">
    <path
        android:fillColor="#FF000000"
        android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.89,2 2,2zM18,16v-5c0,-3.07 -1.64,-5.64 -4.5,-6.32V4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z" />
</vector>

Each item has a visible label, so the icon's contentDescription is null: module 3's rule, or TalkBack reads "Prices, Prices".

A back stack per tab

Here is the behaviour users expect, and the one a single back stack gets wrong. Arjun opens Markets, taps Nashik APMC, taps Nashik onion. He switches to Prices to check Lasalgaon. When he taps Markets again, he expects Nashik onion's detail, where he left it, not the markets list. And Back from the Markets list should take him to Prices before it leaves the app: Android's navigation principles call this a fixed start destination, the screen the user sees last on the way out.

Navigation 3 does not build this in; Google publishes it as a recipe, which Mandi Bhav adapts. The state is one back stack per top-level destination, and which one is showing, in navigation/TopLevelNavigation.kt:

/**
 * The app's navigation state: one back stack per top-level destination, and
 * which of them is showing. Switching tabs keeps every stack as it was.
 *
 * Plain state and plain functions, so it can be unit tested; the composable
 * [rememberTopLevelNavigation] makes it survive rotation and process death.
 */
class TopLevelNavigation(
    val startDestination: NavKey,
    currentTopLevel: MutableState<NavKey>,
    val backStacks: Map<NavKey, NavBackStack<NavKey>>,
) {
    /** The top-level destination whose stack is showing. */
    var currentTopLevel: NavKey by currentTopLevel
        private set

    /** The back stack that is showing. */
    val currentStack: NavBackStack<NavKey>
        get() = backStacks.getValue(currentTopLevel)

    /**
     * Go to [key]. A top-level destination switches to its stack, as it was;
     * tapping the one already showing returns that stack to its first screen.
     * Anything else is pushed onto the current stack.
     */
    fun navigate(key: NavKey) {
        val stack = backStacks[key]
        when {
            stack == null -> currentStack.add(key)
            key == currentTopLevel -> while (stack.size > 1) stack.removeAt(stack.lastIndex)
            else -> currentTopLevel = key
        }
    }

    /**
     * Back: pop the current stack; from the first screen of any other tab,
     * return to the start destination's tab. Returns false when there is
     * nowhere left to go, which is the start destination's first screen.
     */
    fun goBack(): Boolean {
        val stack = currentStack
        return when {
            stack.size > 1 -> {
                stack.removeAt(stack.lastIndex)
                true
            }
            currentTopLevel != startDestination -> {
                currentTopLevel = startDestination
                true
            }
            else -> false
        }
    }

    /**
     * The stacks to show, bottom first: the start destination's, then the
     * current one if it is different. Putting the start tab underneath is what
     * makes Back from another tab land on Prices before leaving the app.
     */
    fun stacksInUse(): List<NavKey> =
        if (currentTopLevel == startDestination) listOf(startDestination)
        else listOf(startDestination, currentTopLevel)
}

Then two composables in the same file. One makes the state survive, by saving every stack with rememberNavBackStack and the current tab with rememberSerializable, the general form of the same mechanism:

@Composable
fun rememberTopLevelNavigation(
    startDestination: NavKey,
    topLevelDestinations: List<NavKey>,
): TopLevelNavigation {
    val currentTopLevel = rememberSerializable(stateSerializer = NavKeySerializer()) {
        mutableStateOf(startDestination)
    }
    val backStacks = topLevelDestinations.associateWith { key -> rememberNavBackStack(key) }
    return remember(startDestination, topLevelDestinations) {
        TopLevelNavigation(startDestination, currentTopLevel, backStacks)
    }
}

The other turns the stacks into what NavDisplay shows. Every stack is decorated all the time, with its own decorators, so a tab in the background keeps its screens' saved state and ViewModels; only the stacks in use are returned:

@Composable
fun TopLevelNavigation.entries(
    entryProvider: (NavKey) -> NavEntry<NavKey>,
): List<NavEntry<NavKey>> {
    val entriesByStack = backStacks.mapValues { (_, stack) ->
        rememberDecoratedNavEntries(
            backStack = stack,
            entryDecorators = listOf(
                rememberSaveableStateHolderNavEntryDecorator(),
                rememberViewModelStoreNavEntryDecorator(),
            ),
            entryProvider = entryProvider,
        )
    }
    return stacksInUse().flatMap { entriesByStack.getValue(it) }
}

The imports are listed at the top of the checkpoint's file; the less obvious ones are rememberSerializable from androidx.compose.runtime.saveable, NavKeySerializer from androidx.navigation3.runtime.serialization, and rememberDecoratedNavEntries from androidx.navigation3.runtime.

Because TopLevelNavigation is plain Kotlin over Compose state, it is tested on the JVM like a ViewModel. From app/src/test/java/com/riztechacademy/mandibhav/navigation/TopLevelNavigationTest.kt:

/** A fresh navigation state, as the app starts: on Prices, every stack at its root. */
private fun navigation() = TopLevelNavigation(
    startDestination = Prices,
    currentTopLevel = mutableStateOf<NavKey>(Prices),
    backStacks = listOf(Prices, Markets, Alerts).associateWith { NavBackStack(it) },
)

@Test
fun switchingTabsKeepsEachStack() {
    val nav = navigation()
    nav.navigate(PriceDetail("pune-onion-2026-11-07"))
    nav.navigate(Markets)
    nav.navigate(Market("Nashik APMC"))
    nav.navigate(Prices)

    assertEquals(Prices, nav.currentTopLevel)
    assertEquals(listOf(Prices, PriceDetail("pune-onion-2026-11-07")), nav.currentStack.toList())
    assertEquals(listOf(Markets, Market("Nashik APMC")), nav.backStacks.getValue(Markets).toList())
}

@Test
fun backPopsThenReturnsToPricesThenHasNowhereToGo() {
    val nav = navigation()
    nav.navigate(Markets)
    nav.navigate(Market("Nashik APMC"))

    assertTrue(nav.goBack())
    assertEquals(listOf<NavKey>(Markets), nav.currentStack.toList())

    assertTrue(nav.goBack())
    assertEquals(Prices, nav.currentTopLevel)

    assertFalse(nav.goBack())
}

The file has three more: a screen pushed onto the current stack, a second tap on the current tab returning it to its first screen, and the start tab always underneath. With MarketSummariesTest (one test of marketSummaries), ./gradlew testDebugUnitTest runs 37 tests.

This code has one bug that none of these tests catches. Open the same price from two tabs, and the two screens share one ViewModel; Back on one tab then clears it while the other tab is still showing it. It stays hidden until the screens hold more than a fixed list, so the architecture module finds and fixes it, in the Hilt lesson, with the logs to prove it.

The bar, the rail, and the bug between them

MandiBhavApp grows to hold the destinations, the entries, and the bar or rail. The destinations for the bar and rail, at the top of the file:

/** A destination in the navigation bar or rail. */
private data class TopLevelDestination(
    val key: NavKey,
    val label: String,
    @param:DrawableRes val icon: Int,
)

private val topLevelDestinations = listOf(
    TopLevelDestination(Prices, "Prices", R.drawable.ic_prices),
    TopLevelDestination(Markets, "Markets", R.drawable.ic_markets),
    TopLevelDestination(Alerts, "Alerts", R.drawable.ic_alerts),
)

My first version chose the layout the obvious way: if the window was at least 600 dp wide, a Row with a NavigationRail and the NavDisplay; otherwise a Scaffold with a NavigationBar and the NavDisplay inside it. Two branches of an if, each with its own NavDisplay. It looked right in portrait and in landscape. The log lines from lesson 3 told a different story. Open a price, rotate twice, press Back:

D Nav     (29313): PriceDetailViewModel created viewModel=195285775 priceId=lasalgaon-onion-2026-11-07
D Nav     (29313): PriceDetailViewModel created viewModel=21627111 priceId=lasalgaon-onion-2026-11-07
D Nav     (29313): PriceDetailViewModel onCleared viewModel=195285775

The first rotation created a second ViewModel for the same screen, and when Back popped the entry, only the first was cleared. The second one leaked. On this screen the cost was a reload; on a screen with a half-typed alert threshold, the user's typing would have vanished on the first rotation and not the second, the kind of bug nobody can reproduce.

The cause: rotating the phone crosses 600 dp, so the if switched branches, and NavDisplay moved to a different place in the composition. rememberSaveable state, and the bookkeeping that ties each entry to its ViewModelStore, is found again by its position in the composition. In a new position, nothing was found, so new state and a new ViewModel were made. Anything that holds saved state must stay at the same place in the tree when the layout changes. So the final version keeps one tree and changes what is in it:

// One tree for both layouts: the rail is added or left out, and the bar
// is added or left out, but NavDisplay stays in the same place. If the
// two layouts were separate branches, NavDisplay would move when the
// window crossed 600 dp, and every screen's saved state and ViewModel
// would be looked up under a different position and not found.
val useRail = windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND)
Row(modifier = modifier.fillMaxSize()) {
    if (useRail) {
        NavigationRail {
            for (destination in topLevelDestinations) {
                NavigationRailItem(
                    selected = destination.key == navigation.currentTopLevel,
                    onClick = { navigation.navigate(destination.key) },
                    icon = { Icon(painterResource(destination.icon), contentDescription = null) },
                    label = { Text(destination.label) },
                )
            }
        }
    }
    Scaffold(
        modifier = Modifier
            .weight(1f)
            // The rail already keeps clear of the system bars and the
            // camera cutout on its side; without this, each screen
            // would leave the same gap again.
            .then(
                if (useRail) {
                    Modifier.consumeWindowInsets(WindowInsets.safeDrawing.only(WindowInsetsSides.Start))
                } else {
                    Modifier
                },
            ),
        // No system insets here: with the bar, the bar handles the bottom;
        // with the rail, each screen handles its own, as it did before.
        contentWindowInsets = WindowInsets(0),
        bottomBar = {
            if (!useRail) {
                NavigationBar {
                    for (destination in topLevelDestinations) {
                        NavigationBarItem(
                            selected = destination.key == navigation.currentTopLevel,
                            onClick = { navigation.navigate(destination.key) },
                            icon = { Icon(painterResource(destination.icon), contentDescription = null) },
                            label = { Text(destination.label) },
                        )
                    }
                }
            }
        },
    ) { innerPadding ->
        NavDisplay(
            entries = navigation.entries(entryProvider),
            onBack = { navigation.goBack() },
            // innerPadding is the bar's height, which includes the system
            // navigation bar under it; consuming it stops each screen's
            // own Scaffold adding that inset a second time.
            modifier = Modifier
                .padding(innerPadding)
                .consumeWindowInsets(innerPadding),
        )
    }
}

The entryProvider above it is lesson 3's, with every screen's callbacks now going through navigation.navigate(…) and navigation.goBack(), and three more entries: entry<Markets>, entry<Market> { key -> } and entry<Alerts>. MandiBhavApp also takes module 4's windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfoV2().windowSizeClass parameter, so a preview can choose the layout. The same log after the change:

D Nav     (31106): PriceDetailViewModel created viewModel=195665820 priceId=lasalgaon-onion-2026-11-07
D Nav     (31106): PriceDetailViewModel onCleared viewModel=195665820

One ViewModel across three rotations, and the saved state measured with lesson 4's log stayed at 4,712 bytes every time it was written, where the broken version's grew by 812 bytes after the first rotation: the orphaned entry's state.

Checking it on the emulator

On the phone: Markets, Nashik APMC, Nashik onion; switch to Prices and back to Markets, and Nashik onion's detail is still there. Then, from that detail screen, Home, adb shell am kill com.riztechacademy.mandibhav, reopen from Recents, and press Back four times:

restored: Nashik onion's detail screen
back 1 -> Nashik APMC's prices
back 2 -> the Markets list
back 3 -> Prices, the start destination
back 4 -> the launcher

Rotate to landscape and the rail replaces the bar, with the screen you were on still showing. In landscape the price grid also shows lesson 1's inset work: the cards scroll behind the gesture handle, because with the rail there is no bar at the bottom.

Material 3 has a component that makes the bar-or-rail decision for you from the window's size, NavigationSuiteScaffold, in androidx.compose.material3:material3-adaptive-navigation-suite (1.4.0 under this project's BOM). Inside a composable with a selected index, an onSelect callback and lists of labels and icons:

NavigationSuiteScaffold(
    navigationSuiteItems = {
        labels.forEachIndexed { index, label ->
            item(
                selected = index == selected,
                onClick = { onSelect(index) },
                icon = { Icon(painterResource(icons[index]), contentDescription = null) },
                label = { Text(label) },
            )
        }
    },
) {
    content()
}

That compiles with the dependency added. Mandi Bhav does it by hand for two reasons: you can see every decision, including the insets and the bug above, which a component would hide until you hit it; and it uses the same 600 dp rule as the price grid, so the two cannot disagree. In a production app, try the component first and keep the hand-built version for when you need control it does not give. What either costs: two layouts to check for every screen.

When a drawer is the wrong choice

A navigation drawer slides in from the side behind a menu button. It is tempting, because it holds any number of destinations and takes no space. That is exactly the problem: what is not on screen is not used. Android's navigation bar guide recommends the bar for three to five destinations of equal importance, where they are always one tap away; the drawer guide's own use cases are categories, account links and less-used features.

You have Use Not
3 to 5 top-level destinations used often NavigationBar on phones, NavigationRail from 600 dp A drawer, which hides them behind a tap
2 destinations Tabs inside one screen, or a bar A drawer with two entries
6 or more, some rarely used A bar or rail for the main few; a drawer (ModalNavigationDrawer) or a settings screen for the rest Six icons squeezed into a bar
One task with steps A back stack: push and pop Tabs, which suggest the steps are independent

Mandi Bhav has three destinations used daily, so a drawer would add a tap to everything and remove nothing. The drawer's place, if the app ever needs one, is for the rarely used: settings, help, about.

Check your work

What makes a destination top-level. The user goes there directly and often, not as a step in a task.

What a separate back stack per tab gives the user. Switching tabs and back returns them where they were.

What a fixed start destination means here. Back from another tab's first screen goes to Prices before leaving the app.

Why the icons have no contentDescription. The label beside them already says it; TalkBack would read it twice.

What the bar-or-rail bug was. The layout switch moved NavDisplay in the composition, so after the first rotation its saved state and ViewModel were not found, and a second ViewModel was created and leaked.

How the fix works. One tree: the rail and the bar are added or left out, and NavDisplay stays in the same place.

When a drawer is right. For destinations used rarely, beside a bar or rail; not for three daily ones.

Practice 4. navigate(Markets) while on Markets returns its stack to the markets list: the stack held Markets, Market and PriceDetail, and is left with Markets. That is the common platform behaviour for tapping the selected tab, and the test tappingTheCurrentTabAgainReturnsToItsFirstScreen pins it.

Practice

  1. Add the keys, icons, screens, TopLevelNavigation, and the new MandiBhavApp. Run the 37 tests, then repeat the tab and process-death check above.
  2. Put the app in split screen with another app on the rotated emulator, drag the divider across 600 dp, and watch the bar and rail swap while the screen you were on stays.
  3. Break it on purpose: rebuild MandiBhavApp as two branches of an if, each with its own NavDisplay. Add lesson 3's log lines, open a price, rotate twice, press Back, and find the leaked ViewModel in the log.
  4. Open Markets, Nashik APMC and Nashik onion, then tap Markets in the bar. Predict what you see, then check.
  5. Write a test for goBack() from the Alerts tab and check it against the app.
  6. Harder: Alerts will one day have a list of alerts and an editor for one alert. Sketch its keys, say what each holds and why, and decide whether opening an alert from a notification should put Alerts' list underneath the editor. (The notifications module comes back to this, with deep links.)

Official documentation

Next: module 7, Architecture.


That is module 6. You can now:

  • replace a scrolling Column with LazyColumn, LazyRow or LazyVerticalGrid, with header items, spans and a sticky header, and measure what it saved instead of assuming;
  • give list items stable keys and content types, recognise state landing on the wrong item, and keep expensive work out of item lambdas;
  • read frame statistics without fooling yourself, and say what an emulator cannot tell you;
  • navigate with Navigation 3: keys, a saved back stack, NavDisplay, entry-scoped ViewModels, and the predictive back gesture; and read a Navigation Compose NavHost when you meet one;
  • pass an id, not an object, and handle the id that no longer points at anything;
  • give an app top-level destinations with a back stack each, a bar on phones and a rail from 600 dp, without losing state when the window changes.

Mandi Bhav now has three tabs, a price detail with its week, and lists that build only what is on screen. It also has three screens that each create their own PriceSource, two ViewModels that expose state in different shapes, and a MarketsRoute that reads data straight from a composable. That works at five screens and will not at fifteen. Module 7, Architecture, gives the app layers, one repository that every screen shares as its single source of truth, UI state modelled as one object per screen, and dependency injection with Hilt.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.

About the internship