style(month): settle the split style's selection and day pane (#53)

Three things still moved badly while paging.

The selection marker was thrown across the outgoing grid. `selected` was read
from outside the AnimatedContent, so both pages rendered the *incoming*
selection — and the new month's 1st is a day the old grid still shows among its
trailing days, so the outline jumped there (bottom right) before the new page
slid in and put it where it belonged (top left). The selection now travels with
the state, so each page keeps its own and the marker only ever appears where its
month has it.

Moving the selection within a month also jumped, outline blinking out of one
cell and into another. Each cell now fades its own outline, so it reads as one
mark crossing the grid. Snapped under reduced motion.

And the day pane swapped its rows outright, popping one list out and another in.
It now cross-fades, the incoming list rising as it arrives — the same entrance
the rest of the family uses for resolved content — with the skeleton handing
over the same way. Rows within a day also animate their placement, so an event
arriving from a sync nudges its neighbours instead of teleporting them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 19:04:08 +02:00
parent e691336b5e
commit f9619e74d9

View File

@@ -2,10 +2,14 @@ package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
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.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -102,6 +106,8 @@ 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
@@ -847,19 +853,25 @@ private fun SplitMonthContent(
is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry)
is MonthUiState.Success -> Column(Modifier.fillMaxSize()) {
AnimatedContent(
targetState = state,
// 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,
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 },
// 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 ->
) { (s, sel) ->
SplitMonthGrid(
state = s,
selected = selected,
selected = sel,
showWeekNumbers = showWeekNumbers,
onSelectDay = onSelectDay,
)
@@ -981,17 +993,25 @@ private fun SplitDayCell(
inMonth -> MaterialTheme.colorScheme.surfaceContainer
else -> MaterialTheme.colorScheme.surfaceContainerLow
}
// Each cell fades its own outline, so moving the selection reads as one
// mark crossing the grid rather than as an outline blinking out here and
// reappearing there. Snapped under reduced motion.
val reduceMotion = rememberReduceMotion()
val fadeSpec = rememberCalendarFadeSpec()
val selection by animateFloatAsState(
targetValue = if (isSelected) 1f else 0f,
animationSpec = if (reduceMotion) snap() else fadeSpec,
label = "split-day-selection",
)
Box(
modifier = modifier
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.clip(CELL_SHAPE)
.background(background)
.then(
if (isSelected) {
Modifier.border(1.5.dp, MaterialTheme.colorScheme.primary, CELL_SHAPE)
} else {
Modifier
},
.border(
width = 1.5.dp,
color = MaterialTheme.colorScheme.primary.copy(alpha = selection),
shape = CELL_SHAPE,
)
.selectable(selected = isSelected, onClick = onClick),
contentAlignment = Alignment.TopCenter,
@@ -1058,39 +1078,58 @@ internal fun SplitDayPane(
modifier: Modifier = Modifier,
) {
val dimCutoff = LocalDimCutoff.current
// A day's list used to be replaced outright, so changing day popped one set
// of rows out and another in. The incoming list rises as it fades, which is
// the same entrance the rest of the family uses for resolved content.
val enter = itemEnter()
val fadeSpec = rememberCalendarFadeSpec()
Column(modifier = modifier) {
AgendaDayHeader(date = date, today = today, onOpenDay = onOpenDay)
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(
AnimatedContent(
targetState = date to events,
modifier = Modifier.weight(1f).fillMaxWidth(),
// Whether the day is still loading is part of the identity, so the
// skeleton hands over to the real list with the same transition.
contentKey = { (d, e) -> d to (e == null) },
transitionSpec = { enter togetherWith fadeOut(fadeSpec) },
label = "split-day-pane",
) { (day, dayEvents) ->
when {
// 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.
dayEvents == null -> SplitPaneSkeleton()
dayEvents.isEmpty() -> AgendaEmptyDayRow(
text = stringResource(R.string.month_split_no_events),
onClick = { onCreateEvent(date) },
onClick = { onCreateEvent(day) },
)
} else {
LazyColumn(
// Bottom inset clears the FAB stack so the last row stays tappable.
else -> LazyColumn(
// Bottom inset clears the FAB stack so the last row stays
// tappable.
contentPadding = PaddingValues(bottom = 96.dp),
) {
itemsIndexed(
items = events,
items = dayEvents,
key = { _, event -> event.instanceId },
) { index, event ->
AgendaEventRow(
event = event,
day = date,
day = day,
zone = zone,
position = positionOf(index, events.size),
position = positionOf(index, dayEvents.size),
dimmed = dimCutoff != null && event.hasEnded(dimCutoff),
onClick = { onEventClick(event) },
// An event added to or removed from the day you are
// already looking at moves its neighbours rather
// than teleporting them.
modifier = animateItemMotion(),
)
}
}
}
}
}
}
/** Stand-in rows for a day pane whose month hasn't arrived yet. */