Drop the back preview when the surface is shown again

This commit is contained in:
2026-09-27 12:06:45 +02:00
parent 1ab46897c3
commit 1d6b1ed2a9
3 changed files with 38 additions and 10 deletions
+6 -1
View File
@@ -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
@@ -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),
@@ -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