fix(month): only tag the dot that has a bar to become (#53)

Dots on the middle of a multi-day run flew in from the top of the grid
instead of fading where they stand.

They were meant to be left unmatched — a bar is drawn once per row, from
the column it starts in, so only that day's dot has a counterpart. But
"unmatched" was implemented as tagged-and-unmatched, which is a different
thing: a shared element entering with no partner has no bounds to start
from, so it animates in from the layout origin. The top.

Seating now says which day a bar is actually drawn from, and only that dot
carries a tag. The rest are plain content and fade in place, which is what
the comment claimed they did all along.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 21:51:45 +02:00
parent 1e9581528e
commit bf38dac5d7
3 changed files with 81 additions and 15 deletions

View File

@@ -1287,7 +1287,7 @@ internal fun SplitMonthGrid(
@Composable
private fun SplitDayCell(
date: LocalDate,
events: List<EventInstance>,
events: List<SeatedEvent>,
/** Events on this day that didn't fit a lane, shown as a "+N" beside the dots. */
hidden: Int,
isToday: Boolean,
@@ -1398,14 +1398,14 @@ private fun SplitDayCell(
* bar with no dot to grow out of.
*/
@Composable
private fun SplitDots(date: LocalDate, events: List<EventInstance>, hidden: Int, dark: Boolean) {
private fun SplitDots(date: LocalDate, events: List<SeatedEvent>, hidden: Int, dark: Boolean) {
if (events.isEmpty()) return
val soften = LocalSoftenColors.current
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
events.forEach { event ->
events.forEach { seated ->
// 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.
@@ -1415,11 +1415,24 @@ private fun SplitDots(date: LocalDate, events: List<EventInstance>, hidden: Int,
// 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.
//
// Only tagged where a bar actually meets it. A dot on the middle of a
// multi-day run has nothing to travel to, and tagging it regardless
// gave it no bounds to start from, so it flew in from the top of the
// grid rather than fading where it stands.
Box(
modifier = Modifier
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
.then(
if (seated.anchored) {
Modifier.morphBounds(
MonthMorphKey.Event(date, seated.event.instanceId),
)
} else {
Modifier
},
)
.size(SPLIT_DOT_SIZE)
.background(eventFill(event.color, dark, soften), CircleShape),
.background(eventFill(seated.event.color, dark, soften), CircleShape),
)
}
if (hidden > 0) {

View File

@@ -39,6 +39,19 @@ data class MonthWeek(
val countByDay: Map<LocalDate, Int>,
)
/**
* One event seated in a lane of one day, and whether *this* day is where the
* expanded grid draws it.
*
* A multi-day event has a dot on every day it covers but only one bar, drawn from
* where it starts in that week. So only the start day's dot has something to
* become; the rest are [anchored] = false and must not be tagged for the morph at
* all. Tagging them anyway gives Compose a shared element that enters with no
* partner and therefore no bounds to start from, and it flies in from the layout
* origin — the top of the grid — instead of quietly fading where it stands (#53).
*/
data class SeatedEvent(val event: EventInstance, val anchored: Boolean)
/**
* The events occupying each lane of [day] — column [col] of this week — in lane
* order, capped at [laneCap] lanes.
@@ -53,16 +66,19 @@ data class MonthWeek(
* left dot _i_ standing for no particular event, and quietly merged two events
* that shared a calendar into a single dot.
*/
fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
val byLane = arrayOfNulls<EventInstance>(laneCap)
fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<SeatedEvent> {
val byLane = arrayOfNulls<SeatedEvent>(laneCap)
spans.forEach { span ->
if (span.lane < laneCap && col in span.startCol..span.endCol) {
byLane[span.lane] = span.event
// A bar is drawn once per row, from the column it starts in — so that
// is the only day of it whose dot has a counterpart to travel to.
byLane[span.lane] = SeatedEvent(span.event, anchored = col == span.startCol)
}
}
val free = (0 until laneCap).filter { byLane[it] == null }
// A single-day event is drawn on its own day, so it is always anchored there.
timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event ->
byLane[free[i]] = event
byLane[free[i]] = SeatedEvent(event, anchored = true)
}
return byLane.filterNotNull()
}