fix(month): let the event marks actually grow, and lift the "+N" out (#53)

Two reasons a mark arrived a beat late instead of animating.

The dots and bars carried morphBounds *inside* their own size modifiers, so
the shared bounds had nothing to drive: the dot stayed pinned at 5dp however
far its bar had travelled, and the bar sat at full width from the first
frame, each snapping into place only once the transition ended. The growth
was being clamped away. morphBounds now sits outside the width/height on
both sides. A bar's offset stays outside it — that is where the bar sits,
not how big it is.

The "+N" overflow markers were untagged, so they spent every transition
painted over by the overlay the tagged pieces draw into, exactly as the
selection outline did. Both the compact grid's and the expanded grid's are
now lifted into it.

The outline itself moves to the fast effects spec, as asked: it is a small
mark that only ever appears or disappears, and at the shared pace it
lingered after the thing it marks had already moved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 20:55:30 +02:00
parent 7fb01ab8d1
commit ff6c4b6a4d
2 changed files with 39 additions and 12 deletions

View File

@@ -3,6 +3,7 @@ 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.core.FiniteAnimationSpec
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
@@ -97,9 +98,11 @@ internal fun Modifier.morphElement(key: MonthMorphKey): Modifier {
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
internal fun Modifier.morphOverlay(zIndex: Float = 1f): Modifier {
internal fun Modifier.morphOverlay(
zIndex: Float = 1f,
fadeSpec: FiniteAnimationSpec<Float> = rememberCalendarFadeSpec(),
): Modifier {
val morph = LocalMonthMorph.current ?: return this
val fadeSpec = rememberCalendarFadeSpec()
val lifted = with(morph.shared) {
this@morphOverlay.renderInSharedTransitionScopeOverlay(zIndexInOverlay = zIndex)
}

View File

@@ -1313,10 +1313,13 @@ private fun SplitDayCell(
// 42 cells at once, which is what made the outline shiver rather than fade.
// Kept in the draw phase, the animation touches nothing but the pixels.
val reduceMotion = rememberReduceMotion()
val fadeSpec = rememberCalendarFadeSpec()
// The outline runs on the *fast* effects spec, unlike everything else here.
// It is a small mark that only ever appears or disappears, and at the shared
// pace it lingered after the thing it marks had already moved.
val outlineSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
val selection = animateFloatAsState(
targetValue = if (isSelected) 1f else 0f,
animationSpec = if (reduceMotion) snap() else fadeSpec,
animationSpec = if (reduceMotion) snap() else outlineSpec,
label = "split-day-selection",
)
val outlineColor = MaterialTheme.colorScheme.primary
@@ -1354,7 +1357,7 @@ private fun SplitDayCell(
Box(
Modifier
.fillMaxSize()
.morphOverlay()
.morphOverlay(fadeSpec = outlineSpec)
.drawBehind {
val alpha = selection.value
if (alpha <= 0f) return@drawBehind
@@ -1408,18 +1411,29 @@ private fun SplitDots(date: LocalDate, events: List<EventInstance>, hidden: Int,
// 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.
//
// morphBounds goes *outside* the size: the shared bounds have to be
// free to drive the measurement. Behind a fixed .size() the dot stayed
// 5dp for the whole transition however far its bar had travelled, and
// only snapped to full width once the transition ended — the growth
// never happened, it was clamped away.
Box(
modifier = Modifier
.size(SPLIT_DOT_SIZE)
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
.size(SPLIT_DOT_SIZE)
.background(eventFill(event.color, dark, soften), CircleShape),
)
}
if (hidden > 0) {
// Lifted into the overlay for the same reason the selection outline
// is: untagged content stays in the regular tree, which the tagged
// pieces paint over wholesale, so it vanished for the length of the
// transition and arrived a beat after everything else had settled.
Text(
text = "+$hidden",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.morphOverlay(),
)
}
}
@@ -1728,19 +1742,25 @@ private fun MonthWeekRow(
x = colW * span.startCol,
y = EVENT_ROW_HEIGHT * span.lane,
)
.width(colW * cols)
.height(EVENT_ROW_HEIGHT)
.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.
//
// Outside the width/height, so the shared bounds
// drive the measurement instead of being pinned to
// the bar's final size from the first frame. The
// offset stays outside it: that is where the bar
// sits, not how big it is.
.morphBounds(
MonthMorphKey.Event(
date = week.days[span.startCol],
instanceId = span.event.instanceId,
),
),
)
.width(colW * cols)
.height(EVENT_ROW_HEIGHT)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
)
}
// Single-day timed pills + overflow, per column. Pills fill the
@@ -1766,10 +1786,10 @@ private fun MonthWeekRow(
x = colW * col,
y = EVENT_ROW_HEIGHT * freeSlots[i],
)
.morphBounds(MonthMorphKey.Event(d, ev.instanceId))
.width(colW)
.height(EVENT_ROW_HEIGHT)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp)
.morphBounds(MonthMorphKey.Event(d, ev.instanceId)),
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
)
}
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
@@ -1786,6 +1806,10 @@ private fun MonthWeekRow(
dark = dark,
modifier = Modifier
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
// Untagged, so lifted into the overlay or it
// spends the transition painted over — see the
// compact grid's "+N".
.morphOverlay()
.width(colW)
.padding(horizontal = 3.dp),
)