Lay timeline columns out through one geometry (#184)
This commit is contained in:
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.common
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -13,15 +14,19 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.ParentDataModifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Gap a timed block leaves to its neighbours in the column. */
|
||||
val BLOCK_OUTER_INSET = 1.dp
|
||||
@@ -224,9 +229,13 @@ fun BlockTimeLabel(
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a timed block sits in its column, after tweening. */
|
||||
/**
|
||||
* Where a timed block sits in its column, after tweening: across as shares of
|
||||
* the column's width, so a column resizing carries its blocks with it (#184),
|
||||
* down in the timeline's own units.
|
||||
*/
|
||||
@Immutable
|
||||
data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
|
||||
data class BlockPlacement(val xFraction: Float, val widthFraction: Float, val y: Dp, val height: Dp)
|
||||
|
||||
/**
|
||||
* A timed block's placement, tweened rather than jumped. Continuity comes from
|
||||
@@ -239,17 +248,49 @@ data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement {
|
||||
val spec: FiniteAnimationSpec<Dp> = if (rememberReduceMotion() ||
|
||||
LocalTimelineZoom.current.isPinching
|
||||
) {
|
||||
snap()
|
||||
} else {
|
||||
MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
fun animatedBlockPlacement(xFraction: Float, widthFraction: Float, y: Dp, height: Dp): BlockPlacement {
|
||||
val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching
|
||||
val dpSpec: FiniteAnimationSpec<Dp> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
val shareSpec: FiniteAnimationSpec<Float> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
val animatedX by animateFloatAsState(xFraction, shareSpec, label = "block-x")
|
||||
val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width")
|
||||
val animatedY by animateDpAsState(y, dpSpec, label = "block-y")
|
||||
val animatedHeight by animateDpAsState(height, dpSpec, label = "block-height")
|
||||
return BlockPlacement(animatedX, animatedWidth, animatedY, animatedHeight)
|
||||
}
|
||||
|
||||
/** Seats a block across its [placement]'s share of a [LaneColumn]. */
|
||||
fun Modifier.laneSlot(placement: BlockPlacement): Modifier =
|
||||
then(LaneSlot(placement.xFraction, placement.widthFraction))
|
||||
|
||||
private data class LaneSlot(val x: Float, val width: Float) : ParentDataModifier {
|
||||
override fun Density.modifyParentData(parentData: Any?): Any = this@LaneSlot
|
||||
}
|
||||
|
||||
/**
|
||||
* A day column's blocks, each measured across the share of the column its
|
||||
* [laneSlot] gives it; anything else fills the column. The shares are applied
|
||||
* at layout time, so the column can change width every frame without its
|
||||
* blocks recomposing.
|
||||
*/
|
||||
@Composable
|
||||
fun LaneColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||
Layout(content = content, modifier = modifier) { measurables, constraints ->
|
||||
val width = constraints.maxWidth
|
||||
val loose = constraints.copy(minWidth = 0, minHeight = 0)
|
||||
val placed = measurables.map { measurable ->
|
||||
val slot = measurable.parentData as? LaneSlot
|
||||
if (slot == null) {
|
||||
measurable.measure(loose) to 0
|
||||
} else {
|
||||
val left = (slot.x * width).roundToInt()
|
||||
val right = ((slot.x + slot.width) * width).roundToInt()
|
||||
val laneWidth = (right - left).coerceAtLeast(0)
|
||||
measurable.measure(loose.copy(minWidth = laneWidth, maxWidth = laneWidth)) to left
|
||||
}
|
||||
}
|
||||
layout(width, constraints.maxHeight) {
|
||||
placed.forEach { (placeable, x) -> placeable.placeRelative(x, 0) }
|
||||
}
|
||||
}
|
||||
val animatedX by animateDpAsState(x, spec, label = "block-x")
|
||||
val animatedY by animateDpAsState(y, spec, label = "block-y")
|
||||
val animatedWidth by animateDpAsState(width, spec, label = "block-width")
|
||||
val animatedHeight by animateDpAsState(height, spec, label = "block-height")
|
||||
return BlockPlacement(animatedX, animatedY, animatedWidth, animatedHeight)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.daysUntil
|
||||
import kotlinx.datetime.plus
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** A day column's place across the timeline, in pixels from its start edge. */
|
||||
@Immutable
|
||||
data class ColumnSlot(val left: Float, val width: Float) {
|
||||
val right: Float get() = left + width
|
||||
}
|
||||
|
||||
/**
|
||||
* [date]'s column on a page showing this span across [width] pixels, with [gap]
|
||||
* between columns. A date off the page carries on at the same pitch, off screen
|
||||
* on its side — where a view switch slides it in from, or out to.
|
||||
*/
|
||||
fun DaySpan.slot(date: LocalDate, width: Float, gap: Float): ColumnSlot {
|
||||
val pitch = (width + gap) / count
|
||||
return ColumnSlot(left = start.daysUntil(date) * pitch, width = pitch - gap)
|
||||
}
|
||||
|
||||
/** The last day of the span. */
|
||||
val DaySpan.end: LocalDate get() = start.plus(count - 1, DateTimeUnit.DAY)
|
||||
|
||||
/**
|
||||
* The timeline's columns: one page's, or — while switching between the day, the
|
||||
* multi-day and the week view (#184) — every day of both pages, each column
|
||||
* between where it sits in the one and where it sits in the other.
|
||||
*
|
||||
* @param progress how far from [from] to [to], read at layout time only.
|
||||
*/
|
||||
@Stable
|
||||
class ColumnGeometry(val from: DaySpan, val to: DaySpan, val progress: () -> Float) {
|
||||
|
||||
/** Every day either page shows, in order: the two overlap, so they run on unbroken. */
|
||||
val dates: List<LocalDate> = run {
|
||||
val first = minOf(from.start, to.start)
|
||||
val last = maxOf(from.end, to.end)
|
||||
(0..first.daysUntil(last)).map { first.plus(it, DateTimeUnit.DAY) }
|
||||
}
|
||||
|
||||
fun slot(date: LocalDate, width: Float, gap: Float): ColumnSlot {
|
||||
val a = from.slot(date, width, gap)
|
||||
if (from == to) return a
|
||||
val b = to.slot(date, width, gap)
|
||||
val p = progress()
|
||||
return ColumnSlot(lerp(a.left, b.left, p), lerp(a.width, b.width, p))
|
||||
}
|
||||
|
||||
/** The span of columns [range] covers, between its extent on each page. */
|
||||
fun extent(fromRange: ClosedRange<LocalDate>, toRange: ClosedRange<LocalDate>, width: Float, gap: Float): ColumnSlot {
|
||||
val a = from.extent(fromRange, width, gap)
|
||||
if (from == to && fromRange == toRange) return a
|
||||
val b = to.extent(toRange, width, gap)
|
||||
val p = progress()
|
||||
val left = lerp(a.left, b.left, p)
|
||||
return ColumnSlot(left, lerp(a.right, b.right, p) - left)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** One page's columns, standing still. */
|
||||
fun of(span: DaySpan): ColumnGeometry = ColumnGeometry(span, span) { 0f }
|
||||
}
|
||||
}
|
||||
|
||||
private fun DaySpan.extent(range: ClosedRange<LocalDate>, width: Float, gap: Float): ColumnSlot {
|
||||
val left = slot(range.start, width, gap).left
|
||||
return ColumnSlot(left, slot(range.endInclusive, width, gap).right - left)
|
||||
}
|
||||
|
||||
internal fun lerp(a: Float, b: Float, p: Float): Float = a + (b - a) * p
|
||||
|
||||
/**
|
||||
* Lays [content] out once per day of [geometry], each across its column. The
|
||||
* columns are placed in the layout pass, so a view switch moving them every
|
||||
* frame re-lays them out without recomposing them. Children fill the height
|
||||
* the layout is given, or wrap it when it has none.
|
||||
*/
|
||||
@Composable
|
||||
fun TimelineColumns(
|
||||
geometry: ColumnGeometry,
|
||||
gap: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable (LocalDate) -> Unit,
|
||||
) {
|
||||
val dates = geometry.dates
|
||||
Layout(
|
||||
content = {
|
||||
dates.forEach { date ->
|
||||
key(date) { Box(propagateMinConstraints = true) { content(date) } }
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
) { measurables, constraints ->
|
||||
val width = constraints.maxWidth
|
||||
val gapPx = gap.toPx()
|
||||
val slots = dates.map { geometry.slot(it, width.toFloat(), gapPx) }
|
||||
val placeables = measurables.mapIndexed { i, measurable ->
|
||||
val left = slots[i].left.roundToInt()
|
||||
val columnWidth = (slots[i].right.roundToInt() - left).coerceAtLeast(0)
|
||||
measurable.measure(constraints.copy(minWidth = columnWidth, maxWidth = columnWidth))
|
||||
}
|
||||
val height = placeables.maxOfOrNull { it.height }?.coerceAtLeast(constraints.minHeight)
|
||||
?: constraints.minHeight
|
||||
layout(width, height) {
|
||||
placeables.forEachIndexed { i, placeable ->
|
||||
placeable.placeRelative(slots[i].left.roundToInt(), 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An all-day bar as each page lays it out: the days it covers there, clipped to
|
||||
* the page, and its lane. A bar only one page has keeps its days on the other,
|
||||
* so it slides out with them rather than vanishing.
|
||||
*/
|
||||
@Immutable
|
||||
data class PlacedBar(
|
||||
val event: EventInstance,
|
||||
/** The day the bar is drawn from on the page it is tagged on, for the view morph. */
|
||||
val keyDate: LocalDate,
|
||||
val fromRange: ClosedRange<LocalDate>,
|
||||
val toRange: ClosedRange<LocalDate>,
|
||||
val fromLane: Int,
|
||||
val toLane: Int,
|
||||
)
|
||||
|
||||
/** [state]'s all-day bars, standing still. */
|
||||
internal fun staticBars(state: WeekUiState.Success): List<PlacedBar> =
|
||||
state.allDaySpans.map { span ->
|
||||
val range = state.days[span.startCol]..state.days[span.endCol]
|
||||
PlacedBar(span.event, range.start, range, range, span.lane, span.lane)
|
||||
}
|
||||
|
||||
/**
|
||||
* The all-day bars of both pages of a view switch, each once: matched by
|
||||
* occurrence, placed on each page as that page lays it out.
|
||||
*/
|
||||
internal fun morphBars(from: WeekUiState.Success?, to: WeekUiState.Success?): List<PlacedBar> {
|
||||
val fromBars = from?.let(::staticBars).orEmpty().associateBy { it.event.occurrence() }
|
||||
val toBars = to?.let(::staticBars).orEmpty().associateBy { it.event.occurrence() }
|
||||
return (fromBars.keys + toBars.keys).map { occurrence ->
|
||||
val a = fromBars[occurrence]
|
||||
val b = toBars[occurrence]
|
||||
PlacedBar(
|
||||
event = (a ?: b)!!.event,
|
||||
keyDate = (a ?: b)!!.keyDate,
|
||||
fromRange = (a ?: b)!!.fromRange,
|
||||
toRange = (b ?: a)!!.toRange,
|
||||
fromLane = (a ?: b)!!.fromLane,
|
||||
toLane = (b ?: a)!!.toLane,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun EventInstance.occurrence(): Pair<Long, Long> = eventId to start.toEpochMilliseconds()
|
||||
@@ -68,6 +68,11 @@ import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.BiasAlignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.jeanlucmakiola.calendula.ui.common.laneSlot
|
||||
import de.jeanlucmakiola.calendula.ui.common.LaneColumn
|
||||
import kotlin.math.roundToInt
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
@@ -670,7 +675,8 @@ private fun WeekFrame(
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f).fillMaxHeight()) { pages() }
|
||||
// Clipped, as the pager was: a view switch slides columns in past its edges.
|
||||
Box(modifier = Modifier.weight(1f).fillMaxHeight().clipToBounds()) { pages() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -728,6 +734,47 @@ private fun WeekPage(
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
) {
|
||||
val geometry = remember(state.weekStart, state.days.size) {
|
||||
ColumnGeometry.of(DaySpan(state.weekStart, state.days.size))
|
||||
}
|
||||
val bars = remember(state) { staticBars(state) }
|
||||
TimelinePageContent(
|
||||
geometry = geometry,
|
||||
today = state.today,
|
||||
timedFor = { state.timedByDay[it].orEmpty() },
|
||||
bars = bars,
|
||||
headerBias = headerBias,
|
||||
allDayHeight = allDayHeight,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
active = active,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A page's header, all-day strip and day columns, laid out over [geometry]: a
|
||||
* single page's, or both pages' of a view switch mid-way (#184).
|
||||
*/
|
||||
@Composable
|
||||
private fun TimelinePageContent(
|
||||
geometry: ColumnGeometry,
|
||||
today: LocalDate,
|
||||
timedFor: (LocalDate) -> List<TimedBlock>,
|
||||
bars: List<PlacedBar>,
|
||||
headerBias: Float,
|
||||
allDayHeight: Dp,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
active: Boolean,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
@@ -735,18 +782,20 @@ private fun WeekPage(
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
WeekDayHeader(days = state.days, today = state.today, bias = headerBias, onOpenDay = onOpenDay)
|
||||
WeekDayHeader(geometry = geometry, today = today, bias = headerBias, onOpenDay = 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(state = state, height = allDayHeight, onEventClick = onEventClick)
|
||||
AllDayStrip(geometry = geometry, bars = bars, height = allDayHeight, onEventClick = onEventClick)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(TIMELINE_TOP_GAP))
|
||||
Timeline(
|
||||
state = state,
|
||||
geometry = geometry,
|
||||
today = today,
|
||||
timedFor = timedFor,
|
||||
allDayHeight = allDayHeight,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
@@ -822,65 +871,64 @@ private fun WeekTopBar(
|
||||
|
||||
@Composable
|
||||
private fun WeekDayHeader(
|
||||
days: List<LocalDate>,
|
||||
geometry: ColumnGeometry,
|
||||
today: LocalDate,
|
||||
bias: Float,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
) {
|
||||
val locale = currentLocale()
|
||||
Row(
|
||||
TimelineColumns(
|
||||
geometry = geometry,
|
||||
gap = 0.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = HEADER_TOP_PADDING, bottom = HEADER_BOTTOM_PADDING, end = TIMELINE_CONTENT_END_INSET),
|
||||
) {
|
||||
days.forEach { date ->
|
||||
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
|
||||
val isToday = date == today
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable { onOpenDay(date) }
|
||||
.padding(start = HEADER_START_INSET * -bias),
|
||||
horizontalAlignment = BiasAlignment.Horizontal(bias),
|
||||
) { date ->
|
||||
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
|
||||
val isToday = date == today
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable { onOpenDay(date) }
|
||||
.padding(start = HEADER_START_INSET * -bias),
|
||||
horizontalAlignment = BiasAlignment.Horizontal(bias),
|
||||
) {
|
||||
// One line, as the gutter cell beside it assumes (see WeekNumberCell).
|
||||
Text(
|
||||
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Clip,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
// Always reserve the circle slot so the header height is
|
||||
// identical whether or not the week contains today.
|
||||
Box(
|
||||
modifier = Modifier.size(DATE_SLOT_SIZE),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// One line, as the gutter cell beside it assumes (see WeekNumberCell).
|
||||
Text(
|
||||
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Clip,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
// Always reserve the circle slot so the header height is
|
||||
// identical whether or not the week contains today.
|
||||
Box(
|
||||
modifier = Modifier.size(DATE_SLOT_SIZE),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isToday) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.morphDay(date).fillMaxSize(),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = date.day.toString(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
if (isToday) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.morphDay(date).fillMaxSize(),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = date.day.toString(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = date.day.toString(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.morphDay(date),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = date.day.toString(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.morphDay(date),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -936,7 +984,8 @@ private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) {
|
||||
|
||||
@Composable
|
||||
private fun AllDayStrip(
|
||||
state: WeekUiState.Success,
|
||||
geometry: ColumnGeometry,
|
||||
bars: List<PlacedBar>,
|
||||
height: Dp,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
) {
|
||||
@@ -957,29 +1006,39 @@ private fun AllDayStrip(
|
||||
// Span bars are positioned absolutely so a multi-day event is one
|
||||
// connected bar across columns rather than a chip per day. clipToBounds
|
||||
// keeps bars from spilling out while the height animates.
|
||||
BoxWithConstraints(
|
||||
Layout(
|
||||
content = {
|
||||
bars.forEach { bar ->
|
||||
key(bar.event.eventId, bar.event.start) {
|
||||
AllDayBar(
|
||||
event = bar.event,
|
||||
dark = dark,
|
||||
onClick = { onEventClick(bar.event) },
|
||||
modifier = Modifier
|
||||
.morphEvent(ViewMorphKey.event(bar.event, bar.keyDate))
|
||||
.padding(horizontal = 1.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.clipToBounds(),
|
||||
) {
|
||||
val colWidth = maxWidth / state.days.size
|
||||
state.allDaySpans.forEach { span ->
|
||||
val spanCols = span.endCol - span.startCol + 1
|
||||
AllDayBar(
|
||||
event = span.event,
|
||||
dark = dark,
|
||||
onClick = { onEventClick(span.event) },
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colWidth * span.startCol,
|
||||
y = ALL_DAY_ROW_HEIGHT * span.lane,
|
||||
)
|
||||
.morphEvent(ViewMorphKey.event(span.event, state.days[span.startCol]))
|
||||
.width(colWidth * spanCols)
|
||||
.height(ALL_DAY_ROW_HEIGHT)
|
||||
.padding(horizontal = 1.dp, vertical = 1.dp),
|
||||
)
|
||||
) { measurables, constraints ->
|
||||
val width = constraints.maxWidth.toFloat()
|
||||
val rowPx = ALL_DAY_ROW_HEIGHT.toPx()
|
||||
val progress = geometry.progress()
|
||||
val placed = measurables.mapIndexed { i, measurable ->
|
||||
val bar = bars[i]
|
||||
val extent = geometry.extent(bar.fromRange, bar.toRange, width, 0f)
|
||||
val left = extent.left.roundToInt()
|
||||
val barWidth = (extent.right.roundToInt() - left).coerceAtLeast(0)
|
||||
val y = (lerp(bar.fromLane.toFloat(), bar.toLane.toFloat(), progress) * rowPx).roundToInt()
|
||||
Triple(measurable.measure(Constraints.fixed(barWidth, rowPx.roundToInt())), left, y)
|
||||
}
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
placed.forEach { (placeable, x, y) -> placeable.placeRelative(x, y) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1020,7 +1079,9 @@ private fun AllDayBar(
|
||||
|
||||
@Composable
|
||||
private fun Timeline(
|
||||
state: WeekUiState.Success,
|
||||
geometry: ColumnGeometry,
|
||||
today: LocalDate,
|
||||
timedFor: (LocalDate) -> List<TimedBlock>,
|
||||
allDayHeight: Dp,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
@@ -1052,8 +1113,14 @@ private fun Timeline(
|
||||
// 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, fitHeight, hourHeight, zoom)
|
||||
// What a block's text is fitted to: the column it will end up in once a
|
||||
// view switch is past half way, so it reflows once rather than per frame.
|
||||
val textColumns by remember(geometry) {
|
||||
derivedStateOf { if (geometry.progress() >= 0.5f) geometry.to.count else geometry.from.count }
|
||||
}
|
||||
val columnWidth = (maxWidth - TIMELINE_CONTENT_END_INSET + COLUMN_GAP) / textColumns - COLUMN_GAP
|
||||
TimelineGrounds(
|
||||
days = state.days,
|
||||
days = geometry.dates,
|
||||
scrollState = scrollState,
|
||||
hourHeight = hourHeight,
|
||||
columnGap = COLUMN_GAP,
|
||||
@@ -1075,7 +1142,9 @@ private fun Timeline(
|
||||
.verticalScroll(scrollState)
|
||||
.onGloballyPositioned { if (active) dragController.geometry.viewport = it },
|
||||
) {
|
||||
Row(
|
||||
TimelineColumns(
|
||||
geometry = geometry,
|
||||
gap = COLUMN_GAP,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(totalHeight)
|
||||
@@ -1091,29 +1160,25 @@ private fun Timeline(
|
||||
it.hourPx = with(density) { hourHeight.toPx() }
|
||||
it.blockInsetPx = blockInsetPx
|
||||
it.columnGapPx = gap
|
||||
it.columnWidthPx = (coords.size.width + gap) / state.days.size
|
||||
it.days = state.days
|
||||
it.columnWidthPx = (coords.size.width + gap) / geometry.to.count
|
||||
it.days = geometry.dates
|
||||
it.isRtl = isRtl
|
||||
}
|
||||
},
|
||||
horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP),
|
||||
) {
|
||||
state.days.forEach { day ->
|
||||
DayColumnCard(
|
||||
blocks = state.timedByDay[day].orEmpty(),
|
||||
dark = dark,
|
||||
date = day,
|
||||
today = state.today,
|
||||
hourHeight = hourHeight,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
) { day ->
|
||||
DayColumnCard(
|
||||
blocks = timedFor(day),
|
||||
dark = dark,
|
||||
date = day,
|
||||
today = today,
|
||||
hourHeight = hourHeight,
|
||||
columnWidth = columnWidth,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1126,6 +1191,7 @@ private fun DayColumnCard(
|
||||
date: LocalDate,
|
||||
today: LocalDate,
|
||||
hourHeight: Dp,
|
||||
columnWidth: Dp,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
@@ -1162,7 +1228,7 @@ private fun DayColumnCard(
|
||||
),
|
||||
modifier = modifier,
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
LaneColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// The hour cells sit over the column background but under the
|
||||
@@ -1179,7 +1245,6 @@ private fun DayColumnCard(
|
||||
}
|
||||
},
|
||||
) {
|
||||
val colWidth = maxWidth
|
||||
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
|
||||
// Keyed by event, so a block that changes time or lane is the *same*
|
||||
// composable afterwards and tweens there. The ordinal disambiguates
|
||||
@@ -1188,14 +1253,13 @@ private fun DayColumnCard(
|
||||
blocks.forEach { block ->
|
||||
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
|
||||
key(block.event.eventId, ordinal) {
|
||||
val laneWidth = colWidth / block.laneCount
|
||||
val top = hourHeight * (block.startMin / 60f)
|
||||
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
||||
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
||||
val place = animatedBlockPlacement(
|
||||
x = laneWidth * block.lane,
|
||||
xFraction = block.lane.toFloat() / block.laneCount,
|
||||
widthFraction = 1f / block.laneCount,
|
||||
y = top,
|
||||
width = laneWidth,
|
||||
height = height,
|
||||
)
|
||||
// Where this block is cut at midnight, which decides both
|
||||
@@ -1214,7 +1278,7 @@ private fun DayColumnCard(
|
||||
block = block,
|
||||
dark = dark,
|
||||
height = blockHeight,
|
||||
width = place.width,
|
||||
width = columnWidth / block.laneCount,
|
||||
date = date,
|
||||
cuts = cuts,
|
||||
topInset = topInset,
|
||||
@@ -1222,9 +1286,9 @@ private fun DayColumnCard(
|
||||
onClick = { onEventClick(block.event) },
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier
|
||||
.offset(x = place.x, y = place.y + topInset)
|
||||
.laneSlot(place)
|
||||
.offset(y = place.y + topInset)
|
||||
.morphEvent(ViewMorphKey.event(block.event, date))
|
||||
.width(place.width)
|
||||
.height(blockHeight)
|
||||
.padding(horizontal = BLOCK_OUTER_INSET),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.plus
|
||||
import kotlin.time.Instant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/** The timeline's columns, standing still and mid view switch (#184). */
|
||||
class ColumnGeometryTest {
|
||||
|
||||
// 2026-09-27 is a Sunday; the week runs Sun..Sat.
|
||||
private val sunday = LocalDate(2026, 9, 27)
|
||||
private val week = DaySpan(sunday, 7)
|
||||
private val monday = sunday.plus(1, DateTimeUnit.DAY)
|
||||
private val day = DaySpan(monday, 1)
|
||||
private val width = 700f
|
||||
private val gap = 7f
|
||||
|
||||
@Test
|
||||
fun `a page's columns follow the pitch`() {
|
||||
val slot = week.slot(sunday.plus(3, DateTimeUnit.DAY), width, gap)
|
||||
assertThat(slot.left).isEqualTo(3 * 101f)
|
||||
assertThat(slot.width).isEqualTo(94f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the ends of a switch are the two pages`() {
|
||||
var p = 0f
|
||||
val geometry = ColumnGeometry(week, day) { p }
|
||||
assertThat(geometry.slot(monday, width, gap)).isEqualTo(week.slot(monday, width, gap))
|
||||
p = 1f
|
||||
assertThat(geometry.slot(monday, width, gap)).isEqualTo(day.slot(monday, width, gap))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `days off the target page end off screen on their side`() {
|
||||
val geometry = ColumnGeometry(week, day) { 1f }
|
||||
assertThat(geometry.slot(sunday, width, gap).right).isAtMost(0f)
|
||||
assertThat(geometry.slot(monday.plus(1, DateTimeUnit.DAY), width, gap).left).isAtLeast(width)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a switch covers both pages without a break`() {
|
||||
// Focus on Saturday: the six-day run starting there reaches into next week.
|
||||
val saturday = sunday.plus(6, DateTimeUnit.DAY)
|
||||
val geometry = ColumnGeometry(week, DaySpan(saturday, 6)) { 0f }
|
||||
assertThat(geometry.dates).hasSize(12)
|
||||
assertThat(geometry.dates.first()).isEqualTo(sunday)
|
||||
assertThat(geometry.dates.zipWithNext().all { (a, b) -> b == a.plus(1, DateTimeUnit.DAY) }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bar spans its extent on each page`() {
|
||||
val tuesday = monday.plus(1, DateTimeUnit.DAY)
|
||||
val range = monday..tuesday
|
||||
val geometry = ColumnGeometry(week, day) { 0f }
|
||||
val extent = geometry.extent(range, monday..monday, width, 0f)
|
||||
assertThat(extent.left).isEqualTo(week.slot(monday, width, 0f).left)
|
||||
assertThat(extent.right).isEqualTo(week.slot(tuesday, width, 0f).right)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bar only one page has keeps its days on the other`() {
|
||||
val bar = AllDaySpan(event(1), startCol = 2, endCol = 3, lane = 1)
|
||||
val from = state(week, listOf(bar))
|
||||
val to = state(day, emptyList())
|
||||
val placed = morphBars(from, to).single()
|
||||
assertThat(placed.fromRange).isEqualTo(placed.toRange)
|
||||
assertThat(placed.toLane).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bar on both pages is placed once, clipped to each`() {
|
||||
val event = event(1)
|
||||
val from = state(week, listOf(AllDaySpan(event, startCol = 0, endCol = 6, lane = 0)))
|
||||
val to = state(day, listOf(AllDaySpan(event, startCol = 0, endCol = 0, lane = 0)))
|
||||
val placed = morphBars(from, to).single()
|
||||
assertThat(placed.fromRange).isEqualTo(sunday..sunday.plus(6, DateTimeUnit.DAY))
|
||||
assertThat(placed.toRange).isEqualTo(monday..monday)
|
||||
}
|
||||
|
||||
private fun state(span: DaySpan, bars: List<AllDaySpan>): WeekUiState.Success {
|
||||
val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) }
|
||||
return WeekUiState.Success(
|
||||
weekStart = span.start,
|
||||
today = sunday,
|
||||
days = days,
|
||||
allDaySpans = bars,
|
||||
timedByDay = emptyMap(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun event(id: Long) = EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = "Event",
|
||||
start = Instant.fromEpochMilliseconds(0),
|
||||
end = Instant.fromEpochMilliseconds(0),
|
||||
isAllDay = true,
|
||||
color = 0xFF000000.toInt(),
|
||||
location = null,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user