Goals & prerequisites
Screen structure has its own responsibilities: bars frame the content, a primary action stays reachable, and system UI must not hide important controls. Scaffold coordinates those regions while your screen still owns state, events, and the content layout.
Complete layouts, state hoisting, Material 3, and the introductory Scaffold lesson first. This lesson covers slot contracts, inset ownership, bottom-bar selection, and a complete screen shell. You will also distinguish a local demonstration of changing content from a navigation back stack.
Scaffold's screen slots
A slot is a composable lambda for a named region. Scaffold measures and positions the regions; it does not load records or decide what happens when someone presses a button. Keep screen events in the state-owning screen or forward them to its caller.
| Slot / component | Responsibility | Your decision |
|---|---|---|
topBar / TopAppBar | Title, contextual actions, optional navigation action | Use a true destination title and meaningful action labels |
bottomBar / NavigationBar | Access to major destinations | Drive selected state from the current destination |
floatingActionButton | One prominent creation or primary action | Show it only where the action makes sense |
snackbarHost | A place to present transient feedback | Remember host state and launch messages from events |
content(innerPadding) | The main screen body | Apply or distribute the provided padding exactly once |
Padding & system insets: choose an owner
The content lambda receives PaddingValues accounting for the scaffold's bars and applicable insets. Receiving those values does not automatically move your content. Apply them to the body or pass them as a lazy container's content padding. These choices differ: one insets the viewport; the other allows items to scroll through padded content.
// Pattern A: inset the viewport and tell descendants it is handled.
LazyColumn(
modifier = Modifier
.padding(innerPadding)
.consumeWindowInsets(innerPadding),
contentPadding = PaddingValues(16.dp),
) { /* item content */ }
// Pattern B: allow list content behind the bars as it scrolls.
// Here innerPadding belongs INSIDE the scrolling content instead.
LazyColumn(
modifier = Modifier.consumeWindowInsets(innerPadding),
contentPadding = innerPadding,
) { /* item content */ }
// Choose one pattern. Do not apply innerPadding in both places.consumeWindowInsets(innerPadding) tells descendant inset-aware modifiers that this space was already handled. It does not itself add padding. Material 3 app bars and navigation bars handle their own relevant system insets by default; adding statusBarsPadding() everywhere can create doubled space. Keyboard insets are a separate concern: a form may also need an intentional imePadding() strategy.
Selection is a value; navigation is a behavior
NavigationBarItem receives selected and onClick. It does not choose or store a destination. In the example below, an integer switches two local content branches. This teaches component wiring without another dependency; it does not implement deep links, a back stack, or independent destination state.
When the app grows
Build a course screen shell
Create CourseScreenShell.kt in the app package and keep its package declaration. Paste this complete example and call CourseScreenShell() inside the generated app theme in setContent. The sample uses only the course's Compose and Material 3 dependencies.
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CourseScreenShell(modifier: Modifier = Modifier) {
var selectedTab by rememberSaveable { mutableStateOf(0) }
var addedLessons by rememberSaveable { mutableStateOf(0) }
var helpVisible by rememberSaveable { mutableStateOf(false) }
val destinations = listOf("Lessons", "Progress")
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(destinations[selectedTab]) },
actions = {
TextButton(onClick = { helpVisible = !helpVisible }) {
Text(if (helpVisible) "Hide help" else "Help")
}
},
)
},
bottomBar = {
NavigationBar {
destinations.forEachIndexed { index, destination ->
NavigationBarItem(
selected = selectedTab == index,
onClick = { selectedTab = index },
icon = {
// Asset-free teaching marks. The label names the destination.
Text(
text = if (index == 0) "L" else "P",
modifier = Modifier.clearAndSetSemantics { },
)
},
label = { Text(destination) },
)
}
}
},
floatingActionButton = {
if (selectedTab == 0) {
ExtendedFloatingActionButton(onClick = { addedLessons += 1 }) {
Text("Add practice lesson")
}
}
},
) { innerPadding ->
LazyColumn(
modifier = Modifier
.padding(innerPadding)
.consumeWindowInsets(innerPadding)
.fillMaxSize(),
contentPadding = PaddingValues(
start = 16.dp, top = 16.dp, end = 16.dp, bottom = 96.dp,
),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (helpVisible) {
item(key = "help") {
Text("Choose Lessons to browse, or Progress for the local demo summary.")
}
}
if (selectedTab == 0) {
items(count = 12 + addedLessons, key = { "lesson-$it" }) { index ->
Text(
text = "Practice lesson ${index + 1}",
style = MaterialTheme.typography.titleMedium,
)
HorizontalDivider()
}
} else {
item(key = "progress") {
Text("You added $addedLessons practice lessons in this demo.")
}
}
}
}
}Tap the floating action twice, select Progress, then rotate the device. The local count and selected branch can restore through saved UI state. Help is an ordinary toggle, and the label changes to describe its next action. The bottom content padding reserves room for this example's floating action; reassess that spacing if the action's dimensions change.
App bars & floating actions
A top app bar's navigationIcon should represent a real navigation action such as Up or opening a drawer. Do not add a decorative back button that cannot navigate. Its actions slot is for screen actions; avoid using it as a second row of destination tabs.
FloatingActionButton suits a concise icon action with an accessible description. ExtendedFloatingActionButton adds a visible label, useful when the action needs explanation. Neither saves data automatically. The sample increments UI state; a real creation flow should delegate to the appropriate screen logic and report its actual outcome.
Accessibility & adaptation
Keep destination labels understandable without relying on the icon. Decorative marks in the sample clear their semantics so TalkBack does not repeat “L, Lessons”. Real resource icons can use a null content description when the visible destination label already provides the meaning. Preserve Material touch targets and verify selected-state announcements.
Test gesture navigation and three-button navigation, landscape, larger text, and a small emulator. Check the last item as well as the first. For wider windows, consider a navigation rail or another adaptive structure in the accessibility and adaptive-layout lesson; changing bar type should not reset the screen's important state.
Diagnose common mistakes
| Symptom | Inspect | Repair |
|---|---|---|
| Title bar covers the first item | The body ignores innerPadding | Choose one padding pattern and apply it |
| An oversized gap appears below the app bar | Status-bar or Scaffold padding was added twice | Keep a single owner for each inset |
| Bottom item sits behind the floating action | Content has no clearance for the action | Reserve appropriate end-of-content space |
| Back changes the page but the tab highlight stays | A selected index duplicates route state | Derive selection from the active route |
Practice: add a meaningful badge
- Wrap the Lessons destination mark in
BadgedBox. - Show a
Badgeonly after a practice lesson has been added. - Verify that the count survives the same recreation scenario as the selected tab.
Expected result: the badge is absent at zero, shows the current added count, and never changes selection by itself. TalkBack should convey what the quantity means; a bare count needs suitable localized context in the destination's accessible label.
Open the reference solution
// Add this import:
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
// Replace the NavigationBarItem icon slot with:
icon = {
BadgedBox(
badge = {
if (index == 0 && addedLessons > 0) {
Badge { Text(addedLessons.toString()) }
}
},
) {
Text(
text = if (index == 0) "L" else "P",
modifier = Modifier.clearAndSetSemantics { },
)
}
},
// Keep the same selectedTab state and onClick callback.
// Inspect TalkBack output: the count needs a useful meaning, not only a number.
// A production label can include a localized "new practice lessons" quantity.Use the existing count as the source of truth. The badge should derive its visibility from that count rather than store a second Boolean.
Recap & knowledge check
Key takeaways
- Scaffold coordinates screen regions; the content still has to handle its padding.
- Padding creates space; consuming insets tells descendants which space is already handled.
- NavigationBarItem displays caller-owned selection and reports clicks.
- Use route state for real navigation, meaningful action labels, and enough room around floating controls.
Quick check
1. What happens merely because Scaffold supplies innerPadding?
2. What does consumeWindowInsets(innerPadding) do?
3. When using real Navigation Compose routes, what should drive the highlighted destination?