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.content.pm.PackageManager
import androidx.compose.animation.Crossfade
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.ui.calendars.CalendarVisibilityNoticeDialog
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.OnboardingSteps
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. */
private sealed interface RootTarget {
@@ -36,7 +41,6 @@ private sealed interface RootTarget {
data class Step(val step: OnboardingStep) : RootTarget
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun RootScreen(
modifier: Modifier = Modifier,
@@ -112,11 +116,32 @@ fun RootScreen(
CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss)
}
// Cross-fade the one-time onboarding gates so granting permission / finishing
// a step eases into the next screen instead of snapping. A fade carries no
// spatial motion, so it stays appropriate under "remove animations" too.
val gateSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
Crossfade(targetState = target, animationSpec = gateSpec, label = "onboardingGate") { shown ->
// The wizard advances along M3's shared-axis X, the same transition adjacent
// months and weeks use: the outgoing step slides and fades one way while the
// next arrives from the other. A plain cross-fade read as a smear here —
// both steps are opaque full-screen scaffolds, so their text dissolved
// 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) {
RootTarget.Loading -> Unit
RootTarget.App -> CalendarHost(
@@ -134,8 +159,8 @@ fun RootScreen(
onEditKeyConsumed = onEditKeyConsumed,
)
// 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
// incoming step should already show.
// transition ends, so the live one carries both halves; each reads
// its own position out of it.
is RootTarget.Step -> current?.let { plan ->
OnboardingSteps(
step = shown.step,

View File

@@ -23,7 +23,10 @@ fun OnboardingSteps(
// Positioned from the step being drawn, not from the plan's own current
// step: mid-crossfade the outgoing screen would otherwise jump to the
// 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) {
null
} else {