From ea96e970c79fd231c709525cfd127244529b6b7d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 28 Jun 2026 15:13:06 +0200 Subject: [PATCH] identity: add the shared content transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit expandEnter/collapseExit (revealable sections), itemEnter (resolved content), animateItemMotion (lazy-list relayout) and fadeThrough — all driven by the M3 Expressive motion scheme and honouring rememberReduceMotion. Drawn from Calendula's motion helpers. Co-Authored-By: Claude Opus 4.8 (1M context) --- gradle/libs.versions.toml | 1 + identity/build.gradle.kts | 1 + .../floret/identity/Transitions.kt | 91 +++++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/Transitions.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2caa391..87ea3d6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", versi androidx-ui = { group = "androidx.compose.ui", name = "ui" } androidx-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } +androidx-animation = { group = "androidx.compose.animation", name = "animation" } androidx-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" } junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" } diff --git a/identity/build.gradle.kts b/identity/build.gradle.kts index 9af4f6f..a9fa649 100644 --- a/identity/build.gradle.kts +++ b/identity/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { implementation(libs.androidx.ui) implementation(libs.androidx.foundation) implementation(libs.androidx.material3) + implementation(libs.androidx.animation) // 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/Transitions.kt b/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/Transitions.kt new file mode 100644 index 0000000..4132e8c --- /dev/null +++ b/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/Transitions.kt @@ -0,0 +1,91 @@ +package de.jeanlucmakiola.floret.identity + +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * The family's content transitions, driven by the active M3 Expressive motion + * scheme so every app reveals, collapses and re-lays-out the same way. Each + * honours [rememberReduceMotion]: when the user has asked the system to remove + * animations, the spatial movement is dropped and only a quick fade remains. + * + * Drawn from Calendula's motion helpers; pair [expandEnter] with [collapseExit] + * on an `AnimatedVisibility`, use [itemEnter] for content revealed once it + * resolves, [animateItemMotion] for `LazyColumn`/`LazyRow` row relayout, and + * [fadeThrough] to swap whole blocks with no spatial direction. + */ + +/** + * Enter for a vertically-revealed section (expandable rows, inline fields): + * height grows from the top while fading in; a quick fade only under reduced + * motion. Pair with [collapseExit]. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun expandEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition { + val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()) + return if (reduceMotion) fade else expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade +} + +/** Exit counterpart to [expandEnter]: shrink + fade, or fade only under reduced motion. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun collapseExit(reduceMotion: Boolean = rememberReduceMotion()): ExitTransition { + val fade = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()) + return if (reduceMotion) fade else shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade +} + +/** + * Enter for content revealed by an `AnimatedContent`/`AnimatedVisibility` (e.g. + * results once a query resolves): a gentle rise + fade; fade only under reduced + * motion. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun itemEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition { + val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()) + return if (reduceMotion) fade else fade + slideInVertically(MaterialTheme.motionScheme.fastSpatialSpec()) { h -> h / 6 } +} + +/** + * `LazyItemScope.animateItem` wired to the app's motion scheme instead of + * Compose's default spring, so list rows fade/relocate consistently. Returns a + * bare [Modifier] under reduced motion so rows snap into place. Requires stable + * item keys. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun LazyItemScope.animateItemMotion(reduceMotion: Boolean = rememberReduceMotion()): Modifier = + if (reduceMotion) { + Modifier + } else { + Modifier.animateItem( + fadeInSpec = MaterialTheme.motionScheme.fastEffectsSpec(), + placementSpec = MaterialTheme.motionScheme.fastSpatialSpec(), + fadeOutSpec = MaterialTheme.motionScheme.fastEffectsSpec(), + ) + } + +/** + * Cross-fade [ContentTransform] for swapping whole screens or blocks with no + * meaningful spatial direction (e.g. onboarding gates). Pure opacity, so it + * doubles as its own reduced-motion form. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun fadeThrough(): ContentTransform { + val fade = MaterialTheme.motionScheme.fastEffectsSpec() + return fadeIn(fade).togetherWith(fadeOut(fade)) +}