diff --git a/CHANGELOG.md b/CHANGELOG.md index dd00cf0..025fe57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,16 @@ are no binary releases; "version" tracks the shared `version` in the root build ### Fixed +- **`identity`** — A committed predictive back no longer snaps the surface back + 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. 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 8431988..b91a0a7 100644 --- a/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/Motion.kt +++ b/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/Motion.kt @@ -3,22 +3,31 @@ package de.jeanlucmakiola.floret.identity import android.provider.Settings import androidx.activity.BackEventCompat import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.animation.ExitTransition import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleOut 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 import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext import kotlin.coroutines.cancellation.CancellationException /** @@ -59,7 +68,10 @@ fun Modifier.animateContentSizeMotion(reduceMotion: Boolean = rememberReduceMoti * The standard Android predictive-back transform for a full-screen surface: as * the back gesture is dragged, the surface scales toward ~90%, shifts toward the * swiped edge and rounds its corners, previewing what's behind. Completing the - * gesture invokes [onBack]; cancelling springs it back. + * 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. 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 @@ -70,15 +82,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 { @@ -87,9 +111,18 @@ fun Modifier.predictiveBack( progress.snapTo(FastOutSlowInEasing.transform(event.progress)) } onBack() - progress.snapTo(0f) - } catch (_: CancellationException) { - 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 (e: CancellationException) { + // A cancelled gesture also cancels this job, so the spring-back must not be. + withContext(NonCancellable) { progress.animateTo(0f) } + throw e } } @@ -104,3 +137,26 @@ fun Modifier.predictiveBack( clip = p > 0f } } + +/** + * 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 + +/** + * The exit for a surface closed through [predictiveBack]: it fades while it keeps + * shrinking from the preview's scale, rather than growing back and sliding off + * at full size. Also reads right for a close without a gesture. Fade only under + * reduced motion. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun predictiveBackExit(reduceMotion: Boolean = rememberReduceMotion()): ExitTransition { + val fade = fadeOut(MaterialTheme.motionScheme.defaultEffectsSpec()) + return if (reduceMotion) { + fade + } else { + fade + scaleOut(MaterialTheme.motionScheme.defaultSpatialSpec(), targetScale = 0.9f) + } +}