diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt index a9c963f370..cf6a752c31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt @@ -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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt index 68e9ba6c66..1457927832 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt @@ -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 { - 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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/NewGeohashChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/NewGeohashChatScreen.kt index ebf206147a..13f71901cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/NewGeohashChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/NewGeohashChatScreen.kt @@ -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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt index 65ccc7dcd9..0d8f4c3428 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt index 6f15e93574..01af32e136 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index d87f54987d..ce87e19328 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index fc64efe0ab..390d718d9b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index 160acdf42a..13d186171f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt index 8176dc4d5b..f14cd1d59e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -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()