mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
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:
+14
-5
@@ -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.
|
||||
|
||||
+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
@@ -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),
|
||||
|
||||
+4
@@ -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)
|
||||
|
||||
+13
-1
@@ -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) {
|
||||
|
||||
+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()
|
||||
|
||||
+13
-1
@@ -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,
|
||||
|
||||
+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