Slide between wizard steps instead of cross-fading (#163)

Both steps are opaque full-screen scaffolds, so a plain cross-fade
dissolved one's text through the other's with nothing to say which way
the flow ran. Reuse the shared-axis X transition adjacent months and
weeks already use; coming off the blank first frame still just fades,
since that is the app opening rather than a step change.
This commit is contained in:
2026-08-08 21:20:29 +02:00
parent a35078f96d
commit 4601967370
2 changed files with 40 additions and 12 deletions

View File

@@ -2,9 +2,10 @@ package de.jeanlucmakiola.calendula.ui
import android.Manifest import android.Manifest
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.compose.animation.Crossfade import androidx.compose.animation.AnimatedContent
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.animation.fadeIn
import androidx.compose.material3.MaterialTheme import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -24,9 +25,13 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeDialog import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeDialog
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeViewModel import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeViewModel
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingViewModel import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingViewModel
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
/** What the root is showing: a wizard step, the app, or neither yet. */ /** What the root is showing: a wizard step, the app, or neither yet. */
private sealed interface RootTarget { private sealed interface RootTarget {
@@ -36,7 +41,6 @@ private sealed interface RootTarget {
data class Step(val step: OnboardingStep) : RootTarget data class Step(val step: OnboardingStep) : RootTarget
} }
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun RootScreen( fun RootScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -112,11 +116,32 @@ fun RootScreen(
CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss) CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss)
} }
// Cross-fade the one-time onboarding gates so granting permission / finishing // The wizard advances along M3's shared-axis X, the same transition adjacent
// a step eases into the next screen instead of snapping. A fade carries no // months and weeks use: the outgoing step slides and fades one way while the
// spatial motion, so it stays appropriate under "remove animations" too. // next arrives from the other. A plain cross-fade read as a smear here —
val gateSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>() // both steps are opaque full-screen scaffolds, so their text dissolved
Crossfade(targetState = target, animationSpec = gateSpec, label = "onboardingGate") { shown -> // through each other with nothing to say which way the flow was going.
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
AnimatedContent(
targetState = target,
transitionSpec = {
// Coming off the blank first frame is not a step change — the app
// has only just opened, so the first screen simply appears.
if (initialState == RootTarget.Loading) {
fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
} else {
calendarSlideTransition(
slideDir = 1,
spec = slideSpec,
fadeSpec = fadeSpec,
reduceMotion = reduceMotion,
)
}
},
label = "onboardingGate",
) { shown ->
when (shown) { when (shown) {
RootTarget.Loading -> Unit RootTarget.Loading -> Unit
RootTarget.App -> CalendarHost( RootTarget.App -> CalendarHost(
@@ -134,8 +159,8 @@ fun RootScreen(
onEditKeyConsumed = onEditKeyConsumed, onEditKeyConsumed = onEditKeyConsumed,
) )
// The plan the outgoing step was drawn with is gone by the time the // The plan the outgoing step was drawn with is gone by the time the
// fade ends, so the live one carries it — its counter is what the // transition ends, so the live one carries both halves; each reads
// incoming step should already show. // its own position out of it.
is RootTarget.Step -> current?.let { plan -> is RootTarget.Step -> current?.let { plan ->
OnboardingSteps( OnboardingSteps(
step = shown.step, step = shown.step,

View File

@@ -23,7 +23,10 @@ fun OnboardingSteps(
// Positioned from the step being drawn, not from the plan's own current // Positioned from the step being drawn, not from the plan's own current
// step: mid-crossfade the outgoing screen would otherwise jump to the // step: mid-crossfade the outgoing screen would otherwise jump to the
// incoming one's number. // incoming one's number.
val index = plan.steps.indexOf(step) + 1 // Coerced because the outgoing half of a transition may be a step the live
// plan has since dropped — the backup step goes once the calendars say it
// does not apply — and a "Step 0 of 3" flash is worse than a stale number.
val index = (plan.steps.indexOf(step) + 1).coerceAtLeast(1)
val progress: (@Composable () -> Unit)? = if (!plan.showsProgress) { val progress: (@Composable () -> Unit)? = if (!plan.showsProgress) {
null null
} else { } else {