Carry the timeline hours between views (#184)

This commit is contained in:
2026-09-27 19:10:49 +02:00
parent 57dcda5b54
commit 39ca7d869a
7 changed files with 254 additions and 41 deletions
@@ -9,6 +9,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInHorizontally
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
@@ -19,6 +20,9 @@ import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.domain.EventForm
@@ -26,6 +30,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
import de.jeanlucmakiola.calendula.ui.common.BelowTopBarClip
import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBackExit
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
@@ -34,8 +39,10 @@ import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScroll
import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
import de.jeanlucmakiola.calendula.ui.common.TimelineScrollMemory
import de.jeanlucmakiola.calendula.ui.common.ViewMorphScope
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberViewMorphBoundsTransform
@@ -371,13 +378,23 @@ fun CalendarHost(
val reduceMotion = rememberReduceMotion()
val morphBounds = rememberViewMorphBoundsTransform()
val morphFade = rememberCalendarFadeSpec()
val morphClip = remember { BelowTopBarClip() }
val density = LocalDensity.current
val topBarBottom = TopAppBarDefaults.windowInsets.getTop(density) +
with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
val timelineScroll = remember { TimelineScrollMemory() }
CompositionLocalProvider(
LocalEventMove provides moveScope,
LocalTimelineScroll provides timelineScroll,
// 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 },
) {
SharedTransitionLayout(Modifier.fillMaxSize()) {
SharedTransitionLayout(
Modifier
.fillMaxSize()
.onGloballyPositioned { morphClip.top = topBarBottom - it.positionInWindow().y },
) {
AnimatedContent(
targetState = view,
transitionSpec = { viewSwitch },
@@ -385,8 +402,8 @@ fun CalendarHost(
) { currentView ->
val sharedScope = this@SharedTransitionLayout
val visibilityScope = this@AnimatedContent
val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade) {
if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade)
val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade, morphClip) {
if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade, morphClip)
}
CompositionLocalProvider(LocalViewMorph provides viewMorph) {
when (currentView) {
@@ -57,9 +57,15 @@ 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.
* height; [color] is resolved by the caller from the theme. [offsetPx] shifts
* the cells up, for a fixed window over a scrolled column.
*/
fun Modifier.hourGridCells(show: Boolean, hourHeightPx: Float, color: Color): Modifier =
fun Modifier.hourGridCells(
show: Boolean,
hourHeightPx: Float,
color: Color,
offsetPx: () -> Float = { 0f },
): Modifier =
if (!show) {
this
} else {
@@ -71,10 +77,11 @@ fun Modifier.hourGridCells(show: Boolean, hourHeightPx: Float, color: Color): Mo
hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()),
)
val cellSize = Size(size.width, cellHeight)
val top = inset - offsetPx()
for (hour in 0 until 24) {
drawRoundRect(
color = color,
topLeft = Offset(0f, hour * hourHeightPx + inset),
topLeft = Offset(0f, hour * hourHeightPx + top),
size = cellSize,
cornerRadius = radius,
)
@@ -0,0 +1,54 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import kotlinx.coroutines.flow.first
import kotlin.math.roundToInt
/**
* Where 00:00 sat in the last timeline shown, measured from the top of its
* view's content, so the next timeline view opens on the same hours (#184): an
* event in both keeps its place and only grows sideways.
*/
class TimelineScrollMemory {
var midnightY: Float? = null
}
val LocalTimelineScroll = staticCompositionLocalOf<TimelineScrollMemory?> { null }
/**
* The scroll state of a timeline whose scrolling area starts [timelineTop] below
* its view's content top. Opens where the last timeline left off, or on noon the
* first time.
*/
@Composable
fun rememberTimelineScrollState(timelineTop: Dp): ScrollState {
val memory = LocalTimelineScroll.current
val topPx = with(LocalDensity.current) { timelineTop.toPx() }
val remembered = memory?.midnightY
val scrollState = rememberScrollState(
initial = remembered?.let { (topPx - it).roundToInt().coerceAtLeast(0) } ?: 0,
)
val currentTop by rememberUpdatedState(topPx)
LaunchedEffect(scrollState) {
if (remembered != null) return@LaunchedEffect
snapshotFlow { scrollState.maxValue }.first { it > 0 }
// Half the scroll range *is* noon: the content spans a full 24 hours, so
// centring the range centres midday at whatever hour height is in force.
scrollState.scrollTo(scrollState.maxValue / 2)
}
if (memory != null) {
LaunchedEffect(scrollState, memory) {
snapshotFlow { currentTop - scrollState.value }.collect { memory.midnightY = it }
}
}
return scrollState
}
@@ -7,6 +7,12 @@ import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@@ -14,7 +20,13 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate
@@ -27,12 +39,17 @@ import kotlinx.datetime.LocalDate
* multi-day event is drawn once per day, per week row or per timeline column,
* and only one element per key may take part. The segment that starts on the
* same day in both views is the one that travels; the rest fade where they are.
*
* A day's ground — its month cell, its timeline column — travels too, so the
* events never arrive ahead of what they sit on.
*/
sealed interface ViewMorphKey {
data class Event(val eventId: Long, val beginMillis: Long, val day: LocalDate) : ViewMorphKey
data class Day(val date: LocalDate) : ViewMorphKey
data class Ground(val date: LocalDate) : ViewMorphKey
companion object {
/** [event]'s occurrence as drawn from [day]. */
fun event(event: EventInstance, day: LocalDate): Event =
@@ -47,8 +64,30 @@ class ViewMorphScope(
val visibility: AnimatedVisibilityScope,
val boundsTransform: BoundsTransform,
val fadeSpec: FiniteAnimationSpec<Float>,
val clip: SharedTransitionScope.OverlayClip,
)
/**
* Keeps what travels below the top bar, which every view has at the same height:
* the overlay draws above the whole screen, so an event scrolled under the bar
* would otherwise cross it. [top] is the bar's bottom edge in the shared layout.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
class BelowTopBarClip : SharedTransitionScope.OverlayClip {
var top = 0f
override fun getClipPath(
sharedContentState: SharedTransitionScope.SharedContentState,
bounds: Rect,
layoutDirection: LayoutDirection,
density: Density,
): Path = Path().apply { addRect(Rect(-FAR, top, FAR, FAR)) }
private companion object {
const val FAR = 1e6f
}
}
val LocalViewMorph = compositionLocalOf<ViewMorphScope?> { null }
/**
@@ -84,6 +123,7 @@ fun Modifier.morphEvent(key: ViewMorphKey.Event): Modifier {
enter = fadeIn(morph.fadeSpec),
exit = fadeOut(morph.fadeSpec),
boundsTransform = morph.boundsTransform,
clipInOverlayDuringTransition = morph.clip,
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
zIndexInOverlay = 1f,
)
@@ -102,10 +142,64 @@ fun Modifier.morphDay(date: LocalDate): Modifier {
enter = fadeIn(morph.fadeSpec),
exit = fadeOut(morph.fadeSpec),
boundsTransform = morph.boundsTransform,
clipInOverlayDuringTransition = morph.clip,
// Over the grounds, which a month cell's number sits inside.
zIndexInOverlay = 1f,
)
}
}
/** Tags a day's ground, under the events and numbers that travel with it. */
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun Modifier.morphGround(date: LocalDate): Modifier {
val morph = LocalViewMorph.current ?: return this
return with(morph.shared) {
this@morphGround.sharedBounds(
sharedContentState = rememberSharedContentState(ViewMorphKey.Ground(date)),
animatedVisibilityScope = morph.visibility,
enter = fadeIn(morph.fadeSpec),
exit = fadeOut(morph.fadeSpec),
boundsTransform = morph.boundsTransform,
clipInOverlayDuringTransition = morph.clip,
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
)
}
}
/**
* The on-screen window of each day's timeline column, painted like the column
* behind the scrolling one: a whole 24-hour column can't travel, its window can.
* Nothing is drawn while there is no morph to take part in.
*/
@Composable
fun TimelineGrounds(
days: List<LocalDate>,
scrollState: ScrollState,
hourHeight: Dp,
columnGap: Dp,
modifier: Modifier = Modifier,
) {
if (LocalViewMorph.current == null) return
val showHourGrid = LocalShowHourGrid.current
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
val cell = MaterialTheme.colorScheme.surfaceContainer
val ground = if (showHourGrid) MaterialTheme.colorScheme.surface else cell
Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(columnGap)) {
days.forEach { day ->
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.morphGround(day)
.clipToBounds()
.background(ground)
.hourGridCells(showHourGrid, hourPx, cell) { scrollState.value.toFloat() },
)
}
}
}
/** The view switch's travel: the M3 Expressive default spatial spring, as the pagers settle. */
@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -12,10 +12,12 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateMapOf
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
import de.jeanlucmakiola.calendula.ui.common.TimelineGrounds
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.ViewMorphPage
import de.jeanlucmakiola.calendula.ui.common.morphEvent
import de.jeanlucmakiola.calendula.ui.common.morphPageActive
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineScrollState
import de.jeanlucmakiola.calendula.ui.common.pagedHeight
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
@@ -146,7 +148,7 @@ import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset
import de.jeanlucmakiola.calendula.ui.common.hourGridCells
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import kotlinx.coroutines.flow.first
import de.jeanlucmakiola.calendula.ui.week.weekDayHeaderHeight
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
@@ -307,15 +309,10 @@ private fun DayContent(
val fadeSpec = rememberCalendarFadeSpec()
// Shared by every page and the gutter, so the vertical scroll position
// survives day-to-day swipes. We only centre on noon once, on first entry
// into the day view (i.e. when arriving from the month/week view).
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
// Half the scroll range *is* noon: the content spans a full 24 hours, so
// centring the range centres midday at whatever hour height is in force.
scrollState.scrollTo(scrollState.maxValue / 2)
}
// survives day-to-day swipes, and carried over from the timeline view
// before this one.
val stripHeight = (state as? DayUiState.Success)?.allDayStripHeight() ?: 0.dp
val scrollState = rememberTimelineScrollState(stripHeight + TIMELINE_TOP_GAP)
// Above the pager: a page change mid-drag would strand the floating block
// inside the outgoing page.
@@ -444,14 +441,15 @@ private fun DayFrame(
// timelines have, so labels and column agree.
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val zoom = LocalTimelineZoom.current
val hourHeight = zoom.scale.hourHeight(maxHeight)
val fitHeight = maxHeight + allDayHeight - weekDayHeaderHeight()
val hourHeight = zoom.scale.hourHeight(fitHeight)
// Start inset so the labels centre on the top bar hamburger,
// matching the week view.
HourGutter(
scrollState = scrollState,
hourHeight = hourHeight,
dragController = dragController,
modifier = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom),
modifier = rememberTimelinePinchZoom(scrollState, fitHeight, hourHeight, zoom),
)
}
}
@@ -521,6 +519,7 @@ private fun DayPage(
Spacer(Modifier.height(TIMELINE_TOP_GAP))
Timeline(
state = state,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
active = active,
@@ -672,6 +671,7 @@ private fun AllDayBar(
@Composable
private fun Timeline(
state: DayUiState.Success,
allDayHeight: Dp,
scrollState: ScrollState,
dragController: TimelineDragController,
active: Boolean,
@@ -692,14 +692,27 @@ private fun Timeline(
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
// timeline's own viewport height, which is only known here — below the top
// bar, date header and all-day strip.
// bar and all-day strip. It fits the week view's timeline instead, so an
// hour is as tall here as there (#184).
val weekHeader = weekDayHeaderHeight()
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val hourHeight = zoom.scale.hourHeight(maxHeight)
val fitHeight = maxHeight + allDayHeight - weekHeader
val hourHeight = zoom.scale.hourHeight(fitHeight)
val totalHeight = hourHeight * 24
// The pinch sits above the scroll viewport: it has to outrank the
// vertical scroll, and it does that by watching the initial pass, which
// only reaches it if it is the viewport's ancestor.
val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom)
val pinch = rememberTimelinePinchZoom(scrollState, fitHeight, hourHeight, zoom)
TimelineGrounds(
days = listOf(state.date),
scrollState = scrollState,
hourHeight = hourHeight,
columnGap = 0.dp,
modifier = Modifier
.fillMaxSize()
.padding(end = TIMELINE_CONTENT_END_INSET)
.clip(RoundedCornerShape(16.dp)),
)
// Scrolls on the same state as the gutter and the other pages, so they
// all stay aligned. A static, rounded-clipped window — the content
// scrolls inside it, so the soft corners are permanent at any scroll
@@ -975,10 +988,11 @@ private fun EventBlock(
private fun DayLoading() {
val scale = LocalTimelineZoom.current.scale
val scrollState = rememberScrollState()
val weekHeader = weekDayHeaderHeight()
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
// Same scale resolution as the loaded timeline, so the skeleton's column
// doesn't resize the moment the real day arrives.
val hourHeight = scale.hourHeight(maxHeight)
val hourHeight = scale.hourHeight(maxHeight - weekHeader - TIMELINE_TOP_GAP)
val totalHeight = hourHeight * 24
// The skeleton wears the loaded column's own ground, so the arrival of
// the real day doesn't flash a solid block into a gapped grid.
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.morphDay
import de.jeanlucmakiola.calendula.ui.common.morphEvent
import de.jeanlucmakiola.calendula.ui.common.morphGround
import de.jeanlucmakiola.calendula.ui.common.morphPageActive
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
import de.jeanlucmakiola.calendula.ui.common.pagedHeight
@@ -2185,6 +2186,7 @@ private fun MonthWeekRow(
.fillMaxHeight()
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.morphElement(MonthMorphKey.Cell(d))
.morphGround(d)
.background(
color = when {
inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer
@@ -135,10 +135,12 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.TimelineGrounds
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.ViewMorphPage
import de.jeanlucmakiola.calendula.ui.common.morphDay
import de.jeanlucmakiola.calendula.ui.common.morphEvent
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineScrollState
import de.jeanlucmakiola.calendula.ui.common.morphPageActive
import de.jeanlucmakiola.calendula.ui.common.withTitleWeight
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
@@ -159,7 +161,6 @@ import de.jeanlucmakiola.calendula.ui.common.hourGridCells
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.time.isoWeekNumber
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
@@ -183,6 +184,15 @@ private val HEADER_BOTTOM_PADDING = 8.dp
/** The header's date slot, reserved whether or not it holds today's circle. */
private val DATE_SLOT_SIZE = 28.dp
/** The weekday header's height, which the day view takes off to fit the week's hours (#184). */
@Composable
internal fun weekDayHeaderHeight(): Dp {
val label = MaterialTheme.typography.labelSmall
val line = label.lineHeight.takeIf { it.isSp } ?: label.fontSize
return HEADER_TOP_PADDING + with(LocalDensity.current) { line.toDp() } + 2.dp +
DATE_SLOT_SIZE + HEADER_BOTTOM_PADDING
}
/** Total all-day strip height for a week (0 when there are no all-day events). */
internal fun WeekUiState.Success.allDayStripHeight(): Dp {
if (allDaySpans.isEmpty()) return 0.dp
@@ -419,16 +429,10 @@ private fun WeekContent(
val fadeSpec = rememberCalendarFadeSpec()
// Shared by every page and the gutter, so the vertical scroll position
// survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on
// noon once, on first entry into the week view (i.e. when arriving from the
// month/day view), not on every swipe.
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
// Half the scroll range *is* noon: the content spans a full 24 hours, so
// centring the range centres midday at whatever hour height is in force.
scrollState.scrollTo(scrollState.maxValue / 2)
}
// survives week-to-week swipes (e.g. 18:00 stays centred), and carried over
// from the timeline view before this one.
val stripHeight = (state as? WeekUiState.Success)?.allDayStripHeight() ?: 0.dp
val scrollState = rememberTimelineScrollState(weekDayHeaderHeight() + stripHeight + TIMELINE_TOP_GAP)
// Above the pager: a page change mid-drag would strand the floating block
// inside the outgoing page.
@@ -587,12 +591,13 @@ private fun WeekFrame(
// timelines have, so labels and columns agree.
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val zoom = LocalTimelineZoom.current
val hourHeight = zoom.scale.hourHeight(maxHeight)
val fitHeight = maxHeight + allDayHeight
val hourHeight = zoom.scale.hourHeight(fitHeight)
HourGutter(
scrollState = scrollState,
hourHeight = hourHeight,
dragController = dragController,
modifier = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom),
modifier = rememberTimelinePinchZoom(scrollState, fitHeight, hourHeight, zoom),
)
}
}
@@ -670,6 +675,7 @@ private fun WeekPage(
Spacer(Modifier.height(TIMELINE_TOP_GAP))
Timeline(
state = state,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
active = active,
@@ -940,6 +946,7 @@ private fun AllDayBar(
@Composable
private fun Timeline(
state: WeekUiState.Success,
allDayHeight: Dp,
scrollState: ScrollState,
dragController: TimelineDragController,
active: Boolean,
@@ -960,14 +967,26 @@ private fun Timeline(
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
// timeline's own viewport height, which is only known here — below the top
// bar, day header and all-day strip.
// 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 hourHeight = zoom.scale.hourHeight(maxHeight)
val fitHeight = maxHeight + allDayHeight
val hourHeight = zoom.scale.hourHeight(fitHeight)
val totalHeight = hourHeight * 24
// The pinch sits above the scroll viewport: it has to outrank the
// vertical scroll, and it does that by watching the initial pass, which
// only reaches it if it is the viewport's ancestor.
val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom)
val pinch = rememberTimelinePinchZoom(scrollState, fitHeight, hourHeight, zoom)
TimelineGrounds(
days = state.days,
scrollState = scrollState,
hourHeight = hourHeight,
columnGap = COLUMN_GAP,
modifier = Modifier
.fillMaxSize()
.padding(end = TIMELINE_CONTENT_END_INSET)
.clip(RoundedCornerShape(16.dp)),
)
// Scrolls on the same state as the gutter and the other pages, so they
// all stay aligned. A static, rounded-clipped window — the content
// scrolls inside it, so the soft corners are permanent at any scroll
@@ -1267,20 +1286,24 @@ private fun EventBlock(
}
}
private val SKELETON_HEADER_HEIGHT = 36.dp
private val SKELETON_HEADER_PADDING = 8.dp
@Composable
private fun WeekLoading(dayCount: Int) {
val scale = LocalTimelineZoom.current.scale
val scrollState = rememberScrollState()
val headerHeight = weekDayHeaderHeight()
Column(modifier = Modifier.fillMaxSize()) {
// Header skeleton
Row(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Row(modifier = Modifier.fillMaxWidth().padding(SKELETON_HEADER_PADDING)) {
Spacer(Modifier.width(GUTTER_WIDTH))
repeat(dayCount) {
Box(
modifier = Modifier
.weight(1f)
.padding(horizontal = 2.dp)
.height(36.dp)
.height(SKELETON_HEADER_HEIGHT)
.background(
MaterialTheme.colorScheme.surfaceContainer,
RoundedCornerShape(8.dp),
@@ -1291,7 +1314,9 @@ private fun WeekLoading(dayCount: Int) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
// Same scale resolution as the loaded timeline, so the skeleton's
// columns don't resize the moment the real week arrives.
val hourHeight = scale.hourHeight(maxHeight)
val fitHeight = maxHeight + SKELETON_HEADER_HEIGHT + SKELETON_HEADER_PADDING * 2 -
headerHeight - TIMELINE_TOP_GAP
val hourHeight = scale.hourHeight(fitHeight)
val totalHeight = hourHeight * 24
// The skeleton wears the loaded columns' own ground, so the arrival
// of the real week doesn't flash solid blocks into a gapped grid.