Keep the selection bar out of the way of a resize (#141)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user