Merge pull request #3765 from vitorpamplona/claude/keyboard-ime-stuck-state-et3yfu

Fix keyboard state tracking and back handler races
This commit is contained in:
Vitor Pamplona
2026-07-28 13:33:14 -04:00
committed by GitHub
9 changed files with 105 additions and 49 deletions
@@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding
@@ -160,6 +161,7 @@ private fun ScaffoldLayout(
mainContent: @Composable (padding: PaddingValues) -> Unit,
) {
val navBarInsets = WindowInsets.navigationBars
val imeInsets = WindowInsets.ime
SubcomposeLayout { constraints ->
val layoutWidth = constraints.maxWidth
val layoutHeight = constraints.maxHeight
@@ -195,13 +197,20 @@ private fun ScaffoldLayout(
}.firstOrNull()?.measure(looseConstraints)
}
// When the bar lambda is provided but its content emits nothing (e.g. AppBottomBar
// hides itself on canPop entries), reserve the system-nav-bar inset so the FAB and
// content stay clear of the navigation bar instead of sliding under it. The
// `bottomBar == null` branch is already handled by navigationBarsPadding on
// rootModifier.
// hides itself on canPop entries, or while the keyboard is up), reserve the
// system-nav-bar inset so the FAB and content stay clear of the navigation bar instead
// of sliding under it. Subtract the IME inset: the root imePadding has already lifted the
// whole scaffold above the keyboard, and WindowInsets.ime spans the nav-bar band, so
// reserving the full nav bar on top of that would double-count and strand a gap above the
// keyboard (the `bottomBar == null` branch gets this for free via navigationBarsPadding,
// which excludes the consumed IME inset). The `bottomBar == null` case is on rootModifier.
val bottomHeight =
(bottomPlaceable?.height ?: 0).let { measured ->
if (bottomBar != null && measured == 0) navBarInsets.getBottom(this) else measured
if (bottomBar != null && measured == 0) {
(navBarInsets.getBottom(this) - imeInsets.getBottom(this)).coerceAtLeast(0)
} else {
measured
}
}
// Publish the measured limits so the nested-scroll connection can clamp correctly.
@@ -20,52 +20,67 @@
*/
package com.vitorpamplona.amethyst.ui.navigation.bottombars
import android.graphics.Rect
import android.view.View
import android.view.ViewTreeObserver
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.LocalDensity
enum class KeyboardState {
Opened,
Closed,
}
/**
* Whether the soft keyboard is currently on screen, derived from [WindowInsets.ime].
*
* This intentionally reads the same animated IME inset that drives `Modifier.imePadding()`
* everywhere else in the app, so the two can never disagree. The previous implementation
* measured `View.getWindowVisibleDisplayFrame` from a `ViewTreeObserver` global-layout
* listener — a pre-edge-to-edge heuristic. Under `enableEdgeToEdge()`
* (`decorFitsSystemWindows = false`) the window content no longer resizes for the IME, so
* that listener fired when the keyboard appeared but frequently never fired again when it
* closed, latching the state at [Opened] even though the keyboard was gone (leaving the
* bottom navigation bar hidden and a stranded gap at the bottom). The IME inset always
* animates back to zero on the persistent root view, so this reading can't get stuck.
*/
@Composable
fun keyboardAsState(): State<KeyboardState> {
val view = LocalView.current
val keyboardState = remember(view) { mutableStateOf(isKeyboardOpen(view)) }
DisposableEffect(view) {
val onGlobalListener =
ViewTreeObserver.OnGlobalLayoutListener {
val newKeyboardValue = isKeyboardOpen(view)
if (newKeyboardValue != keyboardState.value) {
keyboardState.value = newKeyboardValue
}
}
view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener)
onDispose { view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener) }
}
return keyboardState
}
fun isKeyboardOpen(view: View): KeyboardState {
val rect = Rect()
view.getWindowVisibleDisplayFrame(rect)
val screenHeight = view.rootView.height
val keypadHeight = screenHeight - rect.bottom
return if (keypadHeight > screenHeight * 0.15) {
KeyboardState.Opened
} else {
KeyboardState.Closed
val density = LocalDensity.current
val imeInsets = WindowInsets.ime
return remember(density, imeInsets) {
derivedStateOf {
if (imeInsets.getBottom(density) > 0) KeyboardState.Opened else KeyboardState.Closed
}
}
}
/**
* A [BackHandler] that steps aside while the soft keyboard is on screen.
*
* Chat composers (and draft-saving editors) intercept back to flush a draft and pop the screen.
* When that pop happens while the keyboard is still up, it races the predictive-back window
* animation against the IME's close animation. On release builds — fast enough that the window
* animation wins — the IME [WindowInsetsAnimationCompat][androidx.core.view.WindowInsetsAnimationCompat]
* is cancelled before its terminal (zero) frame reaches Compose, so the shared `WindowInsets.ime`
* holder stays "animating" and every `Modifier.imePadding()` in the app freezes at the keyboard
* height until a later inset pass rebalances it (the "stuck IME padding" that survives leaving the
* screen).
*
* Gating on [keyboardAsState] fixes it: while the keyboard is visible we do NOT consume back, so the
* system dismisses the keyboard first with its own animation (which completes cleanly). The next
* back — keyboard already down — runs [onBack] as before. The top bar's back arrow stays an
* always-available exit, so this can never trap the user even if the inset reading were itself stale.
*/
@Composable
fun KeyboardAwareBackHandler(
enabled: Boolean = true,
onBack: () -> Unit,
) {
val keyboardState by keyboardAsState()
BackHandler(enabled = enabled && keyboardState == KeyboardState.Closed, onBack = onBack)
}
@@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
@@ -106,6 +107,9 @@ fun NewGeohashChatScreen(
.fillMaxSize()
.padding(top = pad.calculateTopPadding(), bottom = pad.calculateBottomPadding())
.padding(horizontal = 16.dp)
// consume the scaffold insets so the trailing imePadding() only adds the part of
// the IME the nav-bar padding above doesn't already cover (union, not a sum).
.consumeWindowInsets(pad)
.verticalScroll(rememberScrollState())
.imePadding(),
verticalArrangement = Arrangement.spacedBy(20.dp),
@@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
@@ -171,6 +172,9 @@ fun MarmotGroupInfoScreen(
Modifier
.fillMaxSize()
.padding(padding)
// consume the scaffold insets so imePadding() unions with the nav-bar inset in
// `padding` instead of stacking on top of it while the keyboard is up.
.consumeWindowInsets(padding)
.imePadding(),
) {
// Group header section (fixed at top)
@@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.minichat
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -174,7 +176,17 @@ fun MinichatScreen(
)
},
) { padding ->
Column(Modifier.fillMaxHeight().padding(padding)) {
// The reply composer must ride above the soft keyboard — the bare Material3 Scaffold's
// content insets cover the system bars but not the IME. consumeWindowInsets(padding) before
// imePadding() unions the two so the nav-bar inset already in `padding` is dropped while the
// keyboard is up instead of stacking on top of it.
Column(
Modifier
.fillMaxHeight()
.padding(padding)
.consumeWindowInsets(padding)
.imePadding(),
) {
// Every reply here is rooted at [rootNote], which is already pinned at the top — so
// suppress the redundant reply-to-root preview each reply would otherwise render.
CompositionLocalProvider(LocalSuppressReplyToNoteId provides rootId) {
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Box
@@ -87,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage
@@ -169,7 +169,7 @@ fun NewGroupDMScreen(
WatchAndLoadMyEmojiList(accountViewModel)
BackHandler {
KeyboardAwareBackHandler {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -60,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
@@ -110,7 +110,7 @@ fun PrivateMessageEditFieldRow(
onSendNewMessage: () -> Unit,
nav: INav,
) {
BackHandler {
KeyboardAwareBackHandler {
if (channelScreenModel.message.text.isNotBlank()) {
accountViewModel.launchSigner {
channelScreenModel.sendDraftSync()
@@ -24,8 +24,10 @@ import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -185,7 +187,17 @@ fun ConcordChannelScreen(
)
},
) { padding ->
Column(Modifier.fillMaxHeight().padding(padding)) {
// The composer must ride above the soft keyboard: the bare Material3 Scaffold's content
// insets cover the system bars but not the IME. consumeWindowInsets(padding) before
// imePadding() unions the two so the nav-bar inset already in `padding` is dropped while
// the keyboard is up (WindowInsets.ime already spans that band) instead of stacking on top.
Column(
Modifier
.fillMaxHeight()
.padding(padding)
.consumeWindowInsets(padding)
.imePadding(),
) {
Column(Modifier.fillMaxHeight().weight(1f, true)) {
RefreshingChatroomFeedView(
feedContentState = feedViewModel.feedState,
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -54,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -78,7 +78,7 @@ fun EditFieldRow(
onSendNewMessage: suspend () -> Unit,
nav: INav,
) {
BackHandler {
KeyboardAwareBackHandler {
accountViewModel.launchSigner {
channelScreenModel.sendDraftSync()
channelScreenModel.cancel()