From 2085b82a5e7f445f95e4fb61460c8363202b036e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 28 Jun 2026 14:31:11 +0200 Subject: [PATCH] identity: add the shared predictive-back peek (Modifier.predictiveBack) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standard Android back-gesture preview — the surface scales to ~90%, rounds its corners and shifts toward the swiped edge as the gesture is dragged, then commits or springs back. Plus rememberReduceMotion() to honour the system "remove animations" setting. Drawn from Calendula's CalendarTransitions so both apps preview back the same way; adds the activity-compose dependency. Co-Authored-By: Claude Opus 4.8 (1M context) --- gradle/libs.versions.toml | 2 + identity/build.gradle.kts | 2 + .../jeanlucmakiola/floret/identity/Motion.kt | 87 +++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/Motion.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6bdb93d..2caa391 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,7 @@ agp = "9.2.1" kotlin = "2.3.21" coreKtx = "1.19.0" appcompat = "1.7.1" +activityCompose = "1.13.0" composeBom = "2026.05.01" # Material 3 Expressive APIs live in the 1.5 alpha line; pinned to override the BOM. material3 = "1.5.0-alpha21" @@ -15,6 +16,7 @@ truth = "1.4.5" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-ui = { group = "androidx.compose.ui", name = "ui" } androidx-foundation = { group = "androidx.compose.foundation", name = "foundation" } diff --git a/identity/build.gradle.kts b/identity/build.gradle.kts index c21297b..9af4f6f 100644 --- a/identity/build.gradle.kts +++ b/identity/build.gradle.kts @@ -36,4 +36,6 @@ dependencies { implementation(libs.androidx.ui) implementation(libs.androidx.foundation) implementation(libs.androidx.material3) + // activity-compose for PredictiveBackHandler + BackEventCompat (the back-gesture peek). + implementation(libs.androidx.activity.compose) } diff --git a/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/Motion.kt b/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/Motion.kt new file mode 100644 index 0000000..c02c1de --- /dev/null +++ b/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/Motion.kt @@ -0,0 +1,87 @@ +package de.jeanlucmakiola.floret.identity + +import android.provider.Settings +import androidx.activity.BackEventCompat +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import kotlin.coroutines.cancellation.CancellationException + +/** + * Whether the user has asked the system to remove animations (Settings → + * Accessibility → "Remove animations", which sets the global animator duration + * scale to 0). Compose animations do *not* honour this platform flag on their + * own, so the shared motion helpers check it and fall back to no spatial + * movement — respecting the vestibular intent of the setting while keeping state + * changes legible. + * + * Read once at composition; the scale changes rarely and only takes full effect + * after a process restart anyway. + */ +@Composable +fun rememberReduceMotion(): Boolean { + val resolver = LocalContext.current.contentResolver + return remember(resolver) { + Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f + } +} + +/** + * 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. + * + * 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 + * respects the same back semantics. Under reduced motion the visual preview is + * skipped (the back still works); on API < 34 the system delivers no progress, + * so it degrades to a plain back. Shared so the family's apps preview the same + * way. + * + * @param enabled gate the handler — e.g. set false while a screen's own inner + * back (a sub-section) should take the gesture instead. + */ +@Composable +fun Modifier.predictiveBack( + onBack: () -> Unit, + enabled: Boolean = true, + reduceMotion: Boolean = rememberReduceMotion(), +): Modifier { + val progress = remember { Animatable(0f) } + var fromLeftEdge by remember { mutableStateOf(true) } + + PredictiveBackHandler(enabled = enabled) { events -> + try { + events.collect { event -> + fromLeftEdge = event.swipeEdge == BackEventCompat.EDGE_LEFT + progress.snapTo(FastOutSlowInEasing.transform(event.progress)) + } + onBack() + progress.snapTo(0f) + } catch (_: CancellationException) { + progress.animateTo(0f) + } + } + + if (reduceMotion) return this + return this.graphicsLayer { + val p = progress.value + val scale = 1f - 0.1f * p + scaleX = scale + scaleY = scale + translationX = (if (fromLeftEdge) 1f else -1f) * 24.dp.toPx() * p + shape = RoundedCornerShape(32.dp.toPx() * p) + clip = p > 0f + } +}