fix(month): let the selection outline travel with its cell (#53)
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 5m44s

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 22:19:19 +02:00
parent 7a05fc3ac1
commit fb90c94e37
2 changed files with 61 additions and 6 deletions

View File

@@ -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
}

View File

@@ -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.