feat(month): month view style picker + Split pull-to-expand (#38, #53) #90

Merged
makiolaj merged 36 commits from feat/month-view-style into release/v2.16.0 2026-07-20 21:25:50 +00:00
2 changed files with 36 additions and 57 deletions
Showing only changes of commit 279872aa3b - Show all commits

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),
)