Smooth Discover hero handoffs and scope lane focus to each visit
This commit is contained in:
parent
c2f7ccc438
commit
12e908389f
14 changed files with 801 additions and 87 deletions
|
|
@ -33,6 +33,8 @@ import androidx.compose.ui.platform.LocalContext
|
|||
import androidx.compose.ui.platform.testTag
|
||||
import coil3.compose.AsyncImage
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import coil3.request.transitionFactory
|
||||
import coil3.transition.Transition
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
|
||||
|
|
@ -50,13 +52,14 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false,
|
|||
this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) }
|
||||
}) else null }
|
||||
val context = LocalContext.current
|
||||
val request = remember(context, path, poster, dim) {
|
||||
val heroReady = LocalHeroArtReady.current
|
||||
val request = remember(context, path, poster, dim, heroReady != null) {
|
||||
val upright = poster || dim
|
||||
WolfskinImages.request(context, path,
|
||||
if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth,
|
||||
if (upright) WolfskinTokens.Image.heroPosterRequestHeight else WolfskinTokens.Image.heroRequestHeight, upright)
|
||||
.let { if (heroReady != null) it.newBuilder().transitionFactory(Transition.Factory.NONE).build() else it }
|
||||
}
|
||||
val heroReady = LocalHeroArtReady.current
|
||||
if (painter != null) {
|
||||
androidx.compose.runtime.SideEffect { heroReady?.invoke() }
|
||||
Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
|
@ -47,7 +49,10 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
|||
val focus = remember(state) { object : ShellContentFocus by state {} }
|
||||
DisposableEffect(focus) {
|
||||
shell.contentFocus = focus
|
||||
onDispose { if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false } }
|
||||
onDispose {
|
||||
if (shell.tabs.getOrNull(shell.currentTab) != "discover") state.resetLaneMemory()
|
||||
if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false }
|
||||
}
|
||||
}
|
||||
LaunchedEffect(shell.route, state.snapshot.savedAt) { shell.view.savedAt = state.snapshot.savedAt }
|
||||
LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } }
|
||||
|
|
@ -103,6 +108,7 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
|||
animateFrameScroll(list, target - with(density) { current.toPx() } - list.firstVisibleItemScrollOffset)
|
||||
}
|
||||
}
|
||||
HeroMotionScope {
|
||||
Box(modifier.fillMaxSize().testTag("discover-screen")) {
|
||||
if (state.hasHero && state.service == null && list.firstVisibleItemIndex == 0) state.hero?.let { title ->
|
||||
HeroArtCrossfade(title.key, title, L.row_phero, Modifier.offset { IntOffset(0, -list.firstVisibleItemScrollOffset) }) { DiscoverBackdrop(it) }
|
||||
|
|
@ -129,13 +135,15 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiscoverHero(state: DiscoverState) {
|
||||
val title = state.hero ?: return
|
||||
HeroCopyHandoff(title.key, title) { copy ->
|
||||
Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) {
|
||||
Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) {
|
||||
HeroCopyHandoff(title.key, title) { copy ->
|
||||
Column {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Glyph("seerr", C.accentText, Modifier.size(L.buttonIcon))
|
||||
Text(if (state.snapshot.status == DiscoverStatus.Ready) "For you" else "Trending", color = C.accentText, style = DiscoverType.label)
|
||||
|
|
@ -151,17 +159,32 @@ private fun DiscoverHero(state: DiscoverState) {
|
|||
}
|
||||
Spacer(Modifier.height(D.heroMetaGap))
|
||||
Text(copy.overview, Modifier.width(L.heroOverviewMax), color = C.muted, style = DiscoverType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(D.heroOverviewGap))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DiscoverButton("Details", state.contentFocused && state.focusedRow == 0, "about") { state.openTitle(title) }
|
||||
Row(Modifier.padding(start = D.dotMargin), horizontalArrangement = Arrangement.spacedBy(D.dotGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Glyph("left", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron))
|
||||
state.heroes.forEachIndexed { index, _ -> Box(Modifier.size(if (index == state.slide) D.dotActive else D.dotSize, D.dotSize).background(if (index == state.slide) C.fg else C.fg.copy(alpha = .3f), CircleShape)) }
|
||||
state.heroes.forEachIndexed { index, _ -> DiscoverHeroDot(index, index == state.slide) }
|
||||
Glyph("right", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Pager geometry retargets from its current value independently of artwork loading. */
|
||||
@Composable
|
||||
private fun DiscoverHeroDot(index: Int, selected: Boolean) {
|
||||
val progress = remember { Animatable(if (selected) 1f else 0f) }
|
||||
val reduced = reducedMotion()
|
||||
LaunchedEffect(selected, reduced) {
|
||||
progress.animateTo(if (selected) 1f else 0f,
|
||||
tween(motionDuration(WolfskinTokens.Motion.focusMove, reduced), easing = WolfskinTokens.Motion.easeOut))
|
||||
}
|
||||
val widthProgress = if (reduced) { if (selected) 1f else 0f } else progress.value
|
||||
Box(Modifier.testTag("hero-dot-$index").size(D.dotSize + (D.dotActive - D.dotSize) * widthProgress, D.dotSize)
|
||||
.background(C.fg.copy(alpha = .3f + .7f * progress.value), CircleShape))
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -40,14 +40,14 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
|
|||
val height = if (services) K.entryH else K.posterH
|
||||
val cards = rememberLazyListState()
|
||||
val count = if (services) state.snapshot.services.size else lane.titles.size + if (lane.tail == DiscoverTail.None) 0 else 1
|
||||
val column = (state.shell.view.cards[lane.id] ?: 0).coerceIn(0, (count - 1).coerceAtLeast(0))
|
||||
val column = state.laneColumn(lane.id).coerceIn(0, (count - 1).coerceAtLeast(0))
|
||||
val active = state.contentFocused && state.focusedRow == row
|
||||
val slots = K.posterSlots
|
||||
val first = state.shell.view.cards["first:${lane.id}"] ?: 0
|
||||
val first = state.laneFirsts[lane.id] ?: 0
|
||||
val density = LocalDensity.current
|
||||
LaunchedEffect(column, count, active) {
|
||||
LaunchedEffect(state.service?.id, column, count, active) {
|
||||
val target = shelfFirst(column, first, slots, count)
|
||||
state.shell.view.cards["first:${lane.id}"] = target
|
||||
state.laneFirsts[lane.id] = target
|
||||
cards.scrollShelfTo(target, with(density) { K.posterPitch.toPx() })
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(D.shelfHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/** One person owns a Discover view; the shell retains its root list and card memory. */
|
||||
/** Each person retains the root viewport; lane movement belongs only to this Discover visit. */
|
||||
class DiscoverState(
|
||||
val shell: ShellState,
|
||||
initial: DiscoverSnapshot,
|
||||
|
|
@ -75,6 +75,20 @@ class DiscoverState(
|
|||
private val pendingDismissals = mutableMapOf<String, DiscoverTitle?>()
|
||||
private var rootFilter = "all"
|
||||
|
||||
private val rootLaneMemory = DiscoverLaneMemory()
|
||||
private val serviceLaneMemory = mutableMapOf<Long, DiscoverLaneMemory>()
|
||||
private val laneMemory get() = service?.let { serviceLaneMemory.getValue(it.id) } ?: rootLaneMemory
|
||||
private val laneColumns get() = laneMemory.columns
|
||||
internal val laneFirsts get() = laneMemory.firsts
|
||||
|
||||
internal fun laneColumn(id: String): Int = laneColumns[id] ?: 0
|
||||
|
||||
/** Pushed titles preserve this visit; selecting another root tab discards lane movement. */
|
||||
internal fun resetLaneMemory() {
|
||||
rootLaneMemory.clear()
|
||||
serviceLaneMemory.values.forEach { it.clear() }
|
||||
}
|
||||
|
||||
fun updateSnapshot(value: DiscoverSnapshot) {
|
||||
snapshot = value
|
||||
if (value.savedAt == null && value.status in setOf(DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) savedTime = formatSavedTime()
|
||||
|
|
@ -139,7 +153,7 @@ class DiscoverState(
|
|||
val rowCount: Int get() = firstShelf + lanes.size
|
||||
val focusedRow: Int get() = shell.view.row.coerceIn(0, (rowCount - 1).coerceAtLeast(0))
|
||||
val focusedLane: DiscoverLane? get() = lanes.getOrNull(focusedRow - firstShelf)
|
||||
val column: Int get() = shell.view.cards[rowKey].orZero().coerceAtMost(maxColumn())
|
||||
val column: Int get() = (if (focusedRow >= firstShelf) laneColumn(rowKey) else shell.view.cards[rowKey].orZero()).coerceAtMost(maxColumn())
|
||||
val rowKey: String get() = when (focusedRow) { 0 -> "phero"; 1 -> "dchips"; else -> focusedLane?.id.orEmpty() }
|
||||
val isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null && focusedLane?.pending != true
|
||||
val contentFocused: Boolean get() = !shell.navFocused && !shell.pickerVisible && overlay == null
|
||||
|
|
@ -152,7 +166,8 @@ class DiscoverState(
|
|||
|
||||
fun focus(row: Int, column: Int = 0) {
|
||||
shell.navFocused = false; shell.view.row = row
|
||||
shell.view.cards[rowKey] = column.coerceIn(0, maxColumn())
|
||||
val selected = column.coerceIn(0, maxColumn())
|
||||
if (focusedRow >= firstShelf) laneColumns[rowKey] = selected else shell.view.cards[rowKey] = selected
|
||||
}
|
||||
|
||||
private fun moveRow(direction: Int) {
|
||||
|
|
@ -161,10 +176,7 @@ class DiscoverState(
|
|||
if (target < if (hasHero) 0 else 1) { shell.focusNav(shell.currentTab); return }
|
||||
if (target >= rowCount) return
|
||||
val key = when (target) { 0 -> "phero"; 1 -> "dchips"; else -> lanes[target - firstShelf].id }
|
||||
val nearest = if (focusedRow >= firstShelf && target >= firstShelf)
|
||||
column - shell.view.cards["first:$rowKey"].orZero() + shell.view.cards["first:$key"].orZero()
|
||||
else 0
|
||||
focus(target, shell.view.cards[key] ?: nearest.coerceAtLeast(0))
|
||||
focus(target, if (target >= firstShelf) laneColumn(key) else shell.view.cards[key].orZero())
|
||||
}
|
||||
|
||||
fun openTitle(item: DiscoverTitle) {
|
||||
|
|
@ -225,12 +237,14 @@ class DiscoverState(
|
|||
fun isPick(item: DiscoverTitle): Boolean = snapshot.picks.any { it.key == item.key } || isHidden(item)
|
||||
|
||||
fun openService(item: DiscoverService) {
|
||||
serviceLaneMemory.getOrPut(item.id) { DiscoverLaneMemory() }
|
||||
rootFilter = filter; service = item; filter = "movie"; revision++
|
||||
onPush("discover-service/${item.id}")
|
||||
focus(firstShelf)
|
||||
}
|
||||
|
||||
fun enterRoute(route: String) {
|
||||
if (route in shell.tabs && route != "discover") resetLaneMemory()
|
||||
if (route == "discover") { title = null; collection = null; if (service != null) filter = rootFilter; service = null }
|
||||
else if (route.startsWith("discover-service") || route.startsWith("discover-see-all")) title = null
|
||||
}
|
||||
|
|
@ -261,6 +275,7 @@ class DiscoverState(
|
|||
ShellKey.Left, ShellKey.Right -> {
|
||||
val direction = if (key == ShellKey.Left) -1 else 1
|
||||
if (focusedRow == 0) slide = (slide + direction).coerceIn(0, (heroes.size - 1).coerceAtLeast(0))
|
||||
else if (focusedRow >= firstShelf) laneColumns[rowKey] = (column + direction).coerceIn(0, maxColumn())
|
||||
else shell.view.cards[rowKey] = (column + direction).coerceIn(0, maxColumn())
|
||||
}
|
||||
ShellKey.Ok -> when {
|
||||
|
|
@ -417,6 +432,12 @@ class DiscoverState(
|
|||
}
|
||||
}
|
||||
|
||||
private class DiscoverLaneMemory {
|
||||
val columns = mutableStateMapOf<String, Int>()
|
||||
val firsts = mutableStateMapOf<String, Int>()
|
||||
fun clear() { columns.clear(); firsts.clear() }
|
||||
}
|
||||
|
||||
private fun Int?.orZero(): Int = this ?: 0
|
||||
|
||||
private val DISCOVER_FILTERS = listOf("all", "movie", "tv")
|
||||
|
|
|
|||
|
|
@ -6,95 +6,138 @@ import androidx.compose.foundation.layout.Box
|
|||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.semantics.SemanticsPropertyKey
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
|
||||
|
||||
/** Artwork success or its final fallback releases a pending hero hand-off. */
|
||||
internal val LocalHeroArtReady = compositionLocalOf<(() -> Unit)?> { null }
|
||||
internal val HeroLayerAlpha = SemanticsPropertyKey<Float>("HeroLayerAlpha")
|
||||
internal val HeroLayerTranslation = SemanticsPropertyKey<Float>("HeroLayerTranslation")
|
||||
internal val HeroLayerIdentity = SemanticsPropertyKey<String>("HeroLayerIdentity")
|
||||
|
||||
/** Only the upper hero region blends; the lower backdrop changes without an animated screen layer. */
|
||||
private class HeroPresentation {
|
||||
var identity by mutableStateOf<Any?>(null)
|
||||
}
|
||||
private val LocalHeroPresentation = compositionLocalOf<HeroPresentation?> { null }
|
||||
|
||||
/** Artwork and copy share readiness without coupling either layer to a screen's selection state. */
|
||||
@Composable
|
||||
internal fun HeroMotionScope(content: @Composable () -> Unit) {
|
||||
val presentation = remember { HeroPresentation() }
|
||||
CompositionLocalProvider(LocalHeroPresentation provides presentation, content = content)
|
||||
}
|
||||
|
||||
private class HeroLayer<T>(val identity: Any, var target: T, initialAlpha: Float) {
|
||||
val alpha = Animatable(initialAlpha)
|
||||
var ready by mutableStateOf(false)
|
||||
var presented = false
|
||||
var outgoing by mutableStateOf(false)
|
||||
}
|
||||
|
||||
private class HeroCopyLayer<T>(val identity: Any, var target: T, initialAlpha: Float) {
|
||||
val alpha = Animatable(initialAlpha)
|
||||
val progress = Animatable(initialAlpha)
|
||||
var outgoing by mutableStateOf(false)
|
||||
}
|
||||
|
||||
/** Keep the painted composite while loading; interruptions fade each retained layer from its current alpha. */
|
||||
@Composable
|
||||
fun <T> HeroArtCrossfade(identity: Any, target: T, height: Dp, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) {
|
||||
var displayed by remember { mutableStateOf(target) }
|
||||
var displayedIdentity by remember { mutableStateOf(identity) }
|
||||
var outgoing by remember { mutableStateOf<T?>(null) }
|
||||
val alpha = remember { Animatable(0f) }
|
||||
val initial = remember { HeroLayer(identity, target, 1f).apply { presented = true } }
|
||||
val layers = remember { mutableStateListOf(initial) }
|
||||
val selected = remember(identity) { layers.firstOrNull { it.identity == identity && it.presented && !it.outgoing } ?: HeroLayer(identity, target, 1f) }
|
||||
val reduced = reducedMotion()
|
||||
var readyIdentity by remember { mutableStateOf(identity) }
|
||||
val latestIdentity by rememberUpdatedState(identity)
|
||||
val onReady = remember(identity) { { if (latestIdentity == identity) readyIdentity = identity } }
|
||||
LaunchedEffect(identity, reduced) {
|
||||
if (displayedIdentity != identity) {
|
||||
outgoing = displayed
|
||||
alpha.snapTo(1f)
|
||||
snapshotFlow { readyIdentity == identity }.first { it }
|
||||
displayed = target
|
||||
displayedIdentity = identity
|
||||
}
|
||||
if (outgoing != null) {
|
||||
alpha.animateTo(0f, tween(motionDuration(M.heroFade, reduced), easing = M.easeInOut))
|
||||
outgoing = null
|
||||
}
|
||||
val presentation = LocalHeroPresentation.current
|
||||
LaunchedEffect(selected, reduced) {
|
||||
layers.removeAll { it !== selected && !it.presented }
|
||||
if (selected !in layers) layers.add(0, selected)
|
||||
snapshotFlow { selected.ready }.first { it }
|
||||
selected.presented = true
|
||||
presentation?.identity = identity
|
||||
layers.forEach { if (it !== selected) it.outgoing = true }
|
||||
}
|
||||
Box(modifier) {
|
||||
CompositionLocalProvider(LocalHeroArtReady provides onReady) { content(target) }
|
||||
outgoing?.let { old ->
|
||||
Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, height).graphicsLayer { this.alpha = alpha.value; clip = true }.testTag("hero-outgoing")) {
|
||||
Box(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { content(old) }
|
||||
layers.forEachIndexed { index, layer -> key(layer) {
|
||||
SideEffect { if (layer.identity == identity) layer.target = target }
|
||||
LaunchedEffect(layer.outgoing, reduced) {
|
||||
if (layer.outgoing) {
|
||||
layer.alpha.animateTo(0f, tween(motionDuration(M.heroFade, reduced), easing = M.easeInOut))
|
||||
layers.remove(layer)
|
||||
}
|
||||
}
|
||||
}
|
||||
val outgoing = index != 0
|
||||
val onReady = remember(layer) { { layer.ready = true } }
|
||||
val bounds = if (outgoing) Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, height) else Modifier
|
||||
Box(bounds.graphicsLayer { alpha = layer.alpha.value; clip = outgoing }
|
||||
.testTag(if (outgoing) "hero-outgoing" else "hero-incoming")
|
||||
.semantics { this[HeroLayerAlpha] = layer.alpha.value; this[HeroLayerTranslation] = 0f; this[HeroLayerIdentity] = layer.identity.toString() }) {
|
||||
CompositionLocalProvider(LocalHeroArtReady provides onReady) {
|
||||
Box(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) {
|
||||
content(if (layer.identity == identity) target else layer.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
} }
|
||||
}
|
||||
}
|
||||
|
||||
/** One bounded copy layer fades out, hands off, then rises; controls keep their logical owner. */
|
||||
/** Copy counter-fades after artwork is ready; outgoing blocks hold their current translation. */
|
||||
@Composable
|
||||
fun <T> HeroCopyHandoff(identity: Any, target: T, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) {
|
||||
val initial = remember { HeroCopyLayer(identity, target, 1f) }
|
||||
val layers = remember { mutableStateListOf(initial) }
|
||||
val selected = remember(identity) { layers.lastOrNull { it.identity == identity && !it.outgoing } ?: HeroCopyLayer(identity, target, 0f) }
|
||||
val reduced = reducedMotion()
|
||||
val alpha = remember { Animatable(0f) }
|
||||
val progress = remember { Animatable(0f) }
|
||||
var displayed by remember { mutableStateOf(target) }
|
||||
var previous by remember { mutableStateOf(identity) }
|
||||
var handingOff by remember { mutableStateOf(false) }
|
||||
var entered by remember { mutableStateOf(false) }
|
||||
val rise = with(androidx.compose.ui.platform.LocalDensity.current) { (if (entered) M.copyRise else M.copyEntryRise).toPx() }
|
||||
LaunchedEffect(identity, reduced) {
|
||||
if (previous != identity || handingOff) {
|
||||
handingOff = true
|
||||
alpha.animateTo(0f, tween(motionDuration(M.heroCopyOut, reduced), easing = M.ease))
|
||||
displayed = target
|
||||
previous = identity
|
||||
progress.snapTo(0f)
|
||||
if (!reduced) kotlinx.coroutines.delay((M.heroCopyDelay - M.heroCopyOut).toLong())
|
||||
kotlinx.coroutines.coroutineScope {
|
||||
launch { alpha.animateTo(1f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut)) }
|
||||
launch { progress.animateTo(1f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut)) }
|
||||
}
|
||||
handingOff = false
|
||||
} else if (!entered) {
|
||||
kotlinx.coroutines.coroutineScope {
|
||||
launch { alpha.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) }
|
||||
launch { progress.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) }
|
||||
}
|
||||
val presentation = LocalHeroPresentation.current
|
||||
val rise = with(LocalDensity.current) { M.copyRise.toPx() }
|
||||
LaunchedEffect(selected, reduced) {
|
||||
if (selected !in layers) {
|
||||
if (presentation != null) snapshotFlow { presentation.identity == identity }.first { it }
|
||||
layers.add(selected)
|
||||
layers.forEach { if (it !== selected) it.outgoing = true }
|
||||
}
|
||||
entered = true
|
||||
displayed = target
|
||||
}
|
||||
Box(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - progress.value) }) {
|
||||
content(if (handingOff || previous != identity) displayed else target)
|
||||
Box(modifier, contentAlignment = Alignment.BottomStart) {
|
||||
layers.forEach { layer -> key(layer) {
|
||||
SideEffect { if (layer.identity == identity) layer.target = target }
|
||||
LaunchedEffect(layer.outgoing, reduced) {
|
||||
if (layer.outgoing) {
|
||||
layer.alpha.animateTo(0f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut))
|
||||
layers.remove(layer)
|
||||
} else if (layer.alpha.value != 1f) coroutineScope {
|
||||
launch { layer.alpha.animateTo(1f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut)) }
|
||||
launch { layer.progress.animateTo(1f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut)) }
|
||||
}
|
||||
}
|
||||
Box(Modifier.graphicsLayer {
|
||||
alpha = layer.alpha.value
|
||||
translationY = if (reduced) 0f else rise * (1f - layer.progress.value)
|
||||
}.testTag(if (layer === layers.last()) "hero-copy" else "hero-copy-outgoing").semantics {
|
||||
this[HeroLayerAlpha] = layer.alpha.value
|
||||
this[HeroLayerTranslation] = if (reduced) 0f else rise * (1f - layer.progress.value)
|
||||
this[HeroLayerIdentity] = layer.identity.toString()
|
||||
}) { content(if (layer.identity == identity) target else layer.target) }
|
||||
} }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import org.justinvisser.wolfskin.design.HomeType as T
|
|||
/** The hero occupies one row while its backdrop extends beneath the first shelves. */
|
||||
@Composable
|
||||
fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRating>, onButton: (Int) -> Unit, onHover: (Int) -> Unit) {
|
||||
HeroMotionScope {
|
||||
Box(Modifier.fillMaxWidth().height(L.row_hero)) {
|
||||
if (!row.skeleton) {
|
||||
val title = row.cards.first()
|
||||
|
|
@ -28,7 +29,8 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
|
|||
}
|
||||
Box(artModifier.background(Brush.verticalGradient(0f to C.bg.copy(alpha = 0f), .52f to C.bg.copy(alpha = 0f), .74f to C.bg.copy(alpha = .6f), 1f to C.bg)))
|
||||
Box(artModifier.background(Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f))))
|
||||
HeroCopyHandoff(title.id, title, Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).width(L.heroCopyWidth)) { copy ->
|
||||
Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).width(L.heroCopyWidth)) {
|
||||
HeroCopyHandoff(title.id, title) { copy ->
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.kickerGap)) {
|
||||
Glyph(if (row.heroKind == "pick") "seerr" else "playback", C.accentText, Modifier.size(L.buttonIcon * (28f / 30f)))
|
||||
|
|
@ -44,6 +46,8 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
|
|||
}
|
||||
Spacer(Modifier.height(H.factsBottom))
|
||||
Text(copy.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(H.overviewBottom))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
val detailsOnly = row.heroKind == "pick" || !row.watchReady
|
||||
|
|
@ -52,7 +56,6 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
|
|||
if (!detailsOnly) PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HomeAmbient(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
|
||||
Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).skeletonPulse()) {
|
||||
|
|
@ -64,6 +67,7 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Discover continues in the same scroller, under the active person's identity. */
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.transitionFactory
|
||||
import coil3.transition.Transition
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||
|
|
@ -54,8 +56,9 @@ fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Mo
|
|||
}
|
||||
else if (path != null) {
|
||||
val context = LocalContext.current
|
||||
val request = remember(context, path, width, height, poster) {
|
||||
val request = remember(context, path, width, height, poster, heroReady != null) {
|
||||
WolfskinImages.request(context, path, width, height, poster)
|
||||
.let { if (heroReady != null) it.newBuilder().transitionFactory(Transition.Factory.NONE).build() else it }
|
||||
}
|
||||
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context),
|
||||
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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.GraphicsMode
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** A catalogue read has no right to move the destination's focus into the root navigation. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class DiscoverDetailsFocusTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test fun heroDetailsRetainsDestinationFocusDuringLoadingAndLateMetadata() {
|
||||
rule.mainClock.autoAdvance = false
|
||||
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") }
|
||||
val snapshot = discoverTestFixture().snapshot("justin")
|
||||
val discover = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "justin"), CoroutineScope(Dispatchers.Main.immediate), shell::enterDestination)
|
||||
val selected = requireNotNull(discover.hero)
|
||||
val title = TitleState(TitlePageData(TitleItem(selected.key, selected.title, selected.type), status = TitleReadStatus.Loading), shell.person) {}
|
||||
val art = android.graphics.Bitmap.createBitmap(8, 8, android.graphics.Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap()
|
||||
rule.setContent { WolfskinTheme {
|
||||
CompositionLocalProvider(LocalDiscoverArt provides { art }) {
|
||||
val modifier = Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)
|
||||
if (shell.route.startsWith("title/")) TitleSurface(title, shell, modifier)
|
||||
else ShellSurface(shell, modifier) { DiscoverScreen(discover, it) }
|
||||
}
|
||||
} }
|
||||
rule.mainClock.advanceTimeBy(640)
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
|
||||
repeat(6) {
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
rule.waitForIdle()
|
||||
assertFalse("Loading cannot steal destination focus on a later frame", shell.navFocused)
|
||||
}
|
||||
assertEquals("title/${android.net.Uri.encode(selected.key)}", shell.route)
|
||||
assertFalse("The destination owns focus even while its read is pending", shell.navFocused)
|
||||
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
|
||||
assertFalse("Down on a loading title must not enter nav or a skeleton", shell.navFocused)
|
||||
rule.runOnIdle { title.update(title.data.copy(status = TitleReadStatus.Ready)) }
|
||||
rule.mainClock.advanceTimeBy(640)
|
||||
val before = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
|
||||
rule.runOnIdle { title.update(title.data.copy(item = title.data.item.copy(overview = "Late overview", ratings = listOf(LibraryRating("rt", 88.0))))) }
|
||||
repeat(24) {
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
assertFalse(shell.navFocused)
|
||||
assertEquals("Loaded actions cannot replay their entry or move after late metadata", before, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.request.ErrorResult
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
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
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
/** Real Discover artwork, copy and pager are sampled on every manually advanced native frame. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class DiscoverHeroSequenceTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private val samples = JSONArray()
|
||||
private lateinit var directory: File
|
||||
private lateinit var state: DiscoverState
|
||||
private lateinit var shell: ShellState
|
||||
private val artAvailable = mutableStateOf(true)
|
||||
private val reduced = mutableStateOf(false)
|
||||
private var previousPixel: Int? = null
|
||||
private var largestJump = 0
|
||||
private var previousLayers = emptyMap<String, Pair<Float, Float>>()
|
||||
private val previousDotWidths = mutableMapOf<Int, Float>()
|
||||
private var previousSelection: Int? = null
|
||||
|
||||
private fun launch(scenario: String, failedArt: Boolean = false) {
|
||||
directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-hero/$scenario").apply { mkdirs() }
|
||||
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
|
||||
rule.mainClock.autoAdvance = false
|
||||
WolfskinImages.configureNetwork(rule.activity) { add(Interceptor { chain ->
|
||||
if (chain.request.data.toString().startsWith("sequence:")) {
|
||||
if (failedArt) ErrorResult(image = null, request = chain.request, throwable = IOException("Controlled artwork failure")) else awaitCancellation()
|
||||
} else chain.proceed()
|
||||
}) }
|
||||
val fixture = discoverTestFixture()
|
||||
val colors = listOf(android.graphics.Color.RED, android.graphics.Color.BLUE, android.graphics.Color.GREEN)
|
||||
val titles = fixture.snapshot("justin").picks.take(3).mapIndexed { index, title ->
|
||||
title.copy(backdrop = "sequence:$index", poster = null, posterOnly = false, score = 100.0 - index)
|
||||
}
|
||||
val art = colors.map { color -> Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(color) }.asImageBitmap() }
|
||||
val snapshot = fixture.snapshot("justin").copy(picks = titles)
|
||||
shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") }
|
||||
state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "justin"), CoroutineScope(Dispatchers.Main.immediate), shell::enterDestination)
|
||||
rule.setContent { WolfskinTheme {
|
||||
CompositionLocalProvider(LocalReducedMotion provides reduced.value, LocalDiscoverArt provides { path ->
|
||||
path.removePrefix("sequence:").toIntOrNull()?.let { if (it != 1 || artAvailable.value) art[it] else null }
|
||||
}) {
|
||||
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { DiscoverScreen(state, it) }
|
||||
}
|
||||
} }
|
||||
rule.mainClock.advanceTimeBy(640)
|
||||
}
|
||||
|
||||
@Test fun interruptedHeroNeverSnapsToAnIntermediateSelection() {
|
||||
launch("interrupted")
|
||||
sample("settled", 0)
|
||||
press(Key.DirectionRight)
|
||||
frames("first", 12)
|
||||
press(Key.DirectionRight)
|
||||
frames("interrupted", 28)
|
||||
press(Key.DirectionLeft)
|
||||
frames("reverse", 28)
|
||||
finish()
|
||||
assertSmoothPixels()
|
||||
assertEquals(1, state.slide)
|
||||
}
|
||||
|
||||
@Test fun slowArtRetainsBothThePaintedHeroAndItsCopyUntilReady() {
|
||||
launch("slow")
|
||||
sample("settled", 0)
|
||||
val original = previousPixel
|
||||
rule.runOnIdle { artAvailable.value = false }
|
||||
press(Key.DirectionRight)
|
||||
frames("pending", 24)
|
||||
assertEquals("Pending art must not replace the painted backdrop", original, previousPixel)
|
||||
assertEquals(state.heroes.first().key, copyIdentities().single())
|
||||
rule.runOnIdle { artAvailable.value = true }
|
||||
frames("ready", 28)
|
||||
finish()
|
||||
assertSmoothPixels()
|
||||
assertEquals(state.hero!!.key, copyIdentities().single())
|
||||
}
|
||||
|
||||
@Test fun failedArtBlendsToItsFinalFallbackWithoutErasingThePreviousHero() {
|
||||
launch("failed", failedArt = true)
|
||||
sample("settled", 0)
|
||||
rule.runOnIdle { artAvailable.value = false }
|
||||
press(Key.DirectionRight)
|
||||
frames("fallback", 28)
|
||||
rule.onNodeWithTag("discover-missing-art").assertExists()
|
||||
finish()
|
||||
assertSmoothPixels()
|
||||
assertEquals(state.hero!!.key, copyIdentities().single())
|
||||
}
|
||||
|
||||
@Test fun repeatedPressesRetargetTheCurrentCompositeWithoutSnapBack() {
|
||||
launch("rapid")
|
||||
sample("settled", 0)
|
||||
listOf(Key.DirectionRight, Key.DirectionRight, Key.DirectionLeft, Key.DirectionLeft, Key.DirectionRight, Key.DirectionRight).forEachIndexed { index, key ->
|
||||
press(key)
|
||||
frames("press-$index", 3)
|
||||
}
|
||||
repeat(48) { index ->
|
||||
press(if (index % 2 == 0) Key.DirectionLeft else Key.DirectionRight)
|
||||
frames("burst-$index", 1)
|
||||
}
|
||||
frames("settled-latest", 28)
|
||||
finish()
|
||||
assertSmoothPixels()
|
||||
assertEquals(state.hero!!.key, copyIdentities().single())
|
||||
}
|
||||
|
||||
@Test fun reducedMotionKeepsTheBlendButRemovesCopyTranslation() {
|
||||
launch("reduced")
|
||||
rule.runOnIdle { reduced.value = true }
|
||||
press(Key.DirectionRight)
|
||||
frames("reduced", (WolfskinTokens.Motion.heroFade + 15) / 16)
|
||||
finish()
|
||||
assertEquals(state.hero!!.key, copyIdentities().single())
|
||||
assertEquals(1, rule.onAllNodesWithTag("hero-incoming", useUnmergedTree = true).fetchSemanticsNodes().size)
|
||||
assertTrue(rule.onAllNodesWithTag("hero-outgoing", useUnmergedTree = true).fetchSemanticsNodes().isEmpty())
|
||||
val moving = (0 until samples.length()).map(samples::getJSONObject).filter { row ->
|
||||
val layers = row.getJSONArray("layers")
|
||||
(0 until layers.length()).map(layers::getJSONObject).any {
|
||||
it.getString("kind") == "hero-outgoing" && it.getDouble("alpha") > 0.0 && it.getDouble("alpha") < 1.0
|
||||
}
|
||||
}.map { it.getLong("timeMs") }
|
||||
assertTrue("Reduced artwork fade cannot retain the ordinary 320ms lifetime", moving.last() - moving.first() <= WolfskinTokens.Motion.reducedFade)
|
||||
}
|
||||
|
||||
private fun copyIdentities() = rule.onAllNodesWithTag("hero-copy", useUnmergedTree = true).fetchSemanticsNodes().map { it.config[HeroLayerIdentity] }
|
||||
private fun press(key: Key) = rule.onNodeWithTag("wolfskin-shell").performKeyInput { keyDown(key); keyUp(key) }
|
||||
private fun frames(phase: String, count: Int) = repeat(count) { index -> rule.mainClock.advanceTimeBy(16); sample(phase, index) }
|
||||
private fun finish() = File(directory, "series.json").writeText(samples.toString(2))
|
||||
private fun assertSmoothPixels() = assertTrue("A 16ms hero frame cannot snap to a full intermediate selection; largest channel jump=$largestJump", largestJump <= 48)
|
||||
|
||||
private fun sample(phase: String, index: Int) {
|
||||
rule.waitForIdle()
|
||||
val file = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||
val pixel = try { bitmap.getPixel(1600, 200) } finally { bitmap.recycle() }
|
||||
val channels = channels(pixel)
|
||||
previousPixel?.let { old -> largestJump = maxOf(largestJump, channels.zip(channels(old)).maxOf { (a, b) -> kotlin.math.abs(a - b) }) }
|
||||
previousPixel = pixel
|
||||
val copy = JSONArray()
|
||||
state.heroes.forEach { title -> rule.onAllNodesWithText(title.title, useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
|
||||
copy.put(JSONObject().put("title", title.key).put("x", node.boundsInRoot.left).put("y", node.boundsInRoot.top))
|
||||
} }
|
||||
val layers = layerSamples()
|
||||
val dots = JSONArray()
|
||||
state.heroes.indices.forEach { dot -> rule.onAllNodesWithTag("hero-dot-$dot", useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
|
||||
previousDotWidths[dot]?.let { previous ->
|
||||
val range = with(rule.density) { (WolfskinTokens.Discover.dotActive - WolfskinTokens.Discover.dotSize).toPx() }
|
||||
if (!reduced.value) assertTrue("Pager retarget must not snap a full dot-width step", kotlin.math.abs(node.boundsInRoot.width - previous) <= range / 2)
|
||||
if (reduced.value) {
|
||||
val rest = with(rule.density) { WolfskinTokens.Discover.dotSize.toPx() }
|
||||
assertTrue("Reduced motion snaps pager geometry instead of moving it", node.boundsInRoot.width == rest || node.boundsInRoot.width == rest + range)
|
||||
}
|
||||
if (previousSelection == state.slide) {
|
||||
if (dot == state.slide) assertTrue("The selected pager dot cannot shrink backwards", node.boundsInRoot.width >= previous - .01f)
|
||||
else assertTrue("An outgoing pager dot cannot grow backwards", node.boundsInRoot.width <= previous + .01f)
|
||||
}
|
||||
}
|
||||
previousDotWidths[dot] = node.boundsInRoot.width
|
||||
dots.put(JSONObject().put("index", dot).put("x", node.boundsInRoot.left).put("y", node.boundsInRoot.top).put("width", node.boundsInRoot.width))
|
||||
} }
|
||||
previousSelection = state.slide
|
||||
samples.put(JSONObject().put("phase", phase).put("frame", index).put("timeMs", rule.mainClock.currentTime)
|
||||
.put("selection", state.slide).put("rgb", JSONArray(channels)).put("copy", copy).put("layers", layers).put("dots", dots).put("navFocused", shell.navFocused))
|
||||
}
|
||||
|
||||
private fun layerSamples(): JSONArray {
|
||||
val result = JSONArray()
|
||||
val current = mutableMapOf<String, Pair<Float, Float>>()
|
||||
val nodes = rule.onAllNodes(SemanticsMatcher.keyIsDefined(HeroLayerAlpha), useUnmergedTree = true).fetchSemanticsNodes()
|
||||
val unique = nodes.groupBy { it.config[SemanticsProperties.TestTag] + ":" + it.id }
|
||||
assertTrue("The artwork composite always has an opaque base", nodes.any { it.config[SemanticsProperties.TestTag] == "hero-incoming" && it.config[HeroLayerAlpha] == 1f })
|
||||
val copyResidual = nodes.filter { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") }
|
||||
.fold(1f) { residual, node -> residual * (1f - node.config[HeroLayerAlpha]) }
|
||||
assertTrue("Copy composite cannot fade through a blank frame", 1f - copyResidual >= .5f)
|
||||
val artCount = nodes.count { !it.config[SemanticsProperties.TestTag].startsWith("hero-copy") }
|
||||
val copyCount = nodes.size - artCount
|
||||
assertTrue("Sustained presses cannot extend outgoing art lifetimes", artCount <= (WolfskinTokens.Motion.heroFade + 15) / 16 + 3)
|
||||
assertTrue("Sustained presses cannot accumulate outgoing copy layers", copyCount <= (WolfskinTokens.Motion.heroCopyIn + 15) / 16 + 3)
|
||||
unique.forEach { (key, matching) -> matching.forEach { node ->
|
||||
val alpha = node.config[HeroLayerAlpha]
|
||||
val translation = node.config[HeroLayerTranslation]
|
||||
val tag = node.config[SemanticsProperties.TestTag]
|
||||
if (matching.size == 1) {
|
||||
previousLayers[key]?.let { (previousAlpha, previousTranslation) ->
|
||||
if (tag == "hero-outgoing" || tag == "hero-copy-outgoing") assertTrue("Outgoing layers cannot restart at full opacity: $key", alpha <= previousAlpha + .001f)
|
||||
if (tag == "hero-copy-outgoing") assertEquals("Outgoing copy must fade where its entry stopped", previousTranslation, translation, .001f)
|
||||
if (tag == "hero-copy") {
|
||||
assertTrue("Copy alpha cannot snap backwards: $key", alpha >= previousAlpha - .001f)
|
||||
assertTrue("Copy translation cannot snap backwards: $key", translation <= previousTranslation + .001f)
|
||||
}
|
||||
}
|
||||
current[key] = alpha to translation
|
||||
}
|
||||
if (reduced.value) assertEquals(0f, translation, .001f)
|
||||
result.put(JSONObject().put("kind", tag).put("identity", node.config[HeroLayerIdentity]).put("layerId", node.id).put("alpha", alpha)
|
||||
.put("translationY", translation).put("x", node.boundsInRoot.left).put("y", node.boundsInRoot.top))
|
||||
} }
|
||||
previousLayers = current
|
||||
return result
|
||||
}
|
||||
|
||||
private fun channels(pixel: Int) = listOf(android.graphics.Color.red(pixel), android.graphics.Color.green(pixel), android.graphics.Color.blue(pixel))
|
||||
}
|
||||
|
|
@ -2,6 +2,9 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import android.view.KeyEvent as AndroidKeyEvent
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
|
|
@ -19,10 +22,12 @@ import org.junit.Test
|
|||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** Acceptance data drives real Android events through the shell's production focus adapter. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class DiscoverKeysTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
private val fixture = discoverTestFixture()
|
||||
|
|
@ -33,11 +38,14 @@ class DiscoverKeysTest {
|
|||
{ shell.enterDestination("discover") }, fixture::service)
|
||||
|
||||
private fun launch() {
|
||||
val art = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap()
|
||||
rule.setContent {
|
||||
WolfskinTheme {
|
||||
CompositionLocalProvider(LocalDiscoverArt provides { art }) {
|
||||
ShellSurface(shell, overlay = { DiscoverOverlay(state) }) { modifier ->
|
||||
if (shell.route == "discover" || shell.route.startsWith("discover-") || shell.route.startsWith("title/")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -256,7 +264,69 @@ class DiscoverKeysTest {
|
|||
assertNull(state.collection)
|
||||
assertSame(origin, shell.view)
|
||||
assertEquals(3, shell.view.row)
|
||||
assertEquals(2, shell.view.cards[state.rowKey])
|
||||
assertEquals(2, state.column)
|
||||
}
|
||||
|
||||
@Test fun firstEntryStartsLeftmostAndOnlyMovedLanesRememberTheirPosition() {
|
||||
replay("Open Discover")
|
||||
press(Key.DirectionDown, Key.DirectionDown)
|
||||
assertEquals(0, state.column)
|
||||
repeat(7) { press(Key.DirectionRight) }
|
||||
val moved = state.column
|
||||
assertTrue(moved > 0)
|
||||
press(Key.DirectionDown)
|
||||
assertEquals("An untouched lane starts at its own left edge, not the previous lane's column", 0, state.column)
|
||||
press(Key.DirectionUp)
|
||||
assertEquals(moved, state.column)
|
||||
}
|
||||
|
||||
@Test fun leavingTheTabResetsLaneMovementButPushedTitlesDoNot() {
|
||||
replay("Open Discover")
|
||||
press(Key.DirectionDown, Key.DirectionDown)
|
||||
press(Key.DirectionDown)
|
||||
repeat(4) { press(Key.DirectionRight) }
|
||||
val row = state.focusedRow
|
||||
val moved = state.column
|
||||
press(Key.DirectionCenter)
|
||||
assertNotNull("This round trip must open a title, not a service lane", state.title)
|
||||
press(Key.Back)
|
||||
assertEquals(moved, state.column)
|
||||
press(Key.Back)
|
||||
press(Key.DirectionLeft, Key.DirectionCenter)
|
||||
assertEquals("home", shell.route)
|
||||
press(Key.DirectionUp, Key.DirectionRight)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals("discover", shell.route)
|
||||
assertEquals(row, state.focusedRow)
|
||||
assertEquals(0, state.column)
|
||||
assertEquals(0, state.laneFirsts[state.rowKey] ?: 0)
|
||||
}
|
||||
|
||||
@Test fun rebuildingDiscoverDoesNotReuseThePreviousInstancesLaneColumns() {
|
||||
shell.pickerVisible = false
|
||||
shell.enterDestination("discover")
|
||||
state.focus(state.firstShelf, 4)
|
||||
val rebuilt = DiscoverState(shell, snapshot, operations, CoroutineScope(Dispatchers.Main.immediate))
|
||||
assertEquals(0, rebuilt.column)
|
||||
}
|
||||
|
||||
@Test fun serviceLanesKeepIndependentPositionsAndResetWithTheRootVisit() {
|
||||
shell.pickerVisible = false
|
||||
shell.enterDestination("discover")
|
||||
state.focus(state.firstShelf + 1, 3)
|
||||
state.openService(snapshot.services[0])
|
||||
state.focus(state.firstShelf + 1, 4)
|
||||
state.key(ShellKey.Back)
|
||||
assertEquals("A service must not overwrite the root lane", 3, state.column)
|
||||
state.openService(snapshot.services[1])
|
||||
state.key(ShellKey.Down)
|
||||
assertEquals("Different services can reuse lane ids without sharing columns", 0, state.column)
|
||||
state.key(ShellKey.Back)
|
||||
state.openService(snapshot.services[0])
|
||||
state.key(ShellKey.Down)
|
||||
assertEquals(4, state.column)
|
||||
state.enterRoute("home")
|
||||
assertEquals("Leaving Discover clears service positions too", 0, state.column)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import coil3.asImage
|
||||
import coil3.decode.DataSource
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.request.SuccessResult
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
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
|
||||
import java.io.File
|
||||
|
||||
/** Network-success artwork must not add Coil's independent alpha ramp beneath the hero blend. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class DiscoverNetworkHeroTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test fun delayedNetworkSuccessCannotDipThePaintedHeroOpacity() {
|
||||
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
|
||||
rule.mainClock.autoAdvance = false
|
||||
val red = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.RED) }.asImageBitmap()
|
||||
val blue = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.BLUE) }.asImage()
|
||||
val release = CompletableDeferred<Unit>()
|
||||
WolfskinImages.configureNetwork(rule.activity) { add(Interceptor { chain ->
|
||||
if (chain.request.data == "network:blue") {
|
||||
release.await()
|
||||
SuccessResult(blue, chain.request, DataSource.NETWORK)
|
||||
} else chain.proceed()
|
||||
}) }
|
||||
val fixture = discoverTestFixture()
|
||||
val titles = fixture.snapshot("justin").picks.take(2).mapIndexed { index, title ->
|
||||
title.copy(backdrop = if (index == 0) "network:red" else "network:blue", poster = null, posterOnly = false, score = 100.0 - index)
|
||||
}
|
||||
val snapshot = fixture.snapshot("justin").copy(picks = titles)
|
||||
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") }
|
||||
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "justin"), CoroutineScope(Dispatchers.Main.immediate))
|
||||
rule.setContent { WolfskinTheme {
|
||||
CompositionLocalProvider(LocalDiscoverArt provides { path -> if (path == "network:red") red else null }) {
|
||||
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { DiscoverScreen(state, it) }
|
||||
}
|
||||
} }
|
||||
rule.mainClock.advanceTimeBy(640)
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { keyDown(Key.DirectionRight); keyUp(Key.DirectionRight) }
|
||||
rule.mainClock.advanceTimeBy(192)
|
||||
rule.onNodeWithText(titles.first().title).assertIsDisplayed()
|
||||
val directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-network-hero").apply { mkdirs() }
|
||||
val samples = JSONArray()
|
||||
rule.runOnIdle { release.complete(Unit) }
|
||||
var lastPixel = 0
|
||||
repeat(40) { index ->
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
val file = File(directory, "network-${index.toString().padStart(2, '0')}.png")
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||
lastPixel = try { bitmap.getPixel(1600, 200) } finally { bitmap.recycle() }
|
||||
val redChannel = android.graphics.Color.red(lastPixel)
|
||||
val blueChannel = android.graphics.Color.blue(lastPixel)
|
||||
samples.put(JSONObject().put("timeMs", rule.mainClock.currentTime).put("red", redChannel).put("blue", blueChannel))
|
||||
assertTrue("A second network-image fade exposes the dark placeholder beneath both heroes at frame $index: red=$redChannel blue=$blueChannel", redChannel + blueChannel >= 250)
|
||||
}
|
||||
File(directory, "series.json").writeText(samples.toString(2))
|
||||
assertEquals(android.graphics.Color.BLUE, lastPixel)
|
||||
rule.onNodeWithText(titles.last().title).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,9 @@ import androidx.compose.runtime.mutableStateOf
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
|
|
@ -82,6 +85,35 @@ class HeroMotionTest {
|
|||
} finally { directory.deleteRecursively() }
|
||||
}
|
||||
|
||||
@Test fun homeHeroRetainsTheOutgoingCopyAnchorWhenTheNextTitleWraps() {
|
||||
rule.mainClock.autoAdvance = false
|
||||
val first = HomeTitle("first", "Short title", "movie", backdrop = "red")
|
||||
val next = HomeTitle("next", "A much longer title that must wrap onto a second line in this hero", "movie", backdrop = "blue")
|
||||
val selected = mutableStateOf(first)
|
||||
val art = mapOf("red" to Color.Red, "blue" to Color.Blue).mapValues { (_, color) ->
|
||||
android.graphics.Bitmap.createBitmap(8, 8, android.graphics.Bitmap.Config.ARGB_8888).apply { eraseColor(color.toArgb()) }.asImageBitmap()
|
||||
}
|
||||
rule.setContent { WolfskinTheme {
|
||||
CompositionLocalProvider(LocalTitleBitmaps provides art) {
|
||||
Box(Modifier.requiredSize(960.dp, 540.dp)) {
|
||||
HomeHero(HomeRow("hero", HomeRowKind.Hero, cards = listOf(selected.value)), true, 0, emptyList(), {}, {})
|
||||
}
|
||||
}
|
||||
} }
|
||||
rule.mainClock.advanceTimeBy(640)
|
||||
val anchor = rule.onNodeWithText(first.title).fetchSemanticsNode().boundsInRoot
|
||||
val actionAnchor = rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot
|
||||
update { selected.value = next }
|
||||
repeat(8) {
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
assertEquals("A taller incoming copy cannot move the outgoing title", anchor, rule.onNodeWithText(first.title).fetchSemanticsNode().boundsInRoot)
|
||||
assertEquals("Hero actions remain stationary outside the copy handoff", actionAnchor, rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot)
|
||||
}
|
||||
rule.mainClock.advanceTimeBy(320)
|
||||
rule.onNodeWithText(first.title).assertDoesNotExist()
|
||||
rule.onNodeWithText(next.title).assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun update(block: () -> Unit) {
|
||||
rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue