Merge pull request #3864 from vitorpamplona/claude/thread-reply-ime-padding-j7c9rk

Replace BackHandler with KeyboardAwareBackHandler in posting screens
This commit is contained in:
Vitor Pamplona
2026-08-05 14:29:46 -04:00
committed by GitHub
10 changed files with 114 additions and 33 deletions
@@ -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) }
}
@@ -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))
}
@@ -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()
@@ -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()
}
@@ -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()
@@ -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()
@@ -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()
@@ -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()
}
@@ -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()
@@ -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()
}