diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt new file mode 100644 index 0000000..1cd42c9 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt @@ -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 { 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, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 4ee8200..8dc6994 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -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,50 +924,93 @@ private fun SplitMonthContent( when (state) { MonthUiState.Loading -> MonthGridLoading() is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry) - is MonthUiState.Success -> 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. - transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) }, - label = "split-expand-transition", - ) { isExpanded -> - if (isExpanded) { - 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. - onPickDay = { - onSelectDay(it) - expanded = false + 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 -> + 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) }, - onCollapse = { expanded = false }, - ) - } else { - SplitMonthCollapsed( - state = state, - selected = selected, - slideDir = slideDir, - showWeekNumbers = showWeekNumbers, - swipeModifier = swipeModifier, - onSelectDay = onSelectDay, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - onCreateEvent = onCreateEvent, - onExpand = { expanded = true }, - ) + ) { + 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. + onPickDay = { + onSelectDay(it) + onSetExpanded(false) + }, + onCollapse = { onSetExpanded(false) }, + ) + } else { + SplitMonthCollapsed( + state = state, + selected = selected, + slideDir = slideDir, + showWeekNumbers = showWeekNumbers, + swipeModifier = swipeModifier, + onSelectDay = onSelectDay, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + onCreateEvent = onCreateEvent, + onExpand = { onSetExpanded(true) }, + ) + } +} + /** The split style at rest: compact grid, handle, then the selected day's events. */ @Composable private fun SplitMonthCollapsed( @@ -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) - .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, ) { + 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, + ), + ) 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, hidden: Int, dark: Boolean) { +private fun SplitDots(date: LocalDate, events: List, hidden: Int, dark: Boolean) { if (events.isEmpty()) return val soften = LocalSoftenColors.current Row( @@ -1280,9 +1339,13 @@ private fun SplitDots(events: List, 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