Engineering

There are three kinds of inset, and using the wrong one is why your keyboard fights your layout

Compose exposes system insets as composable values with padding, size and consume modifiers. Knowing the three safe-inset groups and how the keyboard inset animates resolves most edge-to-edge layout problems.

MobileByteSensei 7 min read
Day 96 — Three kinds of inset, and the keyboard
androidcomposekotlinsystem

Day 96 of 100. Yesterday said "consume the insets". Today: which ones, with what, and the keyboard case that behaves differently from all of them.

The case this post is about

One chat screen whose input field is covered by the keyboard:

Column(Modifier.fillMaxSize()) {
    MessageList(Modifier.weight(1f))
    MessageInput(Modifier.fillMaxWidth())
}

Correct layout, correct weights, and when the keyboard opens the field the user is typing into is behind it.

The fix that occurs first reads the ime inset and pads by it, which works and then fights every other inset on the screen — because the keyboard is one of several things claiming the bottom edge, and it animates while the others do not.

This post follows that one input field, because the three inset groups exist precisely so you do not have to reason about which of six individual insets applies, and picking the wrong group is how a slider ends up inside the back-gesture strip.

The symptom: a keyboard that covers the chat input

A chat screen where the keyboard covers the input:

Column(Modifier.fillMaxSize()) {
    MessageList(Modifier.weight(1f))
    MessageInput(Modifier.fillMaxWidth())
}

Tap the field and the keyboard slides up over it. The user is typing into something they can't see. Adding android:windowSoftInputMode="adjustResize" to the manifest helps in a View app and does nothing useful here, because Compose handles the keyboard as an inset rather than by resizing the window.

Why padding by the keyboard height doesn't work

The obvious fix is to pad by the keyboard height:

val ime = WindowInsets.ime.getBottom(LocalDensity.current)
Column(Modifier.padding(bottom = with(LocalDensity.current) { ime.toDp() })) { … }

This works and janks. The keyboard animates in over about 250ms, and reading its height in composition means recomposing the whole column on every frame of that animation — Day 82's problem, in a place where it's very visible.

There's a modifier that does the same thing in the right phase.

Three safe-inset groups, and the modifiers that pad, size or consume them

The mechanism: an inset is four measurements, one per edge

An inset is a set of four measurements — how much the system occupies on each edge. Compose exposes them as WindowInsets values readable in composition.

The individual ones:

WindowInsets.statusBars          WindowInsets.navigationBars
WindowInsets.displayCutout       WindowInsets.ime
WindowInsets.systemGestures      WindowInsets.captionBar

And three groups, which are what you should usually reach for:

safeDrawing — everywhere the system might draw over you. Status bar, navigation bar, cutout, and the keyboard. The default choice for content that must be visible.

safeGestures — where the system reserves gestures: the back-gesture edges, the home-swipe area. The choice for anything draggable, so your slider doesn't sit where a back swipe starts.

safeContent — the union of both. For content that must be visible and interactive.

The rule: safeDrawing for what must be seen, safeGestures for what must be touched, safeContent when both.

The groups are unions, and writing them as unions is what makes the choice checkable rather than remembered:

data class Insets(val left: Int, val top: Int, val right: Int, val bottom: Int) {
    infix fun union(o: Insets) = Insets(
        maxOf(left, o.left), maxOf(top, o.top), maxOf(right, o.right), maxOf(bottom, o.bottom),
    )
}

// What the system reports on a gesture-navigation phone with the keyboard up.
val statusBars     = Insets(0, 24, 0, 0)
val navigationBars = Insets(0, 0, 0, 16)      // the gesture pill
val displayCutout  = Insets(0, 32, 0, 0)
val ime            = Insets(0, 0, 0, 312)     // animates 0 -> 312 on show
val systemGestures = Insets(24, 0, 24, 48)    // back-swipe edges + home swipe

val safeDrawing  = statusBars union navigationBars union displayCutout union ime
val safeGestures = systemGestures
val safeContent  = safeDrawing union safeGestures

The three groups on that one device state:

safeDrawing   Insets(left=0,  top=32, right=0,  bottom=312)
safeGestures  Insets(left=24, top=0,  right=24, bottom=48)
safeContent   Insets(left=24, top=32, right=24, bottom=312)

Which is enough to see both mistakes without a device. A slider padded with safeDrawing gets left=0 — it sits in the 24dp back-swipe strip, so dragging from its left end goes back instead of scrubbing. And a text field padded with safeGestures gets bottom=48 against a keyboard occupying 312, so the field it is editing is under the keyboard.

Note top as well: safeDrawing is 32, not 24. The cutout is deeper than the status bar on this device, and union is why that is handled rather than configured — padding against statusBars alone puts your content 8dp inside the notch, on exactly the devices you do not have on your desk.

Three things to do with one

Each inset supports three modifiers, and the difference matters:

Modifier.windowInsetsPadding(WindowInsets.safeDrawing)   // pad by it
Modifier.windowInsetsTopHeight(WindowInsets.statusBars)  // BE its size — a spacer
Modifier.consumeWindowInsets(paddingValues)              // tell children it's handled

Padding is the common one, with shorthands: safeDrawingPadding(), statusBarsPadding(), navigationBarsPadding(), imePadding().

Size is for a spacer that occupies exactly the bar's area — how you draw a coloured block behind the status bar rather than letting content show through.

Consume is the one that fixes double padding. When a parent has already padded for an inset, children shouldn't pad again:

Scaffold { innerPadding ->
    Box(Modifier.consumeWindowInsets(innerPadding)) {
        // children asking for safeDrawing get zero — already handled above
        InnerContent()
    }
}

Without it, a nested component calling navigationBarsPadding() adds the bar's height a second time. This is the most common edge-to-edge bug after "no padding at all", and it's harder to spot because it looks like a spacing mistake.

The keyboard

WindowInsets.ime is different from the others in two ways: it animates, and it changes frequently.

The right tool is the modifier, not a read:

Column(Modifier.fillMaxSize().imePadding()) {
    MessageList(Modifier.weight(1f))
    MessageInput(Modifier.fillMaxWidth())
}

imePadding() applies the inset in the layout phase, so the keyboard animation costs layout and draw rather than a recomposition per frame — Day 82's deferred read, provided as a modifier.

Two companions worth knowing:

Modifier.imeNestedScroll() on a scrollable makes the keyboard dismiss as you scroll away from the field, which is the behaviour messaging apps have.

WindowInsets.isImeVisible for when you genuinely need the boolean — showing a "send" button only while typing, say. It's a state read, so use it for structure rather than for positioning.

There's a third, less-known option for the case where you want your content to track the keyboard frame by frame rather than settling with it: Modifier.imeNestedScroll() plus the animated variant of the inset. Most screens don't need it — imePadding() already animates in step — but a custom sheet that should follow the keyboard exactly does.

The manifest still matters: android:windowSoftInputMode="adjustResize" is required for the ime inset to be reported at all on some versions. It's the one piece of XML an otherwise-Compose app still needs.

Nested scaffolds and the reason they double

Day 88 mentioned this; here's the mechanism. Each Scaffold consumes the insets available to it and reports the remainder. A nested one sees the original insets again unless the outer one's padding was consumed, so it pads for the status bar a second time.

The fix is either one scaffold per screen — the recommendation — or consumeWindowInsets between them.

The same applies across the interop boundary: a ComposeView inside a layout with fitsSystemWindows="true" gets insets that the View system has already consumed, or hasn't, depending on configuration. A screen with visible double padding after a migration is almost always this.

Drawing behind, deliberately

The look edge-to-edge exists for:

Box {
    AsyncImage(
        model = hero,
        contentScale = ContentScale.Crop,
        modifier = Modifier.fillMaxWidth().height(280.dp),   // NO inset padding — behind the bar
    )
    TopAppBar(
        title = { Text("Order") },
        colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
        modifier = Modifier.statusBarsPadding(),             // only the CONTENT is padded
    )
}

The image extends under the status bar; the title clears it. That split — background ignores the inset, content respects it — is the whole visual idiom, and it's why the modifiers apply per-element rather than per-screen.

How to prove it: yesterday's two checks plus the keyboard

Yesterday's two checks plus one:

Open a screen with a text field, tap it, and watch the transition. The content should rise with the keyboard rather than jumping when it finishes. A jump means the inset is being read in composition rather than applied with imePadding().

For double padding, the tell is a gap that's suspiciously exactly a bar's height. Comment out one consumer and see whether the layout becomes correct — if it does, two things were padding for the same inset.

A useful debugging aid while hunting one:

Modifier.drawBehind {
    drawRect(Color.Red.copy(alpha = 0.2f))     // on the element you suspect
}

Tinting the padded container makes it obvious whether the gap is inside or outside it, which distinguishes "this element padded twice" from "its parent already padded".

What this generalizes to: the environment is measurable per element

The principle is the environment is measurable, and the measurement is per-element. Insets aren't a screen-level setting; they're values that different parts of a layout answer differently — a background ignores them, content respects them, a draggable control respects a different set entirely.

That's why the API is a family of modifiers rather than a flag. fitsSystemWindows was a boolean, and a boolean can't express "the image goes behind and the title doesn't", which is exactly what every modern screen wants.

The general shape: when a system-level concern needs different answers in different parts of a tree, it has to be expressible per element. A screen-level setting can only be right for the majority of a screen, and the exceptions are where the design lives.

Tomorrow, Day 97: predictive back — the gesture that shows the user where they're going before they commit.


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

One post a week, no digest

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

Follow by RSS