perf(month): open the scrolling styles on a small window and grow it (#38)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,11 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.month
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
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.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -430,7 +435,10 @@ private fun ContinuousMonthContent(
|
|||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
) {
|
) {
|
||||||
when (state) {
|
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 ->
|
is ContinuousMonthUiState.Failure ->
|
||||||
CalendarFailure(reason = state.reason, onRetry = onRetry)
|
CalendarFailure(reason = state.reason, onRetry = onRetry)
|
||||||
is ContinuousMonthUiState.Success -> if (dense) {
|
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. */
|
/** A week the sliding window hasn't loaded yet — same height, so nothing jumps. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContinuousWeekPlaceholder() {
|
private fun ContinuousWeekPlaceholder() {
|
||||||
|
val pulse = rememberSkeletonPulse()
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -1048,12 +1057,84 @@ private fun ContinuousWeekPlaceholder() {
|
|||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||||
|
.alpha(pulse)
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainerLow, CELL_SHAPE),
|
.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
|
* 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
|
* a multi-day event is one connected bar across the columns; single-day timed
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
@@ -101,23 +103,45 @@ class MonthViewModel @Inject constructor(
|
|||||||
initialValue = MonthUiState.Loading,
|
initialValue = MonthUiState.Loading,
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Continuous style (#38) -------------------------------------------
|
// --- Continuous + Dense styles (#38) ----------------------------------
|
||||||
//
|
//
|
||||||
// The continuous grid scrolls through every month there is, so it can't load
|
// These scroll through every month there is, so they can't load "a month" —
|
||||||
// one month at a time — it loads a sliding window of month indices around
|
// they load a sliding window of month indices around whatever is on screen.
|
||||||
// whatever is on screen. The window only moves when the visible range comes
|
// The window only moves when the visible range comes within WINDOW_EDGE
|
||||||
// within WINDOW_EDGE months of a loaded edge, so a scroll re-queries
|
// months of a loaded edge, so a scroll re-queries occasionally rather than
|
||||||
// occasionally rather than on every frame.
|
// 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(
|
private val _loadedMonths = MutableStateFlow(
|
||||||
monthIndexOf(YearMonth(todayDate.year, todayDate.month))
|
monthIndexOf(YearMonth(todayDate.year, todayDate.month))
|
||||||
.let { it - WINDOW_PAD..it + WINDOW_PAD },
|
.let { clampMonthWindow(it - INITIAL_PAD..it + INITIAL_PAD) },
|
||||||
)
|
)
|
||||||
|
|
||||||
val continuousState: StateFlow<ContinuousMonthUiState> =
|
val continuousState: StateFlow<ContinuousMonthUiState> =
|
||||||
combine(_loadedMonths, weekStart) { window, ws -> window to ws }
|
combine(_loadedMonths, weekStart, viewStyle) { window, ws, style ->
|
||||||
.flatMapLatest { (window, ws) ->
|
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
|
// 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
|
// 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.
|
// 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)
|
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)) }
|
.catch { emit(ContinuousMonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
.flowOn(io)
|
.flowOn(io)
|
||||||
.stateIn(
|
.stateIn(
|
||||||
@@ -149,10 +177,24 @@ class MonthViewModel @Inject constructor(
|
|||||||
* close enough to a loaded edge to warrant a wider query.
|
* close enough to a loaded edge to warrant a wider query.
|
||||||
*/
|
*/
|
||||||
fun onVisibleMonthsChanged(firstIndex: Int, lastIndex: Int) {
|
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) }
|
?.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(
|
private fun buildContinuousState(
|
||||||
window: IntRange,
|
window: IntRange,
|
||||||
weekStart: DayOfWeek,
|
weekStart: DayOfWeek,
|
||||||
@@ -347,14 +389,25 @@ internal fun monthGridRange(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How many months the continuous window loads beyond the visible range, and how
|
* The sliding window's shape.
|
||||||
* 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
|
* [INITIAL_PAD] is what the first frame waits for — one month either side of the
|
||||||
* well before it would run out of laid-out months.
|
* 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
|
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]:
|
* 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
|
* [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
|
* 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.
|
* 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 =
|
val comfortablyInside =
|
||||||
firstVisible - WINDOW_EDGE >= loaded.first && lastVisible + WINDOW_EDGE <= loaded.last
|
firstVisible - edge >= loaded.first && lastVisible + edge <= loaded.last
|
||||||
if (comfortablyInside) return null
|
if (comfortablyInside) return null
|
||||||
return (firstVisible - WINDOW_PAD)..(lastVisible + WINDOW_PAD)
|
return (firstVisible - pad)..(lastVisible + pad)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -172,25 +172,53 @@ class ContinuousMonthIndexTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `nearing a loaded edge widens the window around the visible range`() {
|
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))
|
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 1, lastVisible = 2))
|
||||||
.isEqualTo(-3..6)
|
.isEqualTo(-4..7)
|
||||||
|
|
||||||
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 98, lastVisible = 100))
|
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 98, lastVisible = 100))
|
||||||
.isEqualTo(94..104)
|
.isEqualTo(93..105)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a jump far outside the window reloads around the destination`() {
|
fun `a jump far outside the window reloads around the destination`() {
|
||||||
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 500, lastVisible = 501))
|
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 500, lastVisible = 501))
|
||||||
.isEqualTo(496..505)
|
.isEqualTo(495..506)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the reloaded window always clears the trigger it just crossed`() {
|
fun `the reloaded window always clears the trigger it just crossed`() {
|
||||||
// Otherwise every scroll frame would re-trigger a query.
|
// Otherwise every scroll frame would re-trigger a query — and with the
|
||||||
val window = nextLoadWindow(loaded = 0..100, firstVisible = 1, lastVisible = 2)!!
|
// window growing from a pad of 1, this has to hold at every rung of the
|
||||||
assertThat(nextLoadWindow(window, firstVisible = 1, lastVisible = 2)).isNull()
|
// 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
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user