Edge-to-edge, and drawing under the system bars
Since module 1, MainActivity has called enableEdgeToEdge(), and module 2 promised that module 4
would explain what that obliges you to do. It means the app draws over the whole screen, under the
status bar at the top and the navigation bar at the bottom, and the app, not Android, is
responsible for keeping text and buttons out from under them. This lesson shows what goes wrong
when you do not, adds a search field to Mandi Bhav, and deals with the one system bar that slides
up from nowhere: the keyboard.
You cannot opt out any more
Your targetSdk |
Running on | Edge-to-edge |
|---|---|---|
| 34 or lower | Any version | Off unless you turn it on |
| 35 | Android 15 or later | Enforced; a theme attribute, windowOptOutEdgeToEdgeEnforcement, could still opt out |
| 36 or higher | Android 16 or later | Enforced; the opt-out is deprecated and ignored |
Mandi Bhav targets 36. On Android 15 and later it draws edge to edge whether or not you call
enableEdgeToEdge(). The call still matters for two reasons: on older versions (the app supports
Android 8.0 and up) it turns the same behaviour on so that every phone looks alike, and it makes
the bars transparent and picks dark or light bar icons to match the system theme. Lesson 5 meets
the bug that last part causes.
Insets
An inset is the amount of the screen, on each side, that some piece of system UI covers.
Compose exposes each kind as a WindowInsets value:
| Inset | What it covers |
|---|---|
WindowInsets.statusBars |
The status bar at the top: clock, battery, notification icons |
WindowInsets.navigationBars |
The gesture handle or the three-button bar, usually at the bottom |
WindowInsets.systemBars |
Both of the above, plus the caption bar of a desktop-style window |
WindowInsets.displayCutout |
The camera hole or notch |
WindowInsets.ime |
The on-screen keyboard, when it is open |
WindowInsets.safeDrawing |
Everything content must not be drawn under: bars, cutout and keyboard |
WindowInsets.safeGestures |
Edges where the system's own swipe gestures win |
Insets change while the app runs: the keyboard opens, the phone rotates and the cutout moves to a side, the user switches from gestures to three buttons. Compose's inset values update and recompose whatever reads them.
Scaffold's innerPadding, and the screen without it
You rarely read insets directly, because Scaffold does it for you. TopAppBar pads itself below
the status bar, and the innerPadding that Scaffold passes to your content is the space taken by
the app bar plus the system bars on the other sides. Module 3 said to apply it to the outermost
element of the content, every time. Here is the evidence.
On the emulator, the finished Mandi Bhav screen was built once with the three inset lines at the
top of the content Column's modifier chain deleted. The search field you will add below was
completely hidden behind the app bar, with no way to scroll to it: it was the first item, and the
first item starts at the very top of the window, under the bar. The first row of filter chips was
cut in half by the bar's lower edge. At the bottom, the last visible card ran under the gesture
handle. Nothing crashed, and the build succeeded.
Lint did catch it. Run it from the terminal (Android Studio can also run the same inspections from its Code menu):
./gradlew lintDebug
Your line number will differ; the message will not:
PricesScreen.kt:62: Error: Content padding parameter innerPadding is not used [UnusedMaterial3ScaffoldPaddingParameter from androidx.compose.material3]
) { innerPadding ->
~~~~~~~~~~~~
It is reported as an error, and lintDebug fails the task, but assembleDebug and the Run button
do not run lint, so nothing stops you shipping it. Run lint before every release, and treat its
errors as build failures.
Rotate the emulator to landscape with the finished screen and look at the left edge. On the Pixel
emulator used here, the content sat about 66 dp from the left and 16 dp from the right: in
landscape the camera cutout is on the left, innerPadding includes it, and the cards moved out of
its way without any code of yours.
Screens without a Scaffold
A screen that does not use Scaffold, such as a full-screen confirmation, handles insets itself
with one of these modifiers:
Modifier.safeDrawingPadding() // pad by everything unsafe
Modifier.windowInsetsPadding(WindowInsets.systemBars) // pad by one kind
Modifier.statusBarsPadding() // shorthand for one side's bar
A search field, and the keyboard
Mandi Bhav now gets a search box above the cards, so a trader can type "lasal" and see only
Lasalgaon. First, more prices to search through. Add these to SamplePrices before all, and
add them to the list:
val vashiPotato = CommodityPrice(
commodity = "Potato",
market = "Vashi APMC, Navi Mumbai",
date = day,
minPrice = 1_600,
maxPrice = 2_600,
modalPrice = 2_100,
)
val vashiWheat = CommodityPrice(
commodity = "Wheat",
market = "Vashi APMC, Navi Mumbai",
date = day,
minPrice = 2_700,
maxPrice = 3_300,
modalPrice = 3_000,
)
val lasalgaonSoybean = CommodityPrice(
commodity = "Soybean",
market = "Lasalgaon APMC",
date = day,
minPrice = 4_200,
maxPrice = 4_800,
modalPrice = 4_550,
)
val puneTur = CommodityPrice(
commodity = "Tur dal",
market = "Pune (Gultekdi) APMC",
date = day,
minPrice = 6_800,
maxPrice = 7_800,
modalPrice = 7_300,
)
val all = listOf(
lasalgaonOnion,
puneOnion,
nashikOnion,
puneTomato,
nashikTomato,
vashiPotato,
vashiWheat,
lasalgaonSoybean,
puneTur,
)
Nine cards are more than any phone shows at once, which is what the keyboard problem needs.
What the keyboard does to an edge-to-edge app
When the keyboard opens, it is one more inset, WindowInsets.ime, covering the bottom of the
screen. An edge-to-edge window is not shrunk to make room. Without handling it, the keyboard sits
over the bottom of your scrolling column: the column still thinks it reaches the bottom of the
screen, so the last cards can only scroll as far as the space behind the keyboard, and a farmer
who searched "pune" can never bring the last result into view while typing.
Two changes fix it. In AndroidManifest.xml, on the <activity>, add:
android:windowSoftInputMode="adjustResize"
That is what lets the app receive the keyboard's inset. Then, in the content Column, add the
keyboard as padding with imePadding(). Here is the whole of PricesScreen with the search field:
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PricesScreen(prices: List<CommodityPrice>, modifier: Modifier = Modifier) {
var query by remember { mutableStateOf("") }
val q = query.trim()
val visible = prices.filter { price ->
price.commodity.contains(q, ignoreCase = true) ||
price.market.contains(q, ignoreCase = true)
}
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(title = { Text(text = stringResource(R.string.app_name)) })
},
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.consumeWindowInsets(innerPadding)
.imePadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text("Search commodity or market") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
if (visible.isEmpty()) {
Text(
text = "No prices match \"${query.trim()}\".",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
for (price in visible) {
PriceCard(price = price, modifier = Modifier.fillMaxWidth())
}
}
}
}
New imports: consumeWindowInsets and imePadding from androidx.compose.foundation.layout,
OutlinedTextField from material3, and getValue, setValue, mutableStateOf and remember
from androidx.compose.runtime. The query is state, as in module 3's text field: module 5
explains it properly, and until then it is lost when the phone rotates.
consumeWindowInsets: not counting the same bar twice
innerPadding already includes the navigation bar at the bottom. The keyboard inset is measured
from the bottom of the screen, so it covers the navigation bar's area too. Add imePadding()
alone and, with the keyboard open, the column is pushed up by the keyboard plus the navigation
bar a second time, leaving an empty band above the keyboard.
consumeWindowInsets(innerPadding) tells everything after it in the chain: "this much of the
insets is already dealt with." imePadding() then adds only the part of the keyboard that is not
already covered. The order matters, as every modifier chain's does:
padding(innerPadding): keep clear of the app bar and system bars.consumeWindowInsets(innerPadding): record that those are handled.imePadding(): add whatever extra the keyboard needs, and nothing when it is closed.verticalScroll(…): scroll inside what is left.padding(horizontal = 16.dp, vertical = 8.dp): margins that scroll with the cards.
Checking it on the emulator. The course emulator has a hardware keyboard enabled, so tapping the search field shows only a small floating keyboard toolbar, not a full keyboard, and the inset is too small to show anything. To see the real behaviour, edit the virtual device in Device Manager and turn off its hardware keyboard option, or run on your own phone. Then tap the field, scroll to the bottom, and check the last card ends right above the keyboard.
What edge-to-edge costs. Every screen has to handle insets, and a screen that forgets looks
fine on your phone and broken on a phone with a different cutout or navigation mode. It also
shows a limit of this layout: because innerPadding is applied outside the scroll, the cards stop
above the gesture handle instead of scrolling behind it, which is the look edge-to-edge is for.
Module 6 replaces this Column with a LazyColumn, whose contentPadding puts the inset inside
the scroll and gets both right.
Check your work
Can an app targeting 36 opt out of edge-to-edge on Android 16? No. The opt-out attribute is deprecated and ignored for apps targeting 36.
Why call enableEdgeToEdge() anyway. For the same behaviour on older Android versions, and
transparent bars with icon colours that follow the system theme.
What innerPadding contains. The app bar's height and the system bars (and cutout) on the
other sides.
What happened without it. The search field was hidden under the app bar and could not be scrolled to, and the last card ran under the gesture handle.
Which tool caught it. Lint, as the error UnusedMaterial3ScaffoldPaddingParameter. The build
and the Run button did not.
Why consumeWindowInsets(innerPadding) comes before imePadding(). So the navigation bar,
already in innerPadding, is not added again inside the keyboard's padding.
Practice 3. The display cutout. In landscape it is on one side, and innerPadding includes it.
Practice 5. One answer:
@Composable
fun AlertSetScreen(onDone: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.safeDrawingPadding()
.padding(16.dp),
verticalArrangement = Arrangement.SpaceBetween,
) {
Text("Price alert set", style = MaterialTheme.typography.headlineSmall)
Button(onClick = onDone, modifier = Modifier.fillMaxWidth()) { Text("Done") }
}
}
safeDrawingPadding() keeps the title below the status bar and the button above the navigation
bar, and above the keyboard if one is open. SpaceBetween puts the title at the top and the
button at the bottom.
Practice
- Add the four prices, the manifest attribute and the search field. Run it and type "lasal", then "vashi", then "xyz".
- Break it on purpose: delete
.padding(innerPadding)and.consumeWindowInsets(innerPadding). Run it and find the search field. Then run./gradlew lintDebugand read the error. Put the lines back. - Rotate the emulator to landscape. Why is the left margin wider than the right?
- On a device with a full on-screen keyboard, remove only
.consumeWindowInsets(innerPadding), open the keyboard and scroll to the end. Measure the gap above the keyboard by eye and say which bar it is the height of. - Harder: write
AlertSetScreen, a full-screen confirmation with noScaffold: a title at the top and a full-width "Done"Buttonat the bottom that never sits under a system bar. Preview it withshowSystemUi = true.
Official documentation
- About window insets — Every inset type, the safe insets, and how padding and consumption work.
- Set up edge-to-edge —
enableEdgeToEdge,adjustResizeand bar colours, step by step. - Android 16 behaviour changes — The removal of the edge-to-edge opt-out for apps targeting API 36.
- System bar protection — When to put a scrim behind transparent bars, and how.
Next: ConstraintLayout, when it earns its place, and FlowRow for the commodity filter.
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