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