Run split month expand in one pager and smooth the collapse (#111, #184)

This commit is contained in:
2026-09-30 18:53:49 +02:00
parent a66227bd20
commit fad4359857
2 changed files with 179 additions and 135 deletions
@@ -53,7 +53,18 @@ class ViewMorphScope(
val fadeSpec: FiniteAnimationSpec<Float>,
val clip: BelowTopBarClip,
val stayingPut: StayingPut,
)
/** Read while matching, so a tag can sit out without leaving the modifier chain. */
val enabled: () -> Boolean = { true },
) {
/** This scope with its tags switched off while [enabled] is false. */
fun gated(enabled: () -> Boolean): ViewMorphScope =
ViewMorphScope(shared, visibility, boundsTransform, fadeSpec, clip, stayingPut, enabled)
internal val config = object : SharedTransitionScope.SharedContentConfig {
override val SharedTransitionScope.SharedContentState.isEnabled: Boolean
get() = enabled()
}
}
/**
* The events a view has but keeps out of the switch, being scrolled out of
@@ -127,7 +138,7 @@ fun Modifier.morphEvent(key: ViewMorphKey.Event): Modifier {
if (key in morph.stayingPut.keys) return this
return with(morph.shared) {
this@morphEvent.sharedBounds(
sharedContentState = rememberSharedContentState(key),
sharedContentState = rememberSharedContentState(key, morph.config),
animatedVisibilityScope = morph.visibility,
enter = fadeIn(morph.fadeSpec),
exit = fadeOut(morph.fadeSpec),
@@ -146,7 +157,7 @@ fun Modifier.morphDay(date: LocalDate): Modifier {
val morph = LocalViewMorph.current ?: return this
return with(morph.shared) {
this@morphDay.sharedBounds(
sharedContentState = rememberSharedContentState(ViewMorphKey.Day(date)),
sharedContentState = rememberSharedContentState(ViewMorphKey.Day(date), morph.config),
animatedVisibilityScope = morph.visibility,
enter = fadeIn(morph.fadeSpec),
exit = fadeOut(morph.fadeSpec),
@@ -10,7 +10,6 @@ import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.rememberUpdatedState
import de.jeanlucmakiola.calendula.ui.common.PublishChrome
import de.jeanlucmakiola.calendula.ui.common.ScreenActions
@@ -24,12 +23,10 @@ import de.jeanlucmakiola.calendula.ui.common.morphDay
import de.jeanlucmakiola.calendula.ui.common.morphEvent
import de.jeanlucmakiola.calendula.ui.common.morphPageActive
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
import de.jeanlucmakiola.calendula.ui.common.pagedHeight
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.drop
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.RepeatMode
@@ -41,6 +38,7 @@ import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
@@ -726,10 +724,6 @@ private fun MonthContent(
* carries the chip drag and the morph tags: its neighbours show some of its
* dates among their leading and trailing days, and would otherwise claim its
* drops and its shared elements.
*
* @param static draw the current page alone, without a pager — for a face of the
* split style on its way out, which must not hold on to the pager state the
* incoming face now drives.
*/
@Composable
private fun MonthPager(
@@ -737,16 +731,9 @@ private fun MonthPager(
weekStart: DayOfWeek,
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
modifier: Modifier = Modifier,
static: Boolean = false,
loading: @Composable () -> Unit = { MonthGridLoading() },
page: @Composable (index: Int, state: MonthUiState.Success) -> Unit,
) {
if (static) {
Box(modifier) {
MonthPage(pagerState.currentPage, true, true, weekStart, monthState, loading, page)
}
return
}
val chipDrag = LocalMonthDrag.current
HorizontalPager(
state = pagerState,
@@ -1304,13 +1291,16 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp
/**
* Split style content: the compact grid keeps the month swipe, the pane below it
* lists whatever day is selected — and a downward drag trades the pane away for
* the full paged grid, an upward one brings it back (#53).
* lists the month's agenda or the selected day — and a downward drag trades the
* pane away for the full paged grid, an upward one brings it back (#53). The
* paged style is the same surface opened the other way up (#111).
*
* The grid slides between months like the paged style, and stands only as many
* rows tall as its own month spans (#162). A swipe between a five-row month and
* a six-row one therefore moves the pane by a row as well as swapping the grid;
* the row it hands back is worth more to the pane than a still edge is.
* One pager holds whole months — grid, handle and pane — and each page switches
* between its two faces itself. An expand therefore morphs inside a page that
* stays put: with a pager per face, every expand swapped one pager for another,
* and whether the pieces paired up came down to frame timing.
*
* The grid stands only as many rows tall as its own month spans (#162).
*
* Expansion is deliberately **not** a stored preference. It is a way to look at
* the month you are on, not a fourth style; persisted, someone would expand it
@@ -1359,53 +1349,90 @@ private fun SplitMonthContent(
}
},
)
// In the split style 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. The paged
// style's grid is its home, so a tap there opens the day (#111).
val onPickDay: (LocalDate) -> Unit = if (initiallyExpanded) {
onOpenDay
} else {
{
onSelectDay(it)
onExpandedChange(false)
}
}
// The view switch's own shared scope: a SharedTransitionLayout nested in the
// host's would be a second lookahead scope. Null under reduced motion.
val viewMorph = LocalViewMorph.current
val shared = viewMorph?.shared
val chipDrag = LocalMonthDrag.current
when (state) {
MonthUiState.Loading -> MonthGridLoading()
is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry)
is MonthUiState.Success -> SharedTransitionLayout(Modifier.fillMaxSize()) {
is MonthUiState.Success -> HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
// The months either side are composed ahead, so a swipe lands on a
// loaded month rather than watching it fill in.
beyondViewportPageCount = 1,
verticalAlignment = Alignment.Top,
// A held chip moves within its month; the page stays put under it.
userScrollEnabled = chipDrag?.isDragging != true,
flingBehavior = PagerDefaults.flingBehavior(
state = pagerState,
snapAnimationSpec = rememberCalendarPageSpec(),
),
) { index ->
val ym = yearMonthForIndex(index)
val pageState by remember(ym, weekStart) { monthState(ym, weekStart) }
.collectAsStateWithLifecycle()
val page = pageState as? MonthUiState.Success ?: return@HorizontalPager
// Only the settled page carries the drag and the morph tags: its
// neighbours repeat some of its dates and would claim them.
val active = index == pagerState.settledPage
// The selection only ever lies in the anchor month.
val pageSelected = selected?.takeIf { it.year == ym.year && it.month == ym.month }
AnimatedContent(
targetState = expanded,
modifier = Modifier.fillMaxSize(),
// Both branches fill the same box — the grid grows into exactly the
// Both faces fill the same page — 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.
// The travel between them is the shared elements' job.
transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) },
label = "split-expand-transition",
label = "split-expand",
) { isExpanded ->
// Only the incoming face drives the pager; the outgoing one draws
// its month still for the length of the cross-fade.
val leaving = transition.targetState == EnterExitState.PostExit
CompositionLocalProvider(
// The split style morphs within itself and so sits the view
// switch's morph out (#184): one element can't travel in two
// shared transitions at once.
LocalViewMorph provides null,
// Null under reduced motion: nothing is tagged, nothing
// travels, and the cross-fade above is the whole transition.
LocalMonthMorph provides if (reduceMotion) {
null
// The view switch's morph stays off a face until it has fully
// entered (#184): both faces tag the same events for it. Switched
// off rather than removed — adding and dropping the tags rebuilt
// every bar's modifier chain around the expand morph, which left
// bars standing still on later collapses.
val faceTransition = transition
val faceViewMorph = remember(viewMorph, active, faceTransition) {
viewMorph?.takeIf { active }?.gated {
faceTransition.currentState == EnterExitState.Visible
}
}
val monthMorph = remember(active, reduceMotion, shared, this) {
if (active && !reduceMotion && shared != null) {
MonthMorphScope(shared, this)
} else {
MonthMorphScope(this@SharedTransitionLayout, this@AnimatedContent)
},
null
}
}
CompositionLocalProvider(
LocalMonthDrag provides chipDrag.takeIf { active },
LocalViewMorph provides faceViewMorph,
LocalMonthMorph provides monthMorph,
) {
val months = SplitMonths(pagerState, weekStart, monthState, static = leaving)
if (isExpanded) {
SplitMonthExpanded(
months = months,
selected = selected,
state = page,
selected = pageSelected,
showWeekNumbers = showWeekNumbers,
expandDrag = expandDrag,
// 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)
onExpandedChange(false)
},
onPickDay = onPickDay,
// A tapped chip is asking for that event, not for its
// day, so it opens the detail from here too rather
// than collapsing (#187).
@@ -1413,10 +1440,22 @@ private fun SplitMonthContent(
onCollapse = { onExpandedChange(false) },
)
} else {
// The pane is still invisible for the first stretch of a
// collapse, so it is built once the grid has mostly
// landed, not on the morph's opening frames.
var paneReady by remember {
mutableStateOf(reduceMotion || transition.currentState == EnterExitState.Visible)
}
LaunchedEffect(Unit) {
if (!paneReady) {
delay(SPLIT_PANE_COMPOSE_DELAY_MILLIS)
paneReady = true
}
}
SplitMonthCollapsed(
state = state,
months = months,
selected = selected,
state = page,
paneReady = paneReady,
selected = pageSelected,
showWeekNumbers = showWeekNumbers,
expandDrag = expandDrag,
onSelectDay = onSelectDay,
@@ -1427,6 +1466,18 @@ private fun SplitMonthContent(
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
onExpand = { onExpandedChange(true) },
// The pane waits for the grid to settle, and clears
// out before it grows, so the two never overlap.
paneModifier = if (reduceMotion) {
Modifier
} else {
Modifier.animateEnterExit(
enter = fadeIn(
tween(SPLIT_PANE_FADE_IN_MILLIS, SPLIT_PANE_FADE_IN_DELAY_MILLIS, SPLIT_PANE_EASING),
),
exit = fadeOut(tween(SPLIT_PANE_FADE_OUT_MILLIS)),
)
},
)
}
}
@@ -1435,19 +1486,11 @@ private fun SplitMonthContent(
}
}
/** What both faces of the split style page through — see [MonthPager]. */
private class SplitMonths(
val pagerState: PagerState,
val weekStart: DayOfWeek,
val monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
val static: Boolean,
)
/** The split style at rest: compact grid, handle, then the selected day's events. */
/** The split style at rest: compact grid, handle, then the agenda or the selected day. */
@Composable
private fun SplitMonthCollapsed(
state: MonthUiState.Success,
months: SplitMonths,
paneReady: Boolean,
selected: LocalDate?,
showWeekNumbers: Boolean,
expandDrag: Modifier,
@@ -1459,76 +1502,64 @@ private fun SplitMonthCollapsed(
onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate) -> Unit,
onExpand: () -> Unit,
paneModifier: Modifier = Modifier,
) {
// The grid stands only as many rows tall as its month spans (#162), so a
// swipe between a five-row and a six-row month moves the pane by a row; the
// blend has it move with the finger rather than jump on settle.
val gridHeights = remember { mutableStateMapOf<Int, Dp>() }
val gridHeight = pagedHeight(months.pagerState, gridHeights, splitGridHeight(state.weeks.size))
Column(Modifier.fillMaxSize()) {
// 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(expandDrag) {
MonthPager(
pagerState = months.pagerState,
weekStart = months.weekStart,
monthState = months.monthState,
static = months.static,
modifier = Modifier.fillMaxWidth().height(gridHeight),
loading = {},
) { index, page ->
val height = splitGridHeight(page.weeks.size)
DisposableEffect(index, height) {
gridHeights[index] = height
onDispose { gridHeights.remove(index) }
}
SplitMonthGrid(
state = page,
selected = selected,
showWeekNumbers = showWeekNumbers,
onSelectDay = onSelectDay,
)
}
SplitMonthGrid(
state = state,
selected = selected,
showWeekNumbers = showWeekNumbers,
onSelectDay = onSelectDay,
modifier = Modifier.height(splitGridHeight(state.weeks.size)),
)
SplitExpandHandle(expanded = false, onToggle = onExpand)
}
Box(
Modifier.weight(1f).fillMaxWidth()
.then(rememberSplitPaneSwipe(onPaneSwipeNext, onPaneSwipePrev)),
) {
if (selected == null) {
if (!paneReady) {
Spacer(Modifier.weight(1f))
} else if (selected == null) {
// The agenda pages with the month, on the pager itself.
Box(Modifier.weight(1f).fillMaxWidth().then(paneModifier)) {
SplitMonthAgendaPane(
state = state,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
)
} else {
Column {
InputChip(
selected = true,
onClick = onClearSelection,
label = { Text(agendaDayLabel(selected, state.today)) },
trailingIcon = {
Icon(
Icons.Default.Close,
contentDescription = stringResource(R.string.month_split_clear_filter),
)
},
modifier = Modifier.padding(start = 16.dp),
)
SplitDayPane(
date = selected,
today = state.today,
events = state.instancesByDay[selected],
zone = state.zone,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
modifier = Modifier.weight(1f).fillMaxWidth(),
)
}
}
} else {
// A selected day steps a day at a time instead, so the swipe is
// taken before the pager sees it.
Column(
Modifier.weight(1f).fillMaxWidth()
.then(paneModifier)
.then(rememberSplitPaneSwipe(onPaneSwipeNext, onPaneSwipePrev)),
) {
InputChip(
selected = true,
onClick = onClearSelection,
label = { Text(agendaDayLabel(selected, state.today)) },
trailingIcon = {
Icon(
Icons.Default.Close,
contentDescription = stringResource(R.string.month_split_clear_filter),
)
},
modifier = Modifier.padding(start = 16.dp),
)
SplitDayPane(
date = selected,
today = state.today,
events = state.instancesByDay[selected],
zone = state.zone,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
modifier = Modifier.weight(1f).fillMaxWidth(),
)
}
}
}
@@ -1544,7 +1575,8 @@ private fun rememberSplitPaneSwipe(onNext: () -> Unit, onPrev: () -> Unit): Modi
var fired = false
detectHorizontalDragGestures(
onDragStart = { distance = 0f; fired = false },
onHorizontalDrag = { _, drag ->
onHorizontalDrag = { change, drag ->
change.consume()
distance += drag
if (!fired && kotlin.math.abs(distance) >= threshold) {
fired = true
@@ -1566,7 +1598,7 @@ private fun rememberSplitPaneSwipe(onNext: () -> Unit, onPrev: () -> Unit): Modi
*/
@Composable
private fun SplitMonthExpanded(
months: SplitMonths,
state: MonthUiState.Success,
/** Marked nowhere here; it only anchors the outline's morph. */
selected: LocalDate?,
showWeekNumbers: Boolean,
@@ -1578,15 +1610,9 @@ private fun SplitMonthExpanded(
// 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(expandDrag)) {
MonthPager(
pagerState = months.pagerState,
weekStart = months.weekStart,
monthState = months.monthState,
static = months.static,
modifier = Modifier.weight(1f),
) { _, page ->
Box(Modifier.weight(1f)) {
MonthGrid(
state = page,
state = state,
showWeekNumbers = showWeekNumbers,
onOpenDay = onPickDay,
onEventClick = onEventClick,
@@ -1664,6 +1690,13 @@ private val SPLIT_HANDLE_WIDTH = 32.dp
private val SPLIT_HANDLE_HEIGHT = 4.dp
private val SPLIT_HANDLE_ROW_HEIGHT = 24.dp
/** The pane below the split grid fades in once the grid's morph has mostly landed. */
private const val SPLIT_PANE_FADE_IN_DELAY_MILLIS = 300
private const val SPLIT_PANE_COMPOSE_DELAY_MILLIS = 250L
private const val SPLIT_PANE_FADE_IN_MILLIS = 250
private const val SPLIT_PANE_FADE_OUT_MILLIS = 100
private val SPLIT_PANE_EASING = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f)
/**
* The split style's grid (#53): the month compressed to day numbers and event
* dots, with the selected day listed underneath by [SplitDayPane].
@@ -1962,7 +1995,7 @@ private fun SplitMonthAgendaPane(
zone = state.zone,
position = positionOf(index, events.size),
dimmed = dimCutoff != null && event.hasEnded(dimCutoff),
modifier = animateItemMotion(),
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day)),
onClick = { onEventClick(event) },
)
}
@@ -2035,7 +2068,7 @@ internal fun SplitDayPane(
// An event added to or removed from the day you are
// already looking at moves its neighbours rather
// than teleporting them.
modifier = animateItemMotion(),
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day)),
)
}
}