Ease a released long press into the selection (#141)

This commit is contained in:
2026-10-09 13:21:48 +02:00
parent 51ac3e4a4d
commit 8ac4f0316a
3 changed files with 57 additions and 16 deletions
@@ -19,6 +19,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
@@ -107,12 +109,25 @@ fun HourGutter(
}
}
}
dragStartMin?.let { startMin ->
// Held while it fades, so it goes out where it was rather than blinking off.
val badgeMin = remember { mutableStateOf<Int?>(null) }
dragStartMin?.let { badgeMin.value = it }
val badgeIn by animateFloatAsState(
targetValue = if (dragStartMin != null) 1f else 0f,
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
label = "gutterBadge",
)
badgeMin.value?.takeIf { badgeIn > 0f }?.let { startMin ->
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.offset(y = top)
.graphicsLayer {
alpha = badgeIn
scaleX = 0.8f + 0.2f * badgeIn
scaleY = 0.8f + 0.2f * badgeIn
}
.height(BADGE_HEIGHT)
.background(MaterialTheme.colorScheme.primary, CircleShape)
.padding(horizontal = 4.dp)
@@ -1,7 +1,7 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.MutatePriority
@@ -117,7 +117,13 @@ data class TimelineDrag(
* action bar up. [date] is the column the press landed in. [moved] holds from a
* move or resize of it until the grid shows where it landed.
*/
data class TimelineSelection(val event: EventInstance, val date: LocalDate, val moved: Boolean = false)
data class TimelineSelection(
val event: EventInstance,
val date: LocalDate,
val moved: Boolean = false,
/** Selected by letting go of a lifted block: the block takes the lift over as it is. */
val fromLift: Boolean = false,
)
/**
* One day column's share of a dragged event, ready to draw. Both halves of an
@@ -407,8 +413,8 @@ class TimelineDragController {
var selection: TimelineSelection? by mutableStateOf(null)
private set
fun select(event: EventInstance, date: LocalDate, moved: Boolean = false) {
selection = TimelineSelection(event, date, moved)
fun select(event: EventInstance, date: LocalDate, moved: Boolean = false, fromLift: Boolean = false) {
selection = TimelineSelection(event, date, moved, fromLift)
}
fun deselect() {
@@ -1190,15 +1196,21 @@ private fun DragCopy(
}
}
/** How far a selected block is lifted, animated between 0 and 1 (#141). */
/**
* How far a selected block is lifted, between 0 and 1 (#141). [snap] takes the
* lift over at once — from the floating copy it replaces — rather than raising
* the block a second time.
*/
@Composable
fun selectionLift(selected: Boolean): Float {
fun selectionLift(selected: Boolean, snap: Boolean = false): Float {
val reduceMotion = rememberReduceMotion()
return animateFloatAsState(
targetValue = if (selected) 1f else 0f,
animationSpec = if (reduceMotion) snap() else MaterialTheme.motionScheme.fastSpatialSpec(),
label = "selection-lift",
).value
val spec = MaterialTheme.motionScheme.fastSpatialSpec<Float>()
val raise = remember { Animatable(if (selected) 1f else 0f) }
LaunchedEffect(selected) {
val target = if (selected) 1f else 0f
if (reduceMotion || (selected && snap)) raise.snapTo(target) else raise.animateTo(target, spec)
}
return raise.value
}
/**
@@ -95,6 +95,7 @@ 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.animation.core.animateFloatAsState
import androidx.compose.ui.layout.layout
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.pointer.pointerInput
@@ -1583,11 +1584,15 @@ private fun EventBlock(
onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) },
onCancel = dragController::cancel,
onRelease = { dragController.select(block.event, date) },
onRelease = { dragController.select(block.event, date, fromLift = draggable) },
)
val lifted = draggable && dragController.ghosts(block)
val ghost = ghostAlpha(lifted)
val raise = selectionLift(selected)
// Let go of without moving, the block takes the floating copy's place as it
// stands: whole and lifted at once, not faded back up and raised again.
val takesOverLift = selected && dragController.selection?.fromLift == true
val ghostFade = ghostAlpha(lifted)
val ghost = if (takesOverLift && !lifted) 1f else ghostFade
val raise = selectionLift(selected, snap = takesOverLift)
// Where the selected event's bottom handle sits against the action bar, which
// compacts itself rather than cover it. Read off the piece that ends the event.
val revealExtraPx = with(density) { (ResizeHandleTarget / 2).toPx() }
@@ -1661,7 +1666,16 @@ private fun EventBlock(
}
}
val end = { dragController.finishResize(zone)?.let(dragController.onResize) }
val handleAlpha = if (handlesShown) Modifier else Modifier.alpha(0f)
val handleIn by animateFloatAsState(
targetValue = if (handlesShown) 1f else 0f,
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
label = "handles",
)
val handleAlpha = Modifier.graphicsLayer {
alpha = handleIn.coerceIn(0f, 1f)
scaleX = 0.5f + 0.5f * handleIn
scaleY = 0.5f + 0.5f * handleIn
}
if (!cuts.top) {
ResizeHandle(
color = accent,