Match edge indicators to event colors and clear before reveal (#188)
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user