Page the month view with a pager that follows the finger (#336)

Paged and Split now page like the week and day views; Split keeps its
vertical expand drag, now vertical-only beside the pager, and the pane
follows the grid's row count mid-swipe. The old trigger swipe and slide
transition are gone with their last callers.

Closes #336
This commit is contained in:
2026-09-25 14:40:04 +02:00
parent eda616b42e
commit a2b6793406
8 changed files with 339 additions and 426 deletions
@@ -17,15 +17,17 @@ import kotlinx.coroutines.flow.collectLatest
import kotlin.math.floor
/**
* One all-day strip height for every page, blended between the two pages a swipe
* sits between so their timelines stay level mid-swipe. It follows the swipe
* directly and only springs when a page's own strip changes at rest. [fallback]
* stands in until the pages have reported theirs, so it doesn't open from zero.
* One height for every page of something that varies from page to page — the
* timelines' all-day strip, the split grid's rows — blended between the two
* pages a swipe sits between, so what sits below it moves with the finger rather
* than jumping on settle. It follows the swipe directly and only springs when a
* page's own height changes at rest. [fallback] stands in until the pages have
* reported theirs, so it doesn't open from zero.
*
* @param heights each composed page's own strip height, by page index.
* @param heights each composed page's own height, by page index.
*/
@Composable
fun pagedStripHeight(pagerState: PagerState, heights: Map<Int, Dp>, fallback: Dp): Dp {
fun pagedHeight(pagerState: PagerState, heights: Map<Int, Dp>, fallback: Dp): Dp {
val currentFallback by rememberUpdatedState(fallback)
val target by remember(pagerState, heights) {
derivedStateOf {
@@ -1,79 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
/**
* Drag distance that turns a calendar page, shared by the month, week and day
* views so all three answer a swipe at the same point.
*
* It was 6dp once, 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.
*/
val CALENDAR_SWIPE_THRESHOLD = 24.dp
/**
* The whole-page horizontal swipe: one page per gesture, committed **the moment
* the drag clears [CALENDAR_SWIPE_THRESHOLD]** rather than when the finger lifts.
*
* Waiting for the lift meant the page sat still under a finger that had already
* travelled far enough to ask for it, and the answer only arrived once you let
* go — which reads as the view being slow rather than as a deliberate commit.
* Firing on the threshold is what makes the gesture feel like it is being
* followed. The trade is that a drag can no longer be taken back by dragging the
* other way; in practice, once you have moved 24dp deliberately you meant it, and
* the page you land on is one swipe back.
*
* Deliberately **horizontal-only**. The week and day timelines scroll vertically
* underneath this, and a two-dimensional detector here would claim those drags
* before the inner scroll ever saw them. As it is, a horizontal drag crosses this
* detector's slop while a vertical one is consumed below, and the two coexist.
* (The month view's split style needs a vertical axis as well, so it keeps its
* own axis-locking detector rather than using this.)
*/
@Composable
fun rememberCalendarPageSwipe(
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
): Modifier {
val threshold = with(LocalDensity.current) { CALENDAR_SWIPE_THRESHOLD.toPx() }
return Modifier.pointerInput(onSwipeNext, onSwipePrev) {
var accum = 0f
// One page per gesture: without this a long drag would keep re-firing
// every time the accumulator crossed the threshold again.
var fired = false
detectHorizontalDragGestures(
onDragStart = {
accum = 0f
fired = false
},
onDragEnd = {
accum = 0f
fired = false
},
onDragCancel = {
accum = 0f
fired = false
},
onHorizontalDrag = { _, drag ->
accum += drag
if (!fired) {
val commit = when {
accum < -threshold -> onSwipeNext
accum > threshold -> onSwipePrev
else -> null
}
if (commit != null) {
fired = true
commit()
}
}
},
)
}
}
@@ -1,13 +1,6 @@
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
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
@@ -19,12 +12,12 @@ import androidx.compose.ui.unit.IntOffset
* the reduce-motion check — now live in floret-kit's identity module
* (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`,
* `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays
* here is only what's specific to paging the calendar grid: the directional
* month/week/day slide and the specs that feed it.
* here is only what's specific to paging the calendar grid: the specs its
* pagers settle with, and that the view switch reuses.
*/
/**
* The M3 Expressive spatial spring used for the month/week/day slide: the
* The M3 Expressive spatial spring the calendar's motion is built on: the
* *default* spring-physics spec from the active motion scheme, rather than a
* fixed easing curve.
*
@@ -59,52 +52,3 @@ fun rememberCalendarPageSpec(): FiniteAnimationSpec<Float> =
@Composable
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
MaterialTheme.motionScheme.defaultEffectsSpec()
/**
* 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 opacity half, and for the whole
* transition under reduced motion; typically
* [rememberCalendarFadeSpec].
* @param reduceMotion when true, drop the movement and cross-fade alone.
*/
fun calendarSlideTransition(
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 / 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. Left off even
// where the two pages differ in height — the split grid stands as many
// rows as its month spans (#162) — since a page sliding out over the row
// below it reads as travel, and the shear reads as a fault.
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
@@ -284,8 +284,8 @@ class TimelineGeometry {
}
/**
* Hoisted drag state for one timeline (#68). It lives above the per-page
* `AnimatedContent`, so a page change mid-drag can't strand a ghost, and the
* Hoisted drag state for one timeline (#68). It lives above the pager, so a
* page change mid-drag can't strand a ghost, and the
* block it renders is drawn in an overlay, clear of the day column's clip.
*/
@Stable
@@ -12,7 +12,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateMapOf
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
import de.jeanlucmakiola.calendula.ui.common.pagedStripHeight
import de.jeanlucmakiola.calendula.ui.common.pagedHeight
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import kotlinx.coroutines.flow.StateFlow
@@ -376,7 +376,7 @@ private fun DayPager(
onDrop: (TimelineDrop) -> Unit,
) {
val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
val allDayHeight = pagedStripHeight(pagerState, stripHeights, initialStripHeight)
val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight)
DayFrame(allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController) {
HorizontalPager(
state = pagerState,
@@ -2,6 +2,19 @@ package de.jeanlucmakiola.calendula.ui.month
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.EnterExitState
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.rememberUpdatedState
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
import de.jeanlucmakiola.calendula.ui.common.pagedHeight
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.drop
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.Animatable
@@ -21,7 +34,6 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -70,7 +82,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.mutableStateOf
@@ -144,7 +155,6 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_SWIPE_THRESHOLD
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
@@ -158,14 +168,12 @@ import de.jeanlucmakiola.calendula.ui.common.trimmedLines
import de.jeanlucmakiola.calendula.ui.common.monthBarShape
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.identity.itemEnter
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.time.isoWeekNumber
import kotlinx.coroutines.delay
@@ -180,7 +188,6 @@ import kotlinx.datetime.plus
import kotlinx.datetime.YearMonth
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime
import kotlin.math.abs
import kotlin.time.Clock
import kotlin.time.Instant
import java.time.format.TextStyle as JavaTextStyle
@@ -222,6 +229,27 @@ fun MonthScreen(
val scope = rememberCoroutineScope()
val scrolling = viewStyle.isScrolling
// The paged and split styles' months. Made afresh whenever a paged style
// comes on screen, opening on the month the scrolling style was showing: a
// pager that was never laid out can't be scrolled to follow it meanwhile.
val anchorPage by viewModel.anchorPage.collectAsStateWithLifecycle()
val pagerState = key(scrolling) {
rememberPagerState(initialPage = anchorPage) { continuousMonthCount() }
}
val pageSpec = rememberCalendarPageSpec()
val reduceMotion = rememberReduceMotion()
// A swipe moves the pager and the anchor follows once it settles; anything
// else that moves the anchor has the pager follow it instead.
LaunchedEffect(anchorPage, scrolling) {
if (scrolling || pagerState.currentPage == anchorPage || pagerState.isScrollInProgress) {
return@LaunchedEffect
}
pagerState.jumpToPage(anchorPage, reduceMotion, pageSpec)
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.settledPage }.drop(1).collect(viewModel::onPageSettled)
}
val dense = viewStyle == MonthViewStyle.Dense
// Today, from whichever state is driving; the clock only covers the first
@@ -260,7 +288,8 @@ fun MonthScreen(
}
}
}
val titleMonth = if (scrolling) visibleMonth else month
// The month under the finger, so the title turns over mid-swipe, not after it.
val titleMonth = if (scrolling) visibleMonth else yearMonthForIndex(pagerState.currentPage)
// Feed the visible range back so the sliding data window can follow. Both
// styles report *months*, whatever they index by — one window serves both.
@@ -313,21 +342,13 @@ fun MonthScreen(
}
}
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
var slideDir by remember { mutableIntStateOf(0) }
val goNext = {
slideDir = 1
viewModel.goToNext()
// Straight to the pager as well as the anchor: a tap mid-fling must still
// land, and the anchor hasn't caught up with the fling yet to tell the
// difference. The continuous stream scrolls to the month instead.
val jumpToMonth: (YearMonth) -> Unit = { ym ->
scope.launch { pagerState.jumpToPage(monthIndexOf(ym), reduceMotion, pageSpec) }
}
val goPrev = {
slideDir = -1
viewModel.goToPrev()
}
// Slide toward today: viewing the future → today comes in from the left
// (back), viewing the past → from the right (forward). The continuous stream
// scrolls to today's week instead — there is nothing to slide.
val jumpToToday = {
val jumpToToday: () -> Unit = {
if (scrolling) {
scope.launch {
listState.animateScrollToItem(
@@ -335,17 +356,12 @@ fun MonthScreen(
else itemIndexForMonth(monthIndexOf(YearMonth(today.year, today.month))),
)
}
Unit
} else {
slideDir = when (val s = state) {
is MonthUiState.Success ->
if (YearMonth(s.today.year, s.today.month) < s.month) -1 else 1
else -> 0
}
viewModel.goToToday()
jumpToMonth(YearMonth(today.year, today.month))
}
}
// Drawer jump-to-date: slide from the side the target month lies on.
// Drawer jump-to-date.
val jumpToDate: (LocalDate) -> Unit = { target ->
if (scrolling) {
scope.launch {
@@ -355,19 +371,10 @@ fun MonthScreen(
)
}
} else {
slideDir = if (YearMonth(target.year, target.month) < month) -1 else 1
viewModel.goToDate(target)
jumpToMonth(YearMonth(target.year, target.month))
}
}
// Selecting a day in the split grid can cross into a neighbour month — a
// tapped leading/trailing day follows to its own month. Point the slide the
// way the month is actually moving, so the incoming page travels the right
// direction instead of reusing whatever the last swipe left in slideDir.
val selectDay: (LocalDate) -> Unit = { date ->
val target = YearMonth(date.year, date.month)
if (target != month) slideDir = if (target < month) -1 else 1
viewModel.selectDate(date)
}
ModalNavigationDrawer(
drawerState = drawerState,
@@ -462,12 +469,18 @@ fun MonthScreen(
SplitMonthContent(
state = state,
selected = selectedDate,
slideDir = slideDir,
pagerState = pagerState,
weekStart = weekStart,
monthState = viewModel::month,
showWeekNumbers = showWeekNumbers,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
onSelectDay = selectDay,
// A tapped leading/trailing day follows to its own
// month, pager and all — straight away, or a swipe
// still settling would land and take the tap back.
onSelectDay = { date ->
viewModel.selectDate(date)
jumpToMonth(YearMonth(date.year, date.month))
},
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = { onCreateEvent(it, null) },
@@ -475,10 +488,10 @@ fun MonthScreen(
} else {
MonthContent(
state = state,
slideDir = slideDir,
pagerState = pagerState,
weekStart = weekStart,
monthState = viewModel::month,
showWeekNumbers = showWeekNumbers,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
@@ -595,41 +608,110 @@ private fun MonthDragOverlay(controller: MonthDragController) {
@Composable
private fun MonthContent(
state: MonthUiState,
slideDir: Int,
pagerState: PagerState,
weekStart: DayOfWeek,
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
showWeekNumbers: Boolean,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
) {
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
val swipeModifier = rememberMonthSwipeModifier(onSwipeNext, onSwipePrev)
// Loading and failure concern the provider as a whole, so they stand in for
// the pager; a page still loading its own month handles that itself.
AnimatedContent(
targetState = state,
modifier = Modifier.fillMaxSize().then(swipeModifier),
contentKey = { s ->
when (s) {
is MonthUiState.Success -> "success-${s.month}"
is MonthUiState.Failure -> "failure-${s.reason}"
MonthUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
label = "month-transition",
modifier = Modifier.fillMaxSize(),
contentKey = { it::class },
transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) },
label = "month-state",
) { s ->
when (s) {
MonthUiState.Loading -> MonthGridLoading()
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is MonthUiState.Success -> MonthGrid(
state = s,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
is MonthUiState.Success -> MonthPager(
pagerState = pagerState,
weekStart = weekStart,
monthState = monthState,
modifier = Modifier.fillMaxSize(),
) { _, page ->
MonthGrid(
state = page,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
}
}
}
}
/**
* The paged and split styles' months as pages that follow the finger and snap
* once a swipe passes half a page or is flung (#336). Only the page on screen
* carries the chip drag and the morph tags: its neighbours show some of its
* dates among their leading and trailing days, and would otherwise claim its
* drops and its shared elements.
*
* @param static draw the current page alone, without a pager — for a face of the
* split style on its way out, which must not hold on to the pager state the
* incoming face now drives.
*/
@Composable
private fun MonthPager(
pagerState: PagerState,
weekStart: DayOfWeek,
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
modifier: Modifier = Modifier,
static: Boolean = false,
loading: @Composable () -> Unit = { MonthGridLoading() },
page: @Composable (index: Int, state: MonthUiState.Success) -> Unit,
) {
if (static) {
Box(modifier) {
MonthPage(pagerState.currentPage, true, weekStart, monthState, loading, page)
}
return
}
val chipDrag = LocalMonthDrag.current
HorizontalPager(
state = pagerState,
modifier = modifier,
// The months either side are composed ahead, so a swipe lands on a loaded
// month rather than watching it fill in.
beyondViewportPageCount = 1,
verticalAlignment = Alignment.Top,
// A held chip moves within its month; the page stays put under it.
userScrollEnabled = chipDrag?.isDragging != true,
flingBehavior = PagerDefaults.flingBehavior(
state = pagerState,
snapAnimationSpec = rememberCalendarPageSpec(),
),
) { index ->
MonthPage(index, index == pagerState.currentPage, weekStart, monthState, loading, page)
}
}
@Composable
private fun MonthPage(
index: Int,
active: Boolean,
weekStart: DayOfWeek,
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
loading: @Composable () -> Unit,
page: @Composable (index: Int, state: MonthUiState.Success) -> Unit,
) {
val ym = yearMonthForIndex(index)
val state by remember(ym, weekStart) { monthState(ym, weekStart) }.collectAsStateWithLifecycle()
val chipDrag = LocalMonthDrag.current
val morph = LocalMonthMorph.current
CompositionLocalProvider(
LocalMonthDrag provides chipDrag.takeIf { active },
LocalMonthMorph provides morph.takeIf { active },
) {
when (val s = state) {
is MonthUiState.Success -> page(index, s)
else -> loading()
}
}
}
@@ -1148,88 +1230,35 @@ internal fun DenseMonthGrid(
}
}
/** Which way a drag went, decided once per gesture and then held. */
private enum class DragAxis { Undecided, Horizontal, Vertical }
/**
* The month grid's drag gesture: horizontal pages the month, vertical expands or
* collapses the split style (#53). The grid doesn't follow the finger, so there is
* no distance to rubber-band against — it commits the moment the accumulated drag
* clears the threshold, once per gesture, exactly as
* [rememberCalendarPageSwipe][de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe]
* does for the week and day views.
*
* This is the month's own detector rather than that shared one because it needs
* two axes, and the axis is **locked on the first movement and held for the whole
* gesture** so a drag can page or expand but never both. Two independent
* detectors on one surface would each see their own component of a diagonal drag
* and both fire.
*
* The horizontal threshold is the shared one. The vertical is larger — swapping
* the whole layout out deserves a more deliberate pull than stepping to the next
* month.
*
* [onExpand]/[onCollapse] are null for the paged style, which leaves the vertical
* axis unclaimed: the lock still happens, so a vertical drag there does nothing
* rather than being re-read as a page turn.
* The split style's expand gesture: a downward drag trades the pane away for the
* full grid, an upward one brings it back (#53). Vertical only — the months page
* on the pager inside it, and each detector only claims its own axis at the
* slop, so a drag can page or expand but never both. Commits the moment the drag
* clears [MONTH_EXPAND_THRESHOLD], once per gesture.
*/
@Composable
private fun rememberMonthSwipeModifier(
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onExpand: (() -> Unit)? = null,
onCollapse: (() -> Unit)? = null,
): Modifier {
val density = LocalDensity.current
val pageThreshold = with(density) { CALENDAR_SWIPE_THRESHOLD.toPx() }
val expandThreshold = with(density) { MONTH_EXPAND_THRESHOLD.toPx() }
return Modifier.pointerInput(onSwipeNext, onSwipePrev, onExpand, onCollapse) {
var accum = Offset.Zero
var axis = DragAxis.Undecided
private fun rememberSplitExpandDrag(onExpand: () -> Unit, onCollapse: () -> Unit): Modifier {
val threshold = with(LocalDensity.current) { MONTH_EXPAND_THRESHOLD.toPx() }
val currentExpand by rememberUpdatedState(onExpand)
val currentCollapse by rememberUpdatedState(onCollapse)
return Modifier.pointerInput(threshold) {
var accum = 0f
// One commit per gesture: without this a long drag would keep re-firing
// every time the accumulator crossed the threshold again.
var fired = false
detectDragGestures(
detectVerticalDragGestures(
onDragStart = {
accum = Offset.Zero
axis = DragAxis.Undecided
accum = 0f
fired = false
},
onDragEnd = {
accum = Offset.Zero
axis = DragAxis.Undecided
fired = false
},
onDragCancel = {
accum = Offset.Zero
axis = DragAxis.Undecided
fired = false
},
onDrag = { _, drag ->
onVerticalDrag = { _, drag ->
accum += drag
if (axis == DragAxis.Undecided) {
// Ties go horizontal, keeping paging the default reading of an
// ambiguous drag as it was before the vertical axis existed.
axis = if (abs(accum.x) >= abs(accum.y)) {
DragAxis.Horizontal
} else {
DragAxis.Vertical
}
}
// Both axes commit the instant the drag clears their threshold,
// rather than on release — see [rememberCalendarPageSwipe], which
// does the same for the week and day views.
if (!fired) {
val commit = when (axis) {
DragAxis.Horizontal -> when {
accum.x < -pageThreshold -> onSwipeNext
accum.x > pageThreshold -> onSwipePrev
else -> null
}
DragAxis.Vertical -> when {
accum.y > expandThreshold -> onExpand
accum.y < -expandThreshold -> onCollapse
else -> null
}
DragAxis.Undecided -> null
val commit = when {
accum > threshold -> currentExpand
accum < -threshold -> currentCollapse
else -> null
}
if (commit != null) {
fired = true
@@ -1265,10 +1294,10 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp
private fun SplitMonthContent(
state: MonthUiState,
selected: LocalDate,
slideDir: Int,
pagerState: PagerState,
weekStart: DayOfWeek,
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
showWeekNumbers: Boolean,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
onSelectDay: (LocalDate) -> Unit,
onOpenDay: (LocalDate) -> Unit,
@@ -1283,12 +1312,7 @@ private fun SplitMonthContent(
// be backed out of. Declared deeper than CalendarHost's view-stack handler,
// which is what makes it win while it is enabled.
BackHandler(enabled = expanded) { expanded = false }
// The swipe wraps the grid rather than living inside it: mid-transition
// there are two grids, and the gesture belongs to neither. The pane is left
// out of it — it scrolls and is full of tappable rows.
val swipeModifier = rememberMonthSwipeModifier(
onSwipeNext = onSwipeNext,
onSwipePrev = onSwipePrev,
val expandDrag = rememberSplitExpandDrag(
onExpand = { expanded = true },
onCollapse = { expanded = false },
)
@@ -1306,6 +1330,9 @@ private fun SplitMonthContent(
transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) },
label = "split-expand-transition",
) { isExpanded ->
// Only the incoming face drives the pager; the outgoing one draws
// its month still for the length of the cross-fade.
val leaving = transition.targetState == EnterExitState.PostExit
CompositionLocalProvider(
// Null under reduced motion: nothing is tagged, nothing
// travels, and the cross-fade above is the whole transition.
@@ -1315,119 +1342,98 @@ private fun SplitMonthContent(
MonthMorphScope(this@SharedTransitionLayout, this@AnimatedContent)
},
) {
SplitMonthBody(
expanded = isExpanded,
state = state,
selected = selected,
slideDir = slideDir,
showWeekNumbers = showWeekNumbers,
swipeModifier = swipeModifier,
onSelectDay = onSelectDay,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
onSetExpanded = { expanded = it },
)
val months = SplitMonths(pagerState, weekStart, monthState, static = leaving)
if (isExpanded) {
SplitMonthExpanded(
months = months,
selected = selected,
showWeekNumbers = showWeekNumbers,
expandDrag = expandDrag,
// A tap in the expanded grid picks the day and drops
// back, which gives the expanded month a job — a
// chooser you dip into — rather than a mode you can
// get stranded in. The collapse then runs with the
// selection already set, so the pane arrives showing
// the day you picked.
onPickDay = {
onSelectDay(it)
expanded = false
},
// A tapped chip is asking for that event, not for its
// day, so it opens the detail from here too rather
// than collapsing (#187).
onEventClick = onEventClick,
onCollapse = { expanded = false },
)
} else {
SplitMonthCollapsed(
state = state,
months = months,
selected = selected,
showWeekNumbers = showWeekNumbers,
expandDrag = expandDrag,
onSelectDay = onSelectDay,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
onExpand = { expanded = true },
)
}
}
}
}
}
}
/** The two faces of the split style, sharing one set of morph tags. */
@Composable
private fun SplitMonthBody(
expanded: Boolean,
state: MonthUiState.Success,
selected: LocalDate,
slideDir: Int,
showWeekNumbers: Boolean,
swipeModifier: Modifier,
onSelectDay: (LocalDate) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate) -> Unit,
onSetExpanded: (Boolean) -> Unit,
) {
if (expanded) {
SplitMonthExpanded(
state = state,
selected = selected,
slideDir = slideDir,
showWeekNumbers = showWeekNumbers,
swipeModifier = swipeModifier,
// A tap in the expanded grid picks the day and drops back, which
// gives the expanded month a job — a chooser you dip into — rather
// than a mode you can get stranded in. The collapse then runs with
// the selection already set, so the pane arrives showing the day
// you picked.
onPickDay = {
onSelectDay(it)
onSetExpanded(false)
},
// A tapped chip is asking for that event, not for its day, so it
// opens the detail from here too rather than collapsing (#187).
onEventClick = onEventClick,
onCollapse = { onSetExpanded(false) },
)
} else {
SplitMonthCollapsed(
state = state,
selected = selected,
slideDir = slideDir,
showWeekNumbers = showWeekNumbers,
swipeModifier = swipeModifier,
onSelectDay = onSelectDay,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
onExpand = { onSetExpanded(true) },
)
}
}
/** What both faces of the split style page through — see [MonthPager]. */
private class SplitMonths(
val pagerState: PagerState,
val weekStart: DayOfWeek,
val monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
val static: Boolean,
)
/** The split style at rest: compact grid, handle, then the selected day's events. */
@Composable
private fun SplitMonthCollapsed(
state: MonthUiState.Success,
months: SplitMonths,
selected: LocalDate,
slideDir: Int,
showWeekNumbers: Boolean,
swipeModifier: Modifier,
expandDrag: Modifier,
onSelectDay: (LocalDate) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate) -> Unit,
onExpand: () -> Unit,
) {
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
// The grid stands only as many rows tall as its month spans (#162), so a
// swipe between a five-row and a six-row month moves the pane by a row; the
// blend has it move with the finger rather than jump on settle.
val gridHeights = remember { mutableStateMapOf<Int, Dp>() }
val gridHeight = pagedHeight(months.pagerState, gridHeights, splitGridHeight(state.weeks.size))
Column(Modifier.fillMaxSize()) {
// Grid and handle drag as one surface — the handle is what advertises the
// gesture, so it has to answer to it as well as to a tap. The pane is
// outside: it scrolls, and is full of tappable rows.
Column(swipeModifier) {
AnimatedContent(
// The selection travels *with* the state so each page keeps its
// own. Read from outside, both pages would show the incoming
// one, and paging visibly threw the marker across the outgoing
// grid — onto the new month's 1st, which the old grid still
// shows among its trailing days — before the new page arrived.
targetState = state to selected,
// Keyed on the month alone, so a provider notification refreshing
// the month you are on — or a tap moving the selection within it
// — updates in place instead of sliding.
contentKey = { (s, _) -> s.month },
transitionSpec = {
calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion)
},
label = "split-month-transition",
) { (s, sel) ->
Column(expandDrag) {
MonthPager(
pagerState = months.pagerState,
weekStart = months.weekStart,
monthState = months.monthState,
static = months.static,
modifier = Modifier.fillMaxWidth().height(gridHeight),
loading = {},
) { index, page ->
val height = splitGridHeight(page.weeks.size)
DisposableEffect(index, height) {
gridHeights[index] = height
onDispose { gridHeights.remove(index) }
}
SplitMonthGrid(
state = s,
selected = sel,
state = page,
selected = selected,
showWeekNumbers = showWeekNumbers,
onSelectDay = onSelectDay,
)
@@ -1461,38 +1467,31 @@ private fun SplitMonthCollapsed(
*/
@Composable
private fun SplitMonthExpanded(
state: MonthUiState.Success,
months: SplitMonths,
/** Marked nowhere here; it only anchors the outline's morph. */
selected: LocalDate,
slideDir: Int,
showWeekNumbers: Boolean,
swipeModifier: Modifier,
expandDrag: Modifier,
onPickDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onCollapse: () -> Unit,
) {
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
// The whole screen drags here, handle included — there is no pane to keep out
// of it, and the handle is the obvious thing to reach for on the way back.
Column(Modifier.fillMaxSize().then(swipeModifier)) {
AnimatedContent(
targetState = state to selected,
Column(Modifier.fillMaxSize().then(expandDrag)) {
MonthPager(
pagerState = months.pagerState,
weekStart = months.weekStart,
monthState = months.monthState,
static = months.static,
modifier = Modifier.weight(1f),
contentKey = { (s, _) -> s.month },
transitionSpec = {
calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion)
},
label = "split-expanded-month-transition",
) { (s, sel) ->
) { _, page ->
MonthGrid(
state = s,
state = page,
showWeekNumbers = showWeekNumbers,
onOpenDay = onPickDay,
onEventClick = onEventClick,
selected = sel,
selected = selected,
)
}
SplitExpandHandle(expanded = true, onToggle = onCollapse)
@@ -1544,6 +1543,15 @@ private fun SplitExpandHandle(
* up goes to the day pane below.
*/
private val SPLIT_ROW_HEIGHT = 46.dp
/** Vertical padding and row gap of [SplitMonthGrid], which [splitGridHeight] pays for. */
private val SPLIT_GRID_VERTICAL_PADDING = 4.dp
private val SPLIT_ROW_GAP = 2.dp
/** How tall [SplitMonthGrid] stands for a month spanning [rows] weeks. */
private fun splitGridHeight(rows: Int): Dp =
SPLIT_ROW_HEIGHT * rows + SPLIT_ROW_GAP * (rows - 1).coerceAtLeast(0) +
SPLIT_GRID_VERTICAL_PADDING * 2
private val SPLIT_DOT_SIZE = 5.dp
// Dots are capped by SPLIT_DOT_LANES, not a constant of their own: they stand for
// the paged grid's lanes, so the two caps have to be the same number or a dot
@@ -1583,8 +1591,8 @@ internal fun SplitMonthGrid(
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
.padding(horizontal = AppBarSpacing.Inset, vertical = SPLIT_GRID_VERTICAL_PADDING),
verticalArrangement = Arrangement.spacedBy(SPLIT_ROW_GAP),
) {
state.weeks.forEach { week ->
Row(Modifier.fillMaxWidth().height(SPLIT_ROW_HEIGHT)) {
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.ui.week.coversDay
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
@@ -25,6 +26,9 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.map
import kotlin.math.abs
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
@@ -44,6 +48,9 @@ import kotlin.time.Clock
import kotlin.time.Instant
import javax.inject.Inject
/** How many months either side of the requested one [MonthViewModel.month] keeps cached. */
private const val MONTH_CACHE_SPAN = 2
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class MonthViewModel @Inject constructor(
@@ -87,6 +94,21 @@ class MonthViewModel @Inject constructor(
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month))
val month: StateFlow<YearMonth> = _month
/** The pager page the anchor month sits on, for the paged and split styles. */
val anchorPage: StateFlow<Int> = _month
.map { monthIndexOf(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = monthIndexOf(_month.value),
)
/**
* The anchor month's state. Once loaded it never falls back to
* [MonthUiState.Loading]: moving the anchor keeps the last result until the
* new month arrives, since the pages draw their own months and this feeds
* the failure screen and the split style's day pane.
*/
val state: StateFlow<MonthUiState> =
combine(_month, weekStart, viewStyle) { ym, ws, style -> Triple(ym, ws, style) }
.flatMapLatest { (ym, ws, style) ->
@@ -94,22 +116,48 @@ class MonthViewModel @Inject constructor(
// Split draw, so under a scrolling style this query is a month
// of provider work for a view that isn't on screen.
if (style.isScrolling) return@flatMapLatest flowOf(MonthUiState.Loading)
val range = monthGridRange(ym, ws, zone)
combine(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildState(ym, ws, calendars, instances)
}
month(ym, ws).filterNot { it is MonthUiState.Loading }
}
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = MonthUiState.Loading,
)
private val months = PageStateCache<Pair<YearMonth, DayOfWeek>, MonthUiState>(
scope = viewModelScope,
initial = MonthUiState.Loading,
// Far enough to cover the pages either side and a swipe back, so paging
// through a year doesn't keep a year of queries around. A week-start
// change lays every month out afresh.
keep = { (cached, cachedWs), (requested, ws) ->
cachedWs == ws && abs(monthIndexOf(cached) - monthIndexOf(requested)) <= MONTH_CACHE_SPAN
},
) { (ym, ws) ->
combine(
repository.calendars(),
repository.instances(monthGridRange(ym, ws, zone)),
) { calendars, instances ->
buildState(ym, ws, calendars, instances)
}
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
}
/**
* Month [ym] laid out with weeks starting on [ws], shared between its pager
* page and [state]. Main thread only.
*/
fun month(ym: YearMonth, ws: DayOfWeek): StateFlow<MonthUiState> = months.get(ym to ws)
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
fun onPageSettled(page: Int) {
if (monthIndexOf(_month.value) == page) return
_month.value = yearMonthForIndex(page)
realignSelection()
}
// --- Continuous + Dense styles (#38) ----------------------------------
//
// These scroll through every month there is, so they can't load "a month" —
@@ -271,16 +319,6 @@ class MonthViewModel @Inject constructor(
_selectedDate.value = selectionForMonth(_month.value, todayDate)
}
fun goToPrev() {
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
realignSelection()
}
fun goToNext() {
_month.value = _month.value.plus(1, DateTimeUnit.MONTH)
realignSelection()
}
fun goToToday() {
_month.value = YearMonth(todayDate.year, todayDate.month)
_selectedDate.value = todayDate
@@ -12,7 +12,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateMapOf
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
import de.jeanlucmakiola.calendula.ui.common.pagedStripHeight
import de.jeanlucmakiola.calendula.ui.common.pagedHeight
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.datetime.DayOfWeek
@@ -418,7 +418,7 @@ private fun WeekPager(
onDrop: (TimelineDrop) -> Unit,
) {
val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
val allDayHeight = pagedStripHeight(pagerState, stripHeights, initialStripHeight)
val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight)
WeekFrame(
weekStart = weekStartForPage(pagerState.currentPage, firstDay),
allDayHeight = allDayHeight,