fix(ime): stop the soft keyboard state from getting stuck open

Leaving a chat with the keyboard up via a back gesture left a large IME
padding stranded at the bottom — and, because WindowInsets.ime is a single
app-wide holder, on every other screen too — until some later inset pass
happened to rebalance it. It only reproduced on release builds.

Root cause: the chat composers install a BackHandler that flushes the draft
and pops the screen. When that pop runs while the keyboard is still visible,
the predictive-back window animation races the IME's close animation. On an
optimized release build the window animation wins, and the IME
WindowInsetsAnimation is cancelled before its terminal (zero) frame reaches
Compose, so the shared insets holder stays "animating" and every
imePadding() in the app freezes at the keyboard height. Debug and benchmark
builds are slow enough that the IME animation completes first, which is why
they were unaffected.

Fixes:
- New KeyboardAwareBackHandler: while the keyboard is on screen it does not
  consume back, so the system dismisses the keyboard first (its animation
  completes cleanly); the next back runs the original handler. The top-bar
  back arrow remains an always-available exit. Adopted in the DM, public-chat
  and new-group-DM composers.
- Rederive keyboardAsState() from WindowInsets.ime instead of the
  pre-edge-to-edge getWindowVisibleDisplayFrame/OnGlobalLayout heuristic,
  which under enableEdgeToEdge() could itself latch Opened after the keyboard
  closed. Now it tracks the same inset that drives imePadding().
- Concord channel chat and the minichat thread view used a bare Material3
  Scaffold whose content insets ignore the IME; add imePadding() so the
  composer rides above the keyboard while typing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dT2RLbPZzan2hULL2cmc6
This commit is contained in:
Claude
2026-07-28 16:29:32 +00:00
parent 775c76621a
commit f734865a31
6 changed files with 66 additions and 44 deletions
@@ -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.Column
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 +175,9 @@ fun MinichatScreen(
)
},
) { padding ->
Column(Modifier.fillMaxHeight().padding(padding)) {
// imePadding so the reply composer rides above the soft keyboard — the bare Material3
// Scaffold's content insets cover the system bars but not the IME.
Column(Modifier.fillMaxHeight().imePadding().padding(padding)) {
// 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()
@@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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 +186,10 @@ fun ConcordChannelScreen(
)
},
) { padding ->
Column(Modifier.fillMaxHeight().padding(padding)) {
// imePadding so the composer rides above the soft keyboard. The bare Material3 Scaffold's
// content insets cover the system bars but not the IME, so without this the message field
// sat behind the keyboard while typing.
Column(Modifier.fillMaxHeight().imePadding().padding(padding)) {
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()