From 7bdd0329bcb579610ad0800486b662f2d599059b Mon Sep 17 00:00:00 2001 From: Tritra002 Date: Fri, 6 Nov 2026 16:44:30 +0100 Subject: [PATCH] Smoother split month expand and collapse, animated week numbers (#473) (#474) Co-authored-by: tritra002 --- CHANGELOG.md | 2 + .../calendula/ui/month/MonthMorph.kt | 12 ++++-- .../calendula/ui/month/MonthScreen.kt | 41 ++++++++++++++++--- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d92ca74..04dbdb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Widgets follow the app's language.** After you picked a language in Settings, widgets kept the old one until something redrew them ([#90]). - **The month widget handles busy months.** It could show "Can't show content", or only one or two events per day. Each day now shows as many events as its height fits, and the rest fold into +N ([#372]). - **Short events late in the day are fully visible.** An event like 23:58–23:59 used to be cut down to a sliver at the bottom of the day, week and 3-day views. It now keeps its usual minimum height and sits flush against midnight ([#483]). +- **Smoother expand and collapse in the split month view.** The day cells morph cleanly between the two layouts, and week numbers now animate along with them ([#473]). ## [2.23.0] — 2026-10-21 @@ -1922,5 +1923,6 @@ automatically, with zero telemetry and no internet permission. [#438]: https://codeberg.org/jlmakiola/calendula/issues/438 [#439]: https://codeberg.org/jlmakiola/calendula/issues/439 [#441]: https://codeberg.org/jlmakiola/calendula/issues/441 +[#473]: https://codeberg.org/jlmakiola/calendula/issues/473 [#476]: https://codeberg.org/jlmakiola/calendula/issues/476 [#483]: https://codeberg.org/jlmakiola/calendula/issues/483 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 index e46e36b..08e867c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt @@ -34,6 +34,10 @@ internal sealed interface MonthMorphKey { data class DayNumber(val date: LocalDate) : MonthMorphKey + data class WeekNumber(val weekStart: LocalDate) : MonthMorphKey + + data class CellRipple(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 @@ -111,8 +115,8 @@ internal fun morphInFlight(): Boolean { * * Rendering in place puts everything back in one z-order and one clip, which is * what makes the grid read as a single surface changing shape rather than a - * stack of pieces sliding past each other. Nothing here needs to escape its - * ancestors: the cells, marks and markers all travel within the grid. + * stack of pieces sliding past each other. Event marks are the exception: they + * render in the overlay so the expanding day backgrounds cannot cover them. */ @OptIn(ExperimentalSharedTransitionApi::class) @Composable @@ -141,6 +145,7 @@ internal fun Modifier.morphElement(key: MonthMorphKey): Modifier { @Composable internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier { val morph = LocalMonthMorph.current ?: return this + val isEvent = key is MonthMorphKey.Event return with(morph.shared) { this@morphBounds.sharedBounds( sharedContentState = rememberSharedContentState(key), @@ -148,7 +153,8 @@ internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier { enter = fadeIn(), exit = fadeOut(), resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, - renderInOverlayDuringTransition = false, + renderInOverlayDuringTransition = isEvent, + zIndexInOverlay = if (isEvent) 1f else 0f, ) } } 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 c5d666e..103546a 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 @@ -2,7 +2,12 @@ package de.jeanlucmakiola.calendula.ui.month import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.indication +import androidx.compose.foundation.LocalIndication import androidx.compose.animation.EnterExitState +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.pager.HorizontalPager @@ -1226,7 +1231,6 @@ private fun SplitMonthContent( onEventClick: (EventInstance) -> Unit, onCreateEvent: (LocalDate) -> Unit, ) { - val fadeSpec = rememberCalendarFadeSpec() val reduceMotion = rememberReduceMotion() val haptics = LocalHapticFeedback.current BackHandler(enabled = expanded != initiallyExpanded) { @@ -1297,8 +1301,9 @@ private fun SplitMonthContent( modifier = Modifier.fillMaxSize(), // 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. - transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) }, + // Fading the faces made the shared cell backgrounds briefly + // disappear beneath their incoming counterparts. + transitionSpec = { EnterTransition.None togetherWith ExitTransition.None }, label = "split-expand", ) { isExpanded -> // The view switch's morph stays off a face until it has fully @@ -1355,6 +1360,9 @@ private fun SplitMonthContent( state = page, paneReady = paneReady, selected = pageSelected, + selectionIndicatorVisible = reduceMotion || + (transition.currentState == EnterExitState.Visible && + transition.targetState == EnterExitState.Visible), showWeekNumbers = showWeekNumbers, showToday = showToday, expandDrag = expandDrag, @@ -1398,6 +1406,7 @@ private fun SplitMonthCollapsed( state: MonthUiState.Success, paneReady: Boolean, selected: LocalDate?, + selectionIndicatorVisible: Boolean, showWeekNumbers: Boolean, showToday: Boolean, expandDrag: Modifier, @@ -1419,6 +1428,7 @@ private fun SplitMonthCollapsed( SplitMonthGrid( state = state, selected = selected, + selectionIndicatorVisible = selectionIndicatorVisible, showWeekNumbers = showWeekNumbers, onSelectDay = onSelectDay, modifier = Modifier.height(splitGridHeight(state.weeks.size)), @@ -1637,6 +1647,7 @@ private val SPLIT_PANE_EASING = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f) internal fun SplitMonthGrid( state: MonthUiState.Success, selected: LocalDate?, + selectionIndicatorVisible: Boolean = true, showWeekNumbers: Boolean, onSelectDay: (LocalDate) -> Unit, modifier: Modifier = Modifier, @@ -1682,6 +1693,7 @@ internal fun SplitMonthGrid( // removes that without timing anything against the // page transition. isSelected = day == selected && inMonth, + selectionIndicatorVisible = selectionIndicatorVisible, inMonth = inMonth, relativeToToday = todayCol?.let { col - it }, rippleProgress = { ripple.value }, @@ -1710,6 +1722,7 @@ private fun SplitDayCell( hidden: List, isToday: Boolean, isSelected: Boolean, + selectionIndicatorVisible: Boolean, inMonth: Boolean, relativeToToday: Int?, rippleProgress: () -> Float, @@ -1739,7 +1752,7 @@ private fun SplitDayCell( // pace it lingered after the thing it marks had already moved. val outlineSpec = MaterialTheme.motionScheme.fastEffectsSpec() val selection = animateFloatAsState( - targetValue = if (isSelected) 1f else 0f, + targetValue = if (isSelected && selectionIndicatorVisible) 1f else 0f, animationSpec = if (reduceMotion) snap() else outlineSpec, label = "split-day-selection", ) @@ -1751,12 +1764,19 @@ private fun SplitDayCell( // 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. + // The cell keeps the click and selection semantics, so TalkBack reads the + // day number with them; the topmost layer only draws the ripple. + val interaction = remember { MutableInteractionSource() } Box( modifier = modifier .padding(horizontal = CELL_GAP, vertical = 1.dp) .todayRipple(relativeToToday, rippleProgress) - .clip(CELL_SHAPE) - .selectable(selected = isSelected, onClick = onClick), + .selectable( + selected = isSelected, + interactionSource = interaction, + indication = null, + onClick = onClick, + ), contentAlignment = Alignment.TopCenter, ) { Box( @@ -1818,6 +1838,13 @@ private fun SplitDayCell( Spacer(Modifier.height(2.dp)) SplitDots(date = date, events = events, hidden = hidden, dark = dark) } + Box( + Modifier + .fillMaxSize() + .morphElement(MonthMorphKey.CellRipple(date)) + .clip(CELL_SHAPE) + .indication(interaction, LocalIndication.current), + ) } } @@ -2520,6 +2547,7 @@ private fun MonthWeekRow( } } .padding(horizontal = CELL_GAP, vertical = 1.dp) + .morphElement(MonthMorphKey.CellRipple(d)) .clip(CELL_SHAPE) .clickable { // Cleared on read: a click with no fresh down @@ -2670,6 +2698,7 @@ private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier Box( modifier = modifier .padding(horizontal = CELL_GAP, vertical = 1.dp) + .morphElement(MonthMorphKey.WeekNumber(weekStart)) .clip(CELL_SHAPE) .background(MaterialTheme.colorScheme.secondaryContainer) .then(if (onOpenWeek != null) Modifier.clickable(onClickLabel = openLabel) {