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:
@@ -354,6 +354,7 @@ fun MonthScreen(
|
|||||||
SplitMonthContent(
|
SplitMonthContent(
|
||||||
state = state,
|
state = state,
|
||||||
selected = selectedDate,
|
selected = selectedDate,
|
||||||
|
slideDir = slideDir,
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
onSwipeNext = goNext,
|
onSwipeNext = goNext,
|
||||||
onSwipePrev = goPrev,
|
onSwipePrev = goPrev,
|
||||||
@@ -806,14 +807,16 @@ private fun rememberMonthSwipeModifier(
|
|||||||
* Split style content: the compact grid keeps the month swipe, the pane below it
|
* Split style content: the compact grid keeps the month swipe, the pane below it
|
||||||
* lists whatever day is selected.
|
* lists whatever day is selected.
|
||||||
*
|
*
|
||||||
* No [AnimatedContent] here, unlike the paged style. The grid is 4–6 rows tall
|
* The grid slides between months like the paged style, which it can only do
|
||||||
* depending on the month, so sliding one month over another would animate a
|
* because it always reserves [SPLIT_GRID_ROWS] rows. Sized to its own month it
|
||||||
* height change under a pane that is trying to hold still.
|
* stood 4–6 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
|
@Composable
|
||||||
private fun SplitMonthContent(
|
private fun SplitMonthContent(
|
||||||
state: MonthUiState,
|
state: MonthUiState,
|
||||||
selected: LocalDate,
|
selected: LocalDate,
|
||||||
|
slideDir: Int,
|
||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
onSwipeNext: () -> Unit,
|
onSwipeNext: () -> Unit,
|
||||||
onSwipePrev: () -> Unit,
|
onSwipePrev: () -> Unit,
|
||||||
@@ -823,23 +826,43 @@ private fun SplitMonthContent(
|
|||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateEvent: (LocalDate) -> 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) {
|
when (state) {
|
||||||
MonthUiState.Loading -> MonthGridLoading()
|
MonthUiState.Loading -> MonthGridLoading()
|
||||||
is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry)
|
is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry)
|
||||||
is MonthUiState.Success -> Column(Modifier.fillMaxSize()) {
|
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(
|
SplitMonthGrid(
|
||||||
state = state,
|
state = s,
|
||||||
selected = selected,
|
selected = selected,
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
onSelectDay = onSelectDay,
|
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(
|
SplitDayPane(
|
||||||
date = selected,
|
date = selected,
|
||||||
today = state.today,
|
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,
|
zone = state.zone,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
@@ -861,6 +884,13 @@ private val SPLIT_ROW_HEIGHT = 46.dp
|
|||||||
private val SPLIT_DOT_SIZE = 5.dp
|
private val SPLIT_DOT_SIZE = 5.dp
|
||||||
private const val SPLIT_MAX_DOTS = 3
|
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
|
* The split style's grid (#53): the month compressed to day numbers and event
|
||||||
* dots, with the selected day listed underneath by [SplitDayPane].
|
* 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(
|
internal fun SplitDayPane(
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
events: List<EventInstance>,
|
/** Null while the selected day's month is still loading; empty means free. */
|
||||||
|
events: List<EventInstance>?,
|
||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
@@ -1015,7 +1052,12 @@ internal fun SplitDayPane(
|
|||||||
val dimCutoff = LocalDimCutoff.current
|
val dimCutoff = LocalDimCutoff.current
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
AgendaDayHeader(date = date, today = today, onOpenDay = onOpenDay)
|
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(
|
AgendaEmptyDayRow(
|
||||||
text = stringResource(R.string.month_split_no_events),
|
text = stringResource(R.string.month_split_no_events),
|
||||||
onClick = { onCreateEvent(date) },
|
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. */
|
/** A week the sliding window hasn't loaded yet — same height, so nothing jumps. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContinuousWeekPlaceholder() {
|
private fun ContinuousWeekPlaceholder() {
|
||||||
|
|||||||
Reference in New Issue
Block a user