Let taps reach events under the edge bar (#188)

This commit is contained in:
2026-09-30 12:48:06 +02:00
parent d1664b28de
commit 8d3081a086
4 changed files with 165 additions and 56 deletions
@@ -2,12 +2,11 @@ package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxWidth
@@ -19,12 +18,15 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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.graphics.Color
@@ -32,6 +34,8 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
@@ -39,15 +43,19 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.color.edgeHintTone
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
/** Thickness of the out-of-view edge bar. */
private val EDGE_BAR_THICKNESS = 3.dp
/** Tappable band the bar sits in, taller than the bar itself. */
/** Band the bar sits in, and the room a revealed event keeps from the edge. */
private val EDGE_TOUCH_HEIGHT = 24.dp
/** How far from the viewport's edge a tap on empty column still hits the bar. */
private val EDGE_TAP_REACH = 16.dp
/**
* How far the bar sits in from the viewport's edges — enough to clear the
* timeline's rounded 16dp corners on the outer columns.
@@ -118,65 +126,136 @@ fun revealScrollTarget(
}
/**
* Thin bars at the top and bottom of a day column's visible viewport, tinted in
* the colour of the nearest timed event hidden past that edge (#188). Tapping
* one scrolls the event into view.
*
* Lives inside the column's scrolling content, so taps on it still let the
* timeline scroll and pinch, and follows the viewport by offsetting against
* [scrollState] at placement rather than recomposing every frame.
* The edge whose bar a tap at [y] (content px) lands on, if that bar is showing
* and the tap is within [reachPx] of the viewport's edge.
*/
@Composable
fun BoxScope.OutOfViewEdgeBars(
blocks: List<TimedBlock>,
scrollState: ScrollState,
hourPx: Float,
dark: Boolean,
fun tappedEdge(
events: OutOfViewEvents,
y: Float,
viewportTop: Float,
viewportPx: Int,
reachPx: Float,
): ViewportEdge? = when {
events.above != null && y - viewportTop <= reachPx -> ViewportEdge.Top
events.below != null && viewportTop + viewportPx - y <= reachPx -> ViewportEdge.Bottom
else -> null
}
/**
* The out-of-view state of one day column (#188): which events hide past its
* edges, and the scroll that reveals them.
*
* The bars take no touches themselves, so event blocks under them keep their
* taps and drags; the column's own tap handler offers taps to [tap] first.
*/
@Stable
class OutOfViewEdges internal constructor(
private val scrollState: ScrollState,
private val scope: CoroutineScope,
) {
val density = LocalDensity.current
val slackPx = with(density) { EDGE_SLACK.toPx() }
val touchPx = with(density) { EDGE_TOUCH_HEIGHT.roundToPx() }
val events by remember(blocks, hourPx, slackPx, scrollState) {
derivedStateOf {
val viewport = scrollState.viewportSize
if (viewport <= 0 || hourPx <= 0f) {
OutOfViewEvents.None
} else {
val top = scrollState.value.toFloat()
outOfViewEvents(
blocks = blocks,
topMin = top / hourPx * 60f,
bottomMin = (top + viewport) / hourPx * 60f,
slackMin = slackPx / hourPx * 60f,
)
}
internal var blocks by mutableStateOf(emptyList<TimedBlock>())
internal var hourPx by mutableFloatStateOf(0f)
internal var slackPx by mutableFloatStateOf(0f)
internal var marginPx by mutableFloatStateOf(0f)
internal var reachPx by mutableFloatStateOf(0f)
internal var reduceMotion = false
internal var revealSpec: FiniteAnimationSpec<Float>? = null
val events: OutOfViewEvents by derivedStateOf {
val viewport = scrollState.viewportSize
if (viewport <= 0 || hourPx <= 0f) {
OutOfViewEvents.None
} else {
val top = scrollState.value.toFloat()
outOfViewEvents(
blocks = blocks,
topMin = top / hourPx * 60f,
bottomMin = (top + viewport) / hourPx * 60f,
slackMin = slackPx / hourPx * 60f,
)
}
}
val scope = rememberCoroutineScope()
val reduceMotion = rememberReduceMotion()
val revealSpec = rememberCalendarPageSpec()
val reveal: (TimedBlock, ViewportEdge) -> Unit = { block, edge ->
/** Scrolls the event hidden past [edge] into view, if there is one. */
fun reveal(edge: ViewportEdge) {
val block = if (edge == ViewportEdge.Top) events.above else events.below
block ?: return
val target = revealScrollTarget(
block = block,
edge = edge,
hourPx = hourPx,
viewportPx = scrollState.viewportSize,
marginPx = touchPx.toFloat(),
marginPx = marginPx,
maxScroll = scrollState.maxValue,
)
val spec = revealSpec
scope.launch {
if (reduceMotion) {
if (reduceMotion || spec == null) {
scrollState.scrollTo(target)
} else {
scrollState.animateScrollTo(target, revealSpec)
scrollState.animateScrollTo(target, spec)
}
}
}
/** Reveals the hidden event if a tap at [y] (content px) lands on a bar. */
fun tap(y: Float): Boolean {
val edge = tappedEdge(
events = events,
y = y,
viewportTop = scrollState.value.toFloat(),
viewportPx = scrollState.viewportSize,
reachPx = reachPx,
) ?: return false
reveal(edge)
return true
}
}
@Composable
fun rememberOutOfViewEdges(
blocks: List<TimedBlock>,
scrollState: ScrollState,
hourPx: Float,
): OutOfViewEdges {
val scope = rememberCoroutineScope()
val edges = remember(scrollState, scope) { OutOfViewEdges(scrollState, scope) }
val density = LocalDensity.current
val reduceMotion = rememberReduceMotion()
val revealSpec = rememberCalendarPageSpec()
SideEffect {
edges.blocks = blocks
edges.hourPx = hourPx
edges.slackPx = with(density) { EDGE_SLACK.toPx() }
edges.marginPx = with(density) { EDGE_TOUCH_HEIGHT.roundToPx().toFloat() }
edges.reachPx = with(density) { EDGE_TAP_REACH.toPx() }
edges.reduceMotion = reduceMotion
edges.revealSpec = revealSpec
}
return edges
}
/**
* Thin bars at the top and bottom of a day column's visible viewport, tinted in
* the colour of the nearest timed event hidden past that edge (#188).
*
* Lives inside the column's scrolling content and follows the viewport by
* offsetting against [scrollState] at placement rather than recomposing every
* frame. Taps reach it through [OutOfViewEdges.tap]; screen readers get a button.
*/
@Composable
fun BoxScope.OutOfViewEdgeBars(
edges: OutOfViewEdges,
scrollState: ScrollState,
dark: Boolean,
) {
val touchPx = with(LocalDensity.current) { EDGE_TOUCH_HEIGHT.roundToPx() }
val events = edges.events
EdgeBar(
block = events.above,
edge = ViewportEdge.Top,
dark = dark,
onReveal = reveal,
onReveal = edges::reveal,
modifier = Modifier
.align(Alignment.TopStart)
.offset { IntOffset(0, scrollState.value) },
@@ -185,7 +264,7 @@ fun BoxScope.OutOfViewEdgeBars(
block = events.below,
edge = ViewportEdge.Bottom,
dark = dark,
onReveal = reveal,
onReveal = edges::reveal,
modifier = Modifier
.align(Alignment.TopStart)
.offset { IntOffset(0, scrollState.value + scrollState.viewportSize - touchPx) },
@@ -198,10 +277,10 @@ private fun EdgeBar(
block: TimedBlock?,
edge: ViewportEdge,
dark: Boolean,
onReveal: (TimedBlock, ViewportEdge) -> Unit,
onReveal: (ViewportEdge) -> Unit,
modifier: Modifier = Modifier,
) {
// Held so the bar keeps its colour, and its tap, while it fades out.
// Held so the bar keeps its colour while it fades out.
val held = remember { mutableStateOf<TimedBlock?>(null) }
SideEffect { if (block != null) held.value = block }
val shown = block ?: held.value
@@ -226,14 +305,14 @@ private fun EdgeBar(
modifier = Modifier
.fillMaxWidth()
.height(EDGE_TOUCH_HEIGHT)
.clickable(
interactionSource = remember { MutableInteractionSource() },
// The scroll it sets off is the feedback; a ripple across the
// column's edge would be the noise the bar is meant to avoid.
indication = null,
role = Role.Button,
) { shown?.let { onReveal(it, edge) } }
.semantics { contentDescription = label },
.semantics {
role = Role.Button
contentDescription = label
onClick {
onReveal(edge)
true
}
},
contentAlignment = if (edge == ViewportEdge.Top) Alignment.TopCenter else Alignment.BottomCenter,
) {
Box(
@@ -107,6 +107,7 @@ import de.jeanlucmakiola.calendula.ui.common.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.OutOfViewEdgeBars
import de.jeanlucmakiola.calendula.ui.common.rememberOutOfViewEdges
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
@@ -764,6 +765,8 @@ private fun DayColumnCard(
// them, or taps land on the scale the column had before the zoom (#148).
val currentHourPx = rememberUpdatedState(hourPx)
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
val edges = rememberOutOfViewEdges(blocks, scrollState, hourPx)
val currentEdges = rememberUpdatedState(edges)
Card(
// Plain rectangular column — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -791,6 +794,7 @@ private fun DayColumnCard(
// only fires on the column background. Snaps to the tapped hour.
.pointerInput(date) {
detectTapGestures { offset ->
if (currentEdges.value.tap(offset.y)) return@detectTapGestures
currentOnCreateAt.value(
date,
tappedMinuteOfDay(offset.y, currentHourPx.value),
@@ -853,9 +857,8 @@ private fun DayColumnCard(
NowLine(date = date, hourHeight = hourHeight)
}
OutOfViewEdgeBars(
blocks = blocks,
edges = edges,
scrollState = scrollState,
hourPx = hourPx,
dark = dark,
)
}
@@ -133,6 +133,7 @@ import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.OutOfViewEdgeBars
import de.jeanlucmakiola.calendula.ui.common.rememberOutOfViewEdges
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
@@ -1040,6 +1041,8 @@ private fun DayColumnCard(
// them, or taps land on the scale the column had before the zoom (#148).
val currentHourPx = rememberUpdatedState(hourPx)
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
val edges = rememberOutOfViewEdges(blocks, scrollState, hourPx)
val currentEdges = rememberUpdatedState(edges)
Card(
// Plain rectangular columns — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -1067,6 +1070,7 @@ private fun DayColumnCard(
// blocks are consumed by their own handler first. Snaps to hour.
.pointerInput(date) {
detectTapGestures { offset ->
if (currentEdges.value.tap(offset.y)) return@detectTapGestures
currentOnCreateAt.value(
date,
tappedMinuteOfDay(offset.y, currentHourPx.value),
@@ -1129,9 +1133,8 @@ private fun DayColumnCard(
NowLine(date = date, hourHeight = hourHeight)
}
OutOfViewEdgeBars(
blocks = blocks,
edges = edges,
scrollState = scrollState,
hourPx = hourPx,
dark = dark,
)
}
@@ -110,4 +110,28 @@ class OutOfViewEdgesTest {
revealScrollTarget(last, ViewportEdge.Bottom, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700),
).isEqualTo(1700)
}
// Viewport 1000..1700 px with both bars showing, 40 px of reach.
private fun tapped(y: Float, events: OutOfViewEvents = OutOfViewEvents(breakfast, dinner)) =
tappedEdge(events, y, viewportTop = 1000f, viewportPx = 700, reachPx = 40f)
@Test
fun `a tap near an edge with a bar hits that bar`() {
assertThat(tapped(1010f)).isEqualTo(ViewportEdge.Top)
assertThat(tapped(1040f)).isEqualTo(ViewportEdge.Top)
assertThat(tapped(1690f)).isEqualTo(ViewportEdge.Bottom)
}
@Test
fun `a tap past the bar's reach is left to the column`() {
assertThat(tapped(1041f)).isNull()
assertThat(tapped(1350f)).isNull()
assertThat(tapped(1659f)).isNull()
}
@Test
fun `a tap near an edge without a bar is left to the column`() {
assertThat(tapped(1010f, OutOfViewEvents(null, dinner))).isNull()
assertThat(tapped(1690f, OutOfViewEvents(breakfast, null))).isNull()
}
}