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 6 deletions
Showing only changes of commit fb90c94e37 - Show all commits

View File

@@ -54,6 +54,15 @@ internal sealed interface MonthMorphKey {
*/ */
data class Overflow(val date: LocalDate) : MonthMorphKey data class Overflow(val date: LocalDate) : MonthMorphKey
/**
* The selected day's outline. The expanded grid draws no outline — once the
* cells carry real event bars it is one mark too many — so this pairs the
* compact grid's outline with an *invisible* stand-in over the same cell
* there. Without one it had nowhere to travel and could only fade in place
* while everything around it moved.
*/
data class Selection(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
} }

View File

@@ -591,6 +591,8 @@ internal fun MonthGrid(
state: MonthUiState.Success, state: MonthUiState.Success,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
/** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */
selected: LocalDate? = null,
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -609,6 +611,7 @@ internal fun MonthGrid(
inMonth = { it.month == month.month && it.year == month.year }, inMonth = { it.month == month.month && it.year == month.year },
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
selected = selected,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f), .weight(1f),
@@ -1003,6 +1006,7 @@ private fun SplitMonthBody(
if (expanded) { if (expanded) {
SplitMonthExpanded( SplitMonthExpanded(
state = state, state = state,
selected = selected,
slideDir = slideDir, slideDir = slideDir,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
swipeModifier = swipeModifier, swipeModifier = swipeModifier,
@@ -1109,6 +1113,8 @@ private fun SplitMonthCollapsed(
@Composable @Composable
private fun SplitMonthExpanded( private fun SplitMonthExpanded(
state: MonthUiState.Success, state: MonthUiState.Success,
/** Marked nowhere here; it only anchors the outline's morph. */
selected: LocalDate,
slideDir: Int, slideDir: Int,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
swipeModifier: Modifier, swipeModifier: Modifier,
@@ -1123,18 +1129,19 @@ private fun SplitMonthExpanded(
// of it, and the handle is the obvious thing to reach for on the way back. // of it, and the handle is the obvious thing to reach for on the way back.
Column(Modifier.fillMaxSize().then(swipeModifier)) { Column(Modifier.fillMaxSize().then(swipeModifier)) {
AnimatedContent( AnimatedContent(
targetState = state, targetState = state to selected,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
contentKey = { it.month }, contentKey = { (s, _) -> s.month },
transitionSpec = { transitionSpec = {
calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion)
}, },
label = "split-expanded-month-transition", label = "split-expanded-month-transition",
) { s -> ) { (s, sel) ->
MonthGrid( MonthGrid(
state = s, state = s,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onPickDay, onOpenDay = onPickDay,
selected = sel,
) )
} }
SplitExpandHandle(expanded = true, onToggle = onCollapse) SplitExpandHandle(expanded = true, onToggle = onCollapse)
@@ -1350,12 +1357,21 @@ 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.
// //
// It can stay plain content now that nothing renders into an overlay above // Tagged only while it is the selected day, and paired with an invisible
// the tree — see [morphElement]. It sits above its own pill by ordinary // stand-in over the same cell in the expanded grid, so it travels with its
// z-order and fades with the page it belongs to, in step with the rest. // cell instead of fading in place. Tagged unconditionally it would put a
// key on all 42 cells against the one partner the expanded grid offers,
// and the other 41 would fly in from the layout origin.
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.then(
if (isSelected) {
Modifier.morphBounds(MonthMorphKey.Selection(date))
} else {
Modifier
},
)
.drawBehind { .drawBehind {
val alpha = selection.value val alpha = selection.value
if (alpha <= 0f) return@drawBehind if (alpha <= 0f) return@drawBehind
@@ -1647,6 +1663,13 @@ private fun MonthWeekRow(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
blankOutside: Boolean = false, blankOutside: Boolean = false,
labelMonthOnFirst: Boolean = false, labelMonthOnFirst: Boolean = false,
/**
* The selected day, when there is one. Draws *nothing* — it only places the
* invisible counterpart the compact grid's outline morphs against. Null for
* every style but the split style's expanded form, which composes no extra
* layer at all.
*/
selected: LocalDate? = null,
) { ) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
@@ -1846,6 +1869,29 @@ private fun MonthWeekRow(
} }
} }
// Invisible stand-in for the compact grid's selection outline, so it
// has somewhere to travel to and from. Its own layer rather than a
// child of the cell pill: that pill is itself a tagged piece, and a
// tag nested inside a tag travels twice. Padded to match the outline's
// own bounds over there, or it would arrive at the wrong size.
if (selected != null) {
Row(Modifier.matchParentSize()) {
week.days.forEach { d ->
if (d == selected && inMonth(d)) {
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.morphBounds(MonthMorphKey.Selection(d)),
)
} else {
Spacer(Modifier.weight(1f).fillMaxHeight())
}
}
}
}
// Tap layer: in month view a tap on any day opens that day. Padded and // Tap layer: in month view a tap on any day opens that day. Padded and
// clipped to the background pill so the ripple matches it. A blanked // clipped to the background pill so the ripple matches it. A blanked
// cell isn't part of this month, so it takes no taps either. // cell isn't part of this month, so it takes no taps either.