feat(month): morph the split style's dots into the expanded grid (#53)

The expand cross-faded two unrelated grids, so a day's cell jumped from a
46dp row near the top of the screen to a full-height one halfway down and
the dots simply vanished where bars appeared. It read as a swap, not as the
month opening up.

Tag the pieces that mean the same thing on both sides and let Compose carry
them: each cell pill grows, its number rides along, and every dot travels
out to the bar it stood for — which only works because the dots are now
seated by lane, so each one has exactly one bar to become.

The tags travel as a composition local with a null default, so the paged,
continuous, and dense styles — which share these same row and cell
composables — provide nothing and pay nothing. Reduced motion provides
nothing either, leaving the plain cross-fade underneath.

A multi-day bar is anchored on the day it starts in its row; the dots on
the days it merely covers are left unmatched and fade where they stand
while the bar sweeps out over them. Only one of them could become the bar
without the others teleporting into it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 19:36:13 +02:00
parent f15fffa799
commit 488de490f9
2 changed files with 226 additions and 51 deletions

View File

@@ -0,0 +1,98 @@
package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import kotlinx.datetime.LocalDate
/**
* What lets the split style's compact grid *become* the full month rather than be
* swapped for it (#53).
*
* The two grids are separate composables — the compact one draws dots, the
* expanded one draws the paged style's bars and pills — so nothing about them is
* shared by construction. Tagging the pieces that mean the same thing on both
* sides with the same [MonthMorphKey] hands Compose enough to animate one into
* the other: a day's cell grows, its number rides along, and each dot travels out
* to the bar it stood for.
*
* It travels as a composition local rather than as parameters because the pieces
* sit five levels below where the scopes exist, and because a null default is
* exactly the right meaning for everyone else: the paged, continuous, and dense
* styles share the same row and cell composables, provide nothing, and pay
* nothing. Reduced motion provides nothing either, which leaves the plain
* cross-fade underneath.
*/
internal sealed interface MonthMorphKey {
/** A day's background pill — the structural anchor the rest rides on. */
data class Cell(val date: LocalDate) : MonthMorphKey
data class DayNumber(val date: LocalDate) : MonthMorphKey
/**
* One event *on one day*. Keyed by date as well as instance because a
* multi-day event has a dot on every day it covers but only one bar, drawn
* from where it starts: the start day's dot is the one that becomes the bar,
* and the rest are left unmatched on purpose. They fade where they stand
* while the bar sweeps out over them, which is the honest reading — one of
* them could not become the bar without the others teleporting into it.
*/
data class Event(val date: LocalDate, val instanceId: Long) : MonthMorphKey
/** The grab handle, which slides from the pane's seam to the foot of the grid. */
data object Handle : MonthMorphKey
}
@OptIn(ExperimentalSharedTransitionApi::class)
internal class MonthMorphScope(
val shared: SharedTransitionScope,
val visibility: AnimatedVisibilityScope,
)
internal val LocalMonthMorph = compositionLocalOf<MonthMorphScope?> { null }
/**
* Tag content that is *the same thing* on both sides — a background pill, a day
* number — so it animates between its two positions and sizes.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
internal fun Modifier.morphElement(key: MonthMorphKey): Modifier {
val morph = LocalMonthMorph.current ?: return this
return with(morph.shared) {
this@morphElement.sharedElement(
sharedContentState = rememberSharedContentState(key),
animatedVisibilityScope = morph.visibility,
)
}
}
/**
* Tag content that means the same thing but *is drawn differently* on each side —
* a 5dp dot and a titled bar — so only the bounds are shared and the contents
* cross-fade inside them.
*
* [ResizeMode.RemeasureToBounds][SharedTransitionScope.ResizeMode] rather than
* scaling: a bar's title laid out at the dot's 5dp and then scaled up would
* arrive as a smear. Remeasuring keeps the text at its real size throughout and
* simply clips it while there is no room, so what grows is the pill, not the type.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier {
val morph = LocalMonthMorph.current ?: return this
return with(morph.shared) {
this@morphBounds.sharedBounds(
sharedContentState = rememberSharedContentState(key),
animatedVisibilityScope = morph.visibility,
enter = fadeIn(),
exit = fadeOut(),
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
)
}
}

View File

@@ -1,6 +1,8 @@
package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
@@ -891,6 +893,7 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp
* screen to explain why. [rememberSaveable] carries it across a rotation, which
* is as long as it should live.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun SplitMonthContent(
state: MonthUiState,
@@ -906,6 +909,7 @@ private fun SplitMonthContent(
onCreateEvent: (LocalDate) -> Unit,
) {
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
var expanded by rememberSaveable { mutableStateOf(false) }
// 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
@@ -920,31 +924,76 @@ private fun SplitMonthContent(
when (state) {
MonthUiState.Loading -> MonthGridLoading()
is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry)
is MonthUiState.Success -> AnimatedContent(
is MonthUiState.Success -> SharedTransitionLayout(Modifier.fillMaxSize()) {
AnimatedContent(
targetState = expanded,
modifier = Modifier.fillMaxSize(),
// Both branches fill the same box — the grid grows into exactly the
// room the pane gives up — so there is no size change to contain.
// The travel between them is the shared elements' job, not a slide.
transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) },
label = "split-expand-transition",
) { isExpanded ->
if (isExpanded) {
CompositionLocalProvider(
// Null under reduced motion: nothing is tagged, nothing
// travels, and the cross-fade above is the whole transition.
LocalMonthMorph provides if (reduceMotion) {
null
} else {
MonthMorphScope(this@SharedTransitionLayout, this@AnimatedContent)
},
) {
SplitMonthBody(
expanded = isExpanded,
state = state,
selected = selected,
slideDir = slideDir,
showWeekNumbers = showWeekNumbers,
swipeModifier = swipeModifier,
onSelectDay = onSelectDay,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
onSetExpanded = { expanded = it },
)
}
}
}
}
}
/** The two faces of the split style, sharing one set of morph tags. */
@Composable
private fun SplitMonthBody(
expanded: Boolean,
state: MonthUiState.Success,
selected: LocalDate,
slideDir: Int,
showWeekNumbers: Boolean,
swipeModifier: Modifier,
onSelectDay: (LocalDate) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate) -> Unit,
onSetExpanded: (Boolean) -> Unit,
) {
if (expanded) {
SplitMonthExpanded(
state = state,
selected = selected,
slideDir = slideDir,
showWeekNumbers = showWeekNumbers,
swipeModifier = swipeModifier,
// A tap in the expanded grid picks the day and drops back,
// which gives the expanded month a job — a chooser you dip
// into — rather than a mode you can get stranded in. The
// collapse then runs with the selection already set, so the
// pane arrives showing the day you picked.
// A tap in the expanded grid picks the day and drops back, which
// gives the expanded month a job — a chooser you dip into — rather
// than a mode you can get stranded in. The collapse then runs with
// the selection already set, so the pane arrives showing the day
// you picked.
onPickDay = {
onSelectDay(it)
expanded = false
onSetExpanded(false)
},
onCollapse = { expanded = false },
onCollapse = { onSetExpanded(false) },
)
} else {
SplitMonthCollapsed(
@@ -957,12 +1006,10 @@ private fun SplitMonthContent(
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
onExpand = { expanded = true },
onExpand = { onSetExpanded(true) },
)
}
}
}
}
/** The split style at rest: compact grid, handle, then the selected day's events. */
@Composable
@@ -1093,6 +1140,9 @@ private fun SplitExpandHandle(
Box(
Modifier
.size(width = SPLIT_HANDLE_WIDTH, height = SPLIT_HANDLE_HEIGHT)
// Tagged too, so it slides from the pane's seam down to the foot
// of the grid rather than blinking out at one and in at the other.
.morphElement(MonthMorphKey.Handle)
.background(MaterialTheme.colorScheme.outlineVariant, CircleShape),
)
}
@@ -1234,19 +1284,28 @@ private fun SplitDayCell(
animationSpec = if (reduceMotion) snap() else fadeSpec,
label = "split-day-selection",
)
// Background and content are separate layers, mirroring how the paged row is
// built — and so the pill can be tagged for the morph on its own. Tagged as
// one piece with its contents inside, the dots would be nested shared
// elements within a shared element and travel twice.
Box(
modifier = modifier
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.selectable(selected = isSelected, onClick = onClick),
contentAlignment = Alignment.TopCenter,
) {
Box(
Modifier
.fillMaxSize()
.morphElement(MonthMorphKey.Cell(date))
.clip(CELL_SHAPE)
.background(background)
.border(
width = 1.5.dp,
color = MaterialTheme.colorScheme.primary.copy(alpha = selection),
shape = CELL_SHAPE,
),
)
.selectable(selected = isSelected, onClick = onClick),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier.fillMaxSize().padding(top = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -1255,10 +1314,10 @@ private fun SplitDayCell(
date = date,
isToday = isToday,
inMonth = inMonth,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().morphElement(MonthMorphKey.DayNumber(date)),
)
Spacer(Modifier.height(2.dp))
SplitDots(events = events, hidden = hidden, dark = dark)
SplitDots(date = date, events = events, hidden = hidden, dark = dark)
}
}
}
@@ -1272,7 +1331,7 @@ private fun SplitDayCell(
* bar with no dot to grow out of.
*/
@Composable
private fun SplitDots(events: List<EventInstance>, hidden: Int, dark: Boolean) {
private fun SplitDots(date: LocalDate, events: List<EventInstance>, hidden: Int, dark: Boolean) {
if (events.isEmpty()) return
val soften = LocalSoftenColors.current
Row(
@@ -1280,9 +1339,13 @@ private fun SplitDots(events: List<EventInstance>, hidden: Int, dark: Boolean) {
verticalAlignment = Alignment.CenterVertically,
) {
events.forEach { event ->
// The dot keeps its own circle and the bar its 4dp corners; they
// cross-fade inside shared bounds, and at the dot's size the two
// radii are a fraction of a pixel apart.
Box(
modifier = Modifier
.size(SPLIT_DOT_SIZE)
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
.background(eventFill(event.color, dark, soften), CircleShape),
)
}
@@ -1540,6 +1603,7 @@ private fun MonthWeekRow(
.weight(1f)
.fillMaxHeight()
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.morphElement(MonthMorphKey.Cell(d))
.background(
color = when {
inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer
@@ -1586,7 +1650,9 @@ private fun MonthWeekRow(
} else {
null
},
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.morphElement(MonthMorphKey.DayNumber(d)),
)
}
}
@@ -1615,7 +1681,17 @@ private fun MonthWeekRow(
)
.width(colW * cols)
.height(EVENT_ROW_HEIGHT)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp)
// Anchored on the day the bar starts *in this row*,
// which is where its dot sat. A bar carried in from
// the previous week starts at column 0, and column
// 0's dot is the one that grows into it.
.morphBounds(
MonthMorphKey.Event(
date = week.days[span.startCol],
instanceId = span.event.instanceId,
),
),
)
}
// Single-day timed pills + overflow, per column. Pills fill the
@@ -1643,7 +1719,8 @@ private fun MonthWeekRow(
)
.width(colW)
.height(EVENT_ROW_HEIGHT)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp)
.morphBounds(MonthMorphKey.Event(d, ev.instanceId)),
)
}
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size