Refine view switching and timeline morph (#184)
This commit is contained in:
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user