Refine view switching and timeline morph (#184)

This commit is contained in:
2026-09-30 13:28:47 +02:00
parent 62ea5ce9d3
commit 51b9c56c3c
7 changed files with 164 additions and 100 deletions
@@ -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<WeekViewModel>(),
hiltViewModel<MultiDayViewModel>(),
hiltViewModel<DayViewModel>(),
)
val switchScope = rememberCoroutineScope()
val switchTo: (CalendarView, (List<CalendarView>) -> List<CalendarView>) -> 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<CalendarView>) -> List<CalendarView>) -> 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<List<CalendarView>, 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
@@ -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) {
)
}
}
@@ -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,
@@ -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<ViewFocus, String> = Saver(
@@ -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<YearMonth?>(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)
@@ -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>(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<PlacedBar>,
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<PlacedBar>,
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<TimedBlock>,
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
}
@@ -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<CalendarSource>,