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.

5 min read
androidcomposekotlinsystem

Day 96 — Three kinds of inset, and the keyboard

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 symptom

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 the obvious fix fails

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 actual mechanism

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.

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 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 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.