From 1d6b1ed2a9c1ebafae3d84df8ae33c775e484eef Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 27 Sep 2026 12:06:45 +0200 Subject: [PATCH] Drop the back preview when the surface is shown again --- CHANGELOG.md | 7 +++- .../floret/components/CollapsingScaffold.kt | 7 ++-- .../jeanlucmakiola/floret/identity/Motion.kt | 34 +++++++++++++++---- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbff252..2e371ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,12 @@ are no binary releases; "version" tracks the shared `version` in the root build to full size before it leaves. `Modifier.predictiveBack()` now holds the preview transform after `onBack`, and the new `predictiveBackExit()` exit fades and keeps shrinking from there; pair them on the surface's - `AnimatedVisibility` instead of a full-size slide-out. + `AnimatedVisibility` instead of a full-size slide-out. Pass that + `AnimatedVisibility`'s `visible` to `predictiveBack(visible = …)` (or + `CollapsingScaffold(visible = …)`) so the preview is dropped as soon as the + surface is shown again — reopened mid-exit it no longer appears shrunk — and + settles back when a back leaves it in place. Without it the preview is held + for a fixed second. - **`core-crash`** — The prefilled crash issue is filed in English again. Its title and body came from string resources, so a crash on a French phone opened the tracker with a French issue — unreadable to whoever has to triage diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/CollapsingScaffold.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/CollapsingScaffold.kt index e6b4d3c..345f16f 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/CollapsingScaffold.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/CollapsingScaffold.kt @@ -45,7 +45,9 @@ import de.jeanlucmakiola.floret.identity.predictiveBack * Optional extras, all defaulting to the bare scaffold so existing callers are * unchanged: [actions] adds trailing app-bar items; [snackbarHost] hosts * transient messages; [predictiveBack] (off by default) wires the gesture - * preview to [onBack] for full-screen surfaces that want it. + * preview to [onBack] for full-screen surfaces that want it, with [visible] + * passed through to [Modifier.predictiveBack] for a surface that is shown and + * hidden in place. * * Set [scrollable] to false when [content] brings its own scrolling container. * The default column scrolls as a whole, which composes every row up front — @@ -65,6 +67,7 @@ fun CollapsingScaffold( modifier: Modifier = Modifier, largeTopBar: Boolean = true, predictiveBack: Boolean = false, + visible: Boolean? = null, scrollable: Boolean = true, actions: @Composable RowScope.() -> Unit = {}, snackbarHost: @Composable () -> Unit = {}, @@ -77,7 +80,7 @@ fun CollapsingScaffold( } Scaffold( modifier = modifier - .then(if (predictiveBack) Modifier.predictiveBack(onBack = onBack) else Modifier) + .then(if (predictiveBack) Modifier.predictiveBack(onBack = onBack, visible = visible) else Modifier) .fillMaxSize() .background(MaterialTheme.colorScheme.surface) .nestedScroll(scrollBehavior.nestedScrollConnection), diff --git a/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/Motion.kt b/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/Motion.kt index 1742dfa..1cc3721 100644 --- a/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/Motion.kt +++ b/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/Motion.kt @@ -13,10 +13,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext @@ -65,9 +68,8 @@ fun Modifier.animateContentSizeMotion(reduceMotion: Boolean = rememberReduceMoti * swiped edge and rounds its corners, previewing what's behind. Completing the * gesture invokes [onBack] and keeps the preview transform, so the surface * leaves from where the finger let go instead of snapping back to full size — - * pair it with [predictiveBackExit] on the surface's own exit. Should the surface - * still be there shortly after (the back was taken without dismissing it), it - * settles back. Cancelling springs it back. + * pair it with [predictiveBackExit] on the surface's own exit. Cancelling + * springs it back. * * A drop-in replacement for a screen's own `BackHandler(onBack)` — register it * once and apply the returned [Modifier] to that screen's root so the preview @@ -78,15 +80,27 @@ fun Modifier.animateContentSizeMotion(reduceMotion: Boolean = rememberReduceMoti * * @param enabled gate the handler — e.g. set false while a screen's own inner * back (a sub-section) should take the gesture instead. + * @param visible whether the surface is currently shown — typically the + * `visible` of the `AnimatedVisibility` it lives in. The preview is held while + * the surface leaves and dropped the moment it is shown again, so reopening it + * mid-exit never starts from the shrunk state; a back that leaves it visible + * settles back. Null when the caller doesn't track it: the preview is then held + * for a fixed time before settling back. */ @Composable fun Modifier.predictiveBack( onBack: () -> Unit, enabled: Boolean = true, + visible: Boolean? = null, reduceMotion: Boolean = rememberReduceMotion(), ): Modifier { val progress = remember { Animatable(0f) } var fromLeftEdge by remember { mutableStateOf(true) } + val currentVisible by rememberUpdatedState(visible) + + LaunchedEffect(visible) { + if (visible == true) progress.snapTo(0f) + } PredictiveBackHandler(enabled = enabled) { events -> try { @@ -95,8 +109,14 @@ fun Modifier.predictiveBack( progress.snapTo(FastOutSlowInEasing.transform(event.progress)) } onBack() - delay(COMMIT_SETTLE_DELAY_MILLIS) - progress.animateTo(0f) + if (currentVisible == null) { + delay(COMMIT_SETTLE_DELAY_MILLIS) + progress.animateTo(0f) + } else { + // Two frames guarantee the recomposition that onBack triggered. + repeat(2) { withFrameNanos { } } + if (currentVisible == true) progress.animateTo(0f) + } } catch (_: CancellationException) { progress.animateTo(0f) } @@ -115,8 +135,8 @@ fun Modifier.predictiveBack( } /** - * How long a committed preview is held before it settles back — past any exit - * the surface would play, so it only fires for a back that left it in place. + * How long a committed preview is held before it settles back when the caller + * doesn't pass `visible` — past any exit the surface would play. */ private const val COMMIT_SETTLE_DELAY_MILLIS = 1_000L