Match edge indicators to event colors and clear before reveal (#188)

This commit is contained in:
2026-09-30 12:48:06 +02:00
parent 8d3081a086
commit 2119a36969
4 changed files with 74 additions and 127 deletions
@@ -122,31 +122,3 @@ const val GREY_CHROMA = 0.02f
/** Chroma band for harmonised colours: distinct, but never electric. */
const val MIN_CHROMA = 0.07f
const val MAX_CHROMA = 0.16f
/**
* Colour for the timeline's out-of-view edge bar (#188): the [eventTone]
* container's hue and chroma, stepped [EDGE_HINT_STEP] away from its lightness
* so the bar still reads apart from a block of the same calendar sitting right
* at the edge. It steps towards the surface's opposite — darker on a light
* scheme, lighter on a dark one — and only turns back when that runs out of
* room.
*/
fun edgeHintTone(rawArgb: Int, dark: Boolean, harmonise: Boolean): Int {
val container = oklchOf(eventTone(rawArgb, dark, harmonise).container)
val towardsContrast = if (dark) EDGE_HINT_STEP else -EDGE_HINT_STEP
val stepped = container.lightness + towardsContrast
val back = container.lightness - towardsContrast
val lightness = when {
stepped in EDGE_HINT_MIN_LIGHTNESS..EDGE_HINT_MAX_LIGHTNESS -> stepped
back in 0f..1f -> back
else -> stepped.coerceIn(0f, 1f)
}
return container.copy(lightness = lightness).toArgb()
}
/** How far the edge bar's lightness sits from its event's container. */
const val EDGE_HINT_STEP = 0.16f
/** Lightness band the edge bar stays inside, clear of pure black and white. */
const val EDGE_HINT_MIN_LIGHTNESS = 0.22f
const val EDGE_HINT_MAX_LIGHTNESS = 0.94f
@@ -1,10 +1,6 @@
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.layout.Box
@@ -14,8 +10,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
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
@@ -29,7 +23,7 @@ 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
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
@@ -40,7 +34,6 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
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
@@ -63,8 +56,8 @@ private val EDGE_TAP_REACH = 16.dp
private val EDGE_BAR_VERTICAL_INSET = 4.dp
private val EDGE_BAR_HORIZONTAL_INSET = 6.dp
/** A block showing no more than this at the edge still counts as out of view. */
private val EDGE_SLACK = 12.dp
/** Distance outside the viewport over which a bar fades before its event appears. */
private val EDGE_FADE_LEAD = 24.dp
/** The nearest timed blocks wholly above and below a column's viewport. */
@Immutable
@@ -83,21 +76,19 @@ internal fun renderedEndMin(block: TimedBlock): Float =
/**
* The blocks of one column lying out of a viewport spanning [topMin]..[bottomMin]
* (minutes of the day), nearest first. A block that shows at most [slackMin] at
* the edge counts as out of view, but only if some of it is actually hidden, so
* a column scrolled to its very top or bottom never points past it.
* (minutes of the day), nearest first. A block leaves this set as soon as any
* part of its rendered height enters the viewport.
*/
fun outOfViewEvents(
blocks: List<TimedBlock>,
topMin: Float,
bottomMin: Float,
slackMin: Float,
): OutOfViewEvents {
val above = blocks
.filter { it.startMin < topMin && renderedEndMin(it) <= topMin + slackMin }
.filter { renderedEndMin(it) <= topMin }
.maxWithOrNull(compareBy<TimedBlock>({ renderedEndMin(it) }, { it.startMin }))
val below = blocks
.filter { renderedEndMin(it) > bottomMin && it.startMin >= bottomMin - slackMin }
.filter { it.startMin >= bottomMin }
.minWithOrNull(compareBy<TimedBlock>({ it.startMin }, { -renderedEndMin(it) }))
return OutOfViewEvents(above, below)
}
@@ -141,6 +132,13 @@ fun tappedEdge(
else -> null
}
/** Fade while the event is still outside; never draw its bar at the boundary. */
internal fun edgeBarAlpha(gapPx: Float, fadeLeadPx: Float): Float = when {
gapPx <= 0f -> 0f
fadeLeadPx <= 0f -> 1f
else -> (gapPx / fadeLeadPx).coerceIn(0f, 1f)
}
/**
* The out-of-view state of one day column (#188): which events hide past its
* edges, and the scroll that reveals them.
@@ -155,7 +153,7 @@ class OutOfViewEdges internal constructor(
) {
internal var blocks by mutableStateOf(emptyList<TimedBlock>())
internal var hourPx by mutableFloatStateOf(0f)
internal var slackPx by mutableFloatStateOf(0f)
internal var fadeLeadPx by mutableFloatStateOf(0f)
internal var marginPx by mutableFloatStateOf(0f)
internal var reachPx by mutableFloatStateOf(0f)
internal var reduceMotion = false
@@ -171,7 +169,6 @@ class OutOfViewEdges internal constructor(
blocks = blocks,
topMin = top / hourPx * 60f,
bottomMin = (top + viewport) / hourPx * 60f,
slackMin = slackPx / hourPx * 60f,
)
}
}
@@ -226,7 +223,7 @@ fun rememberOutOfViewEdges(
SideEffect {
edges.blocks = blocks
edges.hourPx = hourPx
edges.slackPx = with(density) { EDGE_SLACK.toPx() }
edges.fadeLeadPx = with(density) { EDGE_FADE_LEAD.toPx() }
edges.marginPx = with(density) { EDGE_TOUCH_HEIGHT.roundToPx().toFloat() }
edges.reachPx = with(density) { EDGE_TAP_REACH.toPx() }
edges.reduceMotion = reduceMotion
@@ -241,7 +238,9 @@ fun rememberOutOfViewEdges(
*
* 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.
* frame. Opacity follows the gap to the hidden event and reaches zero before
* the event enters view. Taps reach it through [OutOfViewEdges.tap]; screen
* readers get a button.
*/
@Composable
fun BoxScope.OutOfViewEdgeBars(
@@ -256,6 +255,9 @@ fun BoxScope.OutOfViewEdgeBars(
edge = ViewportEdge.Top,
dark = dark,
onReveal = edges::reveal,
scrollState = scrollState,
hourPx = edges.hourPx,
fadeLeadPx = edges.fadeLeadPx,
modifier = Modifier
.align(Alignment.TopStart)
.offset { IntOffset(0, scrollState.value) },
@@ -265,66 +267,62 @@ fun BoxScope.OutOfViewEdgeBars(
edge = ViewportEdge.Bottom,
dark = dark,
onReveal = edges::reveal,
scrollState = scrollState,
hourPx = edges.hourPx,
fadeLeadPx = edges.fadeLeadPx,
modifier = Modifier
.align(Alignment.TopStart)
.offset { IntOffset(0, scrollState.value + scrollState.viewportSize - touchPx) },
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun EdgeBar(
block: TimedBlock?,
edge: ViewportEdge,
dark: Boolean,
onReveal: (ViewportEdge) -> Unit,
scrollState: ScrollState,
hourPx: Float,
fadeLeadPx: Float,
modifier: Modifier = Modifier,
) {
// 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
val soften = LocalSoftenColors.current
val target = shown?.let { Color(edgeHintTone(it.event.color, dark, soften)) } ?: Color.Transparent
val effects = MaterialTheme.motionScheme.defaultEffectsSpec<Float>()
val color by animateColorAsState(
targetValue = target,
animationSpec = MaterialTheme.motionScheme.defaultEffectsSpec(),
label = "edge-bar-color",
)
block ?: return
val color = eventFill(block.event.color, dark, LocalSoftenColors.current)
val label = stringResource(
if (edge == ViewportEdge.Top) R.string.timeline_earlier_events else R.string.timeline_later_events,
)
AnimatedVisibility(
visible = block != null,
enter = fadeIn(effects),
exit = fadeOut(effects),
modifier = modifier,
Box(
modifier = modifier
.fillMaxWidth()
.height(EDGE_TOUCH_HEIGHT)
.graphicsLayer {
val gapPx = when (edge) {
ViewportEdge.Top -> scrollState.value - renderedEndMin(block) / 60f * hourPx
ViewportEdge.Bottom -> block.startMin / 60f * hourPx -
(scrollState.value + scrollState.viewportSize)
}
alpha = edgeBarAlpha(gapPx, fadeLeadPx)
}
.semantics {
role = Role.Button
contentDescription = label
onClick {
onReveal(edge)
true
}
},
contentAlignment = if (edge == ViewportEdge.Top) Alignment.TopCenter else Alignment.BottomCenter,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(EDGE_TOUCH_HEIGHT)
.semantics {
role = Role.Button
contentDescription = label
onClick {
onReveal(edge)
true
}
},
contentAlignment = if (edge == ViewportEdge.Top) Alignment.TopCenter else Alignment.BottomCenter,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = EDGE_BAR_HORIZONTAL_INSET,
vertical = EDGE_BAR_VERTICAL_INSET,
)
.height(EDGE_BAR_THICKNESS)
.background(color, CircleShape),
)
}
.padding(
horizontal = EDGE_BAR_HORIZONTAL_INSET,
vertical = EDGE_BAR_VERTICAL_INSET,
)
.height(EDGE_BAR_THICKNESS)
.background(color, CircleShape),
)
}
}
@@ -157,37 +157,4 @@ class EventToneTest {
.isEqualTo(0xFF000000.toInt())
}
@Test
fun `the edge bar steps clear of its block's container on every hue`() {
for (dark in listOf(false, true)) {
for (harmonise in listOf(false, true)) {
(hues + 0xFFFFF6C0.toInt() + 0xFF000000.toInt() + 0xFFFFFFFF.toInt()).forEach { raw ->
val container = oklchOf(eventTone(raw, dark, harmonise).container)
val bar = oklchOf(edgeHintTone(raw, dark, harmonise))
assertThat(kotlin.math.abs(bar.lightness - container.lightness))
.isAtLeast(EDGE_HINT_STEP - 0.02f)
}
}
}
}
@Test
fun `the edge bar goes darker on a light scheme and lighter on a dark one`() {
hues.forEach { raw ->
val light = eventTone(raw, dark = false, harmonise = true).container
val dark = eventTone(raw, dark = true, harmonise = true).container
assertThat(oklchOf(edgeHintTone(raw, dark = false, harmonise = true)).lightness)
.isLessThan(oklchOf(light).lightness)
assertThat(oklchOf(edgeHintTone(raw, dark = true, harmonise = true)).lightness)
.isGreaterThan(oklchOf(dark).lightness)
}
}
@Test
fun `a pale block on a dark scheme gets a deeper bar rather than a white one`() {
val pale = 0xFFFFF6C0.toInt()
val container = oklchOf(eventTone(pale, dark = true, harmonise = true).container)
assertThat(oklchOf(edgeHintTone(pale, dark = true, harmonise = true)).lightness)
.isLessThan(container.lightness)
}
}
@@ -34,8 +34,8 @@ class OutOfViewEdgesTest {
private val late = block(5, 21 * 60, 22 * 60)
// Viewport 10:00–17:00.
private fun outOf(vararg blocks: TimedBlock, slackMin: Float = 0f) =
outOfViewEvents(blocks.toList(), topMin = 600f, bottomMin = 1020f, slackMin = slackMin)
private fun outOf(vararg blocks: TimedBlock) =
outOfViewEvents(blocks.toList(), topMin = 600f, bottomMin = 1020f)
@Test
fun `the nearest hidden event on each side is picked`() {
@@ -56,19 +56,29 @@ class OutOfViewEdgesTest {
}
@Test
fun `a sliver within the slack still counts as out of view`() {
fun `an event disappears from the edge as soon as a sliver enters view`() {
val sliver = block(6, 9 * 60, 10 * 60 + 5)
assertThat(outOf(sliver).above).isNull()
assertThat(outOf(sliver, slackMin = 10f).above).isEqualTo(sliver)
val tip = block(7, 17 * 60 - 5, 18 * 60)
assertThat(outOf(tip).below).isNull()
assertThat(outOf(tip, slackMin = 10f).below).isEqualTo(tip)
val justAbove = block(8, 9 * 60, 10 * 60)
val justBelow = block(9, 17 * 60, 18 * 60)
assertThat(outOf(justAbove).above).isEqualTo(justAbove)
assertThat(outOf(justBelow).below).isEqualTo(justBelow)
}
@Test
fun `the bar fades before its event reaches the viewport`() {
assertThat(edgeBarAlpha(gapPx = 24f, fadeLeadPx = 24f)).isEqualTo(1f)
assertThat(edgeBarAlpha(gapPx = 12f, fadeLeadPx = 24f)).isEqualTo(0.5f)
assertThat(edgeBarAlpha(gapPx = 0f, fadeLeadPx = 24f)).isEqualTo(0f)
assertThat(edgeBarAlpha(gapPx = -1f, fadeLeadPx = 24f)).isEqualTo(0f)
}
@Test
fun `a column scrolled to its top points at nothing above it`() {
val first = block(6, 0, 30)
val events = outOfViewEvents(listOf(first), topMin = 0f, bottomMin = 420f, slackMin = 30f)
val events = outOfViewEvents(listOf(first), topMin = 0f, bottomMin = 420f)
assertThat(events.above).isNull()
}