The Scaffold parameter everyone ignores is the one that breaks the layout

Scaffold arranges the app shell and hands back the insets its own bars consume. Ignoring that PaddingValues is the single most common Compose layout bug, and edge-to-edge makes it worse.

6 min read
androidcomposekotlinmaterial3

Day 35 — The Scaffold parameter everyone ignores is the one that brea

Day 35 of 100, opening the components pillar. Scaffold is the first Material component most people use and the one with the most-ignored parameter.

The symptom

A screen whose first list item is hidden behind the app bar:

Scaffold(
    topBar = { TopAppBar(title = { Text("Inbox") }) },
) {
    LazyColumn {
        items(mail) { MailRow(it) }
    }
}

The compiler warns about an unused lambda parameter, which is easy to miss. The first row renders underneath the app bar, and the bottom of the list sits under the navigation bar.

On a screen that doesn't scroll it can look almost right — enough to ship.

Why the obvious fix fails

The obvious fix is to pad by the height of the bar:

LazyColumn(modifier = Modifier.padding(top = 64.dp)) { … }

64dp is the height of a small top app bar today, on this device, at this font scale. It's wrong for a MediumTopAppBar, wrong at 2× font scale when the title wraps to two lines, and wrong on a device with a different status bar height.

The second obvious fix is Modifier.padding(innerPadding) on the LazyColumn — which is better and still not right, for a reason that only shows up when you scroll.

Scaffold measures its bars and returns the space they consume as PaddingValues

The actual mechanism

Scaffold measures its own bars and hands back exactly the space they consume:

Scaffold(
    topBar = { TopAppBar(title = { Text("Inbox") }) },
    bottomBar = { NavigationBar { … } },
    floatingActionButton = { FloatingActionButton(onClick = { … }) { … } },
) { innerPadding ->
    LazyColumn(contentPadding = innerPadding) {          // note: contentPadding
        items(mail, key = { it.id }) { MailRow(it) }
    }
}

That PaddingValues is computed from the real measured heights, including system insets, at the current font scale. It is not a constant and there is no correct number to type in its place.

The choice between contentPadding and Modifier.padding is Day 23's distinction, and for a scaffold it decides how the screen feels:

  • contentPadding = innerPadding — the list fills the window and its content is inset. Items scroll up behind a translucent app bar, which is the modern look.
  • Modifier.padding(innerPadding) — the list's viewport is shrunk. Items are clipped at the bar's edge, so nothing ever appears behind it.

For a LazyColumn you almost always want contentPadding. For a non-scrolling screen, Modifier.padding is fine because nothing ever travels under the bar.

Scroll behaviour, and the piece it needs from you

Collapsing app bars need a TopAppBarScrollBehavior, and it must be connected in two places — the bar and the scaffold:

val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()

Scaffold(
    modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),   // 1
    topBar = {
        LargeTopAppBar(
            title = { Text("Inbox") },
            scrollBehavior = scrollBehavior,                                   // 2
        )
    },
) { innerPadding -> … }

Wire only the bar and nothing collapses; wire only the nestedScroll and the bar doesn't know. Both are needed, and forgetting the first is the more common of the two.

The four behaviours differ in when the bar comes back:

  • pinned — never collapses.
  • enterAlways — collapses on scroll down, returns on any scroll up.
  • exitUntilCollapsed — collapses fully, returns only at the top of the list.
  • enterAlwaysCollapsed — collapses immediately, expands only at the top.

enterAlways is the right default for content people scan; exitUntilCollapsed suits a large title you want out of the way.

Snackbars belong to the scaffold

The other parameter worth using rather than reinventing:

val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()

Scaffold(
    snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding -> … }

// somewhere in an event handler
scope.launch { snackbarHostState.showSnackbar("Message archived", actionLabel = "Undo") }

Placing it here means the scaffold positions snackbars above the bottom bar and the FAB automatically — the layout problem people usually solve with a hardcoded bottom offset that's wrong when the bar is hidden.

Note showSnackbar is a suspending call that returns a SnackbarResult, so "was Undo tapped" is a return value rather than a callback:

val result = snackbarHostState.showSnackbar("Archived", actionLabel = "Undo")
if (result == SnackbarResult.ActionPerformed) viewModel.undoArchive()

Edge-to-edge changes the default

On Android 15+ apps are edge-to-edge whether or not they asked, so content draws behind the system bars by default. Scaffold's innerPadding already accounts for system insets, which is most of why using it matters more now than it did.

The case it doesn't cover is content outside the scaffold's body — a full-bleed image header, say — where you handle insets directly:

Box(Modifier.windowInsetsPadding(WindowInsets.statusBars)) { … }

And the case that catches everyone: a Scaffold nested inside another Scaffold applies insets twice, so the content is pushed down by double the status bar. One scaffold per screen; inner regions are plain layouts.

How to prove it

The overlap bug is visible in a preview if the preview shows system bars:

@Preview(showSystemUi = true)
@Composable fun InboxPreview() = InboxScreen(sampleMail)

showSystemUi = true is the part that matters — without it there are no insets to get wrong and the screen looks fine.

Two more checks that find the rest:

@Preview(showSystemUi = true, fontScale = 2f)     // does the title wrap and re-measure?

and on device, scroll a list to the very bottom. If the last item can't clear the navigation bar, innerPadding isn't reaching the list — usually because it was applied to a wrapper Column instead of to the scrolling child itself.

Where the scaffold should live

One structural decision that pays off later: put the Scaffold inside each screen, not around the NavHost.

// Preferred — each screen owns its shell
NavHost(navController, startDestination = "inbox") {
    composable("inbox") { InboxScreen() }        // has its own Scaffold
    composable("compose") { ComposeScreen() }    // different bars, no bottom bar
}

// Awkward — one shell for every destination
Scaffold(topBar = { … }, bottomBar = { … }) { padding ->
    NavHost(navController, …, modifier = Modifier.padding(padding))
}

The wrapping version looks tidier and forces every screen to share one top bar, so each one needs conditional logic to change the title, hide the FAB, or drop the bottom bar on a detail screen. That conditional grows into a when over routes, which is navigation logic living in a layout.

The exception is the bottom navigation bar itself, which genuinely is app-level and should persist across destinations. The common shape is an outer scaffold with only a bottomBar, and a per-screen scaffold inside each destination for its own top bar — the one place nesting is correct, precisely because the outer one contributes no top inset.

What this generalizes to

The pattern is a container that reports what it consumed. The scaffold cannot know what you'll put inside it, and you cannot know how tall its bars will be, so it measures and tells you. That contract shows up throughout Compose — WindowInsets, BoxWithConstraints, onSizeChanged — and it always replaces a number somebody would otherwise hardcode.

Ignoring the reported value and typing 64dp is the same mistake as Day 29's isTablet(): substituting a constant for a measurement, and being wrong on every configuration you didn't test.

Tomorrow, Day 36: buttons — five of them, and the rules for which one goes where.


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