From fb90c94e37224e0c6f844b80144358c80491623a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 22:19:19 +0200 Subject: [PATCH] fix(month): let the selection outline travel with its cell (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The outline was the last piece with nothing to morph against, so it could only fade in place while everything around it moved. The expanded grid draws no outline by design — once the cells carry real event bars it is one mark too many — which left it partnerless. Same answer as the multi-day dots: give it an invisible stand-in over the same cell in the expanded grid. It draws nothing and exists only to be the other end of the morph, so the outline now rides its cell between the two layouts. Tagged only while it is the selected day. Tagged unconditionally it would put a key on all 42 cells against the single partner on offer, and the other 41 would fly in from the layout origin — the failure the dots just had. The stand-in carries the same padding as the outline's own bounds, or it would arrive at the wrong size, and it sits in its own layer rather than inside the cell pill, which is itself tagged and would make it travel twice. MonthGrid takes `selected` again, but for an anchor rather than a mark; the other styles pass nothing and compose no extra layer at all. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthMorph.kt | 9 +++ .../calendula/ui/month/MonthScreen.kt | 58 +++++++++++++++++-- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt index c8d099c..e46e36b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt @@ -54,6 +54,15 @@ internal sealed interface MonthMorphKey { */ data class Overflow(val date: LocalDate) : MonthMorphKey + /** + * The selected day's outline. The expanded grid draws no outline — once the + * cells carry real event bars it is one mark too many — so this pairs the + * compact grid's outline with an *invisible* stand-in over the same cell + * there. Without one it had nowhere to travel and could only fade in place + * while everything around it moved. + */ + data class Selection(val date: LocalDate) : MonthMorphKey + /** The grab handle, which slides from the pane's seam to the foot of the grid. */ data object Handle : MonthMorphKey } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 2675e6e..c69728c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -591,6 +591,8 @@ internal fun MonthGrid( state: MonthUiState.Success, showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, + /** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */ + selected: LocalDate? = null, ) { Column( modifier = Modifier @@ -609,6 +611,7 @@ internal fun MonthGrid( inMonth = { it.month == month.month && it.year == month.year }, showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, + selected = selected, modifier = Modifier .fillMaxWidth() .weight(1f), @@ -1003,6 +1006,7 @@ private fun SplitMonthBody( if (expanded) { SplitMonthExpanded( state = state, + selected = selected, slideDir = slideDir, showWeekNumbers = showWeekNumbers, swipeModifier = swipeModifier, @@ -1109,6 +1113,8 @@ private fun SplitMonthCollapsed( @Composable private fun SplitMonthExpanded( state: MonthUiState.Success, + /** Marked nowhere here; it only anchors the outline's morph. */ + selected: LocalDate, slideDir: Int, showWeekNumbers: Boolean, swipeModifier: Modifier, @@ -1123,18 +1129,19 @@ private fun SplitMonthExpanded( // of it, and the handle is the obvious thing to reach for on the way back. Column(Modifier.fillMaxSize().then(swipeModifier)) { AnimatedContent( - targetState = state, + targetState = state to selected, modifier = Modifier.weight(1f), - contentKey = { it.month }, + contentKey = { (s, _) -> s.month }, transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) }, label = "split-expanded-month-transition", - ) { s -> + ) { (s, sel) -> MonthGrid( state = s, showWeekNumbers = showWeekNumbers, onOpenDay = onPickDay, + selected = sel, ) } SplitExpandHandle(expanded = true, onToggle = onCollapse) @@ -1350,12 +1357,21 @@ private fun SplitDayCell( // is laid out where the collapsed cell actually is and simply fades in, // which is the only size it is ever true at. // - // It can stay plain content now that nothing renders into an overlay above - // the tree — see [morphElement]. It sits above its own pill by ordinary - // z-order and fades with the page it belongs to, in step with the rest. + // Tagged only while it is the selected day, and paired with an invisible + // stand-in over the same cell in the expanded grid, so it travels with its + // cell instead of fading in place. Tagged unconditionally it would put a + // key on all 42 cells against the one partner the expanded grid offers, + // and the other 41 would fly in from the layout origin. Box( Modifier .fillMaxSize() + .then( + if (isSelected) { + Modifier.morphBounds(MonthMorphKey.Selection(date)) + } else { + Modifier + }, + ) .drawBehind { val alpha = selection.value if (alpha <= 0f) return@drawBehind @@ -1647,6 +1663,13 @@ private fun MonthWeekRow( modifier: Modifier = Modifier, blankOutside: Boolean = false, labelMonthOnFirst: Boolean = false, + /** + * The selected day, when there is one. Draws *nothing* — it only places the + * invisible counterpart the compact grid's outline morphs against. Null for + * every style but the split style's expanded form, which composes no extra + * layer at all. + */ + selected: LocalDate? = null, ) { val dark = isSystemInDarkTheme() val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 @@ -1846,6 +1869,29 @@ private fun MonthWeekRow( } } + // Invisible stand-in for the compact grid's selection outline, so it + // has somewhere to travel to and from. Its own layer rather than a + // child of the cell pill: that pill is itself a tagged piece, and a + // tag nested inside a tag travels twice. Padded to match the outline's + // own bounds over there, or it would arrive at the wrong size. + if (selected != null) { + Row(Modifier.matchParentSize()) { + week.days.forEach { d -> + if (d == selected && inMonth(d)) { + Box( + Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = CELL_GAP, vertical = 1.dp) + .morphBounds(MonthMorphKey.Selection(d)), + ) + } else { + Spacer(Modifier.weight(1f).fillMaxHeight()) + } + } + } + } + // Tap layer: in month view a tap on any day opens that day. Padded and // clipped to the background pill so the ripple matches it. A blanked // cell isn't part of this month, so it takes no taps either.