From 51b9c56c3c62e18b84fa90ae25b886a29693513f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 30 Sep 2026 13:28:47 +0200 Subject: [PATCH] Refine view switching and timeline morph (#184) --- .../calendula/ui/CalendarHost.kt | 53 +++----- .../calendula/ui/agenda/AgendaScreen.kt | 11 +- .../calendula/ui/common/HourGrid.kt | 6 +- .../calendula/ui/common/ViewFocus.kt | 8 ++ .../calendula/ui/month/MonthScreen.kt | 48 +++++-- .../calendula/ui/week/WeekScreen.kt | 121 ++++++++++++++---- .../calendula/ui/week/WeekViewModel.kt | 17 --- 7 files changed, 164 insertions(+), 100 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index c5b2f59..6f7b13c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.listSaver @@ -48,10 +49,6 @@ import de.jeanlucmakiola.calendula.ui.common.ChromeHost import de.jeanlucmakiola.calendula.ui.common.ChromeSlot import de.jeanlucmakiola.calendula.ui.common.LocalChromeSlot import de.jeanlucmakiola.calendula.ui.common.next -import de.jeanlucmakiola.calendula.ui.week.DayViewModel -import de.jeanlucmakiola.calendula.ui.week.MultiDayViewModel -import de.jeanlucmakiola.calendula.ui.week.TimelineViewModels -import de.jeanlucmakiola.calendula.ui.week.WeekViewModel import de.jeanlucmakiola.floret.identity.fadeThrough import de.jeanlucmakiola.floret.identity.predictiveBackExit import de.jeanlucmakiola.floret.identity.rememberReduceMotion @@ -83,7 +80,6 @@ import de.jeanlucmakiola.calendula.ui.search.SearchScreen import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen import de.jeanlucmakiola.calendula.ui.week.TimelineScreen import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @@ -142,6 +138,7 @@ fun CalendarHost( var viewStack by rememberSaveable(stateSaver = viewStackSaver) { mutableStateOf(listOf(defaultView)) } + var navigationVersion by remember { mutableIntStateOf(0) } val view = viewStack.last() // The timeline view last shown, so the timeline leaving for another screen // keeps drawing the view it was on while it goes. @@ -155,28 +152,15 @@ fun CalendarHost( // anywhere focuses it and opens the day view there. val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) } - // Into the timeline from another screen, its page is loaded first, briefly, - // so its events are there to morph into rather than arriving mid-way. - val timelineModels = TimelineViewModels( - hiltViewModel(), - hiltViewModel(), - hiltViewModel(), - ) val switchScope = rememberCoroutineScope() - val switchTo: (CalendarView, (List) -> List) -> Unit = { target, move -> - if (target.screen == ViewScreen.Timeline && viewStack.last().screen != ViewScreen.Timeline) { - switchScope.launch { - withTimeoutOrNull(VIEW_DATA_WAIT_MS) { timelineModels[target].prepare(focus.date) } - viewStack = move(viewStack) - } - } else { - viewStack = move(viewStack) - } + val switchTo: ((List) -> List) -> Unit = { move -> + navigationVersion++ + viewStack = move(viewStack) } - val onSelectView: (CalendarView) -> Unit = { target -> switchTo(target) { it.selectView(target) } } + val onSelectView: (CalendarView) -> Unit = { target -> switchTo { it.selectView(target) } } val onOpenDay: (LocalDate) -> Unit = { date -> - focus.date = date - switchTo(CalendarView.Day) { it.drillToDay() } + focus.navigateTo(date) + switchTo { it.drillToDay() } } // The event-detail screen (S4) is a full-screen destination hoisted here so @@ -326,13 +310,14 @@ fun CalendarHost( // [requestedDetailKey]. Date/event opens root the stack in the widget's own // view so backing out returns there (then home), not to the default. LaunchedEffect(widgetNavRequest) { + if (widgetNavRequest != null) navigationVersion++ when (val req = widgetNavRequest) { is WidgetNavRequest.OpenDate -> { // Drill the day view in over the widget's view: drop any overlay // that would cover it, so the open doesn't land under Settings/form. dismissCoveringOverlays() createDateIso = null - focus.date = LocalDate.parse(req.dateIso) + focus.navigateTo(LocalDate.parse(req.dateIso)) // No widget source (an external date tap) roots over the default // home view, so backing out of the day returns home then exits. viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay() @@ -356,7 +341,7 @@ fun CalendarHost( // default home — so backing out returns to the default, then exits. dismissCoveringOverlays() createDateIso = null - focus.date = localToday() + focus.navigateTo(localToday()) viewStack = viewBaseStack(defaultView, req.view ?: defaultView) onWidgetNavConsumed() } @@ -418,6 +403,7 @@ fun CalendarHost( editKey != null || showSettings || showCalendars || showBackup || importUri != null || importForm != null BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) { + navigationVersion++ viewStack = viewStack.dropLast(1) } @@ -444,7 +430,7 @@ fun CalendarHost( LocalViewFocus provides focus, // The failure state's way out when every calendar is switched off // (#239); nothing else in the calendar surfaces navigates here. - LocalManageCalendars provides { showCalendars = true }, + LocalManageCalendars provides remember { { showCalendars = true } }, ) { // One drawer, top bar and FAB for every screen (#184): a switch changes // what they say, from what the screen on show publishes, rather than @@ -462,12 +448,10 @@ fun CalendarHost( viewOrder = drawerViewOrder, onSelectView = { target -> // Once the drawer is out of the way, so the switch is seen whole. + val requestVersion = ++navigationVersion switchScope.launch { - try { - drawerState.close() - } finally { - onSelectView(target) - } + drawerState.close() + if (navigationVersion == requestVersion) onSelectView(target) } }, onJumpToDate = { target -> @@ -487,7 +471,7 @@ fun CalendarHost( host = chromeHost, selectedView = view, quickSwitchViews = quickSwitchViews, - onCycleView = { onSelectView(view.next(quickSwitchViews)) }, + onCycleView = { onSelectView(viewStack.last().next(quickSwitchViews)) }, onOpenDrawer = { switchScope.launch { drawerState.open() } }, onOpenSearch = onOpenSearch, showTodayButton = todayInToolbar, @@ -748,6 +732,3 @@ private val viewStackSaver = listSaver, Int>( private fun localToday(): LocalDate = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date - -/** How long a switch into the timeline waits for its page before going anyway. */ -private const val VIEW_DATA_WAIT_MS = 200L diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index 209867f..737b023 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -87,12 +87,8 @@ fun AgendaScreen( // Opens on the focused date and hands back the day scrolled to the top. val focus = LocalViewFocus.current EnterOnFocus(viewModel) { if (it != viewModel.anchor.value) viewModel.goToDate(it) } - val loadedState by viewModel.state.collectAsStateWithLifecycle() + val state by viewModel.state.collectAsStateWithLifecycle() val anchor by viewModel.anchor.collectAsStateWithLifecycle() - // Rows from before the anchor moved belong to another window: they would - // show the wrong days, and the view switch would match the wrong events. - val state = loadedState.takeUnless { it is AgendaUiState.Success && it.anchor != anchor } - ?: AgendaUiState.Loading val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle() val showToday by viewModel.showToday.collectAsStateWithLifecycle() val weekStart by viewModel.weekStart.collectAsStateWithLifecycle() @@ -195,7 +191,9 @@ fun AgendaScreen( onRetry = viewModel::goToToday, onEventClick = onEventClick, onOpenDay = onOpenDay, - onTopDay = { focus?.date = it }, + onTopDay = if (successState?.anchor == anchor) { + { focus?.date = it } + } else null, modifier = Modifier .weight(1f) .fillMaxWidth(), @@ -442,4 +440,3 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) { ) } } - diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt index 82e4e96..fe933e5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt @@ -57,14 +57,12 @@ internal fun hourCellRadiusPx(cellHeight: Float, cellWidth: Float, maxRadius: Fl * drawn across one. Applied to a day column's content, so the cells sit over the * column background but beneath the event blocks — blocks stay in a continuous * coordinate space and keep spanning cells. [hourHeightPx] is one hour's pixel - * height; [color] is resolved by the caller from the theme. [offsetPx] shifts - * the cells up, for a fixed window over a scrolled column. + * height; [color] is resolved by the caller from the theme. */ fun Modifier.hourGridCells( show: Boolean, hourHeightPx: Float, color: Color, - offsetPx: () -> Float = { 0f }, ): Modifier = if (!show) { this @@ -77,7 +75,7 @@ fun Modifier.hourGridCells( hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()), ) val cellSize = Size(size.width, cellHeight) - val top = inset - offsetPx() + val top = inset for (hour in 0 until 24) { drawRoundRect( color = color, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewFocus.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewFocus.kt index 2393e48..27bf4a0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewFocus.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewFocus.kt @@ -21,6 +21,14 @@ import kotlinx.datetime.LocalDate @Stable class ViewFocus(initial: LocalDate) { var date by mutableStateOf(initial) + /** Explicit navigation, including a request for the view already on screen. */ + var navigationVersion by mutableStateOf(0) + private set + + fun navigateTo(target: LocalDate) { + date = target + navigationVersion++ + } } val ViewFocusSaver: Saver = Saver( 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 953066c..82fc026 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 @@ -85,6 +85,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -215,6 +216,8 @@ fun MonthScreen( val scope = rememberCoroutineScope() val scrolling = viewStyle.isScrolling + var pendingScrollMonth by remember { mutableStateOf(null) } + var scrollRequestId by remember { mutableIntStateOf(0) } // The paged and split styles' months. Made afresh whenever a paged style // comes on screen, opening on the month the scrolling style was showing: a @@ -312,9 +315,14 @@ fun MonthScreen( // back there rather than staying on the month you were looking at. LaunchedEffect(listState, scrolling, dense, weekStart) { if (!scrolling) return@LaunchedEffect - snapshotFlow { visibleMonth } + snapshotFlow { visibleMonth to pendingScrollMonth } .distinctUntilChanged() - .collect { viewModel.syncScrollMonth(it) } + .collect { (visible, pending) -> + if (pending == null || pending == visible) { + viewModel.syncScrollMonth(visible) + if (pending == visible) pendingScrollMonth = null + } + } } val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month) @@ -341,11 +349,21 @@ fun MonthScreen( } val jumpToToday: () -> Unit = { if (scrolling) { + val targetMonth = YearMonth(today.year, today.month) + pendingScrollMonth = targetMonth + val requestId = ++scrollRequestId + viewModel.goToToday() scope.launch { - listState.animateScrollToItem( - if (dense) weekIndexOf(today, weekStart) - else itemIndexForMonth(monthIndexOf(YearMonth(today.year, today.month))), - ) + try { + listState.animateScrollToItem( + if (dense) weekIndexOf(today, weekStart) + else itemIndexForMonth(monthIndexOf(targetMonth)), + ) + } finally { + if (scrollRequestId == requestId) { + pendingScrollMonth = null + } + } } } else { viewModel.goToToday() @@ -355,11 +373,21 @@ fun MonthScreen( // Drawer jump-to-date. val jumpToDate: (LocalDate) -> Unit = { target -> if (scrolling) { + val targetMonth = YearMonth(target.year, target.month) + pendingScrollMonth = targetMonth + val requestId = ++scrollRequestId + viewModel.goToDate(target) scope.launch { - listState.animateScrollToItem( - if (dense) weekIndexOf(LocalDate(target.year, target.month, 1), weekStart) - else itemIndexForMonth(monthIndexOf(YearMonth(target.year, target.month))), - ) + try { + listState.animateScrollToItem( + if (dense) weekIndexOf(LocalDate(target.year, target.month, 1), weekStart) + else itemIndexForMonth(monthIndexOf(targetMonth)), + ) + } finally { + if (scrollRequestId == requestId) { + pendingScrollMonth = null + } + } } } else { viewModel.goToDate(target) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 14c00d8..fbb934d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -68,6 +68,7 @@ import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.flow.first import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.SideEffect import de.jeanlucmakiola.calendula.ui.common.laneSlot import de.jeanlucmakiola.calendula.ui.common.LaneColumn @@ -77,6 +78,8 @@ import androidx.compose.ui.layout.Layout import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned @@ -298,6 +301,8 @@ private fun PagedTimelineScreen( // Opens on the focused date, then carries it along as the pages move. val focus = LocalViewFocus.current + val navigationVersion = focus?.navigationVersion ?: 0 + var handledNavigation by remember { mutableIntStateOf(navigationVersion) } EnterOnFocus(viewModel) { viewModel.jumpTo(it) } // Keyed on the view model: collected unkeyed, a switch would read the view // before's anchor and grid for a frame and open its pager. @@ -307,6 +312,9 @@ private fun PagedTimelineScreen( var lastState by remember { mutableStateOf(WeekUiState.Loading) } val state = if (loadedState is WeekUiState.Loading) lastState else loadedState SideEffect { if (loadedState !is WeekUiState.Loading) lastState = loadedState } + LaunchedEffect(shown, loadedState) { + if (loadedState is WeekUiState.Success) handoffStrip = null + } val anchor by key(viewModel) { viewModel.anchor.collectAsStateWithLifecycle() } val rule by key(viewModel) { viewModel.rule.collectAsStateWithLifecycle() } val position = remember(anchor, rule) { positionOf(anchor, rule) } @@ -357,7 +365,15 @@ private fun PagedTimelineScreen( val currentPager by rememberUpdatedState(pagerState) val currentGrid by rememberUpdatedState(grid) val currentState by rememberUpdatedState(state) - LaunchedEffect(view) { + LaunchedEffect(view, navigationVersion) { + if (navigationVersion != handledNavigation) { + handledNavigation = navigationVersion + morph = null + handoffStrip = null + viewModels[view].jumpTo(focus?.date ?: return@LaunchedEffect) + shown = view + return@LaunchedEffect + } // A switch interrupted by another lands first, and this one starts from there. snapshotFlow { morph }.first { it == null } withFrameNanos { } @@ -382,9 +398,9 @@ private fun PagedTimelineScreen( withTimeoutOrNull(MORPH_DATA_WAIT_MS) { toLoaded.first { it !is WeekUiState.Loading } } morph = switch switch.progress.animateTo(1f, if (switch.isLong) longSwitchSpec else switchSpec) - } finally { handoffStrip = (toLoaded.value as? WeekUiState.Success)?.allDayStripHeight() shown = view + } finally { morph = null } } @@ -656,19 +672,30 @@ private fun TimelineMorphFrame( side?.timedByDay?.get(date).orEmpty() } } - val progress = morph.progress.value val fromStrip = from?.allDayStripHeight() ?: 0.dp val toStrip = to?.allDayStripHeight() ?: fromStrip - val allDayHeight = androidx.compose.ui.unit.lerp(fromStrip, toStrip, progress.coerceIn(0f, 1f)) - val weekNumbers = listOf(morph.fromKind, morph.toKind).map { if (it.showWeekNumber) 1f else 0f } - val weekNumberAlpha = lerp(weekNumbers[0], weekNumbers[1], progress).coerceIn(0f, 1f) + val animatedStrip = remember(morph, fromStrip, toStrip) { + { androidx.compose.ui.unit.lerp(fromStrip, toStrip, morph.progress.value.coerceIn(0f, 1f)) } + } + val fromBadge = if (morph.fromKind.showWeekNumber) 1f else 0f + val toBadge = if (morph.toKind.showWeekNumber) 1f else 0f + val badgeAlpha = remember(morph) { + { lerp(fromBadge, toBadge, morph.progress.value).coerceIn(0f, 1f) } + } + // The header changes its alignment once; its columns and bars move in layout. + val headerBias by remember(morph) { + derivedStateOf { + if (morph.progress.value < 0.5f) morph.fromKind.headerBias else morph.toKind.headerBias + } + } // The events travel inside the columns here, not between views. CompositionLocalProvider(LocalViewMorph provides null) { WeekFrame( weekStart = if (morph.toKind.showWeekNumber) morph.to.start else morph.from.start, - showWeekNumber = weekNumberAlpha > 0f, - allDayHeight = allDayHeight, - weekNumberAlpha = weekNumberAlpha, + showWeekNumber = morph.fromKind.showWeekNumber || morph.toKind.showWeekNumber, + allDayHeight = fromStrip, + animatedAllDayHeight = animatedStrip, + weekNumberAlpha = badgeAlpha, scrollState = scrollState, dragController = dragController, ) { @@ -677,8 +704,9 @@ private fun TimelineMorphFrame( today = today, timedFor = timedFor, bars = bars, - headerBias = lerp(morph.fromKind.headerBias, morph.toKind.headerBias, progress.coerceIn(0f, 1f)), - allDayHeight = allDayHeight, + headerBias = headerBias, + allDayHeight = fromStrip, + animatedAllDayHeight = animatedStrip, scrollState = scrollState, dragController = dragController, active = false, @@ -691,6 +719,23 @@ private fun TimelineMorphFrame( } } +/** Read an animated strip height during measurement, without composing its contents again. */ +private fun Modifier.dynamicHeight(height: () -> Dp): Modifier = layout { measurable, constraints -> + val pixels = height().roundToPx().coerceIn(constraints.minHeight, constraints.maxHeight) + val placeable = measurable.measure(constraints.copy(minHeight = pixels, maxHeight = pixels)) + layout(placeable.width, pixels) { placeable.place(0, 0) } +} + +/** Keep a morph's expensive viewport content measured at one size while its visible edge moves. */ +private fun Modifier.stableMorphViewport(stripHeight: () -> Dp, fullHeight: IntArray): Modifier = + clipToBounds().layout { measurable, constraints -> + if (fullHeight[0] < 0) fullHeight[0] = constraints.maxHeight + stripHeight().roundToPx() + val measured = measurable.measure( + constraints.copy(minHeight = fullHeight[0], maxHeight = fullHeight[0]), + ) + layout(constraints.maxWidth, constraints.maxHeight) { measured.place(0, 0) } + } + /** * What stays put while the weeks swipe: the week-number badge and the hour * gutter, with [pages] filling the rest. @@ -700,11 +745,13 @@ private fun WeekFrame( weekStart: LocalDate, showWeekNumber: Boolean, allDayHeight: Dp, - weekNumberAlpha: Float = 1f, + animatedAllDayHeight: (() -> Dp)? = null, + weekNumberAlpha: () -> Float = { 1f }, scrollState: ScrollState, dragController: TimelineDragController, pages: @Composable () -> Unit, ) { + val gutterFullHeight = remember(animatedAllDayHeight) { intArrayOf(-1) } Row(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.width(GUTTER_WIDTH).fillMaxHeight()) { Column( @@ -715,16 +762,24 @@ private fun WeekFrame( WeekNumberCell( weekStart = weekStart, showBadge = showWeekNumber, - modifier = if (weekNumberAlpha < 1f) Modifier.alpha(weekNumberAlpha) else Modifier, + modifier = if (animatedAllDayHeight == null) Modifier + else Modifier.graphicsLayer { alpha = weekNumberAlpha() }, ) - Spacer(Modifier.height(allDayHeight)) + Spacer(if (animatedAllDayHeight == null) Modifier.height(allDayHeight) + else Modifier.dynamicHeight(animatedAllDayHeight)) } Spacer(Modifier.height(TIMELINE_TOP_GAP)) // Resolves the hour height off the same viewport height the pages' // timelines have, so labels and columns agree. - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + BoxWithConstraints( + modifier = Modifier.fillMaxSize().then( + if (animatedAllDayHeight == null) Modifier + else Modifier.stableMorphViewport(animatedAllDayHeight, gutterFullHeight), + ), + ) { val zoom = LocalTimelineZoom.current - val fitHeight = maxHeight + allDayHeight + val fitHeight = if (animatedAllDayHeight == null) maxHeight + allDayHeight + else maxHeight val hourHeight = zoom.scale.hourHeight(fitHeight) HourGutter( scrollState = scrollState, @@ -827,6 +882,7 @@ private fun TimelinePageContent( bars: List, headerBias: Float, allDayHeight: Dp, + animatedAllDayHeight: (() -> Dp)? = null, scrollState: ScrollState, dragController: TimelineDragController, active: Boolean, @@ -847,7 +903,8 @@ private fun TimelinePageContent( CompositionLocalProvider( LocalChipGround provides MaterialTheme.colorScheme.surface, ) { - AllDayStrip(geometry = geometry, bars = bars, height = allDayHeight, onEventClick = onEventClick) + AllDayStrip(geometry = geometry, bars = bars, height = allDayHeight, + animatedHeight = animatedAllDayHeight, onEventClick = onEventClick) } } Spacer(Modifier.height(TIMELINE_TOP_GAP)) @@ -856,6 +913,7 @@ private fun TimelinePageContent( today = today, timedFor = timedFor, allDayHeight = allDayHeight, + animatedAllDayHeight = animatedAllDayHeight, scrollState = scrollState, dragController = dragController, active = active, @@ -984,6 +1042,7 @@ private fun AllDayStrip( geometry: ColumnGeometry, bars: List, height: Dp, + animatedHeight: (() -> Dp)? = null, onEventClick: (EventInstance) -> Unit, ) { val dark = isSystemInDarkTheme() @@ -993,7 +1052,7 @@ private fun AllDayStrip( .fillMaxWidth() // Height is hoisted + animated so it slides and resizes smoothly; // padding sits inside it so the content area is lanes * row height. - .height(height) + .then(if (animatedHeight == null) Modifier.height(height) else Modifier.dynamicHeight(animatedHeight)) .padding( top = ALL_DAY_VERTICAL_PADDING, bottom = ALL_DAY_VERTICAL_PADDING, @@ -1080,6 +1139,7 @@ private fun Timeline( today: LocalDate, timedFor: (LocalDate) -> List, allDayHeight: Dp, + animatedAllDayHeight: (() -> Dp)? = null, scrollState: ScrollState, dragController: TimelineDragController, active: Boolean, @@ -1087,6 +1147,7 @@ private fun Timeline( onCreateAt: (LocalDate, Int) -> Unit, onDrop: (TimelineDrop) -> Unit, ) { + val fullViewportHeight = remember(geometry) { intArrayOf(-1) } val dark = isSystemInDarkTheme() val zoom = LocalTimelineZoom.current val density = LocalDensity.current @@ -1102,8 +1163,13 @@ private fun Timeline( // timeline's own viewport height, which is only known here — below the top // bar, day header and all-day strip. The strip is given back, so the scale // holds while paging and matches the day view's (#184). - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val fitHeight = maxHeight + allDayHeight + BoxWithConstraints( + modifier = Modifier.fillMaxSize().then( + if (animatedAllDayHeight == null) Modifier + else Modifier.stableMorphViewport(animatedAllDayHeight, fullViewportHeight), + ), + ) { + val fitHeight = if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight val hourHeight = zoom.scale.hourHeight(fitHeight) val totalHeight = hourHeight * 24 // The pinch sits above the scroll viewport: it has to outrank the @@ -1120,9 +1186,12 @@ private fun Timeline( // picks what travels on its first frame. val opening = LocalOpeningTimeline.current val viewportPx = with(density) { maxHeight.toPx() } + val visibleViewport: () -> Float = remember(animatedAllDayHeight, viewportPx, density) { + { viewportPx - (animatedAllDayHeight?.let { with(density) { it().toPx() } } ?: 0f) } + } val contentPx = with(density) { totalHeight.toPx() } - val visibleTop: () -> Float = remember(opening, scrollState, viewportPx, contentPx) { - { opening?.top(viewportPx, contentPx) ?: scrollState.value.toFloat() } + val visibleTop: () -> Float = remember(opening, scrollState, visibleViewport, contentPx) { + { opening?.top(visibleViewport(), contentPx) ?: scrollState.value.toFloat() } } // Scrolls on the same state as the gutter and the other pages, so they // all stay aligned. A static, rounded-clipped window — the content @@ -1169,7 +1238,7 @@ private fun Timeline( hourHeight = hourHeight, columnWidth = columnWidth, visibleTop = visibleTop, - viewportHeight = viewportPx, + viewportHeight = visibleViewport, dragController = dragController, onEventClick = onEventClick, onCreateAt = onCreateAt, @@ -1190,7 +1259,7 @@ private fun DayColumnCard( hourHeight: Dp, columnWidth: Dp, visibleTop: () -> Float, - viewportHeight: Float, + viewportHeight: () -> Float, dragController: TimelineDragController, onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, @@ -1316,9 +1385,9 @@ private fun DayColumnCard( /** Whether [top]..[bottom], in pixels down the scrolled content, lies wholly in its viewport. */ @Composable -private fun rememberInViewport(visibleTop: () -> Float, viewportHeight: Float, top: Float, bottom: Float): Boolean { +private fun rememberInViewport(visibleTop: () -> Float, viewportHeight: () -> Float, top: Float, bottom: Float): Boolean { val inView by remember(visibleTop, viewportHeight, top, bottom) { - derivedStateOf { visibleTop().let { top >= it && bottom <= it + viewportHeight } } + derivedStateOf { visibleTop().let { top >= it && bottom <= it + viewportHeight() } } } return inView } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index 0768529..2acd4ef 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -14,9 +14,6 @@ import de.jeanlucmakiola.calendula.domain.calendarListFailure import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.ui.common.PageStateCache import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.flow.first import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -167,20 +164,6 @@ abstract class PagedTimelineViewModel( fun goToToday() = jumpTo(todayDate) - /** - * Moves to [date] and waits for its page to load, so a switch into this view - * opens on its events instead of watching them arrive mid-way (#184). - */ - suspend fun prepare(date: LocalDate) { - jumpTo(date) - val position = positionOf(date, rule.value) - val page = DaySpan(position.grid.startForPage(position.page), position.grid.dayCount) - coroutineScope { - launch { state.first { it !is WeekUiState.Loading } } - span(page).first { it !is WeekUiState.Loading } - } - } - private fun buildState( span: DaySpan, calendars: List,