From 95f4d28bbb5a57b72143b539deaec07beaeabd1a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 18:23:06 +0200 Subject: [PATCH] perf(month): open the scrolling styles on a small window and grow it (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening Continuous made the first frame wait for nine months of recurrence expansion when only one was about to be looked at — a provider Instances query expands across its whole range, so the window's generosity was paid for up front, every time. The window now starts at one month either side of the visible one — about what the paged style costs — and each completed load reaches two months further in both directions until it spans eleven. The months a scroll can reach arrive while the first one is already on screen, and because a widening is triggered by the previous load landing rather than by a timer, the ladder can never outrun the provider. - The paged and split styles no longer run this query at all. The screen collects the flow whatever the style is set to, so until now every Month view opening paid for a window it would never draw. - The reload trigger is derived from the current pad instead of being fixed, and is held strictly inside it: a trigger at or beyond the pad would re-fire the moment its own reload landed. - The scrolling styles get their own skeleton — the layout they are about to become, at the same measurements, so arriving months replace it in place. It and the per-month placeholders now breathe, so a slow load reads as work rather than as an empty grid. Held still under reduced motion. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthScreen.kt | 83 +++++++++++++++- .../calendula/ui/month/MonthViewModel.kt | 97 +++++++++++++++---- .../ui/month/ContinuousMonthIndexTest.kt | 42 ++++++-- 3 files changed, 195 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index cbca6d4..12ecb20 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -1,6 +1,11 @@ package de.jeanlucmakiola.calendula.ui.month import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -430,7 +435,10 @@ private fun ContinuousMonthContent( onOpenDay: (LocalDate) -> Unit, ) { when (state) { - ContinuousMonthUiState.Loading -> MonthGridLoading() + // The scrolling styles get their own skeleton rather than the paged + // grid's: same row height, same header, so nothing reflows when the + // months land on top of it. + ContinuousMonthUiState.Loading -> ContinuousMonthSkeleton(dense = dense) is ContinuousMonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry) is ContinuousMonthUiState.Success -> if (dense) { @@ -1037,6 +1045,7 @@ internal fun SplitDayPane( /** A week the sliding window hasn't loaded yet — same height, so nothing jumps. */ @Composable private fun ContinuousWeekPlaceholder() { + val pulse = rememberSkeletonPulse() Row( modifier = Modifier .fillMaxWidth() @@ -1048,12 +1057,84 @@ private fun ContinuousWeekPlaceholder() { .weight(1f) .fillMaxHeight() .padding(horizontal = CELL_GAP, vertical = 1.dp) + .alpha(pulse) .background(MaterialTheme.colorScheme.surfaceContainerLow, CELL_SHAPE), ) } } } +/** + * The first-frame skeleton for the scrolling styles: the layout they are about + * to become, at the same measurements, so the arriving months replace it in + * place instead of shifting everything. + * + * In practice the window is small enough that this is rarely on screen for long + * — it is there for the calendar big enough to make even one month's worth of + * recurrence expansion take a moment. + */ +@Composable +private fun ContinuousMonthSkeleton(dense: Boolean) { + val pulse = rememberSkeletonPulse() + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp) + .clipToBounds(), + ) { + if (!dense) { + // Stand-in for the sticky month header, so the first rows start + // where they will once the real one is there. + Box( + modifier = Modifier + .padding(start = 4.dp, top = 20.dp, bottom = 8.dp) + .width(SKELETON_HEADER_WIDTH) + .height(SKELETON_HEADER_HEIGHT) + .alpha(pulse) + .background( + MaterialTheme.colorScheme.surfaceContainerHigh, + MaterialTheme.shapes.small, + ), + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + } else { + Spacer(Modifier.height(4.dp)) + } + // More rows than a viewport holds; the clip takes the overflow. + repeat(6) { + ContinuousWeekPlaceholder() + Spacer(Modifier.height(2.dp)) + } + } +} + +private val SKELETON_HEADER_WIDTH = 128.dp +private val SKELETON_HEADER_HEIGHT = 20.dp + +/** + * The slow breath that tells a skeleton from an empty grid. Held at full opacity + * when the system asks for reduced motion — the placeholders still read as + * unfilled without it. + */ +@Composable +private fun rememberSkeletonPulse(): Float { + if (rememberReduceMotion()) return 1f + val transition = rememberInfiniteTransition(label = "skeleton") + val alpha by transition.animateFloat( + initialValue = 1f, + targetValue = 0.4f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 900), + repeatMode = RepeatMode.Reverse, + ), + label = "skeleton-alpha", + ) + return alpha +} + /** * One week of the grid. Bars (all-day / multi-day) are positioned absolutely so * a multi-day event is one connected bar across the columns; single-day timed diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index db574b5..52edd51 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -20,7 +20,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek @@ -101,23 +103,45 @@ class MonthViewModel @Inject constructor( initialValue = MonthUiState.Loading, ) - // --- Continuous style (#38) ------------------------------------------- + // --- Continuous + Dense styles (#38) ---------------------------------- // - // The continuous grid scrolls through every month there is, so it can't load - // one month at a time — it loads a sliding window of month indices around - // whatever is on screen. The window only moves when the visible range comes - // within WINDOW_EDGE months of a loaded edge, so a scroll re-queries - // occasionally rather than on every frame. + // These scroll through every month there is, so they can't load "a month" — + // they load a sliding window of month indices around whatever is on screen. + // The window only moves when the visible range comes within WINDOW_EDGE + // months of a loaded edge, so a scroll re-queries occasionally rather than + // on every frame. + // + // The window also *starts small and grows*. A provider Instances query + // expands recurrences across its whole range, so opening straight onto a + // year of months made the first frame wait for eleven months of expansion + // when only one was about to be looked at. It now opens on INITIAL_PAD + // months, and each completed load widens by GROWTH_STEP in both directions + // until MAX_PAD — the months you can reach by scrolling arrive while you're + // still looking at the first one. + + /** How far around the visible range we currently load; grows as loads land. */ + @Volatile + private var loadPad = INITIAL_PAD + + /** Last reported visible range, so a widening step can re-centre on it. */ + @Volatile + private var visibleMonths = monthIndexOf(YearMonth(todayDate.year, todayDate.month)) + .let { it..it } - // Seeded around today so the first frame has data. private val _loadedMonths = MutableStateFlow( monthIndexOf(YearMonth(todayDate.year, todayDate.month)) - .let { it - WINDOW_PAD..it + WINDOW_PAD }, + .let { clampMonthWindow(it - INITIAL_PAD..it + INITIAL_PAD) }, ) val continuousState: StateFlow = - combine(_loadedMonths, weekStart) { window, ws -> window to ws } - .flatMapLatest { (window, ws) -> + combine(_loadedMonths, weekStart, viewStyle) { window, ws, style -> + Triple(window, ws, style) + } + .flatMapLatest { (window, ws, style) -> + // Nothing to load for the paged and split styles — they have + // their own single-month flow, and querying a year of months + // behind them is pure waste. + if (!style.isScrolling) return@flatMapLatest flowOf(ContinuousMonthUiState.Loading) // Widened to whole grid weeks at both ends: a month block still // has to know about an event that starts in the boundary week's // clipped-off days, or a bar running into the block would vanish. @@ -135,6 +159,10 @@ class MonthViewModel @Inject constructor( buildContinuousState(window, ws, calendars, instances) } } + // A load landing is the cue to reach further out. Widening from here + // rather than on a timer means each step waits for the previous one, + // so the ladder can never outrun the provider. + .onEach { if (it is ContinuousMonthUiState.Success) widenLoadedWindow() } .catch { emit(ContinuousMonthUiState.Failure(FailureReason.ProviderUnavailable)) } .flowOn(io) .stateIn( @@ -149,10 +177,24 @@ class MonthViewModel @Inject constructor( * close enough to a loaded edge to warrant a wider query. */ fun onVisibleMonthsChanged(firstIndex: Int, lastIndex: Int) { - nextLoadWindow(_loadedMonths.value, firstIndex, lastIndex) + visibleMonths = firstIndex..lastIndex + nextLoadWindow(_loadedMonths.value, firstIndex, lastIndex, loadPad) ?.let { _loadedMonths.value = clampMonthWindow(it) } } + /** + * One rung up the ladder: reach [GROWTH_STEP] further in each direction, + * stopping at [MAX_PAD]. A no-op once there, so the provider notifications + * that re-emit the same window don't restart it. + */ + private fun widenLoadedWindow() { + if (loadPad >= MAX_PAD) return + loadPad = (loadPad + GROWTH_STEP).coerceAtMost(MAX_PAD) + _loadedMonths.value = clampMonthWindow( + visibleMonths.first - loadPad..visibleMonths.last + loadPad, + ) + } + private fun buildContinuousState( window: IntRange, weekStart: DayOfWeek, @@ -347,14 +389,25 @@ internal fun monthGridRange( } /** - * How many months the continuous window loads beyond the visible range, and how - * close the visible range may drift to a loaded edge before it reloads. The pad - * is generous relative to the trigger so a steady scroll crosses the trigger - * well before it would run out of laid-out months. + * The sliding window's shape. + * + * [INITIAL_PAD] is what the first frame waits for — one month either side of the + * visible one, so opening the view costs about what the paged style costs. Each + * completed load then reaches [GROWTH_STEP] further out until [MAX_PAD], filling + * in the months a scroll could reach while the first ones are already on screen. + * + * [WINDOW_EDGE] is how close the visible range may drift to a loaded edge before + * it reloads. It is always kept below the current pad — a trigger at or beyond + * the pad would re-fire the moment its own reload landed. */ -private const val WINDOW_PAD = 4 +private const val INITIAL_PAD = 1 +private const val GROWTH_STEP = 2 +private const val MAX_PAD = 5 private const val WINDOW_EDGE = 2 +/** The reload trigger for a given pad, held strictly inside it. */ +internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLeast(0) + /** * Which day the split style should select when the grid lands on [month]: * [today] when the month holds it, otherwise the 1st. Pure so the rule can be @@ -373,11 +426,17 @@ internal fun selectionForMonth(month: YearMonth, today: LocalDate): LocalDate = * Kept pure and separate from the view model so the hysteresis — the reason a * scroll doesn't re-query the provider on every frame — is testable on its own. */ -internal fun nextLoadWindow(loaded: IntRange, firstVisible: Int, lastVisible: Int): IntRange? { +internal fun nextLoadWindow( + loaded: IntRange, + firstVisible: Int, + lastVisible: Int, + pad: Int = MAX_PAD, +): IntRange? { + val edge = edgeForPad(pad) val comfortablyInside = - firstVisible - WINDOW_EDGE >= loaded.first && lastVisible + WINDOW_EDGE <= loaded.last + firstVisible - edge >= loaded.first && lastVisible + edge <= loaded.last if (comfortablyInside) return null - return (firstVisible - WINDOW_PAD)..(lastVisible + WINDOW_PAD) + return (firstVisible - pad)..(lastVisible + pad) } /** diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt index 7abfc13..ef2c56b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt @@ -172,25 +172,53 @@ class ContinuousMonthIndexTest { @Test fun `nearing a loaded edge widens the window around the visible range`() { - // Within a month of the top edge → reload, padded on both sides. + // Within two months of the top edge → reload, padded on both sides. assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 1, lastVisible = 2)) - .isEqualTo(-3..6) + .isEqualTo(-4..7) assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 98, lastVisible = 100)) - .isEqualTo(94..104) + .isEqualTo(93..105) } @Test fun `a jump far outside the window reloads around the destination`() { assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 500, lastVisible = 501)) - .isEqualTo(496..505) + .isEqualTo(495..506) } @Test fun `the reloaded window always clears the trigger it just crossed`() { - // Otherwise every scroll frame would re-trigger a query. - val window = nextLoadWindow(loaded = 0..100, firstVisible = 1, lastVisible = 2)!! - assertThat(nextLoadWindow(window, firstVisible = 1, lastVisible = 2)).isNull() + // Otherwise every scroll frame would re-trigger a query — and with the + // window growing from a pad of 1, this has to hold at every rung of the + // ladder, not just the widest one. + (1..8).forEach { pad -> + val loaded = (10 - pad)..(10 + pad) + val past = 10 + pad + 1 + val widened = nextLoadWindow(loaded, past, past, pad)!! + assertThat(nextLoadWindow(widened, past, past, pad)).isNull() + } + } + + @Test + fun `the reload trigger stays inside the pad`() { + // A trigger at or beyond the pad would fire again the instant its own + // reload landed, and the window would query forever. + (1..8).forEach { pad -> assertThat(edgeForPad(pad)).isLessThan(pad) } + // A one-month window has no room for hysteresis: reload only on contact. + assertThat(edgeForPad(1)).isEqualTo(0) + assertThat(edgeForPad(0)).isEqualTo(0) + } + + @Test + fun `the smallest window reloads on crossing rather than nearing its edge`() { + // The first frame loads one month either side of today, which leaves no + // room to reload *before* the edge: at pad 1 the trigger is contact. + // Only momentary — the first completed load widens the pad to 3, which + // buys the usual head start back. + val initial = 10..12 + assertThat(nextLoadWindow(initial, firstVisible = 12, lastVisible = 12, pad = 1)).isNull() + assertThat(nextLoadWindow(initial, firstVisible = 13, lastVisible = 13, pad = 1)) + .isEqualTo(12..14) } @Test