Edge-to-edge stopped being a choice, and most apps found out from a bug report
Targeting Android 15 makes edge-to-edge mandatory — the opt-out is gone. What that changes, what breaks, and why the fix is mostly about who consumes the insets.

Day 95 of 100, opening the final pillar. This is the one that arrives as a bug report after a target SDK bump rather than as a feature you chose.
The symptom
An app that looked correct until it was rebuilt:
"The toolbar title is behind the clock." "The bottom button is under the navigation bar." "The list scrolls under the status bar and the first row is unreadable."
Nothing in the app changed. targetSdk went from 34 to 35, and the app now draws
edge-to-edge because on Android 15 that is no longer optional — the framework ignores the
old opt-out, and every screen suddenly extends behind the system bars.
Why the obvious fix fails
The obvious fix is to put the bars back:
// no longer honoured on Android 15+
window.setDecorFitsSystemWindows(true)
The API still compiles and the system ignores it when you target 35 or above. The deprecation path is real: apps get one release cycle of the old behaviour via a manifest flag, and that flag is temporary by design.
The second obvious fix is padding by a constant:
Column(Modifier.padding(top = 24.dp)) { … } // "status bar height"
Which is Day 35's mistake in a new place. The status bar is 24dp on some devices and not on others; the navigation bar is 48dp with buttons and 16dp with gestures, and zero when a keyboard is open.

The actual mechanism
Edge-to-edge means your window occupies the whole screen, including the regions the status bar and navigation bar are drawn over. The system then reports those regions as insets — measurements you consume where it matters.
The opt-in, for anything targeting below 35:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() // before setContent
super.onCreate(savedInstanceState)
setContent { AppTheme { App() } }
}
}
On 35+ this is the default and the call is a no-op for the drawing behaviour, though it still configures the bar appearance — which is the part worth keeping.
Two things follow, and the second is where the work is.
The bars become transparent. Your content shows through them, so the colour behind the status bar is now your app's colour. A dark header with light status-bar icons looks correct; the same header in light mode with light icons is unreadable.
Something must consume the insets. If nothing does, content sits under the bars. If several things do, you get double padding.
Who consumes them
Most of the answer is Day 35's: Scaffold consumes the insets and reports what's left as
innerPadding.
Scaffold(
topBar = { TopAppBar(title = { Text("Orders") }) },
) { innerPadding ->
LazyColumn(contentPadding = innerPadding) {
items(orders, key = { it.id }) { OrderRow(it) }
}
}
The top app bar draws behind the status bar and pads itself so its content clears it. The
innerPadding includes the navigation bar, so the list's last item can scroll clear of it.
Day 35's distinction matters more here than anywhere: contentPadding lets content scroll
behind the translucent bars, which is the intended edge-to-edge look.
Modifier.padding(innerPadding) shrinks the viewport so nothing ever goes behind them,
which throws away the effect.
For screens without a Scaffold, the modifiers are direct:
Modifier.safeDrawingPadding() // all the bars, plus cutouts
Modifier.windowInsetsPadding(WindowInsets.statusBars)
Modifier.windowInsetsPadding(WindowInsets.navigationBars)
Bar icon colour
The part that produces "it works but looks broken":
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
navigationBarStyle = SystemBarStyle.auto(lightScrim, darkScrim),
)
SystemBarStyle.auto picks light or dark icons from the system theme, which is right when
your app follows it. When a screen's background doesn't match the app's overall theme — a
dark photo viewer in a light app — set it explicitly for that screen, or the icons vanish
into the background.
The scrims are the fallback for API levels that can't do transparent navigation bars. Passing transparent for both is common and correct on modern devices; providing a scrim is what keeps three-button navigation legible on older ones.
What breaks, in order of frequency
From most to least common:
A bottom button under the navigation bar. The fix is innerPadding or
navigationBarsPadding() on the button's container.
A list's last item unreachable. contentPadding on the LazyColumn, not
Modifier.padding.
Double padding. Two things consuming the same inset — usually a Scaffold plus a
manual statusBarsPadding() inside it, or Day 88's XML fitsSystemWindows alongside a
Compose consumer.
Invisible bar icons. The colour issue above.
A dialog or sheet ignoring insets. These are separate windows with their own inset
handling, and a full-screen Dialog needs its own safeDrawingPadding().
The audit that finds them all
Rather than waiting for reports, a single pass finds the whole list. Open every screen with gesture navigation on and look at four things:
- The top — is anything under the status bar that shouldn't be?
- The bottom — can the last list item scroll clear? Is a primary button reachable?
- The bar icons — legible against whatever is behind them on this screen?
- Rotate — does the cutout or side navigation bar clip anything?
Twenty screens is an hour, and it's an hour spent before the release rather than after. The checklist is short because the failure modes are few: content under a bar, content unreachable behind a bar, or invisible icons.
How to prove it
The check that catches nearly everything, and takes a minute:
Run the app on a device with gesture navigation, then switch to three-button navigation in system settings and run it again. The navigation bar changes height dramatically between them, so anything using a constant instead of an inset is immediately visible.
Then rotate to landscape, where the navigation bar moves to the side on many devices and a
display cutout enters play on the other. safeDrawingPadding() handles both; a
statusBarsPadding() alone doesn't.
For automation, screenshot tests with different inset configurations are possible, and in practice the two manual checks find more.
What this generalizes to
The principle is Day 35's, arriving with more force: ask the system for the measurement rather than assuming it. The status bar height, the navigation bar height, the cutout region, the keyboard height — every one of them varies by device, by user setting, and by moment.
What changed with Android 15 is that the assumption is no longer allowed to be wrong quietly. An app that consumed insets properly needed no changes; one that padded by constants got a bug report. That's the platform converting a latent assumption into an immediate failure, which is uncomfortable and correct.
It's the same pattern as a language turning a warning into an error, or a library removing a deprecated call. The cost lands on whoever deferred the work, which feels unfair in the moment and is the only mechanism that reliably retires an old assumption.
Tomorrow, Day 96: the insets API in detail — the types, the modifiers, and the keyboard.
Day 95 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Display content edge-to-edge.