diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/color/EventTone.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/color/EventTone.kt index 12f0cc3..9ae51fe 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/color/EventTone.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/color/EventTone.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt index 7064134..7954e3c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt @@ -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, topMin: Float, bottomMin: Float, - slackMin: Float, ): OutOfViewEvents { val above = blocks - .filter { it.startMin < topMin && renderedEndMin(it) <= topMin + slackMin } + .filter { renderedEndMin(it) <= topMin } .maxWithOrNull(compareBy({ renderedEndMin(it) }, { it.startMin })) val below = blocks - .filter { renderedEndMin(it) > bottomMin && it.startMin >= bottomMin - slackMin } + .filter { it.startMin >= bottomMin } .minWithOrNull(compareBy({ 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()) 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(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() - 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), + ) } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/color/EventToneTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/color/EventToneTest.kt index 9d0a7b0..0446a90 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/color/EventToneTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/color/EventToneTest.kt @@ -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) - } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt index 56a5865..6268b26 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt @@ -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() }