From a19df2a494838e50e55cc7eee16b7c047266f0f4 Mon Sep 17 00:00:00 2001 From: Bart van Zeist Date: Tue, 21 Jul 2026 18:48:07 +0200 Subject: [PATCH] fix(sheet): make presentation sheets honour the soft keyboard on Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `.medium` sheet containing a text field was unusable on Android: the keyboard covered the field, so you could not see what you were typing. Cause is structural rather than a missing inset. The sheet cannot react to the IME at all — `contentWindowInsets` is zeroed on the `ModalBottomSheet`, and `PresentationRoot` only applies `imePadding()` when `.bottom` survives in `systemBarEdges`, which it does not for a non-edge-to-edge detent sheet. Since `.medium` is implemented as "content pinned to the bottom half of the screen", and the keyboard covers roughly that same half, the usable area collapsed to a few hundred pixels. Padding the content is not a fix: inside a fixed-height sheet it only squeezes the content and clips it (measured on-device — the field was pushed out of the accessibility tree entirely, so it could not even be selected by a UI test). Instead shift the content area: shrink the detent's top spacer by the IME height and grow the bottom spacer by the same amount. The content area keeps its exact height and translates clear of the keyboard. `topInset` tracks the adjusted value too, since it drives the sheet's rounded-top clip shape. Also fixes the bottom spacer, which computed `max(0, systemBars - ime)`. That clamps to zero the moment the keyboard opens — actively letting content run underneath it. `max(systemBars, ime)` keeps the resting behaviour and clears the keyboard when open. Both spacers collapse to their previous values when the keyboard is closed, so the resting layout is unchanged — verified on-device: with the keyboard down the field bounds are byte-identical to before the patch. Forked from upstream 1.59.0. --- .../SkipUI/SkipUI/Layout/Presentation.swift | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/Sources/SkipUI/SkipUI/Layout/Presentation.swift b/Sources/SkipUI/SkipUI/Layout/Presentation.swift index a5b4cd16..99d507d9 100644 --- a/Sources/SkipUI/SkipUI/Layout/Presentation.swift +++ b/Sources/SkipUI/SkipUI/Layout/Presentation.swift @@ -127,6 +127,20 @@ private let AlertDialogMaxWidth: Dp = 560.dp let verticalSizeClass = EnvironmentValues.shared.verticalSizeClass let isEdgeToEdge = EnvironmentValues.shared._isEdgeToEdge == true + // Height of the soft keyboard, or 0 when it is closed. + // + // The sheet itself cannot react to the IME: `contentWindowInsets` is zeroed just above, + // and `PresentationRoot` only applies `imePadding()` when `.bottom` survives in + // `systemBarEdges` — which it does not for a non-edge-to-edge detent sheet (see the + // `remove(.bottom)` below). The result was that a `.medium` sheet kept its content + // pinned to the bottom half of the screen while the keyboard covered that same half, + // leaving too little room to show the field being typed into. + // + // Rather than pad the content (which only squeezes it inside a fixed-height sheet and + // clips it), shift the whole content area up: shrink the top spacer by the IME height + // and grow the bottom spacer by the same amount. The content area keeps its exact + // height and simply translates clear of the keyboard. + let imeBottom = WindowInsets.ime.asPaddingValues().calculateBottomPadding() let sheetDepth = EnvironmentValues.shared._sheetDepth var systemBarEdges: Edge.Set = isFullScreen ? .all : [.top, .bottom] @@ -162,9 +176,13 @@ private let AlertDialogMaxWidth: Dp = 560.dp inset = topBarHeight + (24 * sheetDepth).dp + 44.dp } - topInset.value = inset + // Lift the sheet by the keyboard height (0 when closed, so the resting layout is + // unchanged). `topInset` also drives the sheet's rounded-top clip shape, so it has + // to track the adjusted value or the clip would detach from the visible top edge. + let detentInset = max(0.dp, inset - imeBottom) + topInset.value = detentInset // Draw the drag handle and the presentation root content area below it - androidx.compose.foundation.layout.Spacer(modifier: Modifier.height(inset - handleHeight - handlePadding)) + androidx.compose.foundation.layout.Spacer(modifier: Modifier.height(max(0.dp, detentInset - handleHeight - handlePadding))) Row(modifier: Modifier.fillMaxWidth(), horizontalArrangement: Arrangement.Center) { // Honor `presentationDragIndicator(.hidden)` by suppressing the capsule while // preserving its vertical footprint so the content does not shift. @@ -210,8 +228,12 @@ private let AlertDialogMaxWidth: Dp = 560.dp } } if !isEdgeToEdge { - // Move the presentation root content area above the bottom bar - let inset = max(0.dp, WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() - WindowInsets.ime.asPaddingValues().calculateBottomPadding()) + // Move the presentation root content area above the bottom bar — and, when the + // keyboard is open, above the keyboard. This previously subtracted the IME height + // from the system-bar inset, which clamps to 0 as soon as the keyboard appears and + // so let the content run underneath it. Taking the max instead keeps the old + // resting behaviour (IME closed => system bars) and clears the keyboard when open. + let inset = max(WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), imeBottom) androidx.compose.foundation.layout.Spacer(modifier: Modifier.height(inset)) } }