Keep the selection bar out of the way of a resize (#141)

This commit is contained in:
2026-10-09 11:54:54 +02:00
parent eefd9430e0
commit 9377e5d3ba
7 changed files with 124 additions and 10 deletions
@@ -588,7 +588,7 @@ fun CalendarHost(
EventMoveHost(
reschedule,
modifier = Modifier.fillMaxSize(),
raised = chromeHost.selection != null,
raised = chromeHost.selectionBarShown,
)
// The bar a long-pressed event brings up in place of the FAB (#141).
@@ -78,6 +78,13 @@ class ChromeHost {
var selection by mutableStateOf<EventSelection?>(null)
internal set
/** Whether the selected event is being dragged or resized right now. */
var selectionBusy by mutableStateOf(false)
internal set
/** Whether the selection's action bar is showing — it steps aside mid-gesture. */
val selectionBarShown: Boolean get() = selection != null && !selectionBusy
var actions: ScreenActions? = null
private set
@@ -101,7 +108,7 @@ fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) {
/** Hands the screen's selected [event] to the host, for as long as it stays selected. */
@Composable
fun PublishSelection(event: EventInstance?, onDismiss: () -> Unit) {
fun PublishSelection(event: EventInstance?, busy: Boolean, onDismiss: () -> Unit) {
val slot = LocalChromeSlot.current ?: return
val currentDismiss by rememberUpdatedState(onDismiss)
val selection = remember(event) { event?.let { EventSelection(it) { currentDismiss() } } }
@@ -111,6 +118,7 @@ fun PublishSelection(event: EventInstance?, onDismiss: () -> Unit) {
if (current) host.selection = selection
onDispose { if (host.selection === selection) host.selection = null }
}
if (current) SideEffect { host.selectionBusy = busy && selection != null }
}
/** The top bar every calendar screen shares. */
@@ -64,7 +64,9 @@ fun EventSelectionHost(
Box(modifier = modifier.fillMaxSize()) {
AnimatedVisibility(
visible = host.selection != null,
// Out of the way while the event is dragged or resized, so the
// bottom of the timeline is there to drop on.
visible = host.selectionBarShown,
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeIn(),
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeOut(),
modifier = Modifier
@@ -127,7 +129,7 @@ fun EventSelectionHost(
.navigationBarsPadding()
.padding(
start = SnackChipMargin,
bottom = SnackChipMargin + if (host.selection != null) SelectionBarClearance else 0.dp,
bottom = SnackChipMargin + if (host.selectionBarShown) SelectionBarClearance else 0.dp,
)
.height(SnackChipHeight),
contentAlignment = Alignment.CenterStart,
@@ -4,6 +4,7 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
@@ -67,7 +68,7 @@ fun HourGutter(
val locale = currentLocale()
// Derived: the drag is rewritten every frame, its snapped start once a slot.
val dragStartMin by remember(dragController) {
derivedStateOf { dragController.drag?.startMin }
derivedStateOf { dragController.drag?.markerMin }
}
val hourAlpha by animateFloatAsState(
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
@@ -106,6 +107,7 @@ fun HourGutter(
}
}
}
Spacer(Modifier.height(dragController.bottomRoom()))
}
dragStartMin?.let { startMin ->
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
@@ -5,6 +5,7 @@ import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
@@ -39,6 +40,7 @@ import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
@@ -99,6 +101,11 @@ data class TimelineDrag(
* drop lands, so it is dropped once the gesture ends ([finish]).
*/
val edgeOnly: Boolean = false,
/**
* The minute of its day the gutter badge names — the top edge, or for a
* bottom-edge resize the end being dragged (#141).
*/
val markerMin: Int = startMin,
) {
/**
* What the snap haptics key off: one tick per changed slot, not per frame.
@@ -267,11 +274,29 @@ enum class ResizeEdge { Top, Bottom }
data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val end: Instant)
/**
* The span after dragging [edge] to [edgeMin], all in minutes from one midnight.
* The span after dragging [edge] to [edgeMin] in day [edgeDay], all in minutes
* from the start day's midnight.
* The edge snaps to the grid, and never comes closer than one snap step to the
* other end — an event can't be resized away to nothing, or turned inside out.
*/
internal fun resizedSpan(edge: ResizeEdge, startMin: Int, endMin: Int, edgeMin: Float): Pair<Int, Int> {
internal fun resizedSpan(
edge: ResizeEdge,
startMin: Int,
endMin: Int,
edgeMin: Float,
edgeDay: Int? = null,
): Pair<Int, Int> {
// Dragged into a day past the other end — the start into a day after the
// event ends, the end into one before it starts: no change, rather than an
// event collapsed against its other end.
if (edgeDay != null) {
val endDay = (endMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
val wrongWay = when (edge) {
ResizeEdge.Top -> edgeDay > endDay
ResizeEdge.Bottom -> edgeDay < startMin.floorDiv(MINUTES_PER_DAY)
}
if (wrongWay) return startMin to endMin
}
val snapped = (edgeMin / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
return when (edge) {
ResizeEdge.Top -> minOf(snapped, endMin - DRAG_SNAP_MINUTES) to endMin
@@ -489,6 +514,9 @@ class TimelineDragController {
/** The slot the resized edge sits on, held against a finger trembling between two. */
private var resizeSlot: Int? = null
/** The day, counted from the event's start day, the finger is resizing in. */
private var resizeEdgeDay = 0
fun begin(
block: TimedBlock,
clipOffsetMin: Int,
@@ -670,6 +698,7 @@ class TimelineDragController {
resizeColumn = column
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
val dayOffset = (days[dayIndex].toEpochDays() - day.toEpochDays()).toInt()
resizeEdgeDay = dayOffset
return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f
}
@@ -679,7 +708,7 @@ class TimelineDragController {
val raw = pointerResizeMinutes() ?: return
val slot = stickySnap(raw - resizeGrabMin, resizeSlot)
resizeSlot = slot
val (start, end) = resizedSpan(edge, resizeStartMin, resizeEndMin, slot.toFloat())
val (start, end) = resizedSpan(edge, resizeStartMin, resizeEndMin, slot.toFloat(), resizeEdgeDay)
val days = geometry.days
val hourPx = geometry.hourPx
val columnPx = geometry.columnWidthPx
@@ -710,9 +739,32 @@ class TimelineDragController {
)
},
edgeOnly = slices.isEmpty(),
markerMin = if (edge == ResizeEdge.Top) {
start.mod(MINUTES_PER_DAY)
} else {
end - (end - 1).floorDiv(MINUTES_PER_DAY) * MINUTES_PER_DAY
},
)
}
/**
* Scroll the timeline so a just-selected block, [extraPx] of handle included,
* clears the action bar [clearancePx] tall over the bottom of the viewport —
* without pushing its top out of sight.
*/
suspend fun revealAboveBar(block: LayoutCoordinates, extraPx: Float, clearancePx: Float) {
val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return
val scroll = geometry.scroll ?: return
if (!block.isAttached) return
val port = viewport.boundsInRoot()
val top = block.positionInRoot().y
val bottom = top + block.size.height + extraPx
val needed = bottom - (port.bottom - clearancePx)
val spare = top - port.top
val by = minOf(needed, spare)
if (by > 0f) scroll.animateScrollBy(by)
}
/**
* Stop ghosting the source — the grid draws the drop itself by now — while
* the copy is still dissolving. Un-ghosting at [release] instead would leave
@@ -1139,6 +1191,13 @@ private fun DragCopy(
}
}
/**
* Extra scroll room under the hours while an event is selected, so the last of
* the day can scroll clear of the action bar (#141). The columns and the gutter
* share one scroll state, so both have to add exactly this.
*/
fun TimelineDragController.bottomRoom(): Dp = if (selection != null) SelectionBarClearance else 0.dp
/** How far a selected block is lifted, animated between 0 and 1 (#141). */
@Composable
fun selectionLift(selected: Boolean): Float {
@@ -65,6 +65,9 @@ import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.zIndex
import androidx.compose.ui.geometry.Offset
import de.jeanlucmakiola.calendula.ui.common.PickUp
import de.jeanlucmakiola.calendula.ui.common.SelectionBarClearance
import de.jeanlucmakiola.calendula.ui.common.bottomRoom
import androidx.compose.ui.layout.LayoutCoordinates
import de.jeanlucmakiola.calendula.ui.common.ResizeEdge
import de.jeanlucmakiola.calendula.ui.common.ResizeHandle
import de.jeanlucmakiola.calendula.ui.common.ResizeHandleTarget
@@ -565,7 +568,11 @@ private fun WeekContent(
// A long-pressed event stays selected (#141) until something else is touched:
// back, another page, a view switch, or any tap.
val selection = dragController.selection
PublishSelection(selection?.event, onDismiss = dragController::deselect)
PublishSelection(
event = selection?.event,
busy = dragController.isDragging,
onDismiss = dragController::deselect,
)
BackHandler(enabled = selection != null) { dragController.deselect() }
LaunchedEffect(pagerState, dragController) {
snapshotFlow { pagerState.currentPage }.drop(1).collect { dragController.deselect() }
@@ -1272,6 +1279,9 @@ private fun Timeline(
.verticalScroll(scrollState)
.onGloballyPositioned { if (active) dragController.geometry.viewport = it },
) {
// Room past midnight to scroll the day's end clear of the action
// bar, matched by the gutter on the same scroll state (#141).
Spacer(Modifier.height(totalHeight + dragController.bottomRoom()))
TimelineColumns(
geometry = geometry,
gap = COLUMN_GAP,
@@ -1582,6 +1592,18 @@ private fun EventBlock(
val lifted = draggable && dragController.ghosts(block)
val ghost = ghostAlpha(lifted)
val raise = selectionLift(selected)
// Scrolled clear of the action bar as it is selected, handle and all.
val blockCoordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
val revealExtraPx = with(density) { (ResizeHandleTarget / 2).toPx() }
val barClearancePx = with(density) { SelectionBarClearance.toPx() }
LaunchedEffect(selected) {
if (!selected) return@LaunchedEffect
// A frame for the room under the hours to be laid out first.
withFrameNanos { }
blockCoordinates[0]?.let {
dragController.revealAboveBar(it, revealExtraPx, barClearancePx)
}
}
// Not while the block is waiting on its own write: until then it still
// shows the times from before it.
LaunchedEffect(selected, block.event, lifted) {
@@ -1597,7 +1619,8 @@ private fun EventBlock(
.zIndex(if (selected) 1f else 0f)
// The source stays put as a ghost while its floating copy travels.
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.then(if (raise > 0f) Modifier.selectionLift(raise, shape) else Modifier),
.then(if (raise > 0f) Modifier.selectionLift(raise, shape) else Modifier)
.onGloballyPositioned { blockCoordinates[0] = it },
) {
Box(
modifier = Modifier
@@ -82,4 +82,24 @@ class ResizedSpanTest {
assertThat(stickySnap(9 * 60 + 12f, held = 9 * 60)).isEqualTo(9 * 60 + 15)
assertThat(stickySnap(9 * 60 + 8f, held = null)).isEqualTo(9 * 60 + 15)
}
@Test
fun `an edge dragged into a day past the other end changes nothing`() {
// The start into the day after a same-day event ends.
assertThat(resizedSpan(ResizeEdge.Top, start, end, 24 * 60 + 9 * 60f, edgeDay = 1))
.isEqualTo(start to end)
// The end into the day before it starts.
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, -60f, edgeDay = -1))
.isEqualTo(start to end)
}
@Test
fun `an edge in the event's own days still clamps against the other end`() {
assertThat(resizedSpan(ResizeEdge.Top, start, end, 12 * 60f, edgeDay = 0))
.isEqualTo(end - DRAG_SNAP_MINUTES to end)
// An overnight event's start may move anywhere up to its end's day.
val overnightEnd = 24 * 60 + 2 * 60
assertThat(resizedSpan(ResizeEdge.Top, start, overnightEnd, 24 * 60 + 60f, edgeDay = 1))
.isEqualTo(24 * 60 + 60 to overnightEnd)
}
}