Engineering

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.

MobileByteSensei 8 min read
Day 95 — Edge-to-edge stopped being a choice
androidcomposekotlinsystem

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 case this post is about

One app that looked correct for two years, rebuilt against a newer SDK, with its title now under the status bar.

Nothing in the app changed. Two things in it had been load-bearing without anybody deciding they were:

// no longer honoured on Android 15+
window.setDecorFitsSystemWindows(true)
Column(Modifier.padding(top = 24.dp)) { … }     // "status bar height"

The first stopped being honoured. The second was a measured constant from one device, and it was correct on that device for as long as the first line kept it from mattering.

This post follows that one screen, because the change is not something the app opted into — on Android 15 the window occupies the whole display whether it asked or not, and the work is all in what consumes the difference.

The symptom: an app that looked correct until it was rebuilt

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 putting the system bars back is the wrong fix

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.

Edge-to-edge draws behind the bars; insets report how much of the screen the system occupies

The mechanism: your window now occupies the whole screen

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.

Consumption is subtractive, and that one property explains both failures — so it is worth modelling rather than describing:

data class Insets(val top: Int, val bottom: Int) {
    val none get() = top == 0 && bottom == 0
}

class InsetChain(private var available: Insets) {
    val log = mutableListOf<String>()

    // A consumer takes what it uses and passes the REMAINDER inward. Nothing resets it.
    fun consume(who: String, top: Boolean = false, bottom: Boolean = false): Insets {
        val took = Insets(if (top) available.top else 0, if (bottom) available.bottom else 0)
        available = Insets(available.top - took.top, available.bottom - took.bottom)
        log += "%-14s took %-18s remaining %s".format(who, took, available)
        return took
    }

    fun finish(leaf: String) {
        log += if (available.none) "%-14s nothing left to consume — correct".format(leaf)
               else "%-14s UNCONSUMED $available — this content is under the bars".format(leaf)
    }
}

The three arrangements of the same screen, on a device reporting 24dp top and 48dp bottom:

// 1. Scaffold consumes, content pads again
InsetChain(Insets(24, 48)).run {
    consume("Scaffold", top = true, bottom = true)
    consume("Column(safeDrawing)", top = true, bottom = true)
    finish("content"); log.forEach(::println)
}
Scaffold       took Insets(24, 48)      remaining Insets(0, 0)
Column(safeDra took Insets(0, 0)        remaining Insets(0, 0)
content        nothing left to consume — correct
# 2. nothing consumes
content        UNCONSUMED Insets(24, 48) — this content is under the bars

# 3. Scaffold consumes, content pads with a HARDCODED 24/48 instead of the inset
Scaffold       took Insets(24, 48)      remaining Insets(0, 0)
Column(24,48)  took Insets(24, 48)      remaining Insets(0, 0)     <- padding applied twice

The third is the one that looks like a Compose bug and is not. Case 1 and case 3 are the same two components in the same order; the difference is that safeDrawing reads what is left and a literal 24.dp reads nothing at all. Subtraction is what makes the correct version compose and the literal version double.

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:

  1. The top — is anything under the status bar that shouldn't be?
  2. The bottom — can the last list item scroll clear? Is a primary button reachable?
  3. The bar icons — legible against whatever is behind them on this screen?
  4. 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 one-minute edge-to-edge check

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: ask the system for the measurement

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.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS