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