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
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.animateFloatAsState
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
* is as long as it should live.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun SplitMonthContent(
state: MonthUiState,
@@ -906,6 +909,7 @@ private fun SplitMonthContent(
onCreateEvent: (LocalDate) -> Unit,
) {
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
var expanded by rememberSaveable { mutableStateOf(false) }
// 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
@@ -920,31 +924,76 @@ private fun SplitMonthContent(
when (state) {
MonthUiState.Loading -> MonthGridLoading()
is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry)
is MonthUiState.Success -> AnimatedContent(
is MonthUiState.Success -> SharedTransitionLayout(Modifier.fillMaxSize()) {
AnimatedContent(
targetState = expanded,
modifier = Modifier.fillMaxSize(),
// 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.
// The travel between them is the shared elements' job, not a slide.
transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) },
label = "split-expand-transition",
) { 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(
state = state,
selected = selected,
slideDir = slideDir,
showWeekNumbers = showWeekNumbers,
swipeModifier = swipeModifier,
// A tap in the expanded grid picks the day and drops back,
// which gives the expanded month a job — a chooser you dip
// into — rather than a mode you can get stranded in. The
// collapse then runs with the selection already set, so the
// pane arrives showing the day you picked.
// A tap in the expanded grid picks the day and drops back, which
// gives the expanded month a job — a chooser you dip into — rather
// than a mode you can get stranded in. The collapse then runs with
// the selection already set, so the pane arrives showing the day
// you picked.
onPickDay = {
onSelectDay(it)
expanded = false
onSetExpanded(false)
},
onCollapse = { expanded = false },
onCollapse = { onSetExpanded(false) },
)
} else {
SplitMonthCollapsed(
@@ -957,11 +1006,9 @@ private fun SplitMonthContent(
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
onExpand = { expanded = true },
onExpand = { onSetExpanded(true) },
)
}
}
}
}
/** The split style at rest: compact grid, handle, then the selected day's events. */
@@ -1093,6 +1140,9 @@ private fun SplitExpandHandle(
Box(
Modifier
.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),
)
}
@@ -1234,19 +1284,28 @@ private fun SplitDayCell(
animationSpec = if (reduceMotion) snap() else fadeSpec,
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(
modifier = modifier
.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)
.background(background)
.border(
width = 1.5.dp,
color = MaterialTheme.colorScheme.primary.copy(alpha = selection),
shape = CELL_SHAPE,
),
)
.selectable(selected = isSelected, onClick = onClick),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier.fillMaxSize().padding(top = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -1255,10 +1314,10 @@ private fun SplitDayCell(
date = date,
isToday = isToday,
inMonth = inMonth,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().morphElement(MonthMorphKey.DayNumber(date)),
)
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.
*/
@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
val soften = LocalSoftenColors.current
Row(
@@ -1280,9 +1339,13 @@ private fun SplitDots(events: List<EventInstance>, hidden: Int, dark: Boolean) {
verticalAlignment = Alignment.CenterVertically,
) {
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(
modifier = Modifier
.size(SPLIT_DOT_SIZE)
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
.background(eventFill(event.color, dark, soften), CircleShape),
)
}
@@ -1540,6 +1603,7 @@ private fun MonthWeekRow(
.weight(1f)
.fillMaxHeight()
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.morphElement(MonthMorphKey.Cell(d))
.background(
color = when {
inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer
@@ -1586,7 +1650,9 @@ private fun MonthWeekRow(
} else {
null
},
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.morphElement(MonthMorphKey.DayNumber(d)),
)
}
}
@@ -1615,7 +1681,17 @@ private fun MonthWeekRow(
)
.width(colW * cols)
.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
@@ -1643,7 +1719,8 @@ private fun MonthWeekRow(
)
.width(colW)
.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