From 675724411ee3a3112e7ab252aa2b9a6099095ca1 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 9 Oct 2026 11:03:32 +0200 Subject: [PATCH] feat(components): revealOnFocus keeps a focused input group above the keyboard On by default in InlineTextField, GroupedSurface and OptionalFormSection. --- .../floret/components/FormSection.kt | 2 +- .../floret/components/GroupedList.kt | 2 +- .../floret/components/InlineTextField.kt | 2 +- .../floret/components/RevealOnFocus.kt | 62 +++++++++++++++++++ 4 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 components/src/main/kotlin/de/jeanlucmakiola/floret/components/RevealOnFocus.kt diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/FormSection.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/FormSection.kt index d190547..b657e0e 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/FormSection.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/FormSection.kt @@ -21,6 +21,6 @@ fun OptionalFormSection(visible: Boolean, content: @Composable ColumnScope.() -> enter = expandEnter(), exit = collapseExit(), ) { - Column(modifier = Modifier.fillMaxWidth(), content = content) + Column(modifier = Modifier.fillMaxWidth().revealOnFocus(), content = content) } } diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt index 292fe67..ee96595 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt @@ -122,7 +122,7 @@ fun GroupedSurface( position == Position.Top || position == Position.Middle -> Modifier.padding(bottom = 2.dp) else -> Modifier } - val base = modifier.fillMaxWidth().then(gap) + val base = modifier.fillMaxWidth().then(gap).revealOnFocus() val haptics = LocalHapticFeedback.current when { onLongClick != null -> Surface( diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InlineTextField.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InlineTextField.kt index 4ec755a..a8c2b13 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InlineTextField.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InlineTextField.kt @@ -91,6 +91,6 @@ fun InlineTextField( innerTextField() } }, - modifier = modifier, + modifier = modifier.revealOnFocus(), ) } diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/RevealOnFocus.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/RevealOnFocus.kt new file mode 100644 index 0000000..7f80f16 --- /dev/null +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/RevealOnFocus.kt @@ -0,0 +1,62 @@ +package de.jeanlucmakiola.floret.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imeAnimationTarget +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull + +/** + * Scrolls this whole element into view while anything inside it has focus — once + * the keyboard has finished opening, and again whenever the element resizes. + * + * A text field on its own only brings its text line into view, so a card's + * padding, its label and whatever appears below the field (suggestions, chips) + * stay under the keyboard. [InlineTextField], [GroupedSurface] and + * [OptionalFormSection] apply it already; add it to an app's own card or row + * that holds an input. Needs a scrolling parent whose viewport shrinks with the + * keyboard (`imePadding`). + * + * The keyboard is only read while focused, so an unfocused element never + * recomposes as the keyboard animates. + */ +@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) +fun Modifier.revealOnFocus(): Modifier = composed { + val requester = remember { BringIntoViewRequester() } + var focused by remember { mutableStateOf(false) } + var size by remember { mutableStateOf(IntSize.Zero) } + val density = LocalDensity.current + val ime = WindowInsets.ime + val imeTarget = WindowInsets.imeAnimationTarget + LaunchedEffect(focused) { + if (!focused) return@LaunchedEffect + // Reveal against the final viewport, not one frame of the keyboard sliding in. + snapshotFlow { + val now = ime.getBottom(density) + if (now == imeTarget.getBottom(density)) now to size else null + } + .filterNotNull() + .distinctUntilChanged() + .collect { requester.bringIntoView() } + } + this + .bringIntoViewRequester(requester) + .onSizeChanged { size = it } + .onFocusChanged { focused = it.hasFocus } +}