mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
+51
-36
@@ -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)
|
||||
}
|
||||
|
||||
+4
-1
@@ -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) {
|
||||
|
||||
+2
-2
@@ -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()
|
||||
|
||||
+2
-2
@@ -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()
|
||||
|
||||
+5
-1
@@ -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,
|
||||
|
||||
+2
-2
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user