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 61 additions and 26 deletions
Showing only changes of commit bdb16d069e - Show all commits

View File

@@ -72,6 +72,29 @@ internal fun Modifier.morphElement(key: MonthMorphKey): Modifier {
}
}
/**
* 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.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
internal fun Modifier.morphOverlay(zIndex: Float = 1f): Modifier {
val morph = LocalMonthMorph.current ?: return this
return with(morph.shared) {
this@morphOverlay.renderInSharedTransitionScopeOverlay(zIndexInOverlay = zIndex)
}
}
/**
* Tag content that means the same thing but *is drawn differently* on each side —
* a 5dp dot and a titled bar — so only the bounds are shared and the contents

View File

@@ -1052,31 +1052,35 @@ private fun SplitMonthCollapsed(
val reduceMotion = rememberReduceMotion()
Column(Modifier.fillMaxSize()) {
AnimatedContent(
// The selection travels *with* the state so each page keeps its
// own. Read from outside, both pages would show the incoming
// one, and paging visibly threw the marker across the outgoing
// grid — onto the new month's 1st, which the old grid still
// shows among its trailing days — before the new page arrived.
targetState = state to selected,
modifier = swipeModifier,
// Keyed on the month alone, so a provider notification refreshing
// the month you are on — or a tap moving the selection within it
// — updates in place instead of sliding.
contentKey = { (s, _) -> s.month },
transitionSpec = {
calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion)
},
label = "split-month-transition",
) { (s, sel) ->
SplitMonthGrid(
state = s,
selected = sel,
showWeekNumbers = showWeekNumbers,
onSelectDay = onSelectDay,
)
// Grid and handle drag as one surface — the handle is what advertises the
// gesture, so it has to answer to it as well as to a tap. The pane is
// outside: it scrolls, and is full of tappable rows.
Column(swipeModifier) {
AnimatedContent(
// The selection travels *with* the state so each page keeps its
// own. Read from outside, both pages would show the incoming
// one, and paging visibly threw the marker across the outgoing
// grid — onto the new month's 1st, which the old grid still
// shows among its trailing days — before the new page arrived.
targetState = state to selected,
// Keyed on the month alone, so a provider notification refreshing
// the month you are on — or a tap moving the selection within it
// — updates in place instead of sliding.
contentKey = { (s, _) -> s.month },
transitionSpec = {
calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion)
},
label = "split-month-transition",
) { (s, sel) ->
SplitMonthGrid(
state = s,
selected = sel,
showWeekNumbers = showWeekNumbers,
onSelectDay = onSelectDay,
)
}
SplitExpandHandle(expanded = false, onToggle = onExpand)
}
SplitExpandHandle(expanded = false, onToggle = onExpand)
SplitDayPane(
date = selected,
today = state.today,
@@ -1115,10 +1119,12 @@ private fun SplitMonthExpanded(
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
Column(Modifier.fillMaxSize()) {
// The whole screen drags here, handle included — there is no pane to keep out
// of it, and the handle is the obvious thing to reach for on the way back.
Column(Modifier.fillMaxSize().then(swipeModifier)) {
AnimatedContent(
targetState = state,
modifier = Modifier.weight(1f).then(swipeModifier),
modifier = Modifier.weight(1f),
contentKey = { it.month },
transitionSpec = {
calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion)
@@ -1340,9 +1346,15 @@ private fun SplitDayCell(
// flashing over a grid that no longer had full-height cells. Untagged it
// 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.
Box(
Modifier
.fillMaxSize()
.morphOverlay()
.drawBehind {
val alpha = selection.value
if (alpha <= 0f) return@drawBehind