Stop the moved event flickering once it lands (#68)

Two causes, both leaving the block at the old slot or dimmed for a frame or
several. The source is now identified for the write's duration by event row
plus the slot it left, not by instance id — the provider renumbers Instances
rows, so a re-read still carrying the old time arrives under a new id and
the ghost stopped matching. And the ghost is dropped when the copy *starts*
dissolving rather than after: un-ghosting last left the block dim under the
fading copy and brightened it once the copy was gone.
This commit is contained in:
2026-08-02 18:39:38 +02:00
parent 1c9a4f7f50
commit 4412038caa
5 changed files with 65 additions and 6 deletions

View File

@@ -140,6 +140,16 @@ class TimelineDragController {
var liftedInstanceId: Long? by mutableStateOf(null) var liftedInstanceId: Long? by mutableStateOf(null)
private set private set
/**
* Where the settled drop came from, and which event row it belongs to. The
* instance id alone can't identify the source block for the length of the
* write: the provider regenerates `Instances` rows, so a re-read that still
* carries the *old* time can arrive under a new instance id — and a source
* matched by instance id would stop being the ghost and flash back to full
* opacity in the slot the event is about to leave.
*/
private var settledOrigin: Triple<Long, LocalDate, Int>? by mutableStateOf(null)
/** Whether a finger is on a block right now — [settling] is not dragging. */ /** Whether a finger is on a block right now — [settling] is not dragging. */
var isDragging: Boolean by mutableStateOf(false) var isDragging: Boolean by mutableStateOf(false)
private set private set
@@ -190,6 +200,22 @@ class TimelineDragController {
drag = null drag = null
settling = null settling = null
settledOnGrid = false settledOnGrid = false
settledOrigin = null
}
/**
* Whether [block] is the one whose copy is in flight, and so must stay a
* ghost. While the finger holds it that is the instance it picked up; once
* dropped it is also whatever now sits in the slot it left, however the
* provider has renumbered it in the meantime.
*/
fun ghosts(block: TimedBlock, date: LocalDate): Boolean {
if (liftedInstanceId == null) return false
if (block.event.instanceId == liftedInstanceId) return true
val (eventId, originDate, originMin) = settledOrigin ?: return false
return block.event.eventId == eventId &&
date == originDate &&
block.startMin == originMin
} }
/** /**
@@ -222,14 +248,26 @@ class TimelineDragController {
if (landed == null || landed.slot == origin) return null if (landed == null || landed.slot == origin) return null
settling = landed settling = landed
liftedInstanceId = landed.event.instanceId liftedInstanceId = landed.event.instanceId
settledOrigin = origin?.let { (date, min) -> Triple(landed.event.eventId, date, min) }
return TimelineDrop(landed.event, landed.date, landed.startMin) return TimelineDrop(landed.event, landed.date, landed.startMin)
} }
/** Hand a settled drop back to the grid, which by now draws it itself. */ /**
* Stop ghosting the source — the grid draws the drop itself by now — while
* the copy is still on screen dissolving. Un-ghosting only at [release]
* would leave the block under the fading copy dim, and brighten it once the
* copy was gone: a dip the eye reads as the event flickering.
*/
fun handOver() {
liftedInstanceId = null
settledOrigin = null
}
/** Drop the copy, once it has faded into the grid's own block. */
fun release() { fun release() {
settling = null settling = null
liftedInstanceId = null
settledOnGrid = false settledOnGrid = false
handOver()
} }
/** /**
@@ -379,6 +417,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
if (controller.settling == null || moveInFlight) return@LaunchedEffect if (controller.settling == null || moveInFlight) return@LaunchedEffect
delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS) delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
handingOver = true handingOver = true
controller.handOver()
delay(SETTLE_FADE_MILLIS.toLong()) delay(SETTLE_FADE_MILLIS.toLong())
controller.release() controller.release()
} }

View File

@@ -733,7 +733,7 @@ private fun EventBlock(
onDrop = { dragController.finish()?.let(onDrop) }, onDrop = { dragController.finish()?.let(onDrop) },
onCancel = dragController::cancel, onCancel = dragController::cancel,
) )
val lifted = draggable && dragController.liftedInstanceId == block.event.instanceId val lifted = draggable && dragController.ghosts(block, date)
val ghost = ghostAlpha(lifted) val ghost = ghostAlpha(lifted)
Box( Box(
modifier = modifier modifier = modifier

View File

@@ -147,10 +147,19 @@ class MonthDragController {
return MonthChipDrop(landed.event, landed.grabDate, target) return MonthChipDrop(landed.event, landed.grabDate, target)
} }
/** Hand a settled drop back to the grid, which by now draws it itself. */ /**
* Stop ghosting the source while the copy is still dissolving — un-ghosting
* only at [release] leaves the chip under it dim and then brightens it, a
* dip the eye reads as the event flickering.
*/
fun handOver() {
liftedInstanceId = null
}
/** Drop the copy, once it has faded into the grid's own chip. */
fun release() { fun release() {
settling = null settling = null
liftedInstanceId = null handOver()
} }
/** Root x of [date]'s column, in whichever visible row shows that day. */ /** Root x of [date]'s column, in whichever visible row shows that day. */

View File

@@ -92,6 +92,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.moveInFlight import de.jeanlucmakiola.calendula.ui.common.moveInFlight
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
import de.jeanlucmakiola.calendula.ui.common.SETTLE_FADE_MILLIS
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
import de.jeanlucmakiola.calendula.domain.spanFirstDay import de.jeanlucmakiola.calendula.domain.spanFirstDay
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
@@ -483,9 +484,13 @@ private fun MonthDragOverlay(controller: MonthDragController) {
// recurring drop's scope dialog is up — then a grace for the re-read. A flat // 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 // 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. // there is no single column to watch for the chip arriving.
var handingOver by remember(controller.settling) { mutableStateOf(false) }
LaunchedEffect(controller.settling, moveInFlight) { LaunchedEffect(controller.settling, moveInFlight) {
if (controller.settling == null || moveInFlight) return@LaunchedEffect if (controller.settling == null || moveInFlight) return@LaunchedEffect
delay(MONTH_SETTLE_HOLD_MILLIS) delay(MONTH_SETTLE_HOLD_MILLIS)
handingOver = true
controller.handOver()
delay(SETTLE_FADE_MILLIS.toLong())
controller.release() controller.release()
} }
Box( Box(
@@ -503,6 +508,11 @@ private fun MonthDragOverlay(controller: MonthDragController) {
targetValue = if (controller.drag == null || reduceMotion) 0f else 1f, targetValue = if (controller.drag == null || reduceMotion) 0f else 1f,
label = "chip-lift", label = "chip-lift",
) )
val copyAlpha by animateFloatAsState(
targetValue = if (handingOver) 0f else 1f,
animationSpec = tween(SETTLE_FADE_MILLIS),
label = "chip-handover",
)
MonthBar( MonthBar(
event = drag.event, event = drag.event,
dark = dark, dark = dark,
@@ -524,6 +534,7 @@ private fun MonthDragOverlay(controller: MonthDragController) {
scaleX = 1f + 0.04f * lift scaleX = 1f + 0.04f * lift
scaleY = 1f + 0.04f * lift scaleY = 1f + 0.04f * lift
shadowElevation = 8.dp.toPx() * lift shadowElevation = 8.dp.toPx() * lift
alpha = copyAlpha
shape = RoundedCornerShape(4.dp) shape = RoundedCornerShape(4.dp)
clip = false clip = false
}, },

View File

@@ -902,7 +902,7 @@ private fun EventBlock(
onDrop = { dragController.finish()?.let(onDrop) }, onDrop = { dragController.finish()?.let(onDrop) },
onCancel = dragController::cancel, onCancel = dragController::cancel,
) )
val lifted = draggable && dragController.liftedInstanceId == block.event.instanceId val lifted = draggable && dragController.ghosts(block, date)
val ghost = ghostAlpha(lifted) val ghost = ghostAlpha(lifted)
Box( Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)