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:
@@ -1287,7 +1287,7 @@ internal fun SplitMonthGrid(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun SplitDayCell(
|
private fun SplitDayCell(
|
||||||
date: LocalDate,
|
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. */
|
/** Events on this day that didn't fit a lane, shown as a "+N" beside the dots. */
|
||||||
hidden: Int,
|
hidden: Int,
|
||||||
isToday: Boolean,
|
isToday: Boolean,
|
||||||
@@ -1398,14 +1398,14 @@ private fun SplitDayCell(
|
|||||||
* bar with no dot to grow out of.
|
* bar with no dot to grow out of.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@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
|
if (events.isEmpty()) return
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
events.forEach { event ->
|
events.forEach { seated ->
|
||||||
// The dot keeps its own circle and the bar its 4dp corners; they
|
// 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
|
// cross-fade inside shared bounds, and at the dot's size the two
|
||||||
// radii are a fraction of a pixel apart.
|
// 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
|
// 5dp for the whole transition however far its bar had travelled, and
|
||||||
// only snapped to full width once the transition ended — the growth
|
// only snapped to full width once the transition ended — the growth
|
||||||
// never happened, it was clamped away.
|
// 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(
|
Box(
|
||||||
modifier = Modifier
|
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)
|
.size(SPLIT_DOT_SIZE)
|
||||||
.background(eventFill(event.color, dark, soften), CircleShape),
|
.background(eventFill(seated.event.color, dark, soften), CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (hidden > 0) {
|
if (hidden > 0) {
|
||||||
|
|||||||
@@ -39,6 +39,19 @@ data class MonthWeek(
|
|||||||
val countByDay: Map<LocalDate, Int>,
|
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
|
* The events occupying each lane of [day] — column [col] of this week — in lane
|
||||||
* order, capped at [laneCap] lanes.
|
* 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
|
* left dot _i_ standing for no particular event, and quietly merged two events
|
||||||
* that shared a calendar into a single dot.
|
* that shared a calendar into a single dot.
|
||||||
*/
|
*/
|
||||||
fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
|
fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<SeatedEvent> {
|
||||||
val byLane = arrayOfNulls<EventInstance>(laneCap)
|
val byLane = arrayOfNulls<SeatedEvent>(laneCap)
|
||||||
spans.forEach { span ->
|
spans.forEach { span ->
|
||||||
if (span.lane < laneCap && col in span.startCol..span.endCol) {
|
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 }
|
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 ->
|
timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event ->
|
||||||
byLane[free[i]] = event
|
byLane[free[i]] = SeatedEvent(event, anchored = true)
|
||||||
}
|
}
|
||||||
return byLane.filterNotNull()
|
return byLane.filterNotNull()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class LaneEventsTest {
|
|||||||
val week = rowOfJuly6(listOf(bar, meeting))
|
val week = rowOfJuly6(listOf(bar, meeting))
|
||||||
|
|
||||||
// Jul 7 is column 1 of a Monday-anchored row starting Jul 6.
|
// Jul 7 is column 1 of a Monday-anchored row starting Jul 6.
|
||||||
assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3))
|
assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3).map { it.event })
|
||||||
.containsExactly(bar, meeting)
|
.containsExactly(bar, meeting)
|
||||||
.inOrder()
|
.inOrder()
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,7 @@ class LaneEventsTest {
|
|||||||
val monday = timed(LocalDate(2026, 7, 6), hour = 9, id = 2L)
|
val monday = timed(LocalDate(2026, 7, 6), hour = 9, id = 2L)
|
||||||
val week = rowOfJuly6(listOf(bar, monday))
|
val week = rowOfJuly6(listOf(bar, monday))
|
||||||
|
|
||||||
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3))
|
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3).map { it.event })
|
||||||
.containsExactly(monday)
|
.containsExactly(monday)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,11 +80,48 @@ class LaneEventsTest {
|
|||||||
|
|
||||||
(1..3).forEach { col ->
|
(1..3).forEach { col ->
|
||||||
val day = LocalDate(2026, 7, 6 + col)
|
val day = LocalDate(2026, 7, 6 + col)
|
||||||
assertThat(week.laneEvents(col, day, laneCap = 3)).containsExactly(bar)
|
assertThat(week.laneEvents(col, day, laneCap = 3).map { it.event }).containsExactly(bar)
|
||||||
}
|
}
|
||||||
assertThat(week.laneEvents(col = 4, day = LocalDate(2026, 7, 10), laneCap = 3)).isEmpty()
|
assertThat(week.laneEvents(col = 4, day = LocalDate(2026, 7, 10), laneCap = 3)).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only the day a bar is drawn from has a counterpart to morph into. Tagging
|
||||||
|
* the other days' dots too gave them a shared element with no partner and so
|
||||||
|
* no bounds to start from, and they flew in from the top of the grid.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `only the day a multi-day bar starts on is anchored`() {
|
||||||
|
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
|
||||||
|
val week = rowOfJuly6(listOf(bar))
|
||||||
|
|
||||||
|
val anchored = (1..3).map { col ->
|
||||||
|
week.laneEvents(col, LocalDate(2026, 7, 6 + col), laneCap = 3).single().anchored
|
||||||
|
}
|
||||||
|
assertThat(anchored).containsExactly(true, false, false).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bar carried in from the previous week restarts at column 0 of this row. */
|
||||||
|
@Test
|
||||||
|
fun `a bar continuing into the row anchors on its first day here`() {
|
||||||
|
val bar = allDay(LocalDate(2026, 7, 3), LocalDate(2026, 7, 8), id = 1L)
|
||||||
|
val week = rowOfJuly6(listOf(bar))
|
||||||
|
|
||||||
|
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3).single().anchored)
|
||||||
|
.isTrue()
|
||||||
|
assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3).single().anchored)
|
||||||
|
.isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a single-day event is always anchored on its own day`() {
|
||||||
|
val meeting = timed(LocalDate(2026, 7, 6), hour = 9, id = 1L)
|
||||||
|
val week = rowOfJuly6(listOf(meeting))
|
||||||
|
|
||||||
|
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3).single().anchored)
|
||||||
|
.isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
/** The bug the colour-gathered dots had: one dot for two events on one calendar. */
|
/** The bug the colour-gathered dots had: one dot for two events on one calendar. */
|
||||||
@Test
|
@Test
|
||||||
fun `events sharing a colour each keep their own lane`() {
|
fun `events sharing a colour each keep their own lane`() {
|
||||||
@@ -92,7 +129,7 @@ class LaneEventsTest {
|
|||||||
val second = timed(LocalDate(2026, 7, 6), hour = 14, id = 2L, color = RED)
|
val second = timed(LocalDate(2026, 7, 6), hour = 14, id = 2L, color = RED)
|
||||||
val week = rowOfJuly6(listOf(first, second))
|
val week = rowOfJuly6(listOf(first, second))
|
||||||
|
|
||||||
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3))
|
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3).map { it.event })
|
||||||
.containsExactly(first, second)
|
.containsExactly(first, second)
|
||||||
.inOrder()
|
.inOrder()
|
||||||
}
|
}
|
||||||
@@ -104,7 +141,7 @@ class LaneEventsTest {
|
|||||||
|
|
||||||
val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)
|
val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)
|
||||||
assertThat(seated).hasSize(3)
|
assertThat(seated).hasSize(3)
|
||||||
assertThat(seated).containsExactlyElementsIn(events.take(3)).inOrder()
|
assertThat(seated.map { it.event }).containsExactlyElementsIn(events.take(3)).inOrder()
|
||||||
assertThat(week.countByDay[LocalDate(2026, 7, 6)]!! - seated.size).isEqualTo(2)
|
assertThat(week.countByDay[LocalDate(2026, 7, 6)]!! - seated.size).isEqualTo(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +156,7 @@ class LaneEventsTest {
|
|||||||
val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)
|
val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)
|
||||||
assertThat(seated).hasSize(3)
|
assertThat(seated).hasSize(3)
|
||||||
assertThat(week.spans.filter { it.lane >= 3 }.map { it.event })
|
assertThat(week.spans.filter { it.lane >= 3 }.map { it.event })
|
||||||
.containsNoneIn(seated)
|
.containsNoneIn(seated.map { it.event })
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
|||||||
Reference in New Issue
Block a user