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 226 additions and 51 deletions
Showing only changes of commit 488de490f9 - Show all commits

View File

@@ -0,0 +1,98 @@
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.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import kotlinx.datetime.LocalDate
/**
* What lets the split style's compact grid *become* the full month rather than be
* swapped for it (#53).
*
* The two grids are separate composables — the compact one draws dots, the
* expanded one draws the paged style's bars and pills — so nothing about them is
* shared by construction. Tagging the pieces that mean the same thing on both
* sides with the same [MonthMorphKey] hands Compose enough to animate one into
* the other: a day's cell grows, its number rides along, and each dot travels out
* to the bar it stood for.
*
* It travels as a composition local rather than as parameters because the pieces
* sit five levels below where the scopes exist, and because a null default is
* exactly the right meaning for everyone else: the paged, continuous, and dense
* styles share the same row and cell composables, provide nothing, and pay
* nothing. Reduced motion provides nothing either, which leaves the plain
* cross-fade underneath.
*/
internal sealed interface MonthMorphKey {
/** A day's background pill — the structural anchor the rest rides on. */
data class Cell(val date: LocalDate) : MonthMorphKey
data class DayNumber(val date: LocalDate) : MonthMorphKey
/**
* One event *on one day*. Keyed by date as well as instance because a
* multi-day event has a dot on every day it covers but only one bar, drawn
* from where it starts: the start day's dot is the one that becomes the bar,
* and the rest are left unmatched on purpose. They fade where they stand
* while the bar sweeps out over them, which is the honest reading — one of
* them could not become the bar without the others teleporting into it.
*/
data class Event(val date: LocalDate, val instanceId: Long) : MonthMorphKey
/** The grab handle, which slides from the pane's seam to the foot of the grid. */
data object Handle : MonthMorphKey
}
@OptIn(ExperimentalSharedTransitionApi::class)
internal class MonthMorphScope(
val shared: SharedTransitionScope,
val visibility: AnimatedVisibilityScope,
)
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.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
internal fun Modifier.morphElement(key: MonthMorphKey): Modifier {
val morph = LocalMonthMorph.current ?: return this
return with(morph.shared) {
this@morphElement.sharedElement(
sharedContentState = rememberSharedContentState(key),
animatedVisibilityScope = morph.visibility,
)
}
}
/**
* 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
* cross-fade inside them.
*
* [ResizeMode.RemeasureToBounds][SharedTransitionScope.ResizeMode] rather than
* scaling: a bar's title laid out at the dot's 5dp and then scaled up would
* arrive as a smear. Remeasuring keeps the text at its real size throughout and
* simply clips it while there is no room, so what grows is the pill, not the type.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier {
val morph = LocalMonthMorph.current ?: return this
return with(morph.shared) {
this@morphBounds.sharedBounds(
sharedContentState = rememberSharedContentState(key),
animatedVisibilityScope = morph.visibility,
enter = fadeIn(),
exit = fadeOut(),
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
)
}
}

View File

@@ -1,6 +1,8 @@
package de.jeanlucmakiola.calendula.ui.month package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap import androidx.compose.animation.core.snap
@@ -891,6 +893,7 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp
* screen to explain why. [rememberSaveable] carries it across a rotation, which * screen to explain why. [rememberSaveable] carries it across a rotation, which
* is as long as it should live. * is as long as it should live.
*/ */
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
private fun SplitMonthContent( private fun SplitMonthContent(
state: MonthUiState, state: MonthUiState,
@@ -906,6 +909,7 @@ private fun SplitMonthContent(
onCreateEvent: (LocalDate) -> Unit, onCreateEvent: (LocalDate) -> Unit,
) { ) {
val fadeSpec = rememberCalendarFadeSpec() val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
var expanded by rememberSaveable { mutableStateOf(false) } var expanded by rememberSaveable { mutableStateOf(false) }
// The swipe wraps the grid rather than living inside it: mid-transition // The swipe wraps the grid rather than living inside it: mid-transition
// there are two grids, and the gesture belongs to neither. The pane is left // there are two grids, and the gesture belongs to neither. The pane is left
@@ -920,31 +924,76 @@ private fun SplitMonthContent(
when (state) { when (state) {
MonthUiState.Loading -> MonthGridLoading() MonthUiState.Loading -> MonthGridLoading()
is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry) is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry)
is MonthUiState.Success -> AnimatedContent( is MonthUiState.Success -> SharedTransitionLayout(Modifier.fillMaxSize()) {
AnimatedContent(
targetState = expanded, targetState = expanded,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
// Both branches fill the same box — the grid grows into exactly the // Both branches fill the same box — the grid grows into exactly the
// room the pane gives up — so there is no size change to contain. // room the pane gives up — so there is no size change to contain.
// The travel between them is the shared elements' job, not a slide.
transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) }, transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) },
label = "split-expand-transition", label = "split-expand-transition",
) { isExpanded -> ) { isExpanded ->
if (isExpanded) { CompositionLocalProvider(
// Null under reduced motion: nothing is tagged, nothing
// travels, and the cross-fade above is the whole transition.
LocalMonthMorph provides if (reduceMotion) {
null
} else {
MonthMorphScope(this@SharedTransitionLayout, this@AnimatedContent)
},
) {
SplitMonthBody(
expanded = isExpanded,
state = state,
selected = selected,
slideDir = slideDir,
showWeekNumbers = showWeekNumbers,
swipeModifier = swipeModifier,
onSelectDay = onSelectDay,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
onSetExpanded = { expanded = it },
)
}
}
}
}
}
/** The two faces of the split style, sharing one set of morph tags. */
@Composable
private fun SplitMonthBody(
expanded: Boolean,
state: MonthUiState.Success,
selected: LocalDate,
slideDir: Int,
showWeekNumbers: Boolean,
swipeModifier: Modifier,
onSelectDay: (LocalDate) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate) -> Unit,
onSetExpanded: (Boolean) -> Unit,
) {
if (expanded) {
SplitMonthExpanded( SplitMonthExpanded(
state = state, state = state,
selected = selected, selected = selected,
slideDir = slideDir, slideDir = slideDir,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
swipeModifier = swipeModifier, swipeModifier = swipeModifier,
// A tap in the expanded grid picks the day and drops back, // A tap in the expanded grid picks the day and drops back, which
// which gives the expanded month a job — a chooser you dip // gives the expanded month a job — a chooser you dip into — rather
// into — rather than a mode you can get stranded in. The // than a mode you can get stranded in. The collapse then runs with
// collapse then runs with the selection already set, so the // the selection already set, so the pane arrives showing the day
// pane arrives showing the day you picked. // you picked.
onPickDay = { onPickDay = {
onSelectDay(it) onSelectDay(it)
expanded = false onSetExpanded(false)
}, },
onCollapse = { expanded = false }, onCollapse = { onSetExpanded(false) },
) )
} else { } else {
SplitMonthCollapsed( SplitMonthCollapsed(
@@ -957,12 +1006,10 @@ private fun SplitMonthContent(
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
onExpand = { expanded = true }, onExpand = { onSetExpanded(true) },
) )
} }
} }
}
}
/** The split style at rest: compact grid, handle, then the selected day's events. */ /** The split style at rest: compact grid, handle, then the selected day's events. */
@Composable @Composable
@@ -1093,6 +1140,9 @@ private fun SplitExpandHandle(
Box( Box(
Modifier Modifier
.size(width = SPLIT_HANDLE_WIDTH, height = SPLIT_HANDLE_HEIGHT) .size(width = SPLIT_HANDLE_WIDTH, height = SPLIT_HANDLE_HEIGHT)
// Tagged too, so it slides from the pane's seam down to the foot
// of the grid rather than blinking out at one and in at the other.
.morphElement(MonthMorphKey.Handle)
.background(MaterialTheme.colorScheme.outlineVariant, CircleShape), .background(MaterialTheme.colorScheme.outlineVariant, CircleShape),
) )
} }
@@ -1234,19 +1284,28 @@ private fun SplitDayCell(
animationSpec = if (reduceMotion) snap() else fadeSpec, animationSpec = if (reduceMotion) snap() else fadeSpec,
label = "split-day-selection", label = "split-day-selection",
) )
// Background and content are separate layers, mirroring how the paged row is
// built — and so the pill can be tagged for the morph on its own. Tagged as
// one piece with its contents inside, the dots would be nested shared
// elements within a shared element and travel twice.
Box( Box(
modifier = modifier modifier = modifier
.padding(horizontal = CELL_GAP, vertical = 1.dp) .padding(horizontal = CELL_GAP, vertical = 1.dp)
.selectable(selected = isSelected, onClick = onClick),
contentAlignment = Alignment.TopCenter,
) {
Box(
Modifier
.fillMaxSize()
.morphElement(MonthMorphKey.Cell(date))
.clip(CELL_SHAPE) .clip(CELL_SHAPE)
.background(background) .background(background)
.border( .border(
width = 1.5.dp, width = 1.5.dp,
color = MaterialTheme.colorScheme.primary.copy(alpha = selection), color = MaterialTheme.colorScheme.primary.copy(alpha = selection),
shape = CELL_SHAPE, shape = CELL_SHAPE,
),
) )
.selectable(selected = isSelected, onClick = onClick),
contentAlignment = Alignment.TopCenter,
) {
Column( Column(
modifier = Modifier.fillMaxSize().padding(top = 4.dp), modifier = Modifier.fillMaxSize().padding(top = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -1255,10 +1314,10 @@ private fun SplitDayCell(
date = date, date = date,
isToday = isToday, isToday = isToday,
inMonth = inMonth, inMonth = inMonth,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth().morphElement(MonthMorphKey.DayNumber(date)),
) )
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
SplitDots(events = events, hidden = hidden, dark = dark) SplitDots(date = date, events = events, hidden = hidden, dark = dark)
} }
} }
} }
@@ -1272,7 +1331,7 @@ private fun SplitDayCell(
* bar with no dot to grow out of. * bar with no dot to grow out of.
*/ */
@Composable @Composable
private fun SplitDots(events: List<EventInstance>, hidden: Int, dark: Boolean) { private fun SplitDots(date: LocalDate, events: List<EventInstance>, hidden: Int, dark: Boolean) {
if (events.isEmpty()) return if (events.isEmpty()) return
val soften = LocalSoftenColors.current val soften = LocalSoftenColors.current
Row( Row(
@@ -1280,9 +1339,13 @@ private fun SplitDots(events: List<EventInstance>, hidden: Int, dark: Boolean) {
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
events.forEach { event -> events.forEach { event ->
// The dot keeps its own circle and the bar its 4dp corners; they
// cross-fade inside shared bounds, and at the dot's size the two
// radii are a fraction of a pixel apart.
Box( Box(
modifier = Modifier modifier = Modifier
.size(SPLIT_DOT_SIZE) .size(SPLIT_DOT_SIZE)
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
.background(eventFill(event.color, dark, soften), CircleShape), .background(eventFill(event.color, dark, soften), CircleShape),
) )
} }
@@ -1540,6 +1603,7 @@ private fun MonthWeekRow(
.weight(1f) .weight(1f)
.fillMaxHeight() .fillMaxHeight()
.padding(horizontal = CELL_GAP, vertical = 1.dp) .padding(horizontal = CELL_GAP, vertical = 1.dp)
.morphElement(MonthMorphKey.Cell(d))
.background( .background(
color = when { color = when {
inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer
@@ -1586,7 +1650,9 @@ private fun MonthWeekRow(
} else { } else {
null null
}, },
modifier = Modifier.weight(1f), modifier = Modifier
.weight(1f)
.morphElement(MonthMorphKey.DayNumber(d)),
) )
} }
} }
@@ -1615,7 +1681,17 @@ private fun MonthWeekRow(
) )
.width(colW * cols) .width(colW * cols)
.height(EVENT_ROW_HEIGHT) .height(EVENT_ROW_HEIGHT)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp), .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp)
// Anchored on the day the bar starts *in this row*,
// which is where its dot sat. A bar carried in from
// the previous week starts at column 0, and column
// 0's dot is the one that grows into it.
.morphBounds(
MonthMorphKey.Event(
date = week.days[span.startCol],
instanceId = span.event.instanceId,
),
),
) )
} }
// Single-day timed pills + overflow, per column. Pills fill the // Single-day timed pills + overflow, per column. Pills fill the
@@ -1643,7 +1719,8 @@ private fun MonthWeekRow(
) )
.width(colW) .width(colW)
.height(EVENT_ROW_HEIGHT) .height(EVENT_ROW_HEIGHT)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp), .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp)
.morphBounds(MonthMorphKey.Event(d, ev.instanceId)),
) )
} }
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size