Engineering

The keyboard covered the field, and the fix was not imePadding

Insets, soft-input mode, scroll position and focus order are four systems, and three are configured outside Compose.

MobileByteSensei 8 min read
composeuiandroid

TL;DR

A text field covered by the keyboard is four separate problems wearing one symptom: whether the window resizes, whether Compose knows about the inset, whether the scrollable container scrolls the focused field into view, and whether focus moves correctly between fields.

Modifier.imePadding() addresses one of them, and only if the window is configured to report the inset in the first place. Ours was not — android:windowSoftInputMode was adjustResize on one activity and absent on another, and enableEdgeToEdge() had never been called — so imePadding() measured zero and looked broken.

Fix the window first, then the inset, then the scroll, then the focus order. In that order, because each one depends on the previous.

The case this post is about

A five-field address form:

@Composable
fun AddressForm(state: AddressState, onChange: (AddressState) -> Unit) {
    Column(Modifier.verticalScroll(rememberScrollState()).padding(16.dp)) {
        OutlinedTextField(state.line1, { onChange(state.copy(line1 = it)) }, label = { Text("Address line 1") })
        OutlinedTextField(state.line2, { onChange(state.copy(line2 = it)) }, label = { Text("Address line 2") })
        OutlinedTextField(state.city,  { onChange(state.copy(city = it)) },  label = { Text("City") })
        OutlinedTextField(state.post,  { onChange(state.copy(post = it)) },  label = { Text("Postcode") })
        OutlinedTextField(state.phone, { onChange(state.copy(phone = it)) }, label = { Text("Phone") })
        Button(onClick = { … }, modifier = Modifier.fillMaxWidth()) { Text("Save") }
    }
}

Nothing unusual, and four separate complaints came out of it.

The symptom

The last two fields were unreachable. Tapping Postcode put the keyboard over it:

screen 2340px, keyboard 980px
  Postcode field at y=1680  → behind the keyboard
  Phone field    at y=1860  → behind the keyboard
  Save button    at y=2040  → behind the keyboard

imePadding() did nothing. The obvious fix, applied and shipped, with no observable change:

Column(Modifier.verticalScroll(…).imePadding())      // no effect

The Enter key dismissed the keyboard instead of advancing. Users had to tap each field, and on the last field Enter did nothing at all.

And on one activity the layout jumped. The same form, opened from a different entry point, behaved differently — the window panned rather than resized, so the whole screen slid up and the header went off-screen.

Why the obvious fix failed

"Add imePadding()." It measures WindowInsets.ime, and that inset is only non-zero when the window is configured to receive it. With adjustPan (or no declaration, inheriting the theme's default) the IME inset reported to the view hierarchy is zero, so imePadding() applies zero padding and appears broken.

"Set windowSoftInputMode="adjustResize"." Necessary and, on its own, it made the first activity work and not the second — because the manifest entry is per-activity and we had set it on one. It also still did not scroll the focused field into view; it only stopped it being covered when it happened to be above the keyboard already.

"Add Modifier.navigationBarsPadding() as well." Different inset, different problem. We were now applying three insets and had not fixed the underlying window configuration, so two of them measured zero.

"Wrap the form in a Scaffold." Scaffold handles some insets for you, which made the symptom change rather than resolve — and masked the fact that the window was still wrong.

The actual mechanism

Four systems, in a dependency chain:

1. The window decides whether it resizes. android:windowSoftInputMode on the activity (or WindowCompat.setDecorFitsSystemWindows) determines whether the keyboard shrinks the window (adjustResize) or slides it (adjustPan). With adjustPan, nothing in your layout is told the keyboard exists.

2. Compose reads the inset, if it is reported. WindowInsets.ime is a value that comes from the window. imePadding() consumes it. If step 1 did not happen, this value is zero and every inset modifier is a no-op.

3. The scrollable container must bring the focused field into view. Even with correct insets, a field below the visible area is still off-screen — something has to scroll. Compose does this automatically only when the focused node is inside a scrollable that supports it and the inset is animating correctly.

4. Focus order and IME actions are separate again. imeAction and keyboardActions control what Enter does; focus order controls where it goes.

So imePadding() sits at step 2 of 4, and it was failing because step 1 was wrong — which is why it looked like the modifier was broken.

Four systems in a dependency chain: the window deciding whether it resizes via soft-input mode, Compose reading the IME inset only if the window reports it, the scrollable container needing to bring the focused field into view, and focus order with IME actions being separate again — with imePadding sitting at step two and measuring zero when step one is misconfigured

imePadding() is step two of four, and it reads a value the window has to provide. A no-op inset modifier almost always means the window configuration, not the modifier.

The fix

All four, in order.

1. The window — edge-to-edge and adjustResize, once, for every activity:

<!-- AndroidManifest.xml — on EVERY activity, not one -->
<activity
    android:name=".MainActivity"
    android:windowSoftInputMode="adjustResize" />
// MainActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
    enableEdgeToEdge()                  // Compose then receives the real insets
    super.onCreate(savedInstanceState)
    setContent { AppTheme { AppRoot() } }
}

enableEdgeToEdge() plus adjustResize is what makes WindowInsets.ime non-zero. Without it, everything below is decoration.

2. The inset, applied at the right level:

@Composable
fun AddressForm(state: AddressState, onChange: (AddressState) -> Unit) {
    Column(
        Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
            .imePadding()                                 // now measures the real inset
            .padding(16.dp)
    ) { … }
}

The sub-case that appears once step 1 is finally right: inside a Scaffold, that imePadding() is now applied twice. Scaffold consumes window insets itself and hands its content a PaddingValues already accounting for them, so adding imePadding() on the content reserves the keyboard's height a second time — a blank gap exactly as tall as the keyboard, which reads as "the inset is too big" and sends people back to tuning the modifier they just fixed.

Consume it in one place. Either let the Scaffold do it:

Scaffold(
    contentWindowInsets = WindowInsets.ime,     // the Scaffold owns it
) { inner ->
    Column(Modifier.padding(inner).verticalScroll(rememberScrollState())) { … }
}

…or opt the Scaffold out and keep imePadding() on the content:

Scaffold(contentWindowInsets = WindowInsets(0)) { inner -> … }

Not both. The general shape: an inset is a resource that gets consumed, and two consumers each doing it correctly is still wrong — which is the same reason nesting two imePadding() modifiers in one tree double-pads.

3. Scroll the focused field into view, which is the step people miss entirely:

OutlinedTextField(
    value = state.post,
    onValueChange = { onChange(state.copy(post = it)) },
    label = { Text("Postcode") },
    modifier = Modifier
        .fillMaxWidth()
        .bringIntoViewRequester(postRequester)
        .onFocusEvent { if (it.isFocused) scope.launch { postRequester.bringIntoView() } },
)

In practice Modifier.imeNestedScroll() on the scrollable plus correct insets handles most cases automatically — and an explicit BringIntoViewRequester is what fixes the stubborn ones, in particular a field near the bottom with a button below it.

4. Focus order and IME actions, declared:

val (line1, line2, city, post, phone) = remember { FocusRequester.createRefs() }

OutlinedTextField(
    value = state.line1, onValueChange = …,
    modifier = Modifier.focusRequester(line1).focusProperties { next = line2 },
    keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
    keyboardActions = KeyboardActions(onNext = { line2.requestFocus() }),
)
…
OutlinedTextField(
    value = state.phone, onValueChange = …,
    modifier = Modifier.focusRequester(phone),
    keyboardOptions = KeyboardOptions(
        keyboardType = KeyboardType.Phone,
        imeAction = ImeAction.Done,                       // last field: Done, not Next
    ),
    keyboardActions = KeyboardActions(onDone = { keyboard?.hide(); onSave() }),
)

Two details worth naming: imeAction = Done on the last field with an onDone that submits is what makes the form completable from the keyboard, and keyboardType per field (Phone, Number, Email) is a one-line change that materially improves the experience and is almost always forgotten.

And the manifest check, because this was a per-activity inconsistency:

# CI: every activity must declare adjustResize
ACTS=$(grep -c '<activity' app/src/main/AndroidManifest.xml)
RESIZE=$(grep -c 'windowSoftInputMode="adjustResize"' app/src/main/AndroidManifest.xml)
[ "$ACTS" -eq "$RESIZE" ] || { echo "❌ $((ACTS-RESIZE)) activity(ies) without adjustResize"; exit 1; }

How we proved it

That the inset was zero before the window fix — the measurement that explained why imePadding() looked broken:

// a temporary debug readout
val ime = WindowInsets.ime.getBottom(LocalDensity.current)
Text("ime inset: $ime px")
keyboard open, before enableEdgeToEdge + adjustResize:   ime inset: 0 px
keyboard open, after:                                     ime inset: 980 px

Zero to 980. That one line of debug output is what redirected the whole investigation from "the modifier is broken" to "the window is misconfigured".

That every field is reachable, asserted rather than eyeballed:

@Test
fun everyFieldIsVisibleWhenFocused() {
    composeTestRule.setContent { AppTheme { AddressForm(state) {} } }
    listOf("Address line 1", "Address line 2", "City", "Postcode", "Phone").forEach { label ->
        composeTestRule.onNodeWithText(label).performClick()
        composeTestRule.waitForIdle()
        composeTestRule.onNodeWithText(label).assertIsDisplayed()   // not merely present
    }
}
before:  FAILED on "Postcode" — not displayed
after:   PASSED for all 5

assertIsDisplayed() rather than assertExists() is the distinction that matters here — a node behind the keyboard exists.

That Enter advances and the last field submits:

@Test
fun imeNextAdvancesAndDoneSubmits() {
    var saved = false
    composeTestRule.setContent { AppTheme { AddressForm(state, onSave = { saved = true }) } }

    composeTestRule.onNodeWithText("Address line 1").performClick()
    composeTestRule.onNodeWithText("Address line 1").performImeAction()
    composeTestRule.onNodeWithText("Address line 2").assertIsFocused()

    composeTestRule.onNodeWithText("Phone").performClick()
    composeTestRule.onNodeWithText("Phone").performImeAction()
    assertTrue(saved)
}

And the per-activity inconsistency, which the CI check now catches:

$ ./scripts/check-manifest-insets.sh
❌ 3 activity(ies) without adjustResize

Three, not one — two more activities had the same latent problem on forms nobody had tested.

One limit: these are instrumented tests on one device profile, and keyboard behaviour varies with the IME (some third-party keyboards report insets differently) and with split-screen and floating-keyboard modes, which we did not test. The window configuration is the durable part; the specific scroll behaviour is worth checking on a real device with a real keyboard, which took about five minutes per form.

What this generalizes to

A no-op fix usually means a missing precondition. imePadding() applied zero padding because the value it reads was zero. When a well-documented API appears to do nothing, check what it reads from before concluding it is broken.

Symptoms span systems; fix them in dependency order. Window, then inset, then scroll, then focus. Applying the second fix while the first is wrong produces a change in symptom that reads as progress and is not.

Configuration that is per-instance will be inconsistent. windowSoftInputMode is per-activity, so it was right on one and absent on three. Anything declared per-instance needs a check that every instance declares it.

Assert "displayed", not "exists". A node behind a keyboard, off-screen, or clipped is present in the tree. For anything about visibility or reachability, the assertion has to be about what the user can actually see.

Keep reading

This is part of 100 Days of Compose — the mechanisms underneath the API, measured rather than guessed. Follow MobileByteSensei for the rest.

One post a week, no digest

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

Follow by RSS