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. */ /** Chroma band for harmonised colours: distinct, but never electric. */
const val MIN_CHROMA = 0.07f const val MIN_CHROMA = 0.07f
const val MAX_CHROMA = 0.16f 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 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.core.FiniteAnimationSpec
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ScrollState import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box 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.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape 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.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
@@ -29,7 +23,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role 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.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.color.edgeHintTone
import de.jeanlucmakiola.calendula.ui.week.TimedBlock import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import kotlinx.coroutines.CoroutineScope 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_VERTICAL_INSET = 4.dp
private val EDGE_BAR_HORIZONTAL_INSET = 6.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. */ /** Distance outside the viewport over which a bar fades before its event appears. */
private val EDGE_SLACK = 12.dp private val EDGE_FADE_LEAD = 24.dp
/** The nearest timed blocks wholly above and below a column's viewport. */ /** The nearest timed blocks wholly above and below a column's viewport. */
@Immutable @Immutable
@@ -83,21 +76,19 @@ internal fun renderedEndMin(block: TimedBlock): Float =
/** /**
* The blocks of one column lying out of a viewport spanning [topMin]..[bottomMin] * 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 * (minutes of the day), nearest first. A block leaves this set as soon as any
* the edge counts as out of view, but only if some of it is actually hidden, so * part of its rendered height enters the viewport.
* a column scrolled to its very top or bottom never points past it.
*/ */
fun outOfViewEvents( fun outOfViewEvents(
blocks: List<TimedBlock>, blocks: List<TimedBlock>,
topMin: Float, topMin: Float,
bottomMin: Float, bottomMin: Float,
slackMin: Float,
): OutOfViewEvents { ): OutOfViewEvents {
val above = blocks val above = blocks
.filter { it.startMin < topMin && renderedEndMin(it) <= topMin + slackMin } .filter { renderedEndMin(it) <= topMin }
.maxWithOrNull(compareBy<TimedBlock>({ renderedEndMin(it) }, { it.startMin })) .maxWithOrNull(compareBy<TimedBlock>({ renderedEndMin(it) }, { it.startMin }))
val below = blocks val below = blocks
.filter { renderedEndMin(it) > bottomMin && it.startMin >= bottomMin - slackMin } .filter { it.startMin >= bottomMin }
.minWithOrNull(compareBy<TimedBlock>({ it.startMin }, { -renderedEndMin(it) })) .minWithOrNull(compareBy<TimedBlock>({ it.startMin }, { -renderedEndMin(it) }))
return OutOfViewEvents(above, below) return OutOfViewEvents(above, below)
} }
@@ -141,6 +132,13 @@ fun tappedEdge(
else -> null 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 * The out-of-view state of one day column (#188): which events hide past its
* edges, and the scroll that reveals them. * edges, and the scroll that reveals them.
@@ -155,7 +153,7 @@ class OutOfViewEdges internal constructor(
) { ) {
internal var blocks by mutableStateOf(emptyList<TimedBlock>()) internal var blocks by mutableStateOf(emptyList<TimedBlock>())
internal var hourPx by mutableFloatStateOf(0f) 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 marginPx by mutableFloatStateOf(0f)
internal var reachPx by mutableFloatStateOf(0f) internal var reachPx by mutableFloatStateOf(0f)
internal var reduceMotion = false internal var reduceMotion = false
@@ -171,7 +169,6 @@ class OutOfViewEdges internal constructor(
blocks = blocks, blocks = blocks,
topMin = top / hourPx * 60f, topMin = top / hourPx * 60f,
bottomMin = (top + viewport) / hourPx * 60f, bottomMin = (top + viewport) / hourPx * 60f,
slackMin = slackPx / hourPx * 60f,
) )
} }
} }
@@ -226,7 +223,7 @@ fun rememberOutOfViewEdges(
SideEffect { SideEffect {
edges.blocks = blocks edges.blocks = blocks
edges.hourPx = hourPx 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.marginPx = with(density) { EDGE_TOUCH_HEIGHT.roundToPx().toFloat() }
edges.reachPx = with(density) { EDGE_TAP_REACH.toPx() } edges.reachPx = with(density) { EDGE_TAP_REACH.toPx() }
edges.reduceMotion = reduceMotion edges.reduceMotion = reduceMotion
@@ -241,7 +238,9 @@ fun rememberOutOfViewEdges(
* *
* Lives inside the column's scrolling content and follows the viewport by * Lives inside the column's scrolling content and follows the viewport by
* offsetting against [scrollState] at placement rather than recomposing every * 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 @Composable
fun BoxScope.OutOfViewEdgeBars( fun BoxScope.OutOfViewEdgeBars(
@@ -256,6 +255,9 @@ fun BoxScope.OutOfViewEdgeBars(
edge = ViewportEdge.Top, edge = ViewportEdge.Top,
dark = dark, dark = dark,
onReveal = edges::reveal, onReveal = edges::reveal,
scrollState = scrollState,
hourPx = edges.hourPx,
fadeLeadPx = edges.fadeLeadPx,
modifier = Modifier modifier = Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.offset { IntOffset(0, scrollState.value) }, .offset { IntOffset(0, scrollState.value) },
@@ -265,66 +267,62 @@ fun BoxScope.OutOfViewEdgeBars(
edge = ViewportEdge.Bottom, edge = ViewportEdge.Bottom,
dark = dark, dark = dark,
onReveal = edges::reveal, onReveal = edges::reveal,
scrollState = scrollState,
hourPx = edges.hourPx,
fadeLeadPx = edges.fadeLeadPx,
modifier = Modifier modifier = Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.offset { IntOffset(0, scrollState.value + scrollState.viewportSize - touchPx) }, .offset { IntOffset(0, scrollState.value + scrollState.viewportSize - touchPx) },
) )
} }
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
private fun EdgeBar( private fun EdgeBar(
block: TimedBlock?, block: TimedBlock?,
edge: ViewportEdge, edge: ViewportEdge,
dark: Boolean, dark: Boolean,
onReveal: (ViewportEdge) -> Unit, onReveal: (ViewportEdge) -> Unit,
scrollState: ScrollState,
hourPx: Float,
fadeLeadPx: Float,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
// Held so the bar keeps its colour while it fades out. block ?: return
val held = remember { mutableStateOf<TimedBlock?>(null) } val color = eventFill(block.event.color, dark, LocalSoftenColors.current)
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",
)
val label = stringResource( val label = stringResource(
if (edge == ViewportEdge.Top) R.string.timeline_earlier_events else R.string.timeline_later_events, if (edge == ViewportEdge.Top) R.string.timeline_earlier_events else R.string.timeline_later_events,
) )
AnimatedVisibility( Box(
visible = block != null, modifier = modifier
enter = fadeIn(effects), .fillMaxWidth()
exit = fadeOut(effects), .height(EDGE_TOUCH_HEIGHT)
modifier = modifier, .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( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(EDGE_TOUCH_HEIGHT) .padding(
.semantics { horizontal = EDGE_BAR_HORIZONTAL_INSET,
role = Role.Button vertical = EDGE_BAR_VERTICAL_INSET,
contentDescription = label )
onClick { .height(EDGE_BAR_THICKNESS)
onReveal(edge) .background(color, CircleShape),
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),
)
}
} }
} }
@@ -157,37 +157,4 @@ class EventToneTest {
.isEqualTo(0xFF000000.toInt()) .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) private val late = block(5, 21 * 60, 22 * 60)
// Viewport 10:00–17:00. // Viewport 10:00–17:00.
private fun outOf(vararg blocks: TimedBlock, slackMin: Float = 0f) = private fun outOf(vararg blocks: TimedBlock) =
outOfViewEvents(blocks.toList(), topMin = 600f, bottomMin = 1020f, slackMin = slackMin) outOfViewEvents(blocks.toList(), topMin = 600f, bottomMin = 1020f)
@Test @Test
fun `the nearest hidden event on each side is picked`() { fun `the nearest hidden event on each side is picked`() {
@@ -56,19 +56,29 @@ class OutOfViewEdgesTest {
} }
@Test @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) val sliver = block(6, 9 * 60, 10 * 60 + 5)
assertThat(outOf(sliver).above).isNull() assertThat(outOf(sliver).above).isNull()
assertThat(outOf(sliver, slackMin = 10f).above).isEqualTo(sliver)
val tip = block(7, 17 * 60 - 5, 18 * 60) val tip = block(7, 17 * 60 - 5, 18 * 60)
assertThat(outOf(tip).below).isNull() 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 @Test
fun `a column scrolled to its top points at nothing above it`() { fun `a column scrolled to its top points at nothing above it`() {
val first = block(6, 0, 30) 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() assertThat(events.above).isNull()
} }