Land a month chip on its seat instead of waiting it out (#68)
The month grid seats a dropped chip in a lane, but the copy was left at whatever height the finger was at for a flat 450ms hold and then cross-faded across that gap — long enough that the event looked like it took its time arriving. The week row now reports where the grid actually seats the drop once the re-read lands, and the copy glides onto it and hands over there. The flat wait is only the fallback for a drop with no seat to find (a day's "+N" overflow, or a failed write).
This commit is contained in:
@@ -13,6 +13,7 @@ import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlin.math.abs
|
||||
|
||||
/** One week row's live geometry, republished on every layout while it is on screen. */
|
||||
class MonthRowGeometry(
|
||||
@@ -80,6 +81,26 @@ class MonthDragController {
|
||||
var liftedInstanceId: Long? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Where the grid itself now draws the drop, in root coordinates — published
|
||||
* by whichever week row ends up holding it. The copy is dropped wherever the
|
||||
* finger was, but the grid seats it in a lane; without this the copy waits
|
||||
* out a flat hold beside the chip the grid has already drawn and then
|
||||
* cross-fades across the gap, which reads as the event taking its time to
|
||||
* arrive. With it the copy glides onto its seat and hands over there.
|
||||
*/
|
||||
var settledInRoot: Offset? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Whether the drop's own chip is still standing in for the copy. Cleared at
|
||||
* [handOver] rather than at [release], for the same reason [liftedInstanceId]
|
||||
* is: held to the end it would keep the chip dim under a copy that has
|
||||
* already faded, and brighten it afterwards — a dip the eye reads as the
|
||||
* event flickering.
|
||||
*/
|
||||
private var settledGhost: Boolean by mutableStateOf(false)
|
||||
|
||||
/** Whether a finger is on a chip right now — [settling] is not dragging. */
|
||||
var isDragging: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
@@ -128,6 +149,35 @@ class MonthDragController {
|
||||
liftedInstanceId = null
|
||||
drag = null
|
||||
settling = null
|
||||
settledInRoot = null
|
||||
settledGhost = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [days] holds the chip the grid has drawn for the settled drop —
|
||||
* the seat the copy is on its way to, which must ghost until it gets there.
|
||||
* Matched on the landing day plus either the event row or its title: a
|
||||
* single-occurrence move writes an exception row with a new `eventId`, and
|
||||
* the provider hands re-read instances new ids, so neither alone identifies
|
||||
* the chip the drop became.
|
||||
*/
|
||||
fun isSettledChip(event: EventInstance, days: List<LocalDate>?): Boolean {
|
||||
val landed = settling?.takeIf { settledGhost } ?: return false
|
||||
if (days == null || landed.targetDate !in days) return false
|
||||
return event.eventId == landed.event.eventId || event.title == landed.event.title
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a seat. The continuous style can show the same week twice, once in
|
||||
* each adjoining month, so both copies of the landing row offer one: the one
|
||||
* nearest where the chip was dropped is the one the finger was over.
|
||||
*/
|
||||
fun noteSettled(topLeftInRoot: Offset) {
|
||||
val landed = settling ?: return
|
||||
val current = settledInRoot
|
||||
val closer = current == null ||
|
||||
abs(topLeftInRoot.y - landed.topLeftInRoot.y) < abs(current.y - landed.topLeftInRoot.y)
|
||||
if (closer) settledInRoot = topLeftInRoot
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,6 +194,7 @@ class MonthDragController {
|
||||
topLeftInRoot = Offset(left ?: landed.topLeftInRoot.x, landed.topLeftInRoot.y),
|
||||
)
|
||||
liftedInstanceId = landed.event.instanceId
|
||||
settledGhost = true
|
||||
return MonthChipDrop(landed.event, landed.grabDate, target)
|
||||
}
|
||||
|
||||
@@ -154,11 +205,13 @@ class MonthDragController {
|
||||
*/
|
||||
fun handOver() {
|
||||
liftedInstanceId = null
|
||||
settledGhost = false
|
||||
}
|
||||
|
||||
/** Drop the copy, once it has faded into the grid's own chip. */
|
||||
fun release() {
|
||||
settling = null
|
||||
settledInRoot = null
|
||||
handOver()
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
@@ -93,6 +95,7 @@ import de.jeanlucmakiola.calendula.ui.common.moveInFlight
|
||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
|
||||
import de.jeanlucmakiola.calendula.ui.common.SETTLE_FADE_MILLIS
|
||||
import de.jeanlucmakiola.calendula.ui.common.SETTLE_GRACE_MILLIS
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
@@ -466,8 +469,12 @@ fun MonthScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** How long a landed chip holds its day while the write and re-read land. */
|
||||
private const val MONTH_SETTLE_HOLD_MILLIS = 450L
|
||||
/**
|
||||
* How long to wait for a grid that never seats the drop — it landed in a day's
|
||||
* "+N" overflow, or on a day this month doesn't show, or the write failed and
|
||||
* nothing changed. The copy has to go either way.
|
||||
*/
|
||||
private const val MONTH_SETTLE_TIMEOUT_MILLIS = 450L
|
||||
|
||||
/** The chip in flight, drawn over the grid and following the finger. */
|
||||
@Composable
|
||||
@@ -481,13 +488,26 @@ private fun MonthDragOverlay(controller: MonthDragController) {
|
||||
// every frame, and this composable is the one that is meant to.
|
||||
DragSnapHaptics(controller.drag?.targetDate)
|
||||
// Hold the landed chip until the write is done — including the whole time a
|
||||
// recurring drop's scope dialog is up — then a grace for the re-read. A flat
|
||||
// wait, unlike the timeline's: the grid re-lays a whole month of rows, so
|
||||
// there is no single column to watch for the chip arriving.
|
||||
// recurring drop's scope dialog is up — and then, unlike the timeline, carry
|
||||
// it to its seat: a chip is dropped at whatever height the finger was at,
|
||||
// while the grid seats it in a lane, so the copy has a gap to close before
|
||||
// the two are the same chip and the hand-over can be invisible.
|
||||
var handingOver by remember(controller.settling) { mutableStateOf(false) }
|
||||
LaunchedEffect(controller.settling, moveInFlight) {
|
||||
if (controller.settling == null || moveInFlight) return@LaunchedEffect
|
||||
delay(MONTH_SETTLE_HOLD_MILLIS)
|
||||
var gliding by remember(controller.settling) { mutableStateOf(false) }
|
||||
val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
||||
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
|
||||
val seat = controller.settledInRoot
|
||||
LaunchedEffect(controller.settling, moveInFlight, seat) {
|
||||
val landed = controller.settling
|
||||
if (landed == null || moveInFlight) return@LaunchedEffect
|
||||
if (seat == null) {
|
||||
delay(MONTH_SETTLE_TIMEOUT_MILLIS)
|
||||
} else {
|
||||
if (!gliding) glide.snapTo(landed.topLeftInRoot)
|
||||
gliding = true
|
||||
if (reduceMotion) glide.snapTo(seat) else glide.animateTo(seat, glideSpec)
|
||||
delay(SETTLE_GRACE_MILLIS)
|
||||
}
|
||||
handingOver = true
|
||||
controller.handOver()
|
||||
delay(SETTLE_FADE_MILLIS.toLong())
|
||||
@@ -522,9 +542,10 @@ private fun MonthDragOverlay(controller: MonthDragController) {
|
||||
// Absolute: these are root coordinates, and the direction-aware
|
||||
// offset would mirror them across the screen in an RTL layout.
|
||||
.absoluteOffset {
|
||||
val at = if (gliding) glide.value else drag.topLeftInRoot
|
||||
IntOffset(
|
||||
(drag.topLeftInRoot.x - origin.x).roundToInt(),
|
||||
(drag.topLeftInRoot.y - origin.y).roundToInt(),
|
||||
(at.x - origin.x).roundToInt(),
|
||||
(at.y - origin.y).roundToInt(),
|
||||
)
|
||||
}
|
||||
.width(with(density) { drag.sizePx.width.toDp() })
|
||||
@@ -1846,6 +1867,37 @@ private fun MonthWeekRow(
|
||||
DisposableEffect(rowToken, dragController) {
|
||||
onDispose { dragController?.removeRow(rowToken) }
|
||||
}
|
||||
// Where this row seats a drop that landed on one of its days, once the
|
||||
// re-read arrives — the copy in flight glides onto it. Null until the grid
|
||||
// actually holds the moved event: before the re-read the week still shows it
|
||||
// on the day it came from, and a chip that lands in the "+N" overflow gets no
|
||||
// seat at all.
|
||||
val landed = dragController?.settling
|
||||
val seat = if (landed == null) {
|
||||
null
|
||||
} else {
|
||||
remember(week, landed) {
|
||||
val target = landed.targetDate ?: return@remember null
|
||||
val col = week.days.indexOf(target).takeIf { it >= 0 } ?: return@remember null
|
||||
val lane = (0 until MAX_EVENT_ROWS).firstOrNull { lane ->
|
||||
val chip = week.chipAt(col, lane, MAX_EVENT_ROWS)
|
||||
chip != null && dragController.isSettledChip(chip, listOf(target))
|
||||
} ?: return@remember null
|
||||
col to lane
|
||||
}
|
||||
}
|
||||
LaunchedEffect(seat, dragController) {
|
||||
val (col, lane) = seat ?: return@LaunchedEffect
|
||||
val band = bandCoordinates[0]?.takeIf { it.isAttached } ?: return@LaunchedEffect
|
||||
val origin = band.positionInRoot()
|
||||
val column = if (isRtl) week.days.lastIndex - col else col
|
||||
dragController?.noteSettled(
|
||||
Offset(
|
||||
x = origin.x + column * (band.size.width / 7f),
|
||||
y = origin.y + lane * rowHeightPx,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Row(modifier) {
|
||||
// Optional calendar-week gutter, sized so the seven day columns below
|
||||
@@ -1961,6 +2013,7 @@ private fun MonthWeekRow(
|
||||
dark = dark,
|
||||
continuesLeft = span.continuesLeft,
|
||||
continuesRight = span.continuesRight,
|
||||
days = week.days.subList(span.startCol, span.endCol + 1),
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colW * span.startCol,
|
||||
@@ -2030,6 +2083,7 @@ private fun MonthWeekRow(
|
||||
dark = dark,
|
||||
continuesLeft = false,
|
||||
continuesRight = false,
|
||||
days = listOf(d),
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colW * col,
|
||||
@@ -2270,6 +2324,14 @@ private fun MonthBar(
|
||||
continuesLeft: Boolean,
|
||||
continuesRight: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
/**
|
||||
* The days this chip covers in its row, for the drag (#68). A drop's own chip
|
||||
* has to ghost like the source it came from until the copy has landed on it,
|
||||
* and the instance id can't find it: the provider hands the re-read instance a
|
||||
* new one, so the chip the drop became would otherwise sit at full opacity
|
||||
* under the copy still travelling towards it — the event drawn twice.
|
||||
*/
|
||||
days: List<LocalDate>? = null,
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
@@ -2280,7 +2342,8 @@ private fun MonthBar(
|
||||
// The source stays put as a ghost while its floating copy travels, then
|
||||
// fades out as the copy settles on its new day.
|
||||
val monthDrag = LocalMonthDrag.current
|
||||
val lifted = monthDrag?.liftedInstanceId == event.instanceId
|
||||
val lifted = monthDrag?.liftedInstanceId == event.instanceId ||
|
||||
monthDrag?.isSettledChip(event, days) == true
|
||||
val ghost = ghostAlpha(lifted)
|
||||
val shape = RoundedCornerShape(
|
||||
topStart = if (continuesLeft) 0.dp else 4.dp,
|
||||
|
||||
Reference in New Issue
Block a user