Move the chip into floret-kit, and tween the blocks (#68)

The confirmation pill is now floret-kit's SnackChip, shared with Agendula's
swipe-delete undo, wearing the FAB's colours and crossfading when its
message is answered.

Timed blocks are keyed by event and their bounds tweened, so a landing drop,
the neighbour that has to make room for it, and an undo all slide instead of
popping. The source ghost travels with them rather than fading out, and the
floating copy dissolves into the block once the grid has caught up.
This commit is contained in:
2026-08-02 18:12:48 +02:00
parent 5eb7d76f8a
commit 7a86014461
8 changed files with 163 additions and 141 deletions

View File

@@ -0,0 +1,41 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.snap
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
/** Where a timed block sits in its column, after tweening. */
@Immutable
data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
/**
* A timed block's placement, tweened rather than jumped. Every bound a block
* has changes for a reason the user just caused — a drop landing at a new time,
* an undo putting it back, a neighbour arriving and halving both lanes — and all
* of them read better as motion than as a new layout appearing.
*
* Continuity comes from the caller keying each block by identity; a block
* composed for the first time starts *at* its target, so nothing flies in from
* the corner on the first frame.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement {
val spec: FiniteAnimationSpec<Dp> = if (rememberReduceMotion()) {
snap()
} else {
MaterialTheme.motionScheme.fastSpatialSpec()
}
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)
}

View File

@@ -1,25 +1,13 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.contentColorFor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -29,14 +17,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.components.SnackChip
import de.jeanlucmakiola.floret.components.SnackChipHeight
import de.jeanlucmakiola.floret.components.SnackChipMargin
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.locale.localizedDateFormatter import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -51,8 +37,6 @@ private const val CHIP_MILLIS = 4_000L
/** The FAB's own band at the bottom end, which the chip must not run into. */ /** The FAB's own band at the bottom end, which the chip must not run into. */
private val FAB_BAND = 88.dp private val FAB_BAND = 88.dp
private val CHIP_HEIGHT = 56.dp
/** /**
* The two surfaces a drag-and-drop reschedule needs on top of the calendar: the * The two surfaces a drag-and-drop reschedule needs on top of the calendar: the
* recurring-scope prompt, and the confirmation chip carrying Undo. * recurring-scope prompt, and the confirmation chip carrying Undo.
@@ -113,78 +97,26 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
modifier = Modifier modifier = Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.navigationBarsPadding() .navigationBarsPadding()
.padding(start = 16.dp, bottom = 16.dp) .padding(start = SnackChipMargin, bottom = SnackChipMargin)
.height(CHIP_HEIGHT), .height(SnackChipHeight),
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
) { ) {
MoveChip( SnackChip(
visible = outcome != null, visible = outcome != null,
message = shownMessage, message = shownMessage,
maxWidth = chipMaxWidth, maxWidth = chipMaxWidth,
onUndo = shownUndo?.let { undo -> { viewModel.undo(undo) } }, actionLabel = stringResource(R.string.event_move_undo)
.takeIf { shownUndo != null },
onAction = shownUndo?.let { undo -> { viewModel.undo(undo) } },
// The FAB's own colours: the chip shares its band, so sharing its
// fill reads as one bottom layer rather than two competing ones.
color = FloatingActionButtonDefaults.containerColor,
contentColor = contentColorFor(FloatingActionButtonDefaults.containerColor),
) )
} }
} }
} }
/**
* The confirmation pill: sized to its content, sliding up from the bottom, with
* Undo where the write has a clean inverse.
*/
@Composable
private fun MoveChip(
visible: Boolean,
message: String,
maxWidth: Dp,
onUndo: (() -> Unit)?,
) {
AnimatedVisibility(
visible = visible,
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut(),
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = MaterialTheme.colorScheme.onSurface,
shape = RoundedCornerShape(50),
shadowElevation = 6.dp,
modifier = Modifier.widthIn(max = maxWidth),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(
start = 20.dp,
end = if (onUndo != null) 8.dp else 20.dp,
top = 6.dp,
bottom = 6.dp,
),
) {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f, fill = false)
.semantics { liveRegion = LiveRegionMode.Polite },
)
if (onUndo != null) {
TextButton(
onClick = onUndo,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
) {
Text(
text = stringResource(R.string.event_move_undo),
style = MaterialTheme.typography.labelLarge,
)
}
}
}
}
}
}
/** /**
* "Fri, 7 Aug, 09:00" — the day, plus the time for a timed event. All-day events * "Fri, 7 Aug, 09:00" — the day, plus the time for a timed event. All-day events
* are read back on the UTC calendar day they are anchored to (#65, #82). * are read back on the UTC calendar day they are anchored to (#65, #82).

View File

@@ -62,17 +62,15 @@ fun moveInFlight(): Boolean {
const val GHOST_ALPHA: Float = 0.3f const val GHOST_ALPHA: Float = 0.3f
/** /**
* Opacity for a block whose copy is in flight: ghosted while the finger holds * Opacity for a block whose copy is in flight: ghosted from the lift until the
* it, then faded out once the copy has landed, so the slot it came from is * copy is handed back, then animated up rather than switched. The ghost keeps
* already empty by the time the grid re-reads the move. * its place through the write and *travels* to the new slot when the grid
* re-reads it, arriving under the copy as that fades — so what the eye follows
* is one block moving, not one vanishing and another appearing.
*/ */
@Composable @Composable
fun ghostAlpha(lifted: Boolean, landed: Boolean): Float = animateFloatAsState( fun ghostAlpha(lifted: Boolean): Float = animateFloatAsState(
targetValue = when { targetValue = if (lifted) GHOST_ALPHA else 1f,
!lifted -> 1f
landed -> 0f
else -> GHOST_ALPHA
},
label = "ghost-alpha", label = "ghost-alpha",
).value ).value

View File

@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ScrollState import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.MutatePriority import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -300,11 +301,19 @@ fun TimelineDrop.startInstant(zone: TimeZone): Instant =
LocalDateTime(date, LocalTime(startMin / 60, startMin % 60)).toInstant(zone) LocalDateTime(date, LocalTime(startMin / 60, startMin % 60)).toInstant(zone)
/** /**
* How long a landed block keeps its position after the write reports back. The * How long a landed block keeps its position after the write reports back — the
* provider's change notification and the re-query land a beat later; releasing * provider's change notification and the re-query land a beat later, and
* on the write alone would still flash the old slot. * releasing on the write alone would still flash the old slot.
*/ */
const val SETTLE_GRACE_MILLIS: Long = 450L const val SETTLE_GRACE_MILLIS: Long = 200L
/**
* The hand-over: the copy dissolves over this while the grid's own block slides
* in under it, so the two overlap rather than one replacing the other. Kept
* short, because a full-width copy sits over any neighbour it now shares a lane
* with until it is gone.
*/
const val SETTLE_FADE_MILLIS: Int = 250
/** /**
* The floating block, drawn over the whole calendar so it is free of the day * The floating block, drawn over the whole calendar so it is free of the day
@@ -328,10 +337,14 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
if (controller.isDragging) controller.autoScroll() if (controller.isDragging) controller.autoScroll()
} }
// Hold the landed copy until the write is done — including the whole time a // Hold the landed copy until the write is done — including the whole time a
// recurring drop's scope dialog is up — then a grace for the re-read. // recurring drop's scope dialog is up — then a grace for the re-read, then
// fade it into the grid's own block, which has slid in underneath by now.
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(SETTLE_GRACE_MILLIS) delay(SETTLE_GRACE_MILLIS)
handingOver = true
delay(SETTLE_FADE_MILLIS.toLong())
controller.release() controller.release()
} }
@@ -351,6 +364,11 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
targetValue = if (landed || reduceMotion) 0f else 1f, targetValue = if (landed || reduceMotion) 0f else 1f,
label = "drag-lift", label = "drag-lift",
) )
val copyAlpha by animateFloatAsState(
targetValue = if (handingOver) 0f else 1f,
animationSpec = tween(SETTLE_FADE_MILLIS),
label = "drag-handover",
)
val fill = eventFill(drag.event.color, dark, soften) val fill = eventFill(drag.event.color, dark, soften)
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) } val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}" + val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}" +
@@ -374,6 +392,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
scaleX = 1f + 0.02f * lift scaleX = 1f + 0.02f * lift
scaleY = 1f + 0.02f * lift scaleY = 1f + 0.02f * 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

@@ -42,6 +42,7 @@ import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -79,6 +80,7 @@ import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.MoveRequest import de.jeanlucmakiola.calendula.ui.common.MoveRequest
@@ -640,25 +642,39 @@ private fun DayColumnCard(
) { ) {
val colWidth = maxWidth val colWidth = maxWidth
val minEventHeight = hourHeight * MIN_EVENT_FRACTION 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
// the rare column holding two occurrences of one series, which would
// otherwise be two blocks under one key.
val ordinals = mutableMapOf<Long, Int>()
blocks.forEach { block -> blocks.forEach { block ->
val laneWidth = colWidth / block.laneCount val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
val top = hourHeight * (block.startMin / 60f) key(block.event.eventId, ordinal) {
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f) val laneWidth = colWidth / block.laneCount
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight val top = hourHeight * (block.startMin / 60f)
EventBlock( val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
block = block, val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
dark = dark, val place = animatedBlockPlacement(
height = height, x = laneWidth * block.lane,
date = date, y = top,
dragController = dragController, width = laneWidth,
onClick = { onEventClick(block.event) }, height = height,
onDrop = onDrop, )
modifier = Modifier EventBlock(
.offset(x = laneWidth * block.lane, y = top) block = block,
.width(laneWidth) dark = dark,
.height(height) height = place.height,
.padding(horizontal = 1.dp), date = date,
) dragController = dragController,
onClick = { onEventClick(block.event) },
onDrop = onDrop,
modifier = Modifier
.offset(x = place.x, y = place.y)
.width(place.width)
.height(place.height)
.padding(horizontal = 1.dp),
)
}
} }
// Current-time line, on top of the events, only on today's column. // Current-time line, on top of the events, only on today's column.
if (date == today) { if (date == today) {
@@ -714,12 +730,12 @@ private fun EventBlock(
onCancel = dragController::cancel, onCancel = dragController::cancel,
) )
val lifted = draggable && dragController.liftedInstanceId == block.event.instanceId val lifted = draggable && dragController.liftedInstanceId == block.event.instanceId
val ghost = ghostAlpha(lifted, landed = dragController.settling != null) val ghost = ghostAlpha(lifted)
Box( Box(
modifier = modifier modifier = modifier
// The source stays put as a ghost while its floating copy travels, // The source stays put as a ghost while its floating copy travels,
// then fades out as the copy settles on its new slot. // then fades out as the copy settles on its new slot.
.then(if (lifted) Modifier.alpha(ghost) else Modifier) .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.background(fill, RoundedCornerShape(4.dp)) .background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass; // After clickable, so it is the inner node and wins the main pass;

View File

@@ -2266,7 +2266,7 @@ private fun MonthBar(
// fades out as the copy settles on its new day. // fades out as the copy settles on its new day.
val monthDrag = LocalMonthDrag.current val monthDrag = LocalMonthDrag.current
val lifted = monthDrag?.liftedInstanceId == event.instanceId val lifted = monthDrag?.liftedInstanceId == event.instanceId
val ghost = ghostAlpha(lifted, landed = monthDrag?.settling != null) val ghost = ghostAlpha(lifted)
val shape = RoundedCornerShape( val shape = RoundedCornerShape(
topStart = if (continuesLeft) 0.dp else 4.dp, topStart = if (continuesLeft) 0.dp else 4.dp,
bottomStart = if (continuesLeft) 0.dp else 4.dp, bottomStart = if (continuesLeft) 0.dp else 4.dp,
@@ -2275,7 +2275,7 @@ private fun MonthBar(
) )
Box( Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.then(if (lifted) Modifier.alpha(ghost) else Modifier) .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.background(fill, shape) .background(fill, shape)
.padding(horizontal = 4.dp) .padding(horizontal = 4.dp)
.semantics { .semantics {

View File

@@ -47,6 +47,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.key
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -88,6 +89,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.MoveRequest import de.jeanlucmakiola.calendula.ui.common.MoveRequest
@@ -788,26 +790,40 @@ private fun DayColumnCard(
) { ) {
val colWidth = maxWidth val colWidth = maxWidth
val minEventHeight = hourHeight * MIN_EVENT_FRACTION 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
// the rare column holding two occurrences of one series, which would
// otherwise be two blocks under one key.
val ordinals = mutableMapOf<Long, Int>()
blocks.forEach { block -> blocks.forEach { block ->
val laneWidth = colWidth / block.laneCount val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
val top = hourHeight * (block.startMin / 60f) key(block.event.eventId, ordinal) {
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f) val laneWidth = colWidth / block.laneCount
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight val top = hourHeight * (block.startMin / 60f)
EventBlock( val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
block = block, val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
dark = dark, val place = animatedBlockPlacement(
height = height, x = laneWidth * block.lane,
width = laneWidth, y = top,
date = date, width = laneWidth,
dragController = dragController, height = height,
onClick = { onEventClick(block.event) }, )
onDrop = onDrop, EventBlock(
modifier = Modifier block = block,
.offset(x = laneWidth * block.lane, y = top) dark = dark,
.width(laneWidth) height = place.height,
.height(height) width = place.width,
.padding(horizontal = 1.dp), date = date,
) dragController = dragController,
onClick = { onEventClick(block.event) },
onDrop = onDrop,
modifier = Modifier
.offset(x = place.x, y = place.y)
.width(place.width)
.height(place.height)
.padding(horizontal = 1.dp),
)
}
} }
// Current-time line, on top of the events, only on today's column. // Current-time line, on top of the events, only on today's column.
if (date == today) { if (date == today) {
@@ -883,12 +899,12 @@ private fun EventBlock(
onCancel = dragController::cancel, onCancel = dragController::cancel,
) )
val lifted = draggable && dragController.liftedInstanceId == block.event.instanceId val lifted = draggable && dragController.liftedInstanceId == block.event.instanceId
val ghost = ghostAlpha(lifted, landed = dragController.settling != null) val ghost = ghostAlpha(lifted)
Box( Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
// The source stays put as a ghost while its floating copy travels, // The source stays put as a ghost while its floating copy travels,
// then fades out as the copy settles on its new slot. // then fades out as the copy settles on its new slot.
.then(if (lifted) Modifier.alpha(ghost) else Modifier) .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.background(fill, RoundedCornerShape(4.dp)) .background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass; // After clickable, so it is the inner node and wins the main pass;