fix(month): drag from the handle, and stop the outline hiding behind its cell (#53)

The handle advertised a gesture it didn't answer to — the drag lived on the
grid alone, so the one part of the screen that says "pull me" was the one
part that ignored being pulled. Grid and handle now drag as a single
surface; the pane stays outside it, since it scrolls and is full of
tappable rows. Tapping the handle still toggles: a parent drag and a child
clickable coexist the same way they do in any scrollable list.

The selection outline was still flickering through an expand or collapse,
and being untagged was not enough to explain it. Tagged pieces paint into
an overlay above the whole regular tree, so the outline — correctly left in
that tree, to keep the collapsed cell's bounds — spent every transition
hidden behind its own morphing pill and reappeared the moment it ended.
It now renders into that same overlay while keeping its own bounds, which
is what the untagged layout was for in the first place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 20:34:56 +02:00
parent beb8536e8a
commit bdb16d069e
2 changed files with 61 additions and 26 deletions

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