fix(month): stop the row clip swallowing marks in mid-flight (#53)

Frame-by-frame, the marks were not fading in late — they were invisible
for the first half of their journey and then appearing already near the
destination, row by row from the top down.

A mark's two homes are in different rows: the dot for the 20th sits a third
of the way down the compact grid, its bar most of the way down the expanded
one. The events box clips to its own row, so a mark arriving there is out
of bounds — and undrawn — until it crosses in. The lower the row, the
further it travels and the later it showed, which is exactly the order the
capture shows them appearing in.

Rendering in place rather than in an overlay is what subjects marks to that
clip, and that is worth keeping — it is what puts the whole grid in one
z-order. So the clip yields instead, and only while pieces are in flight;
at rest it still stops a bar spilling into the row below.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 21:40:46 +02:00
parent 279872aa3b
commit 1e9581528e
2 changed files with 25 additions and 1 deletions

View File

@@ -66,6 +66,25 @@ internal class MonthMorphScope(
internal val LocalMonthMorph = compositionLocalOf<MonthMorphScope?> { null } internal val LocalMonthMorph = compositionLocalOf<MonthMorphScope?> { null }
/**
* True while the grid is mid-morph, for content that has to stop clipping to let
* the travelling pieces through.
*
* A mark's two homes are in *different rows*: the dot for the 20th sits a third
* of the way down the compact grid, its bar most of the way down the expanded
* one. Clipped to the row it is arriving at, a mark spends the first half of its
* journey outside those bounds and is simply not drawn — so it appears from
* nowhere, part-way through, already near its destination. Rendering in place
* rather than in an overlay is what subjects them to that clip, and is worth
* keeping; the clip is what has to yield, and only while pieces are in flight.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
internal fun morphInFlight(): Boolean {
val morph = LocalMonthMorph.current ?: return false
return morph.shared.isTransitionActive
}
/** /**
* Tag content that is *the same thing* on both sides — a background pill, a day * 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. * number — so it animates between its two positions and sizes.

View File

@@ -1647,6 +1647,7 @@ private fun MonthWeekRow(
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS) val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
val morphing = morphInFlight()
Row(modifier) { Row(modifier) {
// Optional calendar-week gutter, sized so the seven day columns below // Optional calendar-week gutter, sized so the seven day columns below
@@ -1720,11 +1721,15 @@ private fun MonthWeekRow(
// Breathing room between the day number (and today's circle) and the // Breathing room between the day number (and today's circle) and the
// first event row. // first event row.
Spacer(Modifier.height(DAY_NUMBER_GAP)) Spacer(Modifier.height(DAY_NUMBER_GAP))
// Clipped at rest so a bar can't spill into the row below, but not
// while a morph is in flight: a mark travels between two different
// rows, and clipping it to the one it is arriving at hides it for
// the whole first half of the journey. See [morphInFlight].
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f) .weight(1f)
.clipToBounds(), .then(if (morphing) Modifier else Modifier.clipToBounds()),
) { ) {
// Spanning bars on their shared lanes. // Spanning bars on their shared lanes.
week.spans.filter { it.lane < shownLanes }.forEach { span -> week.spans.filter { it.lane < shownLanes }.forEach { span ->