Compact the selection bar instead of scrolling the event up (#141)

This commit is contained in:
2026-10-09 12:06:54 +02:00
parent 9377e5d3ba
commit a64fbc9e70
6 changed files with 71 additions and 30 deletions
@@ -588,7 +588,7 @@ fun CalendarHost(
EventMoveHost(
reschedule,
modifier = Modifier.fillMaxSize(),
raised = chromeHost.selectionBarShown,
raised = chromeHost.selectionBarShown && !chromeHost.selectionCompact,
)
// The bar a long-pressed event brings up in place of the FAB (#141).
@@ -82,6 +82,10 @@ class ChromeHost {
var selectionBusy by mutableStateOf(false)
internal set
/** Whether the bar would cover the selected event, and so draws compact. */
var selectionCompact 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
@@ -108,7 +112,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?, busy: Boolean, onDismiss: () -> Unit) {
fun PublishSelection(event: EventInstance?, busy: Boolean, compact: Boolean, onDismiss: () -> Unit) {
val slot = LocalChromeSlot.current ?: return
val currentDismiss by rememberUpdatedState(onDismiss)
val selection = remember(event) { event?.let { EventSelection(it) { currentDismiss() } } }
@@ -118,7 +122,12 @@ fun PublishSelection(event: EventInstance?, busy: Boolean, onDismiss: () -> Unit
if (current) host.selection = selection
onDispose { if (host.selection === selection) host.selection = null }
}
if (current) SideEffect { host.selectionBusy = busy && selection != null }
if (current) {
SideEffect {
host.selectionBusy = busy && selection != null
host.selectionCompact = compact && selection != null
}
}
}
/** The top bar every calendar screen shares. */
@@ -11,6 +11,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreHoriz
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -18,6 +22,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -61,12 +66,16 @@ fun EventSelectionHost(
// Held past the selection clearing, so the bar has something to draw on its way out.
val held = remember { mutableStateOf<EventSelection?>(null) }
host.selection?.let { held.value = it }
// Over the selected event's bottom handle the bar shrinks to one button in
// the FAB's corner; tapping it brings the whole bar back for this selection.
var opened by remember(host.selection?.event, host.selectionCompact) { mutableStateOf(false) }
val compact = host.selectionCompact && !opened
Box(modifier = modifier.fillMaxSize()) {
AnimatedVisibility(
// 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,
visible = host.selectionBarShown && !compact,
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeIn(),
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeOut(),
modifier = Modifier
@@ -106,6 +115,26 @@ fun EventSelectionHost(
)
}
AnimatedVisibility(
visible = host.selectionBarShown && compact,
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeIn(),
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeOut(),
modifier = Modifier
.align(Alignment.BottomEnd)
.navigationBarsPadding()
.padding(end = SnackChipMargin, bottom = SnackChipMargin),
) {
FloatingActionButton(
onClick = { opened = true },
containerColor = MaterialTheme.colorScheme.primaryContainer,
) {
Icon(
imageVector = Icons.Default.MoreHoriz,
contentDescription = stringResource(R.string.event_selection_actions),
)
}
}
val message = when (failure) {
null -> null
EventActionFailure.LoadFailed -> stringResource(R.string.event_action_load_failed)
@@ -129,7 +158,7 @@ fun EventSelectionHost(
.navigationBarsPadding()
.padding(
start = SnackChipMargin,
bottom = SnackChipMargin + if (host.selectionBarShown) SelectionBarClearance else 0.dp,
bottom = SnackChipMargin + if (host.selectionBarShown && !compact) SelectionBarClearance else 0.dp,
)
.height(SnackChipHeight),
contentAlignment = Alignment.CenterStart,
@@ -137,7 +166,8 @@ fun EventSelectionHost(
SnackChip(
visible = failure != null,
message = shownMessage.value,
maxWidth = chipMaxWidth,
// Clear of the compact button in the FAB's corner.
maxWidth = chipMaxWidth - if (compact) SelectionBarClearance else 0.dp,
)
}
}
@@ -5,7 +5,6 @@ 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
@@ -415,8 +414,13 @@ class TimelineDragController {
fun deselect() {
selection = null
selectionCovered = false
}
/** Whether the selected block's bottom sits where the action bar would cover it. */
var selectionCovered: Boolean by mutableStateOf(false)
private set
/**
* Whether [block] is the selected event. Matched on the series and start too,
* since the provider regenerates `Instances` rows and a re-read can bring the
@@ -748,21 +752,17 @@ class TimelineDragController {
}
/**
* 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.
* Note where the selected block's bottom edge, [extraPx] of handle included,
* sits against the action bar [clearancePx] tall over the bottom of the
* viewport. Under it, the bar draws itself compact out of the way (#141).
*/
suspend fun revealAboveBar(block: LayoutCoordinates, extraPx: Float, clearancePx: Float) {
fun noteSelectedBottom(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)
val bottom = block.positionInRoot().y + block.size.height + extraPx
val covered = bottom > port.bottom - clearancePx
if (covered != selectionCovered) selectionCovered = covered
}
/**
@@ -67,7 +67,6 @@ 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
@@ -571,6 +570,7 @@ private fun WeekContent(
PublishSelection(
event = selection?.event,
busy = dragController.isDragging,
compact = dragController.selectionCovered,
onDismiss = dragController::deselect,
)
BackHandler(enabled = selection != null) { dragController.deselect() }
@@ -1592,18 +1592,11 @@ 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) }
// 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() }
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)
}
}
val tracksBar = selected && !cuts.bottom
// 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) {
@@ -1620,7 +1613,15 @@ private fun EventBlock(
// 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)
.onGloballyPositioned { blockCoordinates[0] = it },
.then(
if (tracksBar) {
Modifier.onGloballyPositioned {
dragController.noteSelectedBottom(it, revealExtraPx, barClearancePx)
}
} else {
Modifier
},
),
) {
Box(
modifier = Modifier
+1
View File
@@ -99,6 +99,7 @@
<string name="event_move_done">Moved to %1$s</string>
<string name="event_resize_done">Changed to %1$s</string>
<string name="event_resize_recurring_title">Change recurring event</string>
<string name="event_selection_actions">Event actions</string>
<string name="event_action_load_failed">Couldn\'t open the event</string>
<string name="event_move_undo">Undo</string>
<string name="event_move_undone">Move undone</string>