feat(month): month view style picker + Split pull-to-expand (#38, #53) #90

Merged
makiolaj merged 36 commits from feat/month-view-style into release/v2.16.0 2026-07-20 21:25:50 +00:00
2 changed files with 55 additions and 15 deletions
Showing only changes of commit e691336b5e - Show all commits

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.