Swipe between wizard steps and frame the previews (#163)

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.
This commit is contained in:
2026-08-08 21:57:51 +02:00
parent 4ffeed8409
commit 3e68ed8068
6 changed files with 170 additions and 129 deletions

View File

@@ -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,

View File

@@ -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 <T> 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)

View File

@@ -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<IntOffset> =
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<IntOffset>,
fadeSpec: FiniteAnimationSpec<Float>,
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),
)
}

View File

@@ -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 <T> 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

View File

@@ -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)

View File

@@ -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)