Merge predictive-back fixes

This commit is contained in:
Jean-Luc Makiola
2026-09-30 13:32:24 +02:00
3 changed files with 75 additions and 6 deletions
+10
View File
@@ -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
@@ -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),
@@ -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)
}
}