Bar, rail, drawer — the same destinations, chosen by window width

NavigationBar, NavigationRail and the drawer are three presentations of the same destination list. Window size decides which, and NavigationSuiteScaffold does the switching — with rules about how many destinations belong there at all.

6 min read
androidcomposekotlinmaterial3

Day 40 — Bar, rail, drawer — the same destinations, chosen by window

Day 40 of 100. Top-level navigation is where the adaptive pillar and the components pillar meet, and there's a component that does the meeting for you.

The symptom

A bottom bar that's still a bottom bar on a tablet:

Scaffold(
    bottomBar = {
        NavigationBar {
            destinations.forEach { dest ->
                NavigationBarItem(
                    selected = current == dest,
                    onClick = { navigate(dest) },
                    icon = { Icon(dest.icon, null) },
                    label = { Text(dest.label) },
                )
            }
        }
    },
) { … }

On a 1000dp window, five items sit in a row across the bottom of a very wide screen, each one an inch from its neighbour, while the left third of the display is empty. It works and it wastes the space the adaptive work was meant to use.

Why the obvious fix fails

The obvious fix is a when on the size class:

when (widthClass) {
    COMPACT -> Scaffold(bottomBar = { NavigationBar { items() } }) { Content(it) }
    MEDIUM  -> Row { NavigationRail { items() }; Content() }
    EXPANDED -> PermanentNavigationDrawer(drawerContent = { items() }) { Content() }
}

It's correct, and now the destination list is written three times with three different item components — NavigationBarItem, NavigationRailItem, NavigationDrawerItem. Adding a sixth destination means three edits, and the three copies drift.

There's also a subtler cost: switching between the branches remounts Content(), because it's a different position in the composition tree. Scroll position resets when you unfold the device.

One destination list, three presentations, chosen by window width

The actual mechanism

NavigationSuiteScaffold takes the destination list once and picks the presentation:

NavigationSuiteScaffold(
    navigationSuiteItems = {
        destinations.forEach { dest ->
            item(
                selected = current == dest,
                onClick = { navigate(dest) },
                icon = { Icon(dest.icon, contentDescription = null) },
                label = { Text(dest.label) },
            )
        }
    },
) {
    Content()
}

One list, one item call per destination. The scaffold reads currentWindowAdaptiveInfo() — Day 29, again — and renders a bottom bar on compact, a rail on medium, and a permanent drawer on expanded. Content() stays in the same place in the tree, so unfolding doesn't reset it.

Overriding the choice is a parameter rather than a rewrite:

NavigationSuiteScaffold(
    layoutType = if (immersiveMode) NavigationSuiteType.None
                 else NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(
                     currentWindowAdaptiveInfo()
                 ),
    …
)

NavigationSuiteType.None hides navigation entirely, which is what a video player or a camera screen wants.

How many destinations

The component won't stop you, so the constraint is worth stating: three to five top-level destinations. Below three, a bar is unnecessary — use a single screen with tabs or a menu. Above five, items get too narrow to label and users stop reading them.

If you have seven, the honest answers are to group some behind a "More" destination, or to accept that some of them aren't top-level. A navigation bar is a claim about what the app is for, and seven equally-weighted claims is none.

The rail relaxes this slightly — vertical space is cheaper than horizontal, so seven rail items are legible where seven bar items are not. That is a reason to hide some destinations on compact, not a reason to add more overall.

Labels matter too. alwaysShowLabel = false on NavigationBarItem hides labels for unselected items, which looks cleaner and measurably hurts discoverability — an icon alone is ambiguous to anyone who hasn't already learned it.

Wiring it to the nav host

The navigation logic itself is separate, and there's one detail that produces a bug in every app that misses it:

fun navigateToTopLevel(route: String) {
    navController.navigate(route) {
        popUpTo(navController.graph.findStartDestination().id) { saveState = true }
        launchSingleTop = true
        restoreState = true
    }
}

Without launchSingleTop, tapping the same destination twice pushes it twice, so back goes to the same screen again. Without saveState/restoreState, switching tabs and returning loses the scroll position and any in-progress input on the first tab.

Those three options are the difference between navigation that feels native and navigation that feels like a web page from 2005, and they're easy to omit because the app works without them.

The modal drawer is a different thing

ModalNavigationDrawer — the one that slides over the content — is for secondary navigation, not top-level. Settings, help, account switching, archived sections.

val drawerState = rememberDrawerState(DrawerValue.Closed)

ModalNavigationDrawer(
    drawerState = drawerState,
    drawerContent = { ModalDrawerSheet { SecondaryDestinations() } },
) {
    NavigationSuiteScaffold(…) { Content() }
}

Both can coexist — a bar for the three things people do daily, a drawer for the twelve they do occasionally. Putting the daily destinations in a drawer hides them behind a gesture, which is why the pattern fell out of favour for primary navigation.

How to prove it

The three presentations are one preview annotation each:

@Preview(widthDp = 400) @Preview(widthDp = 700) @Preview(widthDp = 1100)
@Composable fun NavAcrossWidths() = AppShell()

Bar, rail, drawer — with a single implementation. If you see the same bottom bar three times, the layout type isn't being calculated.

The state-preservation bug needs a device: pick tab two, scroll down, go to tab one, come back. If you're at the top of tab two, saveState/restoreState are missing. Then unfold: if the scroll resets, Content() is being remounted by a when.

Badges

Unread counts belong on the item, and there's a slot for them rather than a hand-placed overlay:

item(
    icon = {
        BadgedBox(
            badge = {
                if (unread > 0) Badge { Text(if (unread > 99) "99+" else "$unread") }
            },
        ) { Icon(dest.icon, contentDescription = null) }
    },
    …
)

Two details that are easy to get wrong. Cap the number — a four-digit badge stretches the item and breaks the row's spacing. And an unlabelled Badge { } with no content is the correct choice for "something is new" without a count, rather than a badge showing zero.

For accessibility, the count needs to reach the semantics tree, since a screen reader won't infer it from the drawing:

Modifier.semantics { contentDescription = "$unread unread messages" }

Without that, the item announces "Inbox" and a sighted user's most important cue is invisible to everyone else.

What this generalizes to

The pattern is separating what from how. The destination list is app content; the choice of bar, rail or drawer is presentation, and it depends on a window property that changes at runtime. Writing them together means writing the content three times.

It also explains why three item types exist despite looking interchangeable. They differ in layout — horizontal for a bar, vertical for a rail, full-width for a drawer — and the scaffold picks the right one from the same item() declaration. You describe the destination; the component decides what a destination looks like in the presentation it chose.

That's the same separation as Day 30's canonical layouts, one level up — and it's why NavigationSuiteScaffold exists rather than better documentation for the when. When a branch produces duplicated content, the fix is usually a component that takes the content once.

Tomorrow, Day 41: progress indicators — determinate versus indeterminate, and why the spinner you show for 200ms is worse than no spinner.


Day 40 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Navigation components.