mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
Merge pull request #3864 from vitorpamplona/claude/thread-reply-ime-padding-j7c9rk
Replace BackHandler with KeyboardAwareBackHandler in posting screens
This commit is contained in:
+91
-15
@@ -21,14 +21,24 @@
|
||||
package com.vitorpamplona.amethyst.ui.navigation.bottombars
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
enum class KeyboardState {
|
||||
Opened,
|
||||
@@ -59,28 +69,94 @@ fun keyboardAsState(): State<KeyboardState> {
|
||||
}
|
||||
}
|
||||
|
||||
/** How long to wait for the IME inset to reach zero before running the action anyway. */
|
||||
private const val IME_SETTLE_TIMEOUT_MS = 700L
|
||||
|
||||
/**
|
||||
* A [BackHandler] that steps aside while the soft keyboard is on screen.
|
||||
* Returns a runner that defers an action until the soft keyboard is fully off 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).
|
||||
* Popping a screen while the keyboard is still up races the 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.
|
||||
* Any exit that leaves a keyboard-bearing screen has to serialize the two animations rather than
|
||||
* overlap them. With the keyboard already down the action runs inline — same frame, no behavior
|
||||
* change. With it up we dismiss the keyboard, wait for the inset to actually reach zero, and only
|
||||
* then act, so the IME animation always completes before the window animation begins.
|
||||
*
|
||||
* Re-entrant calls while an action is pending are dropped: the deferral widens the window in which
|
||||
* a second tap on a Post/Save button would fire the action twice.
|
||||
*
|
||||
* [IME_SETTLE_TIMEOUT_MS] bounds the wait — if the inset never reports zero (precisely the failure
|
||||
* this guards against) the action still runs, so a stale reading can never trap the user on screen.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberAfterKeyboardCloses(): (() -> Unit) -> Unit {
|
||||
val density = LocalDensity.current
|
||||
val imeInsets = WindowInsets.ime
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val pending = remember { AtomicBoolean(false) }
|
||||
|
||||
return remember(density, imeInsets, keyboard, focusManager, scope, pending) {
|
||||
{ action: () -> Unit ->
|
||||
if (imeInsets.getBottom(density) <= 0) {
|
||||
action()
|
||||
} else if (pending.compareAndSet(false, true)) {
|
||||
// Clear focus first so nothing re-requests the IME as it retracts.
|
||||
focusManager.clearFocus(true)
|
||||
keyboard?.hide()
|
||||
scope.launch {
|
||||
try {
|
||||
withTimeoutOrNull(IME_SETTLE_TIMEOUT_MS) {
|
||||
snapshotFlow { imeInsets.getBottom(density) }.first { it <= 0 }
|
||||
}
|
||||
action()
|
||||
} finally {
|
||||
pending.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [BackHandler] that lets the system dismiss the soft keyboard before it consumes back.
|
||||
*
|
||||
* Chat composers (and draft-saving editors) intercept back to flush a draft and pop the screen,
|
||||
* which is the pop-during-IME-animation race described on [rememberAfterKeyboardCloses]. While the
|
||||
* keyboard is up we do NOT consume back, so the system dismisses it first with its own animation
|
||||
* (which completes cleanly, and on recent Android follows the back gesture). The next back runs
|
||||
* [onBack] as before.
|
||||
*
|
||||
* The gate reads [WindowInsets.imeAnimationTarget] — where the IME is *heading* — not the animated
|
||||
* [WindowInsets.ime]. Gating on the animated value left a hole: it stays above zero for the whole
|
||||
* close animation, ~250ms in which the IME has already stopped consuming back but this handler was
|
||||
* still disabled, so a second back fell through to the NavController and popped the screen without
|
||||
* ever running [onBack] — silently dropping the draft it exists to save. The target flips to zero
|
||||
* the moment the hide begins, so back keeps reaching [onBack] throughout.
|
||||
*
|
||||
* Re-enabling that early means [onBack] can now fire mid-animation, so it is routed through
|
||||
* [rememberAfterKeyboardCloses] to wait for the inset to settle before popping.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun KeyboardAwareBackHandler(
|
||||
enabled: Boolean = true,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val keyboardState by keyboardAsState()
|
||||
BackHandler(enabled = enabled && keyboardState == KeyboardState.Closed, onBack = onBack)
|
||||
val density = LocalDensity.current
|
||||
val imeTarget = WindowInsets.imeAnimationTarget
|
||||
val afterKeyboardCloses = rememberAfterKeyboardCloses()
|
||||
|
||||
val keyboardIsStaying by remember(density, imeTarget) {
|
||||
derivedStateOf { imeTarget.getBottom(density) > 0 }
|
||||
}
|
||||
|
||||
BackHandler(enabled = enabled && !keyboardIsStaying) { afterKeyboardCloses(onBack) }
|
||||
}
|
||||
|
||||
+7
-2
@@ -31,6 +31,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.rememberAfterKeyboardCloses
|
||||
import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding
|
||||
@@ -45,6 +46,10 @@ fun ActionTopBar(
|
||||
onPost: () -> Unit,
|
||||
additionalActions: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
// Both exits pop the screen, and on a composer the keyboard is up while typing — the same
|
||||
// pop-during-IME-animation race the back gesture avoids, just reached by a tap instead.
|
||||
val afterKeyboardCloses = rememberAfterKeyboardCloses()
|
||||
|
||||
ShorterTopAppBar(
|
||||
title = {
|
||||
if (titleRes != null) {
|
||||
@@ -60,7 +65,7 @@ fun ActionTopBar(
|
||||
navigationIcon = {
|
||||
CloseButton(
|
||||
modifier = HalfHorzPadding,
|
||||
onPress = onCancel,
|
||||
onPress = { afterKeyboardCloses(onCancel) },
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
@@ -70,7 +75,7 @@ fun ActionTopBar(
|
||||
Button(
|
||||
modifier = HalfHorzPadding,
|
||||
enabled = isActive(),
|
||||
onClick = onPost,
|
||||
onClick = { afterKeyboardCloses(onPost) },
|
||||
) {
|
||||
Text(text = stringRes(postRes))
|
||||
}
|
||||
|
||||
+2
-2
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.nip22Comments
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -67,6 +66,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
|
||||
@@ -177,7 +177,7 @@ fun GenericCommentPostScreen(
|
||||
|
||||
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
|
||||
|
||||
BackHandler {
|
||||
KeyboardAwareBackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
postViewModel.sendDraftSync()
|
||||
postViewModel.cancel()
|
||||
|
||||
+2
-2
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -52,6 +51,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.Nip05OrPubkeyLine
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
@@ -88,7 +88,7 @@ fun AwardBadgeScreen(
|
||||
onDispose { userSuggestions.reset() }
|
||||
}
|
||||
|
||||
BackHandler {
|
||||
KeyboardAwareBackHandler {
|
||||
nav.popBack()
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -96,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
|
||||
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer
|
||||
@@ -149,7 +149,7 @@ fun LongFormPostScreen(
|
||||
|
||||
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
|
||||
|
||||
BackHandler {
|
||||
KeyboardAwareBackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
postViewModel.sendDraftSync()
|
||||
postViewModel.cancel()
|
||||
|
||||
+2
-2
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -53,6 +52,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
|
||||
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.topbars.PostingTopBar
|
||||
@@ -141,7 +141,7 @@ fun NewProductScreen(
|
||||
|
||||
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
|
||||
|
||||
BackHandler {
|
||||
KeyboardAwareBackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
postViewModel.sendDraftSync()
|
||||
postViewModel.cancel()
|
||||
|
||||
+2
-2
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -93,6 +92,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview
|
||||
import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
@@ -235,7 +235,7 @@ internal fun NewPostScreenInner(
|
||||
|
||||
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
|
||||
|
||||
BackHandler {
|
||||
KeyboardAwareBackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
postViewModel.sendDraftSync()
|
||||
postViewModel.cancel()
|
||||
|
||||
+2
-2
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.nip75Goals
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -48,6 +47,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
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.topbars.PostingTopBar
|
||||
@@ -81,7 +81,7 @@ fun NewGoalScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
BackHandler {
|
||||
KeyboardAwareBackHandler {
|
||||
goalViewModel.cancel()
|
||||
nav.popBack()
|
||||
}
|
||||
|
||||
+2
-2
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -64,6 +63,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
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.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.topbars.PostingTopBar
|
||||
@@ -134,7 +134,7 @@ fun NewPublicMessageScreen(
|
||||
|
||||
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
|
||||
|
||||
BackHandler {
|
||||
KeyboardAwareBackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
postViewModel.sendDraftSync()
|
||||
postViewModel.cancel()
|
||||
|
||||
+2
-2
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -61,6 +60,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
@@ -80,7 +80,7 @@ fun NewWorkoutScreen(
|
||||
postViewModel.init(accountViewModel)
|
||||
postViewModel.prefill(prefill)
|
||||
|
||||
BackHandler {
|
||||
KeyboardAwareBackHandler {
|
||||
postViewModel.cancel()
|
||||
nav.popBack()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user