fix(month): steady the split style's paging (#53)

Two things made a swipe between months feel rough, both from the grid and the
pane disagreeing about what was happening.

The grid sized itself to its month, 4 to 6 rows, so every swipe shunted the pane
up or down by a row while the grid content swapped underneath with no transition
at all. It now always reserves six rows, padding a short month with blank ones —
which both holds the pane still and makes the paged style's directional slide
safe to use here, since there is no longer a height change to animate under it.

The pane also flashed "Nothing scheduled" mid-page. Paging moves the selection
to the new month before that month's events have arrived, and the pane was
handed `instancesByDay[selected].orEmpty()` — so a day that simply wasn't loaded
yet was indistinguishable from a day with nothing on it, and got reported as
free. It now takes a nullable list and shows skeleton rows for the gap, saying
"still looking" rather than making a claim about the day.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 18:52:32 +02:00
parent a7ae8ff912
commit 4cee160c12

View File

@@ -354,6 +354,7 @@ fun MonthScreen(
SplitMonthContent(
state = state,
selected = selectedDate,
slideDir = slideDir,
showWeekNumbers = showWeekNumbers,
onSwipeNext = goNext,
onSwipePrev = goPrev,
@@ -806,14 +807,16 @@ private fun rememberMonthSwipeModifier(
* Split style content: the compact grid keeps the month swipe, the pane below it
* lists whatever day is selected.
*
* No [AnimatedContent] here, unlike the paged style. The grid is 46 rows tall
* depending on the month, so sliding one month over another would animate a
* height change under a pane that is trying to hold still.
* The grid slides between months like the paged style, which it can only do
* because it always reserves [SPLIT_GRID_ROWS] rows. Sized to its own month it
* stood 46 rows tall, so every swipe shunted the pane up or down by a row on
* top of swapping the grid — the pane now holds still and only the grid moves.
*/
@Composable
private fun SplitMonthContent(
state: MonthUiState,
selected: LocalDate,
slideDir: Int,
showWeekNumbers: Boolean,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
@@ -823,23 +826,43 @@ private fun SplitMonthContent(
onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate) -> Unit,
) {
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
// 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, onSwipePrev)
when (state) {
MonthUiState.Loading -> MonthGridLoading()
is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry)
is MonthUiState.Success -> Column(Modifier.fillMaxSize()) {
AnimatedContent(
targetState = state,
modifier = swipeModifier,
// Keyed on the month alone, so a provider notification refreshing
// the month you are on updates in place instead of sliding.
contentKey = { it.month },
transitionSpec = {
calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion)
},
label = "split-month-transition",
) { s ->
SplitMonthGrid(
state = state,
state = s,
selected = selected,
showWeekNumbers = showWeekNumbers,
onSelectDay = onSelectDay,
// The swipe lives on the grid alone — the pane scrolls and is
// full of tappable rows, so it shouldn't also be a month gesture.
modifier = rememberMonthSwipeModifier(onSwipeNext, onSwipePrev),
)
}
SplitDayPane(
date = selected,
today = state.today,
events = state.instancesByDay[selected].orEmpty(),
// Null, not empty: the selection moves to the new month before
// its data arrives, and a missing key means "not loaded yet".
// Passing an empty list would claim the day was free.
events = state.instancesByDay[selected],
zone = state.zone,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
@@ -861,6 +884,13 @@ private val SPLIT_ROW_HEIGHT = 46.dp
private val SPLIT_DOT_SIZE = 5.dp
private const val SPLIT_MAX_DOTS = 3
/**
* Rows the split grid always reserves — the most any month needs. A month that
* fits in fewer pads the remainder with blank rows rather than shrinking, which
* is what lets the pane below hold still from month to month.
*/
private const val SPLIT_GRID_ROWS = 6
/**
* The split style's grid (#53): the month compressed to day numbers and event
* dots, with the selected day listed underneath by [SplitDayPane].
@@ -909,6 +939,12 @@ internal fun SplitMonthGrid(
}
}
}
// Hold the grid at a constant height whatever shape the month is, so the
// pane beneath it doesn't move as you page and one month can slide over
// another without a height change under it.
repeat(SPLIT_GRID_ROWS - state.weeks.size) {
Spacer(Modifier.fillMaxWidth().height(SPLIT_ROW_HEIGHT))
}
}
}
@@ -1005,7 +1041,8 @@ private fun SplitDots(events: List<EventInstance>, dark: Boolean) {
internal fun SplitDayPane(
date: LocalDate,
today: LocalDate,
events: List<EventInstance>,
/** Null while the selected day's month is still loading; empty means free. */
events: List<EventInstance>?,
zone: TimeZone,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
@@ -1015,7 +1052,12 @@ internal fun SplitDayPane(
val dimCutoff = LocalDimCutoff.current
Column(modifier = modifier) {
AgendaDayHeader(date = date, today = today, onOpenDay = onOpenDay)
if (events.isEmpty()) {
if (events == null) {
// The day isn't in the loaded grid yet — paging lands the selection
// on the new month before its events arrive. "Nothing scheduled"
// here would be a claim about the day rather than about the wait.
SplitPaneSkeleton()
} else if (events.isEmpty()) {
AgendaEmptyDayRow(
text = stringResource(R.string.month_split_no_events),
onClick = { onCreateEvent(date) },
@@ -1043,6 +1085,31 @@ internal fun SplitDayPane(
}
}
/** Stand-in rows for a day pane whose month hasn't arrived yet. */
@Composable
private fun SplitPaneSkeleton() {
val pulse = rememberSkeletonPulse()
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
repeat(2) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(SPLIT_SKELETON_ROW_HEIGHT)
.alpha(pulse)
.background(
MaterialTheme.colorScheme.surfaceContainer,
MaterialTheme.shapes.medium,
),
)
}
}
}
private val SPLIT_SKELETON_ROW_HEIGHT = 56.dp
/** A week the sliding window hasn't loaded yet — same height, so nothing jumps. */
@Composable
private fun ContinuousWeekPlaceholder() {