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:
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user