Glide card focus and carry key-driven scroll momentum
This commit is contained in:
parent
74b737fb52
commit
6f54bae38e
9 changed files with 726 additions and 26 deletions
|
|
@ -7,6 +7,8 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.foundation.hoverable
|
import androidx.compose.foundation.hoverable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||||
|
|
@ -18,6 +20,9 @@ import androidx.compose.ui.draw.drawWithCache
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
|
@ -37,7 +42,7 @@ fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier {
|
||||||
/** Lift shadows remain specific to cards and buttons. */
|
/** Lift shadows remain specific to cards and buttons. */
|
||||||
enum class FocusLift { None, Card, Button }
|
enum class FocusLift { None, Card, Button }
|
||||||
|
|
||||||
/** Every focus ring straddles the surface edge above content, without a background gap. */
|
/** Hosted cards keep their lift and share the surface stroke; standalone rings straddle the surface edge. */
|
||||||
@Composable
|
@Composable
|
||||||
fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift = FocusLift.None): Modifier {
|
fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift = FocusLift.None): Modifier {
|
||||||
val reduced = reducedMotion()
|
val reduced = reducedMotion()
|
||||||
|
|
@ -55,6 +60,23 @@ fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift
|
||||||
alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity
|
alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val host = LocalCardRing.current.takeIf { lift == FocusLift.Card }
|
||||||
|
if (host != null) {
|
||||||
|
val owner = remember { Any() }
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val scale = WolfskinTokens.Focus.scaleCard
|
||||||
|
val corner = with(density) { radius.toPx() } * scale
|
||||||
|
val stroke = with(density) { width.toPx() } * scale
|
||||||
|
DisposableEffect(host, focused) {
|
||||||
|
onDispose { if (focused) host.release(owner) }
|
||||||
|
}
|
||||||
|
SideEffect { if (focused) host.retainFocus() }
|
||||||
|
return if (!focused) shadow else shadow.onGloballyPositioned { coordinates ->
|
||||||
|
val center = coordinates.localToRoot(Offset(coordinates.size.width / 2f, coordinates.size.height / 2f))
|
||||||
|
val size = Size(coordinates.size.width * scale, coordinates.size.height * scale)
|
||||||
|
host.update(owner, CardRingGeometry(Rect(center - Offset(size.width / 2, size.height / 2), size), corner, stroke))
|
||||||
|
}
|
||||||
|
}
|
||||||
return shadow.drawWithCache {
|
return shadow.drawWithCache {
|
||||||
val stroke = Stroke(width.toPx())
|
val stroke = Stroke(width.toPx())
|
||||||
val corner = CornerRadius(radius.toPx())
|
val corner = CornerRadius(radius.toPx())
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,136 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.AnimationVector4D
|
||||||
|
import androidx.compose.animation.core.TwoWayConverter
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.layout.onPlaced
|
||||||
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.CoroutineStart
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.yield
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
|
||||||
|
|
||||||
/** A shell surface draws one shared card focus ring above its content and below the nav and sheets. */
|
private val boundsConverter = TwoWayConverter<Rect, AnimationVector4D>(
|
||||||
|
{ AnimationVector4D(it.left, it.top, it.right, it.bottom) },
|
||||||
|
{ Rect(it.v1, it.v2, it.v3, it.v4) },
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class CardRingGeometry(val bounds: Rect, val radius: Float, val stroke: Float)
|
||||||
|
private data class CardRingTarget(val owner: Any, val geometry: CardRingGeometry)
|
||||||
|
internal val LocalCardRing = staticCompositionLocalOf<CardRingMotion?> { null }
|
||||||
|
|
||||||
|
/** Target geometry follows layout; only the displacement from it carries spring velocity. */
|
||||||
|
@Stable
|
||||||
|
internal class CardRingMotion(private val scope: CoroutineScope) {
|
||||||
|
var reduced = false
|
||||||
|
private var target by mutableStateOf<CardRingTarget?>(null)
|
||||||
|
private var retained by mutableStateOf<CardRingGeometry?>(null)
|
||||||
|
private val displacement = Animatable(Rect.Zero, boundsConverter)
|
||||||
|
private val radiusDisplacement = Animatable(0f)
|
||||||
|
val opacity = Animatable(0f)
|
||||||
|
private var geometryJob: Job? = null
|
||||||
|
private var fadeJob: Job? = null
|
||||||
|
private var releaseJob: Job? = null
|
||||||
|
private var stroke = Stroke(0f)
|
||||||
|
|
||||||
|
fun geometry(): CardRingGeometry? {
|
||||||
|
val destination = target?.geometry ?: return retained
|
||||||
|
if (reduced) return destination
|
||||||
|
return CardRingGeometry(destination.bounds + displacement.value,
|
||||||
|
(destination.radius + radiusDisplacement.value).coerceAtLeast(0f), destination.stroke)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(owner: Any, geometry: CardRingGeometry) {
|
||||||
|
releaseJob?.cancel()
|
||||||
|
if (stroke.width != geometry.stroke) stroke = Stroke(geometry.stroke)
|
||||||
|
val previous = target
|
||||||
|
if (previous?.owner === owner) {
|
||||||
|
target = CardRingTarget(owner, geometry)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val current = this.geometry()
|
||||||
|
val velocity = displacement.velocity
|
||||||
|
val radiusVelocity = radiusDisplacement.velocity
|
||||||
|
geometryJob?.cancel()
|
||||||
|
target = CardRingTarget(owner, geometry)
|
||||||
|
geometryJob = scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||||
|
displacement.snapTo(if (previous != null && current != null && !reduced) current.bounds - geometry.bounds else Rect.Zero)
|
||||||
|
radiusDisplacement.snapTo(if (previous != null && current != null && !reduced) current.radius - geometry.radius else 0f)
|
||||||
|
if (!reduced) coroutineScope {
|
||||||
|
launch { displacement.animateTo(Rect.Zero, spring(M.navBlobDampingRatio, M.navBlobStiffness), initialVelocity = velocity) }
|
||||||
|
launch { radiusDisplacement.animateTo(0f, spring(M.navBlobDampingRatio, M.navBlobStiffness), initialVelocity = radiusVelocity) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (previous == null) fade(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun drawRing(scope: DrawScope, origin: Offset) {
|
||||||
|
val geometry = target?.geometry ?: retained ?: return
|
||||||
|
val alpha = opacity.value
|
||||||
|
if (alpha <= 0f) return
|
||||||
|
val delta = if (target != null && !reduced) displacement.value else Rect.Zero
|
||||||
|
val radius = geometry.radius + if (target != null && !reduced) radiusDisplacement.value else 0f
|
||||||
|
scope.drawRoundRect(WolfskinTokens.Color.accent.copy(alpha = alpha),
|
||||||
|
topLeft = geometry.bounds.topLeft + Offset(delta.left, delta.top) - origin,
|
||||||
|
size = Size(geometry.bounds.width + delta.right - delta.left, geometry.bounds.height + delta.bottom - delta.top),
|
||||||
|
cornerRadius = CornerRadius(radius.coerceAtLeast(0f)), style = stroke)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun retainFocus() { releaseJob?.cancel() }
|
||||||
|
|
||||||
|
fun release(owner: Any) {
|
||||||
|
// A replacement card registers during layout in the same composition; it must not fade the ring.
|
||||||
|
releaseJob?.cancel()
|
||||||
|
releaseJob = scope.launch {
|
||||||
|
yield()
|
||||||
|
if (target?.owner === owner) {
|
||||||
|
retained = geometry()
|
||||||
|
target = null
|
||||||
|
geometryJob?.cancel()
|
||||||
|
fade(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fade(visible: Boolean) {
|
||||||
|
fadeJob?.cancel()
|
||||||
|
fadeJob = scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||||
|
opacity.animateTo(if (visible) 1f else 0f, tween(motionDuration(M.focusMove, reduced), easing = M.easeOut))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private operator fun Rect.plus(other: Rect) = Rect(left + other.left, top + other.top, right + other.right, bottom + other.bottom)
|
||||||
|
private operator fun Rect.minus(other: Rect) = Rect(left - other.left, top - other.top, right - other.right, bottom - other.bottom)
|
||||||
|
|
||||||
|
/** One draw-only card ring sits above surface content without changing layout or focus ownership. */
|
||||||
@Composable
|
@Composable
|
||||||
fun FocusRingHost(content: @Composable () -> Unit) {
|
fun FocusRingHost(content: @Composable () -> Unit) {
|
||||||
content()
|
val scope = rememberCoroutineScope()
|
||||||
|
val motion = remember(scope) { CardRingMotion(scope) }
|
||||||
|
val reduced = reducedMotion()
|
||||||
|
SideEffect { motion.reduced = reduced }
|
||||||
|
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||||
|
Box(Modifier.onPlaced { origin = it.positionInRoot() }.drawWithContent {
|
||||||
|
drawContent()
|
||||||
|
motion.drawRing(this, origin)
|
||||||
|
}) {
|
||||||
|
CompositionLocalProvider(LocalCardRing provides motion, content = content)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,72 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import androidx.compose.animation.core.animate
|
import androidx.compose.animation.core.AnimationState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.animateTo
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.CoroutineStart
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.WeakHashMap
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.sign
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
/** Every frame follows focus with the same bounded scroll tween. */
|
private class ScrollMomentum {
|
||||||
internal suspend fun animateFrameScroll(list: LazyListState, distance: Float) {
|
var velocity = 0f
|
||||||
if (distance == 0f) return
|
var frameTime = Long.MIN_VALUE
|
||||||
list.scroll {
|
var generation = 0L
|
||||||
var previous = 0f
|
fun reset() { velocity = 0f; frameTime = Long.MIN_VALUE }
|
||||||
animate(0f, distance, animationSpec = tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll,
|
|
||||||
easing = WolfskinTokens.Motion.easeOut)) { value, _ ->
|
fun release(context: CoroutineContext, generation: Long) {
|
||||||
scrollBy(value - previous)
|
if (this.generation != generation) return
|
||||||
previous = value
|
if (context.isActive) { reset(); return }
|
||||||
|
// A replacement must claim the carry before the next frame, without keeping the old mutation alive.
|
||||||
|
CoroutineScope(context + NonCancellable).launch(start = CoroutineStart.UNDISPATCHED) {
|
||||||
|
try { withFrameNanos { } }
|
||||||
|
finally { if (this@ScrollMomentum.generation == generation) reset() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Scroll mutations and animation callbacks run on the UI thread; values never retain their weak keys.
|
||||||
|
private val scrollMomentum = WeakHashMap<LazyListState, ScrollMomentum>()
|
||||||
|
private val scrollAngularFrequency = sqrt(WolfskinTokens.Motion.navBlobStiffness)
|
||||||
|
|
||||||
|
/** Key-driven frames and shelves share a bounded nav spring and carry same-direction velocity. */
|
||||||
|
internal suspend fun animateFrameScroll(list: LazyListState, distance: Float) {
|
||||||
|
val momentum = scrollMomentum.getOrPut(list) { ScrollMomentum() }
|
||||||
|
val generation = ++momentum.generation
|
||||||
|
if (distance == 0f) { momentum.reset(); return }
|
||||||
|
try {
|
||||||
|
list.scroll {
|
||||||
|
if (NetworkArtTransition.reduced) {
|
||||||
|
momentum.reset()
|
||||||
|
scrollBy(distance)
|
||||||
|
} else {
|
||||||
|
val maximum = scrollAngularFrequency * abs(distance)
|
||||||
|
val velocity = if (momentum.velocity.sign == distance.sign) momentum.velocity.coerceIn(-maximum, maximum) else 0f
|
||||||
|
var previous = 0f
|
||||||
|
// A fresh frame-clock start would insert a stationary frame on every held-key repeat.
|
||||||
|
AnimationState(0f, velocity, lastFrameTimeNanos = momentum.frameTime).animateTo(distance,
|
||||||
|
animationSpec = spring(WolfskinTokens.Motion.navBlobDampingRatio, WolfskinTokens.Motion.navBlobStiffness),
|
||||||
|
sequentialAnimation = momentum.frameTime != Long.MIN_VALUE) {
|
||||||
|
scrollBy(value - previous)
|
||||||
|
previous = value
|
||||||
|
momentum.velocity = this.velocity
|
||||||
|
momentum.frameTime = lastFrameTimeNanos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
val context = currentCoroutineContext()
|
||||||
|
momentum.release(context, generation)
|
||||||
|
context.ensureActive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.gestures.animateScrollBy
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
|
@ -69,7 +67,7 @@ fun HomeScreen(
|
||||||
(heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx()
|
(heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx()
|
||||||
}.coerceAtLeast(0f)
|
}.coerceAtLeast(0f)
|
||||||
val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset
|
val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset
|
||||||
view.listState.animateScrollBy(target - current, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut))
|
animateFrameScroll(view.listState, target - current)
|
||||||
view.home.positionedKey = key
|
view.home.positionedKey = key
|
||||||
view.home.positionedReset = view.home.resetScroll
|
view.home.positionedReset = view.home.resetScroll
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.gestures.animateScrollBy
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
|
@ -57,13 +55,12 @@ val SETTINGS_GROUPS = listOf(
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null, installedVersion: String = "") {
|
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null, installedVersion: String = "") {
|
||||||
val list = state.view.listState
|
val list = state.view.listState
|
||||||
val reduced = reducedMotion()
|
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
LaunchedEffect(state.settingsRow, state.navFocused) {
|
LaunchedEffect(state.settingsRow, state.navFocused) {
|
||||||
if (state.navFocused) return@LaunchedEffect
|
if (state.navFocused) return@LaunchedEffect
|
||||||
val target = (state.settingsRow - WolfskinTokens.Update.settingsVisibleRows.toInt() + 1).coerceAtLeast(0)
|
val target = (state.settingsRow - WolfskinTokens.Update.settingsVisibleRows.toInt() + 1).coerceAtLeast(0)
|
||||||
val distance = with(density) { (L.row_srow * (target - list.firstVisibleItemIndex)).toPx() } - list.firstVisibleItemScrollOffset
|
val distance = with(density) { (L.row_srow * (target - list.firstVisibleItemIndex)).toPx() } - list.firstVisibleItemScrollOffset
|
||||||
list.animateScrollBy(distance, tween(if (reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut))
|
animateFrameScroll(list, distance)
|
||||||
}
|
}
|
||||||
Box(modifier.fillMaxSize()) {
|
Box(modifier.fillMaxSize()) {
|
||||||
Row(Modifier.offset(L.safeSide, WolfskinTokens.Update.headerTop).height(WolfskinTokens.Update.headerHeight),
|
Row(Modifier.offset(L.safeSide, WolfskinTokens.Update.headerTop).height(WolfskinTokens.Update.headerHeight),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.gestures.animateScrollBy
|
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
|
||||||
/** Preserve one lookahead card and clamp short shelves and row ends to their real contents. */
|
/** Preserve one lookahead card and clamp short shelves and row ends to their real contents. */
|
||||||
|
|
@ -15,7 +13,6 @@ internal fun shelfFirst(column: Int, previous: Int, slots: Int, count: Int): Int
|
||||||
/** Reconcile the actual track on every focus transition, including interrupted or restored offsets. */
|
/** Reconcile the actual track on every focus transition, including interrupted or restored offsets. */
|
||||||
internal suspend fun LazyListState.scrollShelfTo(first: Int, pitch: Float) {
|
internal suspend fun LazyListState.scrollShelfTo(first: Int, pitch: Float) {
|
||||||
val distance = (first - firstVisibleItemIndex) * pitch - firstVisibleItemScrollOffset
|
val distance = (first - firstVisibleItemIndex) * pitch - firstVisibleItemScrollOffset
|
||||||
if (distance != 0f) animateScrollBy(distance, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.base,
|
animateFrameScroll(this, distance)
|
||||||
easing = WolfskinTokens.Motion.ease))
|
|
||||||
if (firstVisibleItemIndex != first || firstVisibleItemScrollOffset != 0) scrollToItem(first)
|
if (firstVisibleItemIndex != first || firstVisibleItemScrollOffset != 0) scrollToItem(first)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,251 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.view.View
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.snapshots.Snapshot
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.math.abs
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
/** Native ring pixels cover translation, shape morphing, scroll tracking and legacy parity. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class FocusGlideTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
private val directory get() = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/E02").apply { mkdirs() }
|
||||||
|
private val accentArgb = WolfskinTokens.Color.accent.toArgb()
|
||||||
|
private var pixels = IntArray(0)
|
||||||
|
|
||||||
|
@Before fun fullscreen() {
|
||||||
|
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun shelfRightAndRowDownGlideAsOneShape() = shelves(false)
|
||||||
|
@Test fun reducedShelfGeometrySnapsInOneFrame() = shelves(true)
|
||||||
|
|
||||||
|
private fun shelves(reduced: Boolean) {
|
||||||
|
val row = mutableIntStateOf(0)
|
||||||
|
val column = mutableIntStateOf(0)
|
||||||
|
val memory = HomeViewMemory()
|
||||||
|
val cards = List(5) { HomeTitle("$it", "Film $it", "movie") }
|
||||||
|
val tiles = HomeRow("tiles", HomeRowKind.Shelf, cards = cards, cardKind = HomeCardKind.Tile)
|
||||||
|
val posters = tiles.copy(id = "posters", cardKind = HomeCardKind.Poster)
|
||||||
|
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
|
||||||
|
Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).background(WolfskinTokens.Color.bg)) {
|
||||||
|
FocusRingHost { Column(Modifier.padding(top = 40.dp)) {
|
||||||
|
HomeShelf(tiles, memory, row.intValue == 0, column.intValue, emptyList(), {}, {})
|
||||||
|
HomeShelf(posters, memory, row.intValue == 1, column.intValue, emptyList(), {}, {})
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
} } }
|
||||||
|
rule.mainClock.advanceTimeBy(1000)
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
val suffix = if (reduced) "reduced/" else ""
|
||||||
|
val samples = mutableListOf<String>()
|
||||||
|
fun frame(phase: String, index: Int): Rect {
|
||||||
|
val image = capture("$suffix$phase-${index.toString().padStart(2, '0')}")
|
||||||
|
val bounds = accentBounds(image)
|
||||||
|
val runs = edgeRuns(image, bounds.centerY())
|
||||||
|
samples += "$phase,${index * 16},${bounds.left},${bounds.top},${bounds.width()},${bounds.height()},$runs"
|
||||||
|
image.recycle()
|
||||||
|
return bounds
|
||||||
|
}
|
||||||
|
val start = frame("shelf-right", 0)
|
||||||
|
commit { column.intValue = 1 }
|
||||||
|
val right = List(21) { index -> step(); frame("shelf-right", index + 1) }
|
||||||
|
commit { row.intValue = 1 }
|
||||||
|
val down = List(21) { index -> step(); frame("row-down", index) }
|
||||||
|
commit { row.intValue = -1 }
|
||||||
|
val fadeBudget = if (reduced) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.focusMove
|
||||||
|
repeat((fadeBudget + 15) / 16 + 1) { step() }
|
||||||
|
val hidden = capture("${suffix}nav-exit")
|
||||||
|
assertTrue("Leaving cards fades the ring completely", accentBounds(hidden).isEmpty)
|
||||||
|
val lastRing = down.last()
|
||||||
|
val background = WolfskinTokens.Color.bg.toArgb()
|
||||||
|
for (y in lastRing.top - 3..lastRing.top + 7) for (shift in listOf(0, 8, 16)) {
|
||||||
|
val pixel = hidden.getPixel(lastRing.centerX(), y)
|
||||||
|
assertTrue("No residual ring alpha after the fade deadline",
|
||||||
|
abs((pixel ushr shift and 255) - (background ushr shift and 255)) <= 2)
|
||||||
|
}
|
||||||
|
hidden.recycle()
|
||||||
|
File(directory, "${suffix}samples.csv").writeText("phase,timeMs,left,top,width,height,edgeRuns\n" + samples.joinToString("\n"))
|
||||||
|
assertTrue("Ring must travel to the next card", right.last().left > start.left)
|
||||||
|
monotonic(listOf(start.left) + right.map { it.left })
|
||||||
|
assertTrue("Nav spring must be visually settled by 300ms", abs(right.last().left - right[18].left) <= 2)
|
||||||
|
monotonic(listOf(start.width()) + down.map { it.width() })
|
||||||
|
monotonic(listOf(start.height()) + down.map { it.height() })
|
||||||
|
if (reduced) {
|
||||||
|
assertEquals("Reduced horizontal move snaps on frame one", right.last(), right.first())
|
||||||
|
assertEquals("Reduced shape change snaps on frame one", down.last(), down.first())
|
||||||
|
} else {
|
||||||
|
assertTrue("Horizontal move must glide, not jump", right.first().left < right.last().left)
|
||||||
|
assertTrue("Width must morph", down.first().width() != down.last().width())
|
||||||
|
assertTrue("Height must morph", down.first().height() != down.last().height())
|
||||||
|
}
|
||||||
|
samples.filter { it.startsWith("shelf-right") }.forEach { assertEquals("Exactly one ring crosses its midline: $it", 2, it.substringAfterLast(',').toInt()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun circleRadiusMorphsOnTheNavSpring() = circleRadius(false)
|
||||||
|
@Test fun reducedCircleRadiusSnapsInOneFrame() = circleRadius(true)
|
||||||
|
|
||||||
|
private fun circleRadius(reduced: Boolean) {
|
||||||
|
val selected = mutableIntStateOf(0)
|
||||||
|
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
|
||||||
|
Box(Modifier.requiredSize(320.dp).background(WolfskinTokens.Color.bg)) {
|
||||||
|
FocusRingHost {
|
||||||
|
for (index in 0..1) key(index) {
|
||||||
|
val radius = if (index == 0) WolfskinTokens.Radius.card else 60.dp
|
||||||
|
Box(Modifier.offset(80.dp, 80.dp).size(120.dp)
|
||||||
|
.focusScale(selected.intValue == index, WolfskinTokens.Focus.scaleCard)
|
||||||
|
.focusRing(selected.intValue == index, radius, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
|
||||||
|
.background(WolfskinTokens.Color.surface, androidx.compose.foundation.shape.RoundedCornerShape(radius)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} } }
|
||||||
|
rule.mainClock.advanceTimeBy(1000)
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
fun topSpan(name: String): Int {
|
||||||
|
val bitmap = capture(name)
|
||||||
|
val bounds = accentBounds(bitmap)
|
||||||
|
val pixels = (bounds.left until bounds.right).filter { accent(bitmap.getPixel(it, bounds.top + 3)) }
|
||||||
|
bitmap.recycle()
|
||||||
|
return if (pixels.isEmpty()) 0 else pixels.last() - pixels.first() + 1
|
||||||
|
}
|
||||||
|
val prefix = if (reduced) "reduced/circle" else "circle"
|
||||||
|
val start = topSpan("$prefix-start")
|
||||||
|
commit { selected.intValue = 1 }
|
||||||
|
val spans = List(22) { step(); topSpan("$prefix-$it") }
|
||||||
|
File(directory, "$prefix-samples.csv").writeText("timeMs,topEdgeSpanPx\n0,$start\n" +
|
||||||
|
spans.mapIndexed { index, span -> "${(index + 1) * 16},$span" }.joinToString("\n"))
|
||||||
|
assertTrue("Radius change must change corner pixels", start > spans.last() + 20)
|
||||||
|
monotonic(listOf(start) + spans)
|
||||||
|
if (reduced) assertEquals(spans.last(), spans.first())
|
||||||
|
else assertTrue("Radius must morph, not jump", spans.first() > spans.last())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun posterTileAndSeeAllSettledRingsMatchLegacyPixels() {
|
||||||
|
val hosted = mutableStateOf(false)
|
||||||
|
val kind = mutableIntStateOf(0)
|
||||||
|
rule.setContent { WolfskinTheme {
|
||||||
|
Box(Modifier.requiredSize(500.dp, 320.dp).background(WolfskinTokens.Color.bg)) {
|
||||||
|
@Composable fun card() {
|
||||||
|
val width = if (kind.intValue == 1) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW
|
||||||
|
val height = if (kind.intValue == 1) WolfskinTokens.Card.tileH else WolfskinTokens.Card.posterH
|
||||||
|
val modifier = Modifier.offset(60.dp, 40.dp)
|
||||||
|
if (kind.intValue == 2) LibrarySeeAllCard(40, true, {}, modifier, width, height, HomeType.listTitle, HomeType.label)
|
||||||
|
else TitleCard(true, modifier, width, height) { Box(Modifier.fillMaxSize().background(WolfskinTokens.Color.surface)) }
|
||||||
|
}
|
||||||
|
if (hosted.value) FocusRingHost { card() } else card()
|
||||||
|
}
|
||||||
|
} }
|
||||||
|
for (shape in 0..2) {
|
||||||
|
rule.runOnIdle { hosted.value = false; kind.intValue = shape }
|
||||||
|
rule.mainClock.advanceTimeBy(1000)
|
||||||
|
val legacy = capture("legacy-$shape")
|
||||||
|
rule.runOnIdle { hosted.value = true }
|
||||||
|
rule.mainClock.advanceTimeBy(1000)
|
||||||
|
val shared = capture("settled-$shape")
|
||||||
|
assertEquals("Settled ring bounds for shape $shape", accentBounds(legacy), accentBounds(shared))
|
||||||
|
var maximumDifference = 0
|
||||||
|
for (y in 0 until legacy.height) for (x in 0 until legacy.width) {
|
||||||
|
val expected = legacy.getPixel(x, y)
|
||||||
|
val actual = shared.getPixel(x, y)
|
||||||
|
for (channel in 0..3) {
|
||||||
|
val shift = channel * 8
|
||||||
|
maximumDifference = maxOf(maximumDifference, abs((expected ushr shift and 255) - (actual ushr shift and 255)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue("Settled shape $shape differs by $maximumDifference channel levels", maximumDifference <= 2)
|
||||||
|
legacy.recycle(); shared.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun libraryRingTracksFrameScrollAndRapidRetargets() = library(false)
|
||||||
|
@Test fun reducedLibraryRingTracksFrameScrollAndRapidRetargets() = library(true)
|
||||||
|
|
||||||
|
private fun library(reduced: Boolean) {
|
||||||
|
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("movies"); navFocused = false }
|
||||||
|
val grid = LibraryGridState("Movies", List(60) { LibraryTitle("$it", "Film $it", "movie") })
|
||||||
|
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme { FocusRingHost { LibraryScreen(shell, grid, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) } } } }
|
||||||
|
rule.mainClock.advanceTimeBy(1000)
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
rule.runOnIdle { grid.key(ShellKey.Down) }
|
||||||
|
repeat(3) { step() }
|
||||||
|
rule.runOnIdle { grid.key(ShellKey.Right) }
|
||||||
|
repeat(3) { step() }
|
||||||
|
rule.runOnIdle { grid.key(ShellKey.Down) }
|
||||||
|
repeat(30) { step() }
|
||||||
|
val image = capture(if (reduced) "reduced/library-settled" else "library-settled")
|
||||||
|
val ring = accentBounds(image)
|
||||||
|
image.recycle()
|
||||||
|
assertFalse(ring.isEmpty)
|
||||||
|
assertEquals("Scroll target stays on the pinned line", WolfskinTokens.Layout.pinnedLine.value * 2 - WolfskinTokens.Card.posterH.value * (WolfskinTokens.Focus.scaleCard - 1f), ring.top.toFloat(), 5f)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun commit(action: () -> Unit) = rule.runOnIdle { action(); Snapshot.sendApplyNotifications() }
|
||||||
|
private fun step() { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() }
|
||||||
|
private fun capture(name: String): Bitmap {
|
||||||
|
val file = File(directory, "$name.png").apply { parentFile!!.mkdirs() }
|
||||||
|
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||||
|
return BitmapFactory.decodeFile(file.absolutePath)
|
||||||
|
}
|
||||||
|
private fun accent(pixel: Int): Boolean {
|
||||||
|
for (channel in 0..2) {
|
||||||
|
val shift = channel * 8
|
||||||
|
if (abs((pixel ushr shift and 255) - (accentArgb ushr shift and 255)) > 8) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
private fun accentBounds(image: Bitmap): Rect {
|
||||||
|
val bounds = Rect(image.width, image.height, 0, 0)
|
||||||
|
val count = image.width * image.height
|
||||||
|
if (pixels.size < count) pixels = IntArray(count)
|
||||||
|
image.getPixels(pixels, 0, image.width, 0, 0, image.width, image.height)
|
||||||
|
var index = 0
|
||||||
|
for (y in 0 until image.height) for (x in 0 until image.width) if (accent(pixels[index++])) {
|
||||||
|
bounds.left = minOf(bounds.left, x); bounds.top = minOf(bounds.top, y)
|
||||||
|
bounds.right = maxOf(bounds.right, x + 1); bounds.bottom = maxOf(bounds.bottom, y + 1)
|
||||||
|
}
|
||||||
|
return bounds
|
||||||
|
}
|
||||||
|
private fun edgeRuns(image: Bitmap, y: Int): Int {
|
||||||
|
var runs = 0
|
||||||
|
var inside = false
|
||||||
|
for (x in 0 until image.width) {
|
||||||
|
val next = accent(image.getPixel(x, y))
|
||||||
|
if (next && !inside) runs++
|
||||||
|
inside = next
|
||||||
|
}
|
||||||
|
return runs
|
||||||
|
}
|
||||||
|
private fun monotonic(values: List<Int>) {
|
||||||
|
val direction = if (values.last() >= values.first()) 1 else -1
|
||||||
|
values.zipWithNext().forEach { (before, after) -> assertTrue("Geometry reverses or overshoots: $values", (after - before) * direction >= -1) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.view.View
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.snapshots.Snapshot
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import java.io.File
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
/** Native toast pixels prove retention, interruption and bounded reduced-motion fades. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class RecoveryToastMotionTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun toastFadesInHoldsAndFadesOutWithoutStacking() = exercise(false)
|
||||||
|
@Test fun reducedToastFadesInHoldsAndFadesOutWithoutStacking() = exercise(true)
|
||||||
|
|
||||||
|
private fun exercise(reduced: Boolean) {
|
||||||
|
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||||
|
val message = mutableStateOf<String?>(null)
|
||||||
|
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
|
||||||
|
Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).background(WolfskinTokens.Color.bg)) {
|
||||||
|
RecoveryToastHost(message.value)
|
||||||
|
}
|
||||||
|
} } }
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/P02${if (reduced) "/reduced" else ""}").apply { mkdirs() }
|
||||||
|
val samples = mutableListOf<String>()
|
||||||
|
var sampleX = 0
|
||||||
|
var sampleY = 0
|
||||||
|
fun frame(phase: String, index: Int): Int {
|
||||||
|
val file = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
|
||||||
|
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||||
|
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||||
|
val pixel = bitmap.getPixel(sampleX, sampleY)
|
||||||
|
bitmap.recycle()
|
||||||
|
val brightness = android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
|
||||||
|
samples += "$phase,${index * 16},$brightness"
|
||||||
|
return brightness
|
||||||
|
}
|
||||||
|
fun step() { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() }
|
||||||
|
fun count() = rule.onAllNodesWithTag("recovery-toast", useUnmergedTree = true).fetchSemanticsNodes().size
|
||||||
|
fun show(value: String?) = rule.runOnIdle { message.value = value; Snapshot.sendApplyNotifications() }
|
||||||
|
show(RecoveryMessage.StillUnreachable)
|
||||||
|
step()
|
||||||
|
var inserted = 0
|
||||||
|
while (count() == 0 && inserted++ < 3) step()
|
||||||
|
val bounds = rule.onNodeWithTag("recovery-toast", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||||
|
sampleX = (bounds.left + 40).toInt()
|
||||||
|
sampleY = (bounds.center.y - 6).toInt()
|
||||||
|
val entering = List(14) { index -> frame("toast-in", index).also { step() } }
|
||||||
|
assertTrue("Fade-in must change native toast pixels: $entering", entering.last() > entering.first())
|
||||||
|
entering.zipWithNext().forEach { (before, after) -> assertTrue("Fade-in reverses: $entering", after >= before - 1) }
|
||||||
|
val deadline = (if (reduced) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.base) / 16 + 1
|
||||||
|
assertEquals("Fade-in must settle by its token deadline", entering.last(), entering[deadline])
|
||||||
|
rule.mainClock.advanceTimeBy(WolfskinTokens.Motion.toast.toLong())
|
||||||
|
assertEquals(entering.last(), frame("hold", 0))
|
||||||
|
show(RecoveryMessage.PlaybackUnavailable)
|
||||||
|
step()
|
||||||
|
assertEquals(1, count())
|
||||||
|
rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertExists()
|
||||||
|
assertEquals("Replacing a visible message must not restart its fade", entering.last(), frame("replace", 0))
|
||||||
|
show(RecoveryMessage.StillUnreachable)
|
||||||
|
step()
|
||||||
|
show(null)
|
||||||
|
step()
|
||||||
|
assertEquals("Retain the row while fading out", 1, count())
|
||||||
|
val leaving = List(14) { index -> frame("toast-out", index).also { step() } }
|
||||||
|
assertTrue("Fade-out must change native toast pixels: $leaving", leaving.first() > leaving.last())
|
||||||
|
leaving.zipWithNext().forEach { (before, after) -> assertTrue("Fade-out reverses: $leaving", after <= before + 1) }
|
||||||
|
assertEquals(leaving.last(), leaving[deadline])
|
||||||
|
rule.onNodeWithTag("recovery-toast").assertDoesNotExist()
|
||||||
|
show(RecoveryMessage.StillUnreachable)
|
||||||
|
repeat(14) { step() }
|
||||||
|
show(null)
|
||||||
|
step(); step(); step()
|
||||||
|
val interrupted = frame("interrupted", 0)
|
||||||
|
assertTrue(interrupted < entering.last())
|
||||||
|
show(RecoveryMessage.StillUnreachable)
|
||||||
|
step()
|
||||||
|
val resumed = List(14) { index -> frame("resume", index).also { step() } }
|
||||||
|
// Recomposition may consume one final outgoing frame before retargeting.
|
||||||
|
assertTrue("Re-entry retains current opacity instead of resetting or snapping",
|
||||||
|
resumed.first() >= leaving[3] - 1 && resumed.first() <= interrupted + 1)
|
||||||
|
resumed.zipWithNext().forEach { (before, after) -> assertTrue(after >= before - 1) }
|
||||||
|
assertEquals(entering.last(), resumed.last())
|
||||||
|
assertEquals(1, count())
|
||||||
|
File(directory, "samples.csv").writeText("phase,timeMs,brightness\n" + samples.joinToString("\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.view.View
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.runtime.snapshots.Snapshot
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import java.io.File
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
/** Held keys exercise real shelf/frame targets and measure velocity across interrupted scrolls. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class ScrollMomentumTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun heldRightCarriesVelocityAndSettlesOnShelfFirst() = exercise(false, false)
|
||||||
|
@Test fun heldDownCarriesVelocityAndSettlesOnLibraryRow() = exercise(false, true)
|
||||||
|
@Test fun reducedShelfScrollSnapsInOneFrame() = exercise(true, false)
|
||||||
|
@Test fun reducedLibraryScrollSnapsInOneFrame() = exercise(true, true)
|
||||||
|
|
||||||
|
private fun exercise(reduced: Boolean, library: Boolean) {
|
||||||
|
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||||
|
val shell = ShellState(fixturePeople(), "justin").apply {
|
||||||
|
pickerVisible = false
|
||||||
|
if (library) enterDestination("movies")
|
||||||
|
navFocused = false
|
||||||
|
}
|
||||||
|
val row = HomeRow("long", HomeRowKind.Shelf, cards = List(30) { HomeTitle("$it", "Film $it", "movie") })
|
||||||
|
shell.view.cards[row.id] = 4
|
||||||
|
val grid = LibraryGridState("Movies", List(120) { LibraryTitle("$it", "Film $it", "movie") })
|
||||||
|
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
|
||||||
|
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
|
||||||
|
if (library) LibraryScreen(shell, grid, modifier)
|
||||||
|
else HomeScreen(shell, HomeSnapshot(listOf(row)), modifier) {}
|
||||||
|
}
|
||||||
|
} } }
|
||||||
|
rule.mainClock.advanceTimeBy(1000)
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
val track = if (library) shell.view.listState else shell.view.home.tracks.getValue(row.id)
|
||||||
|
val pitch = (WolfskinTokens.Card.posterW + WolfskinTokens.Card.posterGap).value * 2
|
||||||
|
val framePitch = (WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand + WolfskinTokens.Layout.gridRowGap).value * 2
|
||||||
|
fun offset(): Float {
|
||||||
|
val first = track.firstVisibleItemIndex
|
||||||
|
if (!library) return first * pitch + track.firstVisibleItemScrollOffset
|
||||||
|
val top = if (first == 0) 0f else WolfskinTokens.Layout.row_lhead.value * 2 +
|
||||||
|
if (first < 2) 0f else WolfskinTokens.Layout.row_chipsGrid.value * 2 + framePitch * (first - 2)
|
||||||
|
return top + track.firstVisibleItemScrollOffset
|
||||||
|
}
|
||||||
|
fun target(): Float = if (!library) shell.view.home.firstCards.getValue(row.id) * pitch else
|
||||||
|
if (grid.index / grid.columns == 0) 0f else (WolfskinTokens.Layout.row_lhead.value + WolfskinTokens.Layout.row_chipsGrid.value - WolfskinTokens.Layout.pinnedLine.value) * 2 + framePitch * (grid.index / grid.columns)
|
||||||
|
fun press() = rule.runOnIdle { shell.key(if (library) ShellKey.Down else ShellKey.Right); Snapshot.sendApplyNotifications() }
|
||||||
|
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/E05${if (library) "/library" else ""}${if (reduced) "/reduced" else ""}").apply { mkdirs() }
|
||||||
|
val samples = mutableListOf<String>()
|
||||||
|
val offsets = mutableListOf<Float>()
|
||||||
|
val velocities = mutableListOf<Float>()
|
||||||
|
val repeats = mutableListOf<Int>()
|
||||||
|
press()
|
||||||
|
// Start sampling at the animation's zero-time frame, after composition commits the new focus.
|
||||||
|
repeat(2) { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() }
|
||||||
|
val singleTarget = target()
|
||||||
|
for (index in 0..80) {
|
||||||
|
if (index >= 32 && index <= 47 && (index - 32) % 3 == 0) { repeats += index; press() }
|
||||||
|
rule.mainClock.advanceTimeByFrame()
|
||||||
|
rule.waitForIdle()
|
||||||
|
val position = offset()
|
||||||
|
offsets += position
|
||||||
|
velocities += if (index == 0) position / .016f else (position - offsets[index - 1]) / .016f
|
||||||
|
samples += "${(index + 1) * 16},$position,${velocities.last()},${target()}"
|
||||||
|
val file = File(directory, "held-${if (library) "down" else "right"}-${index.toString().padStart(2, '0')}.png")
|
||||||
|
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||||
|
}
|
||||||
|
File(directory, "samples.csv").writeText("timeMs,offsetPx,velocityPxPerSecond,targetPx\n" + samples.joinToString("\n"))
|
||||||
|
offsets.zipWithNext().forEach { (before, after) -> assertTrue("Track reversed: $offsets", after >= before) }
|
||||||
|
assertTrue("No overshoot of final target", offsets.all { it <= target() + 1 })
|
||||||
|
assertEquals("Final target must reconcile exactly", target(), offsets.last(), 1f)
|
||||||
|
assertTrue("Single press must reach 95 percent by 200ms: $offsets", offsets[12] >= singleTarget * .95f)
|
||||||
|
assertEquals("Single press is visually settled by 320ms", singleTarget, offsets[19], 2f)
|
||||||
|
if (reduced) assertEquals("Reduced scroll lands in one frame", singleTarget, offsets.first(), 1f)
|
||||||
|
else repeats.drop(2).forEach { index ->
|
||||||
|
assertTrue("Repeated key must not stop the track", velocities[index] > 0f)
|
||||||
|
assertTrue("Retarget must carry velocity instead of restarting: ${velocities.subList(index - 1, index + 2)}", velocities[index + 1] >= velocities[index - 1] * .5f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun cancelledScrollCannotReuseAnAbandonedFrameClock() = cancelledScroll(false)
|
||||||
|
@Test fun reducedCancelledScrollStillSnapsOnReentry() = cancelledScroll(true)
|
||||||
|
|
||||||
|
private fun cancelledScroll(reduced: Boolean) {
|
||||||
|
val visible = mutableStateOf(true)
|
||||||
|
val target = mutableFloatStateOf(0f)
|
||||||
|
val list = LazyListState()
|
||||||
|
fun offset() = list.firstVisibleItemIndex * 200f + list.firstVisibleItemScrollOffset
|
||||||
|
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
|
||||||
|
if (visible.value) {
|
||||||
|
LaunchedEffect(target.floatValue) { animateFrameScroll(list, target.floatValue - offset()) }
|
||||||
|
LazyRow(Modifier.requiredSize(500.dp, 100.dp), state = list) {
|
||||||
|
items(30) { Box(Modifier.size(100.dp)) }
|
||||||
|
}
|
||||||
|
} else Box(Modifier.requiredSize(500.dp, 100.dp))
|
||||||
|
} } }
|
||||||
|
rule.mainClock.advanceTimeBy(1000)
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
rule.runOnIdle { target.floatValue = 600f; Snapshot.sendApplyNotifications() }
|
||||||
|
repeat(5) { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() }
|
||||||
|
rule.runOnIdle { visible.value = false; Snapshot.sendApplyNotifications() }
|
||||||
|
rule.mainClock.advanceTimeByFrame()
|
||||||
|
rule.waitForIdle()
|
||||||
|
rule.mainClock.advanceTimeBy(1000)
|
||||||
|
val resumedTarget = offset() + 600f
|
||||||
|
rule.runOnIdle { target.floatValue = resumedTarget; visible.value = true; Snapshot.sendApplyNotifications() }
|
||||||
|
repeat(3) { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() }
|
||||||
|
if (reduced) assertEquals(resumedTarget, offset(), 1f)
|
||||||
|
else assertTrue("An abandoned frame timestamp must not turn a fresh scroll into a snap", offset() < resumedTarget - 300f)
|
||||||
|
rule.mainClock.advanceTimeBy(1000)
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertEquals(resumedTarget, offset(), 1f)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue