style(calendar): make the page transition a proper shared-axis (#38, #53)

Paging between months, weeks and days looked rough, and the transition was the
reason: a full-width slide with no fade at all. AnimatedContent stacks the two
pages and both were fully opaque, so what you saw was one grid racing across
another and clipping at the viewport edge — the movement was carrying the entire
transition, over the longest distance available.

It is now M3's shared-axis X. The offset only hints the direction — a fifth of
the width — while a cross-fade does the swapping, position springs and opacity
eases, and the size transform no longer clips. All three calendar views share
this transition, so all three settle.

Also lifts the month view's swipe threshold from 6dp to the 24dp the week and
day views already used. Six is inside the distance a tap wanders, so brushing
the grid turned the page — which reads as the animation firing at random rather
than as an over-eager gesture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 18:57:45 +02:00
parent 4cee160c12
commit e691336b5e
2 changed files with 55 additions and 15 deletions

View File

@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.common
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
@@ -23,9 +24,13 @@ import androidx.compose.ui.unit.IntOffset
*/
/**
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
* spring-physics spec from the active motion scheme — snappy with a subtle
* springy settle, rather than a fixed easing curve.
* The M3 Expressive spatial spring used for the month/week/day slide: the
* *default* spring-physics spec from the active motion scheme, rather than a
* fixed easing curve.
*
* Default rather than fast: `fastSpatialSpec` is tuned for small, incidental
* movement, and driving a whole calendar page with it made the settle read as a
* twitch instead of a glide.
*
* Read it in a composable scope (this helper) so it can be captured by the
* non-composable `AnimatedContent` transitionSpec lambda.
@@ -33,27 +38,34 @@ import androidx.compose.ui.unit.IntOffset
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
MaterialTheme.motionScheme.fastSpatialSpec()
MaterialTheme.motionScheme.defaultSpatialSpec()
/**
* The fast effects spec from the active motion scheme, for opacity (fade)
* transitions. Captured in composable scope alongside [rememberCalendarSlideSpec]
* for use in non-composable transition lambdas and as the reduced-motion fallback.
* The effects spec from the active motion scheme, for the opacity half of the
* transition. Captured in composable scope alongside [rememberCalendarSlideSpec]
* for use in non-composable transition lambdas, and reused on its own as the
* reduced-motion fallback.
*
* Opacity gets its own spec on purpose: M3 Expressive springs *position* and
* eases *opacity*, and a fade that bounced with the slide would shimmer.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
MaterialTheme.motionScheme.fastEffectsSpec()
MaterialTheme.motionScheme.defaultEffectsSpec()
/**
* Horizontal slide for navigating between adjacent months/weeks/days.
* Navigating between adjacent months/weeks/days, as M3's shared-axis X: the
* outgoing page slides and fades one way while the incoming one arrives from the
* other, position on a spring and opacity on an easing curve.
*
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
* (e.g. "today"); a jump reuses the forward direction.
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
* @param fadeSpec effects spec for the reduced-motion fade, typically
* @param fadeSpec effects spec for the opacity half, and for the whole
* transition under reduced motion; typically
* [rememberCalendarFadeSpec].
* @param reduceMotion when true, swap the directional slide for a plain cross-fade.
* @param reduceMotion when true, drop the movement and cross-fade alone.
*/
fun calendarSlideTransition(
slideDir: Int,
@@ -65,6 +77,26 @@ fun calendarSlideTransition(
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
}
val dir = if (slideDir == 0) 1 else slideDir
return slideInHorizontally(spec) { w -> dir * w }
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
return ContentTransform(
targetContentEnter =
slideInHorizontally(spec) { w -> dir * w / SLIDE_TRAVEL_DIVISOR } + fadeIn(fadeSpec),
initialContentExit =
slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec),
// AnimatedContent clips to the animating container by default, which
// shears the pages against the viewport edge as they pass. There is no
// size change here to contain — both pages are the same grid.
sizeTransform = SizeTransform(clip = false),
)
}
/**
* How far a page travels, as a fraction of the container width.
*
* A full width was the obvious reading of "paging", but the two pages are
* stacked and both opaque, so a full-width slide showed one grid racing across
* another — the movement carried the whole transition and had a long way to go.
* Under M3's shared-axis pattern the offset only has to *hint* the direction
* while the cross-fade does the swapping, so a fifth of the width is plenty and
* leaves nothing skating past.
*/
private const val SLIDE_TRAVEL_DIVISOR = 5

View File

@@ -777,15 +777,20 @@ internal fun DenseMonthGrid(
/**
* The month-changing horizontal swipe, shared by the paged and split styles.
* Accumulates the drag and commits past a small threshold on release — the grid
* Accumulates the drag and commits past a threshold on release — the grid
* doesn't follow the finger, so there is no distance to rubber-band against.
*
* The threshold matches the week and day views'. It used to be 6dp, which is
* inside the distance a tap wanders: brushing the grid changed the month, and a
* page that turns on an unintended gesture reads as the animation misfiring
* rather than as the gesture being over-eager.
*/
@Composable
private fun rememberMonthSwipeModifier(
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
): Modifier {
val threshold = with(LocalDensity.current) { 6.dp.toPx() }
val threshold = with(LocalDensity.current) { MONTH_SWIPE_THRESHOLD.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
return Modifier.pointerInput(Unit) {
detectHorizontalDragGestures(
@@ -803,6 +808,9 @@ private fun rememberMonthSwipeModifier(
}
}
/** Drag distance that commits a month change, matching the week and day views. */
private val MONTH_SWIPE_THRESHOLD = 24.dp
/**
* Split style content: the compact grid keeps the month swipe, the pane below it
* lists whatever day is selected.