Bundle the timeline's shared scroll, drag and callbacks into TimelineActions

This commit is contained in:
2026-10-16 14:07:39 +02:00
parent 7a43ee0595
commit 28c4e68de4
3 changed files with 114 additions and 135 deletions
@@ -0,0 +1,57 @@
package de.jeanlucmakiola.calendula.ui.week
import androidx.compose.foundation.ScrollState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
import de.jeanlucmakiola.calendula.ui.common.TimelineResizeDrop
import kotlinx.datetime.LocalDate
/**
* What every timeline page hands down to its day columns: the shared vertical
* scroll, the drag controller, and the callbacks. One instance per screen, so
* passing it never makes a page recompose.
*/
@Stable
internal class TimelineActions(
val scrollState: ScrollState,
val dragController: TimelineDragController,
val onEventClick: (EventInstance) -> Unit,
val onOpenDay: (LocalDate) -> Unit,
val onCreateAt: (LocalDate, Int) -> Unit,
val onDrop: (TimelineDrop) -> Unit,
val onResize: (TimelineResizeDrop) -> Unit,
)
/** The callbacks are read through their latest values, so the holder outlives them. */
@Composable
internal fun rememberTimelineActions(
scrollState: ScrollState,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
): TimelineActions {
val eventClick = rememberUpdatedState(onEventClick)
val openDay = rememberUpdatedState(onOpenDay)
val createAt = rememberUpdatedState(onCreateAt)
val drop = rememberUpdatedState(onDrop)
val resize = rememberUpdatedState(onResize)
return remember(scrollState, dragController) {
TimelineActions(
scrollState = scrollState,
dragController = dragController,
onEventClick = { eventClick.value(it) },
onOpenDay = { openDay.value(it) },
onCreateAt = { date, minutes -> createAt.value(date, minutes) },
onDrop = { drop.value(it) },
onResize = { resize.value(it) },
)
}
}
@@ -39,7 +39,6 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -516,7 +515,7 @@ private fun PagedTimelineScreen(
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
onCreateAt = onCreateEvent,
onCreateDraft = onCreateDraft,
draftMinutes = draftMinutes,
modifier = Modifier
@@ -648,6 +647,16 @@ private fun WeekContent(
}
}
val actions = rememberTimelineActions(
scrollState = scrollState,
dragController = dragController,
onEventClick = openEvent,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
Box(modifier = modifier) {
CompositionLocalProvider(LocalOpeningTimeline provides timelineScroll) {
// Loading and failure concern the provider as a whole, so they stand in
@@ -663,17 +672,7 @@ private fun WeekContent(
WeekUiState.Loading -> WeekLoading(dayCount = grid.dayCount)
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is WeekUiState.Success -> if (morph != null) {
TimelineMorphFrame(
morph = morph,
today = today,
scrollState = scrollState,
dragController = dragController,
onEventClick = openEvent,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
TimelineMorphFrame(morph = morph, today = today, actions = actions)
} else WeekPager(
pagerState = pagerState,
grid = grid,
@@ -681,13 +680,7 @@ private fun WeekContent(
span = span,
today = today,
initialStripHeight = handoffStrip ?: s.allDayStripHeight(),
scrollState = scrollState,
dragController = dragController,
onEventClick = openEvent,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
actions = actions,
)
}
}
@@ -708,22 +701,16 @@ private fun WeekPager(
span: (DaySpan) -> StateFlow<WeekUiState>,
today: LocalDate,
initialStripHeight: Dp,
scrollState: ScrollState,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
actions: TimelineActions,
) {
val dragController = actions.dragController
val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight)
WeekFrame(
weekStart = grid.startForPage(pagerState.currentPage),
showWeekNumber = kind.showWeekNumber,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
actions = actions,
) {
HorizontalPager(
state = pagerState,
@@ -756,14 +743,8 @@ private fun WeekPager(
},
headerBias = kind.headerBias,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
actions = actions,
active = page == pagerState.currentPage,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
}
@@ -792,13 +773,7 @@ private fun emptySpan(span: DaySpan, today: LocalDate): WeekUiState.Success {
private fun TimelineMorphFrame(
morph: TimelineMorph,
today: LocalDate,
scrollState: ScrollState,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
actions: TimelineActions,
) {
val fromState by morph.fromState.collectAsStateWithLifecycle()
val toState by morph.toState.collectAsStateWithLifecycle()
@@ -836,8 +811,7 @@ private fun TimelineMorphFrame(
allDayHeight = fromStrip,
animatedAllDayHeight = animatedStrip,
weekNumberAlpha = badgeAlpha,
scrollState = scrollState,
dragController = dragController,
actions = actions,
) {
TimelinePageContent(
geometry = geometry,
@@ -847,14 +821,8 @@ private fun TimelineMorphFrame(
headerBias = headerBias,
allDayHeight = fromStrip,
animatedAllDayHeight = animatedStrip,
scrollState = scrollState,
dragController = dragController,
actions = actions,
active = false,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
}
@@ -888,8 +856,7 @@ private fun WeekFrame(
allDayHeight: Dp,
animatedAllDayHeight: (() -> Dp)? = null,
weekNumberAlpha: () -> Float = { 1f },
scrollState: ScrollState,
dragController: TimelineDragController,
actions: TimelineActions,
pages: @Composable () -> Unit,
) {
val gutterFullHeight = remember(animatedAllDayHeight) { intArrayOf(-1) }
@@ -923,10 +890,10 @@ private fun WeekFrame(
else maxHeight
val hourHeight = zoom.scale.hourHeight(fitHeight)
HourGutter(
scrollState = scrollState,
scrollState = actions.scrollState,
hourHeight = hourHeight,
dragController = dragController,
modifier = rememberTimelinePinchZoom(scrollState, fitHeight, hourHeight, zoom),
dragController = actions.dragController,
modifier = rememberTimelinePinchZoom(actions.scrollState, fitHeight, hourHeight, zoom),
)
}
}
@@ -941,34 +908,21 @@ internal fun WeekSuccess(
state: WeekUiState.Success,
showWeekNumber: Boolean = true,
headerBias: Float = 0f,
scrollState: ScrollState,
allDayHeight: Dp,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
actions: TimelineActions,
) {
WeekFrame(
weekStart = state.weekStart,
showWeekNumber = showWeekNumber,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
actions = actions,
) {
WeekPage(
state = state,
headerBias = headerBias,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
actions = actions,
active = true,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
}
@@ -984,14 +938,8 @@ private fun WeekPage(
state: WeekUiState.Success,
headerBias: Float,
allDayHeight: Dp,
scrollState: ScrollState,
dragController: TimelineDragController,
actions: TimelineActions,
active: Boolean,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
) {
val geometry = remember(state.weekStart, state.days.size) {
ColumnGeometry.of(DaySpan(state.weekStart, state.days.size))
@@ -1004,14 +952,8 @@ private fun WeekPage(
bars = bars,
headerBias = headerBias,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
actions = actions,
active = active,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
@@ -1028,14 +970,8 @@ private fun TimelinePageContent(
headerBias: Float,
allDayHeight: Dp,
animatedAllDayHeight: (() -> Dp)? = null,
scrollState: ScrollState,
dragController: TimelineDragController,
actions: TimelineActions,
active: Boolean,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
Column(
@@ -1043,14 +979,14 @@ private fun TimelinePageContent(
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface),
) {
WeekDayHeader(geometry = geometry, today = today, bias = headerBias, onOpenDay = onOpenDay)
WeekDayHeader(geometry = geometry, today = today, bias = headerBias, onOpenDay = actions.onOpenDay)
// This strip is painted on `surface`, so an outlined chip has to
// fill with that and not with the columns' container (#230).
CompositionLocalProvider(
LocalChipGround provides MaterialTheme.colorScheme.surface,
) {
AllDayStrip(geometry = geometry, bars = bars, height = allDayHeight,
animatedHeight = animatedAllDayHeight, onEventClick = onEventClick)
animatedHeight = animatedAllDayHeight, onEventClick = actions.onEventClick)
}
}
Spacer(Modifier.height(TIMELINE_TOP_GAP))
@@ -1060,13 +996,8 @@ private fun TimelinePageContent(
timedFor = timedFor,
allDayHeight = allDayHeight,
animatedAllDayHeight = animatedAllDayHeight,
scrollState = scrollState,
dragController = dragController,
actions = actions,
active = active,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
}
@@ -1290,14 +1221,11 @@ private fun Timeline(
timedFor: (LocalDate) -> List<TimedBlock>,
allDayHeight: Dp,
animatedAllDayHeight: (() -> Dp)? = null,
scrollState: ScrollState,
dragController: TimelineDragController,
actions: TimelineActions,
active: Boolean,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
) {
val scrollState = actions.scrollState
val dragController = actions.dragController
val fullViewportHeight = remember(geometry) { intArrayOf(-1) }
val dark = LocalFloretDarkTheme.current
val zoom = LocalTimelineZoom.current
@@ -1390,12 +1318,7 @@ private fun Timeline(
columnWidth = columnWidth,
visibleTop = visibleTop,
viewportHeight = visibleViewport,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
scrollState = scrollState,
actions = actions,
modifier = Modifier.fillMaxHeight(),
)
}
@@ -1413,14 +1336,11 @@ private fun DayColumnCard(
columnWidth: Dp,
visibleTop: () -> Float,
viewportHeight: () -> Float,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
scrollState: ScrollState,
actions: TimelineActions,
modifier: Modifier = Modifier,
) {
val scrollState = actions.scrollState
val dragController = actions.dragController
val density = LocalDensity.current
val hourPx = with(density) { hourHeight.toPx() }
val morphing = LocalViewMorph.current != null
@@ -1438,13 +1358,13 @@ private fun DayColumnCard(
}
// The tap detector outlives the composition that installed it — a pinch or a
// Settings change moves the hour height without restarting it — so it reads
// the height and the callback through state handles instead of capturing
// them, or taps land on the scale the column had before the zoom (#148).
// the height through a state handle instead of capturing it, or taps land on
// the scale the column had before the zoom (#148). The actions read their
// callbacks' latest values themselves.
val currentHourPx = rememberUpdatedState(hourPx)
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
val edges = rememberOutOfViewEdges(blocks, scrollState, hourPx)
val currentEdges = rememberUpdatedState(edges)
val draftSurface = rememberDraftSurface(date, edges, hourPx, dragController, onDrop)
val draftSurface = rememberDraftSurface(date, edges, hourPx, dragController, actions.onDrop)
Card(
// Plain rectangular columns — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -1478,7 +1398,7 @@ private fun DayColumnCard(
return@detectTapGestures
}
if (currentEdges.value.tap(offset.y)) return@detectTapGestures
currentOnCreateAt.value(
actions.onCreateAt(
date,
tappedMinuteOfDay(offset.y, currentHourPx.value),
)
@@ -1538,9 +1458,9 @@ private fun DayColumnCard(
cuts = cuts,
topInset = topInset,
dragController = dragController,
onClick = { onEventClick(block.event) },
onDrop = onDrop,
onResize = onResize,
onClick = { actions.onEventClick(block.event) },
onDrop = actions.onDrop,
onResize = actions.onResize,
modifier = Modifier
.laneSlot(place)
.offset(y = place.y + topInset)
@@ -52,14 +52,16 @@ internal fun WeekViewPreview(
state = state,
showWeekNumber = dayCount == 7,
headerBias = if (dayCount == 1) TimelineKind.Day.headerBias else 0f,
scrollState = scrollState,
allDayHeight = state.allDayStripHeight(),
dragController = rememberTimelineDragController(),
onEventClick = {},
onOpenDay = {},
onCreateAt = { _, _ -> },
onDrop = {},
onResize = {},
actions = rememberTimelineActions(
scrollState = scrollState,
dragController = rememberTimelineDragController(),
onEventClick = {},
onOpenDay = {},
onCreateAt = { _, _ -> },
onDrop = {},
onResize = {},
),
)
}
}