Let taps reach events under the edge bar (#188)
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user