fix(month): morph in place instead of over the top of the grid (#53)

Travelling pieces were painted into an overlay above the entire tree —
Compose's default, and right for a thumbnail flying across a screen, wrong
for a grid changing shape in place. Everything tagged left the tree's
z-order, so every untagged neighbour spent the transition buried under it.

Lifting each of those out in turn — the selection outline, then both "+N"
markers — fixed the burying and bought something worse: they floated over
the grid on a layer of their own, out of step with what they belong to.
Three pieces needing the same escape hatch was the tell that the overlay
itself was wrong here.

Nothing renders in the overlay now. One z-order, one clip, so the grid
reads as a single surface changing shape rather than a stack of pieces
sliding past each other. The outline goes back to being plain content that
sits above its own pill the ordinary way, and morphOverlay is gone.

The "+N" markers become a matched pair instead of an orphan. The compact
grid writes the count as text and the expanded one as a row of dots, but
they stand for the same events on exactly the same days — both seat three
and overflow the rest — so they were always two halves of one marker.

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

View File

@@ -3,13 +3,11 @@ package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import kotlinx.datetime.LocalDate
/**
@@ -46,6 +44,16 @@ internal sealed interface MonthMorphKey {
*/
data class Event(val date: LocalDate, val instanceId: Long) : MonthMorphKey
/**
* A day's "+N" marker. The compact grid writes it as a count beside the dots
* and the expanded one as a row of small dots under the bars, but they stand
* for the same events and appear on exactly the same days — both sides seat
* three and overflow the rest — so they are a matched pair, not two unrelated
* bits of text. Tagged, the marker travels with its day like everything else
* in the cell.
*/
data class Overflow(val date: LocalDate) : MonthMorphKey
/** The grab handle, which slides from the pane's seam to the foot of the grid. */
data object Handle : MonthMorphKey
}
@@ -61,6 +69,22 @@ internal val LocalMonthMorph = compositionLocalOf<MonthMorphScope?> { null }
/**
* 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.
*
* ### Why nothing renders in the overlay
*
* By default a travelling piece is painted into an overlay above the *entire*
* regular tree, so it can fly over anything in its way. That is right for a
* thumbnail crossing a screen, and wrong here: everything is moving inside one
* grid, and the overlay meant every untagged neighbour — the selection outline,
* the "+N" markers — spent the transition buried under pieces that had left the
* tree's z-order behind. Lifting each of them out in turn fixed the burying and
* bought a worse problem: they then floated over the grid on their own layer,
* out of step with it.
*
* Rendering in place puts everything back in one z-order and one clip, which is
* what makes the grid read as a single surface changing shape rather than a
* stack of pieces sliding past each other. Nothing here needs to escape its
* ancestors: the cells, marks and markers all travel within the grid.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
@@ -70,47 +94,7 @@ internal fun Modifier.morphElement(key: MonthMorphKey): Modifier {
this@morphElement.sharedElement(
sharedContentState = rememberSharedContentState(key),
animatedVisibilityScope = morph.visibility,
)
}
}
/**
* Lift *untagged* content into the shared-transition overlay for the duration of
* a transition, so the travelling pieces don't bury it.
*
* Tagged content is drawn in the overlay, which is painted above the whole
* regular tree. Anything left in that tree therefore disappears behind it while a
* transition runs, however it was layered locally — which is what hid the split
* style's selection outline behind its own cell pill from the first frame of an
* expand to the last.
*
* The content keeps its own layout bounds; only where it is *painted* changes. So
* the outline still draws at the size the collapsed cell actually is, which is
* the whole reason it was left untagged.
*
* Lifting content out of the regular tree also lifts it out of the enter/exit
* layer [AnimatedContent][androidx.compose.animation.AnimatedContent] fades the
* outgoing page with, so it no longer fades at all — it stood at full strength
* for the whole transition and then blinked out at the end of it. The fade is
* therefore reapplied here, on the lifted content's own layer, so it travels with
* it. Anything sent to the overlay needs this, which is why it lives in the
* helper rather than at the call site.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
internal fun Modifier.morphOverlay(
zIndex: Float = 1f,
fadeSpec: FiniteAnimationSpec<Float> = rememberCalendarFadeSpec(),
): Modifier {
val morph = LocalMonthMorph.current ?: return this
val lifted = with(morph.shared) {
this@morphOverlay.renderInSharedTransitionScopeOverlay(zIndexInOverlay = zIndex)
}
return with(morph.visibility) {
lifted.animateEnterExit(
enter = fadeIn(fadeSpec),
exit = fadeOut(fadeSpec),
label = "morph-overlay-fade",
renderInOverlayDuringTransition = false,
)
}
}
@@ -136,6 +120,7 @@ internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier {
enter = fadeIn(),
exit = fadeOut(),
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
renderInOverlayDuringTransition = false,
)
}
}

View File

@@ -1350,14 +1350,12 @@ 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.
//
// Untagged is not enough on its own, though: the tagged pieces paint into
// an overlay above the whole regular tree, so the outline spent every
// transition hidden behind its own pill and reappeared when it ended.
// morphOverlay lifts it into that same overlay while keeping its bounds.
// 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.
Box(
Modifier
.fillMaxSize()
.morphOverlay(fadeSpec = outlineSpec)
.drawBehind {
val alpha = selection.value
if (alpha <= 0f) return@drawBehind
@@ -1425,15 +1423,14 @@ private fun SplitDots(date: LocalDate, events: List<EventInstance>, hidden: Int,
)
}
if (hidden > 0) {
// Lifted into the overlay for the same reason the selection outline
// is: untagged content stays in the regular tree, which the tagged
// pieces paint over wholesale, so it vanished for the length of the
// transition and arrived a beat after everything else had settled.
// Tagged, not lifted: this count and the expanded grid's dot row are
// the same marker on the same day, so it travels with its cell like
// everything else rather than riding above the grid on its own layer.
Text(
text = "+$hidden",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.morphOverlay(),
modifier = Modifier.morphBounds(MonthMorphKey.Overflow(date)),
)
}
}
@@ -1806,10 +1803,7 @@ private fun MonthWeekRow(
dark = dark,
modifier = Modifier
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
// Untagged, so lifted into the overlay or it
// spends the transition painted over — see the
// compact grid's "+N".
.morphOverlay()
.morphBounds(MonthMorphKey.Overflow(d))
.width(colW)
.padding(horizontal = 3.dp),
)