mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
Merge pull request #3870 from vitorpamplona/claude/thread-reply-ime-padding-j7c9rk
fix(ime): settle the keyboard in Nav so every screen stops stranding imePadding
This commit is contained in:
-104
@@ -20,25 +20,13 @@
|
||||
*/
|
||||
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,
|
||||
@@ -68,95 +56,3 @@ 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
|
||||
|
||||
/**
|
||||
* Returns a runner that defers an action until the soft keyboard is fully off 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).
|
||||
*
|
||||
* 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 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) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.navigation.navs
|
||||
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
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.withTimeoutOrNull
|
||||
|
||||
/** How long to wait for the IME inset to reach zero before navigating anyway. */
|
||||
const val IME_SETTLE_TIMEOUT_MS = 700L
|
||||
|
||||
/**
|
||||
* Waits for the soft keyboard to be fully off screen. Installed on [Nav] so that every navigation
|
||||
* in the app serializes the IME and window animations instead of overlapping them.
|
||||
*
|
||||
* Navigating while the keyboard is 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. `WindowInsets.ime` is a single app-wide holder, so it
|
||||
* stays "animating" and every `Modifier.imePadding()` in the app — not just the screen being left —
|
||||
* freezes at the keyboard height until some later inset pass happens to rebalance it.
|
||||
*
|
||||
* This is not a composer-screen problem, which is why it lives here rather than in the screens.
|
||||
* Any destination that can hold focus in a text field can strand the padding on the way out, by any
|
||||
* exit: a back gesture, a top-bar button, a bottom-nav tab, or tapping a result. Search is the
|
||||
* clearest case — it focuses its field on arrival, so the keyboard is already up before the user
|
||||
* has done anything, and every way out of it is a navigation.
|
||||
*/
|
||||
fun interface ImeSettler {
|
||||
suspend fun settle()
|
||||
|
||||
companion object {
|
||||
/** For [EmptyNav] and previews, where there is no window to read insets from. */
|
||||
val None = ImeSettler { }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the same animated `WindowInsets.ime` that drives `Modifier.imePadding()`, so the settler
|
||||
* and the padding can never disagree about whether the keyboard is gone.
|
||||
*
|
||||
* Focus is cleared before hiding so nothing re-requests the IME as it retracts. The wait is bounded
|
||||
* by [IME_SETTLE_TIMEOUT_MS] — if the inset never reports zero, which is precisely the failure this
|
||||
* guards against, navigation still proceeds rather than stranding the user on the screen.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberImeSettler(): ImeSettler {
|
||||
val density = LocalDensity.current
|
||||
val imeInsets = WindowInsets.ime
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
return remember(density, imeInsets, keyboard, focusManager) {
|
||||
ImeSettler {
|
||||
if (imeInsets.getBottom(density) > 0) {
|
||||
focusManager.clearFocus(true)
|
||||
keyboard?.hide()
|
||||
withTimeoutOrNull(IME_SETTLE_TIMEOUT_MS) {
|
||||
snapshotFlow { imeInsets.getBottom(density) }.first { it <= 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,13 @@ import kotlin.reflect.KClass
|
||||
class Nav(
|
||||
val controller: NavHostController,
|
||||
override val navigationScope: CoroutineScope,
|
||||
/**
|
||||
* Awaited before every transition below. Leaving a screen while the soft keyboard is still
|
||||
* animating strands `imePadding()` app-wide; see [ImeSettler]. Every in-app navigation goes
|
||||
* through this class, so this is the one place that has to get it right — no screen, top bar
|
||||
* or back handler needs to think about the keyboard on its way out.
|
||||
*/
|
||||
private val ime: ImeSettler = ImeSettler.None,
|
||||
) : INav {
|
||||
override val drawerState = DrawerState(DrawerValue.Closed)
|
||||
|
||||
@@ -63,6 +70,7 @@ class Nav(
|
||||
|
||||
override fun nav(route: Route) {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
if (getRouteWithArguments(route::class, controller) != route) {
|
||||
controller.navigate(route)
|
||||
}
|
||||
@@ -71,6 +79,7 @@ class Nav(
|
||||
|
||||
override fun nav(computeRoute: suspend () -> Route?) {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
val route = computeRoute()
|
||||
if (route != null && getRouteWithArguments(route::class, controller) != route) {
|
||||
controller.navigate(route)
|
||||
@@ -80,6 +89,7 @@ class Nav(
|
||||
|
||||
override fun newStack(route: Route) {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
controller.navigate(route) {
|
||||
popUpTo(route) {
|
||||
inclusive = true
|
||||
@@ -91,6 +101,7 @@ class Nav(
|
||||
|
||||
override fun navBottomBar(route: Route) {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
controller.navigate(route) {
|
||||
// Clear sibling bottom-nav entries but keep Home (the start
|
||||
// destination) below, so back-swipe from any tab returns to
|
||||
@@ -149,6 +160,7 @@ class Nav(
|
||||
|
||||
override fun popBack() {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
controller.navigateUp()
|
||||
}
|
||||
}
|
||||
@@ -159,6 +171,7 @@ class Nav(
|
||||
klass: KClass<T>,
|
||||
) {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
controller.navigate(route) {
|
||||
popUpTo(klass) { inclusive = true }
|
||||
}
|
||||
|
||||
+3
-2
@@ -29,9 +29,10 @@ import androidx.navigation.compose.rememberNavController
|
||||
fun rememberNav(): Nav {
|
||||
val navController = rememberNavController()
|
||||
val scope = rememberCoroutineScope()
|
||||
val ime = rememberImeSettler()
|
||||
|
||||
return remember(navController, scope) {
|
||||
Nav(navController, scope)
|
||||
return remember(navController, scope, ime) {
|
||||
Nav(navController, scope, ime)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-7
@@ -31,7 +31,6 @@ 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
|
||||
@@ -46,10 +45,6 @@ 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) {
|
||||
@@ -65,7 +60,7 @@ fun ActionTopBar(
|
||||
navigationIcon = {
|
||||
CloseButton(
|
||||
modifier = HalfHorzPadding,
|
||||
onPress = { afterKeyboardCloses(onCancel) },
|
||||
onPress = onCancel,
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
@@ -75,7 +70,7 @@ fun ActionTopBar(
|
||||
Button(
|
||||
modifier = HalfHorzPadding,
|
||||
enabled = isActive(),
|
||||
onClick = { afterKeyboardCloses(onPost) },
|
||||
onClick = onPost,
|
||||
) {
|
||||
Text(text = stringRes(postRes))
|
||||
}
|
||||
|
||||
+2
-2
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
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
|
||||
@@ -66,7 +67,6 @@ 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)
|
||||
|
||||
KeyboardAwareBackHandler {
|
||||
BackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
postViewModel.sendDraftSync()
|
||||
postViewModel.cancel()
|
||||
|
||||
+2
-2
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
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
|
||||
@@ -51,7 +52,6 @@ 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() }
|
||||
}
|
||||
|
||||
KeyboardAwareBackHandler {
|
||||
BackHandler {
|
||||
nav.popBack()
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -21,6 +21,7 @@
|
||||
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
|
||||
@@ -86,7 +87,6 @@ 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)
|
||||
|
||||
KeyboardAwareBackHandler {
|
||||
BackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
postViewModel.sendDraftSync()
|
||||
postViewModel.cancel()
|
||||
|
||||
+2
-2
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
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
|
||||
@@ -59,7 +60,6 @@ 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,
|
||||
) {
|
||||
KeyboardAwareBackHandler {
|
||||
BackHandler {
|
||||
if (channelScreenModel.message.text.isNotBlank()) {
|
||||
accountViewModel.launchSigner {
|
||||
channelScreenModel.sendDraftSync()
|
||||
|
||||
+2
-2
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
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
|
||||
@@ -53,7 +54,6 @@ 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,
|
||||
) {
|
||||
KeyboardAwareBackHandler {
|
||||
BackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
channelScreenModel.sendDraftSync()
|
||||
channelScreenModel.cancel()
|
||||
|
||||
+2
-2
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
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
|
||||
@@ -95,7 +96,6 @@ 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)
|
||||
|
||||
KeyboardAwareBackHandler {
|
||||
BackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
postViewModel.sendDraftSync()
|
||||
postViewModel.cancel()
|
||||
|
||||
+2
-2
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
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
|
||||
@@ -52,7 +53,6 @@ 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)
|
||||
|
||||
KeyboardAwareBackHandler {
|
||||
BackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
postViewModel.sendDraftSync()
|
||||
postViewModel.cancel()
|
||||
|
||||
+2
-2
@@ -23,6 +23,7 @@ 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
|
||||
@@ -92,7 +93,6 @@ 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)
|
||||
|
||||
KeyboardAwareBackHandler {
|
||||
BackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
postViewModel.sendDraftSync()
|
||||
postViewModel.cancel()
|
||||
|
||||
+2
-2
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
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
|
||||
@@ -47,7 +48,6 @@ 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,
|
||||
) {
|
||||
KeyboardAwareBackHandler {
|
||||
BackHandler {
|
||||
goalViewModel.cancel()
|
||||
nav.popBack()
|
||||
}
|
||||
|
||||
+2
-2
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
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
|
||||
@@ -63,7 +64,6 @@ 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)
|
||||
|
||||
KeyboardAwareBackHandler {
|
||||
BackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
postViewModel.sendDraftSync()
|
||||
postViewModel.cancel()
|
||||
|
||||
+2
-2
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
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
|
||||
@@ -60,7 +61,6 @@ 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)
|
||||
|
||||
KeyboardAwareBackHandler {
|
||||
BackHandler {
|
||||
postViewModel.cancel()
|
||||
nav.popBack()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavOptionsBuilder
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.ImeSettler
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Leaving a screen while the soft keyboard is still animating strands `imePadding()` at keyboard
|
||||
* height for the whole app, because `WindowInsets.ime` is a single shared holder. The fix is that
|
||||
* [Nav] waits for the IME to be gone before it moves, so these assert the ordering rather than any
|
||||
* visual result: every transition must settle the keyboard *first*.
|
||||
*
|
||||
* Without the settle calls in [Nav] each of these records only "navigate" and fails.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class NavImeSettleTest {
|
||||
private fun controllerRecording(order: MutableList<String>): NavHostController =
|
||||
mockk<NavHostController>(relaxed = true) {
|
||||
every { navigate(any<Route>(), any<NavOptionsBuilder.() -> Unit>()) } answers
|
||||
{ order.add("navigate") }
|
||||
every { navigate(any<Route>()) } answers { order.add("navigate") }
|
||||
every { navigateUp() } answers {
|
||||
order.add("navigate")
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun popBackSettlesTheKeyboardBeforeNavigating() =
|
||||
runTest {
|
||||
val order = mutableListOf<String>()
|
||||
val nav = Nav(controllerRecording(order), this, ImeSettler { order.add("settle") })
|
||||
|
||||
nav.popBack()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("settle", "navigate"), order)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bottomBarSettlesTheKeyboardBeforeNavigating() =
|
||||
runTest {
|
||||
// The search tab focuses its field on arrival, so the keyboard is already up when the
|
||||
// user taps another tab — the exit that has no BackHandler and no top bar to guard it.
|
||||
val order = mutableListOf<String>()
|
||||
val nav = Nav(controllerRecording(order), this, ImeSettler { order.add("settle") })
|
||||
|
||||
nav.navBottomBar(Route.Home)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("settle", "navigate"), order)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newStackSettlesTheKeyboardBeforeNavigating() =
|
||||
runTest {
|
||||
val order = mutableListOf<String>()
|
||||
val nav = Nav(controllerRecording(order), this, ImeSettler { order.add("settle") })
|
||||
|
||||
nav.newStack(Route.Home)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("settle", "navigate"), order)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSlowKeyboardStillHoldsTheNavigationBack() =
|
||||
runTest {
|
||||
// The real settler suspends for the length of the IME close animation. Navigation must
|
||||
// wait for it, not fire alongside it — that overlap is the bug.
|
||||
val order = mutableListOf<String>()
|
||||
val nav =
|
||||
Nav(
|
||||
controllerRecording(order),
|
||||
this,
|
||||
ImeSettler {
|
||||
delay(250)
|
||||
order.add("settle")
|
||||
},
|
||||
)
|
||||
|
||||
nav.popBack()
|
||||
assertEquals(emptyList<String>(), order)
|
||||
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf("settle", "navigate"), order)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user