Merge predictive-back fixes
This commit is contained in:
@@ -58,6 +58,16 @@ are no binary releases; "version" tracks the shared `version` in the root build
|
|||||||
|
|
||||||
### Fixed
|
### 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
|
- **`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
|
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
|
opened the tracker with a French issue — unreadable to whoever has to triage
|
||||||
|
|||||||
+5
-2
@@ -45,7 +45,9 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
|
|||||||
* Optional extras, all defaulting to the bare scaffold so existing callers are
|
* Optional extras, all defaulting to the bare scaffold so existing callers are
|
||||||
* unchanged: [actions] adds trailing app-bar items; [snackbarHost] hosts
|
* unchanged: [actions] adds trailing app-bar items; [snackbarHost] hosts
|
||||||
* transient messages; [predictiveBack] (off by default) wires the gesture
|
* 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.
|
* Set [scrollable] to false when [content] brings its own scrolling container.
|
||||||
* The default column scrolls as a whole, which composes every row up front —
|
* The default column scrolls as a whole, which composes every row up front —
|
||||||
@@ -65,6 +67,7 @@ fun CollapsingScaffold(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
largeTopBar: Boolean = true,
|
largeTopBar: Boolean = true,
|
||||||
predictiveBack: Boolean = false,
|
predictiveBack: Boolean = false,
|
||||||
|
visible: Boolean? = null,
|
||||||
scrollable: Boolean = true,
|
scrollable: Boolean = true,
|
||||||
actions: @Composable RowScope.() -> Unit = {},
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
snackbarHost: @Composable () -> Unit = {},
|
snackbarHost: @Composable () -> Unit = {},
|
||||||
@@ -77,7 +80,7 @@ fun CollapsingScaffold(
|
|||||||
}
|
}
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.then(if (predictiveBack) Modifier.predictiveBack(onBack = onBack) else Modifier)
|
.then(if (predictiveBack) Modifier.predictiveBack(onBack = onBack, visible = visible) else Modifier)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
|||||||
@@ -3,22 +3,31 @@ package de.jeanlucmakiola.floret.identity
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.activity.BackEventCompat
|
import androidx.activity.BackEventCompat
|
||||||
import androidx.activity.compose.PredictiveBackHandler
|
import androidx.activity.compose.PredictiveBackHandler
|
||||||
|
import androidx.compose.animation.ExitTransition
|
||||||
import androidx.compose.animation.animateContentSize
|
import androidx.compose.animation.animateContentSize
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
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 standard Android predictive-back transform for a full-screen surface: as
|
||||||
* the back gesture is dragged, the surface scales toward ~90%, shifts toward the
|
* 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
|
* 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
|
* 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
|
* 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
|
* @param enabled gate the handler — e.g. set false while a screen's own inner
|
||||||
* back (a sub-section) should take the gesture instead.
|
* 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
|
@Composable
|
||||||
fun Modifier.predictiveBack(
|
fun Modifier.predictiveBack(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
|
visible: Boolean? = null,
|
||||||
reduceMotion: Boolean = rememberReduceMotion(),
|
reduceMotion: Boolean = rememberReduceMotion(),
|
||||||
): Modifier {
|
): Modifier {
|
||||||
val progress = remember { Animatable(0f) }
|
val progress = remember { Animatable(0f) }
|
||||||
var fromLeftEdge by remember { mutableStateOf(true) }
|
var fromLeftEdge by remember { mutableStateOf(true) }
|
||||||
|
val currentVisible by rememberUpdatedState(visible)
|
||||||
|
|
||||||
|
LaunchedEffect(visible) {
|
||||||
|
if (visible == true) progress.snapTo(0f)
|
||||||
|
}
|
||||||
|
|
||||||
PredictiveBackHandler(enabled = enabled) { events ->
|
PredictiveBackHandler(enabled = enabled) { events ->
|
||||||
try {
|
try {
|
||||||
@@ -87,9 +111,18 @@ fun Modifier.predictiveBack(
|
|||||||
progress.snapTo(FastOutSlowInEasing.transform(event.progress))
|
progress.snapTo(FastOutSlowInEasing.transform(event.progress))
|
||||||
}
|
}
|
||||||
onBack()
|
onBack()
|
||||||
progress.snapTo(0f)
|
if (currentVisible == null) {
|
||||||
} catch (_: CancellationException) {
|
delay(COMMIT_SETTLE_DELAY_MILLIS)
|
||||||
progress.animateTo(0f)
|
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
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user