From 3e68ed8068447ad6e7c740a3e75f81d1b1856c7f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 8 Aug 2026 21:57:51 +0200 Subject: [PATCH] Swipe between wizard steps and frame the previews (#163) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The steps now travel a full width on the Expressive spatial spring instead of a fifth of one under a cross-fade, so moving through the wizard reads as a swipe. The view previews get a tonal bezel — they draw the app's own surface, so against a chooser drawing the same surface they had no edge of their own — and a wider gap under them before the option rows. The frame is shared with the Settings preview pickers so the same choice still looks the same wherever it is made. --- .../jeanlucmakiola/calendula/ui/RootScreen.kt | 16 +++-- .../calendula/ui/common/ViewPreview.kt | 60 ++++++++++++++++++ .../ui/onboarding/OnboardingMotion.kt | 61 +++++++++++++++++++ .../calendula/ui/onboarding/ViewStep.kt | 58 ++++++------------ .../ui/settings/MonthViewStylePicker.kt | 53 ++++------------ .../calendula/ui/settings/WeekStartPicker.kt | 51 ++++------------ 6 files changed, 170 insertions(+), 129 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingMotion.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt index 05edef1..6523d30 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt @@ -27,12 +27,12 @@ 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.calendula.ui.onboarding.onboardingSlideTransition +import de.jeanlucmakiola.calendula.ui.onboarding.rememberOnboardingSlideSpec import de.jeanlucmakiola.floret.identity.rememberReduceMotion /** What the root is showing: a wizard step, the app, or neither yet. */ @@ -130,12 +130,10 @@ fun RootScreen( val slideDir = if (ordinal < lastOrdinal) -1 else 1 SideEffect { lastOrdinal = ordinal } - // 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() + // The wizard swipes: the steps sit side by side and the whole width travels, + // so the flow reads as moving through a stack of screens rather than as one + // screen redrawing itself. + val slideSpec = rememberOnboardingSlideSpec() val fadeSpec = rememberCalendarFadeSpec() val reduceMotion = rememberReduceMotion() AnimatedContent( @@ -146,7 +144,7 @@ fun RootScreen( if (initialState == RootTarget.Loading) { fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) } else { - calendarSlideTransition( + onboardingSlideTransition( slideDir = slideDir, spec = slideSpec, fadeSpec = fadeSpec, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewPreview.kt index 552c0ca..3168ac9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewPreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewPreview.kt @@ -1,12 +1,20 @@ package de.jeanlucmakiola.calendula.ui.common +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer @@ -18,6 +26,7 @@ import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.floret.identity.rememberReduceMotion import kotlin.math.roundToInt /** @@ -86,3 +95,54 @@ internal fun ScaledViewPreview( Column(Modifier.fillMaxSize()) { content() } } } + +/** + * The bezel every preview picker frames its preview with, cross-fading as + * [selected] changes. + * + * A preview draws the app's own surface, so on a chooser screen — which draws the + * same surface — it had no edge of its own and dissolved into the page. The tonal + * ring gives it one: the preview reads as a screen being shown rather than as + * part of the screen showing it. + */ +@Composable +internal fun ViewPreviewFrame( + selected: T, + label: String, + modifier: Modifier = Modifier, + content: @Composable (T) -> Unit, +) { + val reduceMotion = rememberReduceMotion() + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .clip(FRAME_SHAPE) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(FRAME_INSET), + ) { + Crossfade( + targetState = selected, + animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250), + label = label, + ) { shown -> + // Each preview renders the real view at phone size and scales it + // down, so its own corners are square — the frame rounds them. + Box( + modifier = Modifier + .clip(PREVIEW_SHAPE) + .background(MaterialTheme.colorScheme.surface) + .clipToBounds(), + ) { + content(shown) + } + } + } + } +} + +private val FRAME_INSET = 8.dp +private val FRAME_SHAPE = RoundedCornerShape(20.dp) +private val PREVIEW_SHAPE = RoundedCornerShape(12.dp) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingMotion.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingMotion.kt new file mode 100644 index 0000000..cdeb156 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingMotion.kt @@ -0,0 +1,61 @@ +package de.jeanlucmakiola.calendula.ui.onboarding + +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.IntOffset + +/** + * The M3 Expressive spatial spring the wizard's steps travel on. Read in + * composable scope so it can be captured by `AnimatedContent`'s non-composable + * transitionSpec lambda. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun rememberOnboardingSlideSpec(): FiniteAnimationSpec = + MaterialTheme.motionScheme.defaultSpatialSpec() + +/** + * Moving between wizard steps (#163): the steps sit side by side and the whole + * width swipes across, position on a spring so the arriving step settles with a + * little give rather than stopping dead. + * + * No cross-fade, unlike the calendar's shared-axis paging. Adjacent months are + * near-identical grids, so there the fade does the swapping and a short slide + * only hints the direction; here the two steps have nothing in common and fading + * one through the other read as a smear. Both steps are opaque and travel in + * lockstep, so a full-width slide shows one screen pushing the other off with no + * seam between them. + * + * @param slideDir +1 = forward (incoming from the right), -1 = back. + * @param spec spatial spec, typically [rememberOnboardingSlideSpec]. + * @param fadeSpec effects spec used for the whole transition under reduced + * motion. + * @param reduceMotion when true, drop the movement and cross-fade alone. + */ +internal fun onboardingSlideTransition( + slideDir: Int, + spec: FiniteAnimationSpec, + fadeSpec: FiniteAnimationSpec, + reduceMotion: Boolean, +): ContentTransform { + if (reduceMotion) { + return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) + } + val dir = if (slideDir == 0) 1 else slideDir + return ContentTransform( + targetContentEnter = slideInHorizontally(spec) { w -> dir * w }, + initialContentExit = slideOutHorizontally(spec) { w -> -dir * w }, + // Clipping to the animating container would shear both steps against the + // viewport edge as they pass; there is no size change here to contain. + sizeTransform = SizeTransform(clip = false), + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt index d472419..aa6d5ab 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt @@ -1,24 +1,15 @@ package de.jeanlucmakiola.calendula.ui.onboarding -import androidx.compose.animation.Crossfade -import androidx.compose.animation.core.snap -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -28,6 +19,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaViewPreview import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.PickerDescription +import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame import de.jeanlucmakiola.calendula.ui.common.icon import de.jeanlucmakiola.calendula.ui.common.labelRes import de.jeanlucmakiola.calendula.ui.day.DayViewPreview @@ -42,7 +34,6 @@ import de.jeanlucmakiola.floret.components.OnboardingSpace import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.SelectedCheck import de.jeanlucmakiola.floret.components.positionOf -import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.locale.currentLocale /** @@ -196,40 +187,31 @@ private fun StepPickerScaffold( ) } -/** The live preview, cross-fading as [selected] changes, framed like the picker's. */ +/** + * The live preview, framed like the picker's. The gap under it is wider than the + * one above: the rows below are the things to tap, and they need to look like a + * group of their own rather than the preview's caption. + */ @Composable private fun StepPreview(selected: T, content: @Composable (T) -> Unit) { - val reduceMotion = rememberReduceMotion() - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp) - .height(PREVIEW_HEIGHT), - contentAlignment = Alignment.Center, - ) { - Crossfade( - targetState = selected, - animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250), - label = "step-preview", - ) { shown -> - // Each preview renders the real view at phone size and scales it - // down, so its own corners are square — the frame rounds it. - Box( - modifier = Modifier - .clip(PREVIEW_SHAPE) - .background(MaterialTheme.colorScheme.surface) - .clipToBounds(), - ) { - content(shown) - } - } - } + ViewPreviewFrame( + selected = selected, + label = "step-preview", + modifier = Modifier.padding( + horizontal = 12.dp, + vertical = OnboardingSpace.xs, + ).padding(bottom = OnboardingSpace.sm), + content = content, + ) } /** + * Sized so the preview plus its frame occupies what the unframed preview used to: + * the bezel comes out of the picture, not out of the step, which still has to fit + * without scrolling. + * * Smaller than the Settings chooser's preview: this screen also carries the step * counter and the Continue button, and the whole step has to fit without * scrolling. */ -private val PREVIEW_HEIGHT: Dp = 200.dp -private val PREVIEW_SHAPE = RoundedCornerShape(12.dp) +private val PREVIEW_HEIGHT: Dp = 184.dp diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt index 53101bf..3570772 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt @@ -1,24 +1,13 @@ package de.jeanlucmakiola.calendula.ui.settings -import androidx.compose.animation.Crossfade -import androidx.compose.animation.core.snap -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.ui.common.PickerDescription +import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.month.descriptionRes @@ -27,7 +16,6 @@ import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.SelectedCheck import de.jeanlucmakiola.floret.components.positionOf -import de.jeanlucmakiola.floret.identity.rememberReduceMotion import kotlinx.datetime.DayOfWeek /** @@ -51,39 +39,21 @@ internal fun MonthViewStylePicker( onDismiss: () -> Unit, ) { val options = MonthViewStyle.entries - val reduceMotion = rememberReduceMotion() FullScreenPicker( title = stringResource(R.string.settings_month_view_style), onDismiss = onDismiss, predictiveBack = true, ) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp) - .height(PREVIEW_HEIGHT), - contentAlignment = Alignment.Center, - ) { - Crossfade( - targetState = selected, - animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250), - label = "month-style-preview", - ) { shown -> - // The preview renders the real grid at phone size and scales it - // down, so its own corners are square — the frame rounds it. - Box( - modifier = Modifier - .clip(PREVIEW_SHAPE) - .background(MaterialTheme.colorScheme.surface) - .clipToBounds(), - ) { - MonthStylePreview( - style = shown, - weekStart = weekStart, - height = PREVIEW_HEIGHT, - ) - } - } + ViewPreviewFrame( + selected = selected, + label = "month-style-preview", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) { shown -> + MonthStylePreview( + style = shown, + weekStart = weekStart, + height = PREVIEW_HEIGHT, + ) } // The selected style's own blurb lives here, under its preview, rather // than on every row — the rows stay single-line and dense, and the words @@ -109,4 +79,3 @@ internal fun MonthViewStylePicker( } private val PREVIEW_HEIGHT = 280.dp -private val PREVIEW_SHAPE = RoundedCornerShape(12.dp) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WeekStartPicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WeekStartPicker.kt index 732d2b1..8540c0d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WeekStartPicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WeekStartPicker.kt @@ -1,33 +1,21 @@ package de.jeanlucmakiola.calendula.ui.settings -import androidx.compose.animation.Crossfade -import androidx.compose.animation.core.snap -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.ui.common.PickerDescription +import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.SelectedCheck import de.jeanlucmakiola.floret.components.positionOf -import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.locale.currentLocale /** @@ -46,38 +34,22 @@ internal fun WeekStartPicker( onDismiss: () -> Unit, ) { val locale = currentLocale() - val reduceMotion = rememberReduceMotion() val resolved = selected.resolveFirstDay(locale) FullScreenPicker( title = stringResource(R.string.settings_week_start), onDismiss = onDismiss, predictiveBack = true, ) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp) - .height(PREVIEW_HEIGHT), - contentAlignment = Alignment.Center, - ) { - Crossfade( - targetState = resolved, - animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250), - label = "week-start-preview", - ) { day -> - Box( - modifier = Modifier - .clip(PREVIEW_SHAPE) - .background(MaterialTheme.colorScheme.surface) - .clipToBounds(), - ) { - MonthStylePreview( - style = monthStyle, - weekStart = day, - height = PREVIEW_HEIGHT, - ) - } - } + ViewPreviewFrame( + selected = resolved, + label = "week-start-preview", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) { day -> + MonthStylePreview( + style = monthStyle, + weekStart = day, + height = PREVIEW_HEIGHT, + ) } PickerDescription(stringResource(R.string.settings_week_start_hint)) options.forEachIndexed { index, option -> @@ -107,4 +79,3 @@ internal fun WeekStartPicker( /** Short enough to leave the first options on screen under it. */ private val PREVIEW_HEIGHT = 200.dp -private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)