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 + } +}