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:
@@ -3,13 +3,11 @@ package de.jeanlucmakiola.calendula.ui.month
|
|||||||
import androidx.compose.animation.AnimatedVisibilityScope
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
import androidx.compose.animation.SharedTransitionScope
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,6 +44,16 @@ internal sealed interface MonthMorphKey {
|
|||||||
*/
|
*/
|
||||||
data class Event(val date: LocalDate, val instanceId: Long) : 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. */
|
/** The grab handle, which slides from the pane's seam to the foot of the grid. */
|
||||||
data object Handle : MonthMorphKey
|
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
|
* 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.
|
* 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)
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -70,47 +94,7 @@ internal fun Modifier.morphElement(key: MonthMorphKey): Modifier {
|
|||||||
this@morphElement.sharedElement(
|
this@morphElement.sharedElement(
|
||||||
sharedContentState = rememberSharedContentState(key),
|
sharedContentState = rememberSharedContentState(key),
|
||||||
animatedVisibilityScope = morph.visibility,
|
animatedVisibilityScope = morph.visibility,
|
||||||
)
|
renderInOverlayDuringTransition = false,
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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",
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,6 +120,7 @@ internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier {
|
|||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut(),
|
exit = fadeOut(),
|
||||||
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
|
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
|
||||||
|
renderInOverlayDuringTransition = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1350,14 +1350,12 @@ private fun SplitDayCell(
|
|||||||
// is laid out where the collapsed cell actually is and simply fades in,
|
// is laid out where the collapsed cell actually is and simply fades in,
|
||||||
// which is the only size it is ever true at.
|
// which is the only size it is ever true at.
|
||||||
//
|
//
|
||||||
// Untagged is not enough on its own, though: the tagged pieces paint into
|
// It can stay plain content now that nothing renders into an overlay above
|
||||||
// an overlay above the whole regular tree, so the outline spent every
|
// the tree — see [morphElement]. It sits above its own pill by ordinary
|
||||||
// transition hidden behind its own pill and reappeared when it ended.
|
// z-order and fades with the page it belongs to, in step with the rest.
|
||||||
// morphOverlay lifts it into that same overlay while keeping its bounds.
|
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.morphOverlay(fadeSpec = outlineSpec)
|
|
||||||
.drawBehind {
|
.drawBehind {
|
||||||
val alpha = selection.value
|
val alpha = selection.value
|
||||||
if (alpha <= 0f) return@drawBehind
|
if (alpha <= 0f) return@drawBehind
|
||||||
@@ -1425,15 +1423,14 @@ private fun SplitDots(date: LocalDate, events: List<EventInstance>, hidden: Int,
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (hidden > 0) {
|
if (hidden > 0) {
|
||||||
// Lifted into the overlay for the same reason the selection outline
|
// Tagged, not lifted: this count and the expanded grid's dot row are
|
||||||
// is: untagged content stays in the regular tree, which the tagged
|
// the same marker on the same day, so it travels with its cell like
|
||||||
// pieces paint over wholesale, so it vanished for the length of the
|
// everything else rather than riding above the grid on its own layer.
|
||||||
// transition and arrived a beat after everything else had settled.
|
|
||||||
Text(
|
Text(
|
||||||
text = "+$hidden",
|
text = "+$hidden",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.morphOverlay(),
|
modifier = Modifier.morphBounds(MonthMorphKey.Overflow(date)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1806,10 +1803,7 @@ private fun MonthWeekRow(
|
|||||||
dark = dark,
|
dark = dark,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
|
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
|
||||||
// Untagged, so lifted into the overlay or it
|
.morphBounds(MonthMorphKey.Overflow(d))
|
||||||
// spends the transition painted over — see the
|
|
||||||
// compact grid's "+N".
|
|
||||||
.morphOverlay()
|
|
||||||
.width(colW)
|
.width(colW)
|
||||||
.padding(horizontal = 3.dp),
|
.padding(horizontal = 3.dp),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user