Restore sequential hero copy and first-appearance entry
This commit is contained in:
parent
7bfa431350
commit
79f64f6e18
5 changed files with 226 additions and 53 deletions
|
|
@ -66,16 +66,16 @@ Home heroes and shelves, Library rows, Discover hero/lanes, title headers, episo
|
|||
| Poster caption in / out | 60 ms delay + 180 ms ease-out / 100 ms ease-out | 10 px rise on entry only; exit holds its position, including when entry was interrupted |
|
||||
| Network artwork only | 160 ms, Coil painter alpha | None; memory, disk and local hits are instant |
|
||||
| Row/tab content reveal | 180 ms, ease-out | One layer per row, 24 px rise |
|
||||
| Initial title copy | 180 ms, ease-out | 16 px rise; already-loaded Home/Discover hero copy paints immediately |
|
||||
| Initial hero/title copy | 180 ms, ease-out | 16 px rise (`copyEntryRise`); saved hero entry state suppresses replay on remount, not first appearance |
|
||||
| Hero backdrop hand-off | 320 ms, ease-in-out (`.65,0,.35,1`) | Alpha only; retain previous art until incoming art or its final fallback is ready |
|
||||
| Hero copy hand-off | Incoming and outgoing alpha counter-fade over 200 ms ease-out, after artwork or final fallback is ready | 14 px rise for incoming copy only; interrupted outgoing copy holds its current translation |
|
||||
| Hero copy hand-off | Outgoing 120 ms ease (`.25,.1,.25,1`); incoming starts after 140 ms and enters over 200 ms ease-out, gated on artwork or final fallback readiness | One copy slot: no overlapping text; 14 px incoming rise, outgoing translation held where entry stopped |
|
||||
| Late ratings, genres, overview and readiness | 200 ms, ease | Alpha only, inside existing measured slots; rating plates keep their existing plate fade rather than adding a nested fade |
|
||||
| Horizontal / vertical focus-follow scroll | 160 ms ease / 180 ms ease-out | Existing scroll geometry |
|
||||
| Discover pager | 180 ms, ease-out, retargeting current width and opacity | Buttons and pager remain outside the copy transition; reduced motion snaps width and caps opacity fades at 80 ms |
|
||||
|
||||
Row reveals are grouped rather than the browser prototype's per-card staging: the armv7 frame budget permits one animated layer per shelf, not one per card. Tabs have no full-screen transition layer. The upper Home hero blend is bounded to **1920×820 px**, Discover to **1920×740 px**; the entire painted backdrop stays static while incoming artwork loads, and the lower backdrop changes only when artwork or its final fallback is ready, without an animated screen-sized layer. Hero copy is a separate bounded block with stationary action controls. Ordinary image reveal uses Coil's painter; hero-controlled requests bypass that independent fade so only the token-backed hero blend owns the transition. Existing poster-only blur is static.
|
||||
|
||||
The title page's scroll dim is a draw-only colour overlay, not an alpha-animated full-screen layer. Discover scrolls its backdrop through placement offsets rather than a full-screen translation layer. Initial loading animated layers are Home **2**, Library **up to 3** lazy rows, Discover **2**, title header **1**, person **2**; pending title episode/cast shelves add **1 each**, and each pending Discover lane owns **1**. Deep Home loading is bounded to **4** visible shelf layers. These are animated layers, not independent pulse clocks or every static display-list node: one shared pulse clock drives the loading screen without adding a layer. An ordinary hero hand-off animates **3** bounded owners (outgoing art and two copy blocks) above an opaque artwork base. Interruptions retain the painted composite without resetting alpha; each outgoing owner keeps its own original 320 ms artwork / 200 ms copy lifetime rather than restarting with later presses. Focus changes can animate **2** card transforms, **1** caption and at most **2** rating plates; ordinary artwork fades are painter draws without additional compositing layers. Late title metadata adds at most **4** bounded alpha layers: title ratings, play-target ratings, genres and overview. No animated owner is full-screen.
|
||||
The title page's scroll dim is a draw-only colour overlay, not an alpha-animated full-screen layer. Discover scrolls its backdrop through placement offsets rather than a full-screen translation layer. Initial loading animated layers are Home **2**, Library **up to 3** lazy rows, Discover **2**, title header **1**, person **2**; pending title episode/cast shelves add **1 each**, and each pending Discover lane owns **1**. Deep Home loading is bounded to **4** visible shelf layers. These are animated layers, not independent pulse clocks or every static display-list node: one shared pulse clock drives the loading screen without adding a layer. An ordinary hero hand-off animates **2** bounded owners (outgoing art and one sequential copy block) above an opaque artwork base. Art interruptions retain the painted composite without resetting alpha; each outgoing art owner keeps its original 320 ms lifetime. Copy retargets its current alpha, fades out before replacing text, then uses the approved delay/entry tokens; never two copy layers are visible together. Focus changes can animate **2** card transforms, **1** caption and at most **2** rating plates; ordinary artwork fades are painter draws without additional compositing layers. Late title metadata adds at most **4** bounded alpha layers: title ratings, play-target ratings, genres and overview. No animated owner is full-screen.
|
||||
|
||||
The approved title follow-up (2026-11-07) supersedes the fixed readiness reservation: an absent readiness line occupies **0 px**, while a present line occupies **56 px (28 dp)**. Late arrival/removal animates that height over the **200 ms late-line duration with token ease-out**; reduced motion snaps. Overview, actions and following rows move monotonically with that band; an already-pinned focused row stays pinned through same-remeasure scroll compensation. Ratings remain inside the fixed-height facts band and appear once: an explicit episode page uses its opened episode's own set, including the episode score, rather than the series-only set. Series and episode pages have no redundant Seasons shortcut; Down reaches the season chips. Skeletons have no click/hover focus targets; pending episode/cast rows remain absent from the key-navigation state until real cards exist. Initial title/person loading retains destination focus rather than focusing the root nav; Down cannot enter a skeleton, and Back still pops the route without opening the picker. An already-ready catalogue or held title stays Ready while a same-route, same-person refresh reads new data; the existing signed-in authentication guard remains authoritative, and loaded copy/rows do not remount or replay their entry motion.
|
||||
|
||||
|
|
@ -85,7 +85,7 @@ Discover key traversal skips pending lanes in both directions. If every remainin
|
|||
|
||||
Native **1920×1080** before/after frames and the frame-budget inventory are published at [the motion build proof](https://pages.justinvisser.org/wolfskin/tv-motion-build/). Before frames use the pre-change surface sources in this same isolated worktree; after frames use the shipped Compose surfaces. No device access, installation or optimized APK is part of this lane. On-box acceptance remains: no series layout change after first content, Home scrolling no worse than **39.6% janky / p90 65 ms**. Native JVM rendering does not measure that GPU/frame-timing target.
|
||||
|
||||
The [round-2 Discover native proof](https://pages.justinvisser.org/wolfskin/tv-round2-discover/) publishes paired 1920×1080 frame strips and exact 16 ms alpha/translation/position series for ready, slow, failed, sustained rapid-selection and reduced-motion scenarios. Both versions use identical fixture data, immediate key-down/up, and the same selection timestamps. The regression suite checks an opaque artwork base, visible copy composite, monotonic per-layer transitions, stationary controls and token-bounded outgoing lifetimes. These are native-rendering proofs, not on-box frame-pacing measurements.
|
||||
The [round-2 Discover native proof](https://pages.justinvisser.org/wolfskin/tv-round2-discover/) publishes paired 1920×1080 frame strips and exact 16 ms alpha/translation/position series for ready, slow, failed, sustained rapid-selection and reduced-motion scenarios. The reviewed counter-fade remains available separately for comparison with the corrected sequential copy; initial-entry/remount and Home hero series are also published. The regression suite checks an opaque artwork base, at most one visible copy, monotonic per-phase motion, stationary controls and token-bounded outgoing artwork lifetimes. The approved fade-through may briefly have no visible copy; it never paints overlapping titles. These are native-rendering proofs, not on-box frame-pacing measurements.
|
||||
|
||||
|
||||
## Card metadata and bottom state line — revision 6, option B
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import androidx.compose.runtime.key
|
|||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
|
|
@ -27,6 +29,9 @@ import androidx.compose.ui.semantics.semantics
|
|||
import androidx.compose.ui.unit.Dp
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.launch
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
|
||||
|
||||
|
|
@ -55,11 +60,7 @@ private class HeroLayer<T>(val identity: Any, var target: T, initialAlpha: Float
|
|||
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)
|
||||
}
|
||||
private class HeroCopyTarget<T>(val identity: Any, var target: T)
|
||||
|
||||
/** Keep the painted composite while loading; interruptions fade each retained layer from its current alpha. */
|
||||
@Composable
|
||||
|
|
@ -102,42 +103,59 @@ fun <T> HeroArtCrossfade(identity: Any, target: T, height: Dp, modifier: Modifie
|
|||
}
|
||||
}
|
||||
|
||||
/** Copy counter-fades after artwork is ready; outgoing blocks hold their current translation. */
|
||||
/** One copy slot fades through after artwork readiness; no two titles can be painted together. */
|
||||
@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) }
|
||||
var entered by rememberSaveable { mutableStateOf(false) }
|
||||
val selected = remember(identity) { HeroCopyTarget(identity, target) }
|
||||
val currentSelected by rememberUpdatedState(selected)
|
||||
var displayed by remember { mutableStateOf(selected) }
|
||||
val alpha = remember { Animatable(if (entered) 1f else 0f) }
|
||||
val progress = remember { Animatable(if (entered) 1f else 0f) }
|
||||
var initialEntry by remember { mutableStateOf(!entered) }
|
||||
var handingOff by remember { mutableStateOf(false) }
|
||||
var outgoing by remember { mutableStateOf(false) }
|
||||
val reduced = reducedMotion()
|
||||
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 }
|
||||
}
|
||||
}
|
||||
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)) }
|
||||
val rise = with(LocalDensity.current) { (if (initialEntry) M.copyEntryRise else M.copyRise).toPx() }
|
||||
SideEffect { selected.target = target }
|
||||
LaunchedEffect(presentation, reduced) {
|
||||
snapshotFlow { currentSelected.takeIf { presentation == null || presentation.identity == it.identity } }
|
||||
.filterNotNull()
|
||||
.distinctUntilChangedBy { it.identity }
|
||||
.collectLatest { next ->
|
||||
if (!entered) {
|
||||
displayed = next
|
||||
entered = true
|
||||
} else if (displayed.identity != next.identity || handingOff) {
|
||||
handingOff = true
|
||||
outgoing = true
|
||||
coroutineScope {
|
||||
val fade = if (alpha.value > 0f) launch {
|
||||
alpha.animateTo(0f, tween(motionDuration(M.heroCopyOut, reduced), easing = M.ease))
|
||||
} else null
|
||||
if (!reduced) kotlinx.coroutines.delay(M.heroCopyDelay.toLong())
|
||||
fade?.join()
|
||||
}
|
||||
outgoing = false
|
||||
displayed = next
|
||||
initialEntry = false
|
||||
progress.snapTo(0f)
|
||||
}
|
||||
if (alpha.value != 1f || progress.value != 1f) coroutineScope {
|
||||
val duration = motionDuration(if (initialEntry) M.rowEnter else M.heroCopyIn, reduced)
|
||||
launch { alpha.animateTo(1f, tween(duration, easing = M.easeOut)) }
|
||||
launch { progress.animateTo(1f, tween(duration, easing = M.easeOut)) }
|
||||
}
|
||||
handingOff = false
|
||||
}
|
||||
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) }
|
||||
} }
|
||||
}
|
||||
Box(modifier.graphicsLayer {
|
||||
this.alpha = alpha.value
|
||||
translationY = if (reduced) 0f else rise * (1f - progress.value)
|
||||
}.testTag(if (outgoing) "hero-copy-outgoing" else "hero-copy").semantics {
|
||||
this[HeroLayerAlpha] = alpha.value
|
||||
this[HeroLayerTranslation] = if (reduced) 0f else rise * (1f - progress.value)
|
||||
this[HeroLayerIdentity] = displayed.identity.toString()
|
||||
}) { content(if (displayed.identity == identity) target else displayed.target) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import androidx.activity.ComponentActivity
|
|||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.input.key.Key
|
||||
|
|
@ -46,13 +47,14 @@ class DiscoverHeroSequenceTest {
|
|||
private val artAvailable = mutableStateOf(true)
|
||||
private val reduced = mutableStateOf(false)
|
||||
private var previousPixel: Int? = null
|
||||
private val mounted = mutableStateOf(true)
|
||||
private var lowerPixel = 0
|
||||
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) {
|
||||
private fun launch(scenario: String, failedArt: Boolean = false, settle: Boolean = true) {
|
||||
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")
|
||||
|
|
@ -73,13 +75,37 @@ class DiscoverHeroSequenceTest {
|
|||
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 {
|
||||
val saved = rememberSaveableStateHolder()
|
||||
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) }
|
||||
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
|
||||
if (mounted.value) saved.SaveableStateProvider("discover") { DiscoverScreen(state, modifier) }
|
||||
}
|
||||
}
|
||||
} }
|
||||
rule.mainClock.advanceTimeBy(640)
|
||||
if (settle) rule.mainClock.advanceTimeBy(640) else rule.mainClock.advanceTimeByFrame()
|
||||
}
|
||||
|
||||
@Test fun initialCopyEntersOnceAndDoesNotReplayOnDestinationRemount() {
|
||||
launch("initial", settle = false)
|
||||
sample("initial", 0)
|
||||
fun copy() = rule.onAllNodes(SemanticsMatcher.keyIsDefined(HeroLayerAlpha), useUnmergedTree = true)
|
||||
.fetchSemanticsNodes().single { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") }
|
||||
assertEquals(0f, copy().config[HeroLayerAlpha], .001f)
|
||||
assertEquals(with(rule.density) { WolfskinTokens.Motion.copyEntryRise.toPx() }, copy().config[HeroLayerTranslation], .001f)
|
||||
frames("entry", 16)
|
||||
assertEquals(1f, copy().config[HeroLayerAlpha], .001f)
|
||||
rule.runOnIdle { mounted.value = false }
|
||||
frames("absent", 1)
|
||||
rule.runOnIdle { mounted.value = true }
|
||||
repeat(8) { frame ->
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
sample("remount", frame)
|
||||
assertEquals("Returning to a saved destination cannot replay its entrance", 1f, copy().config[HeroLayerAlpha], .001f)
|
||||
assertEquals(0f, copy().config[HeroLayerTranslation], .001f)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
@Test fun interruptedHeroNeverSnapsToAnIntermediateSelection() {
|
||||
|
|
@ -206,7 +232,7 @@ class DiscoverHeroSequenceTest {
|
|||
} }
|
||||
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("lowerRgb", JSONArray(channels(lowerPixel))).put("copy", copy).put("layers", layers).put("dots", dots).put("navFocused", shell.navFocused))
|
||||
.put("selection", state.slide).put("mounted", mounted.value).put("rgb", JSONArray(channels)).put("lowerRgb", JSONArray(channels(lowerPixel))).put("copy", copy).put("layers", layers).put("dots", dots).put("navFocused", shell.navFocused))
|
||||
}
|
||||
|
||||
private fun layerSamples(): JSONArray {
|
||||
|
|
@ -214,14 +240,11 @@ class DiscoverHeroSequenceTest {
|
|||
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)
|
||||
if (mounted.value) assertTrue("The artwork composite always has an opaque base", nodes.any { it.config[SemanticsProperties.TestTag] == "hero-incoming" && it.config[HeroLayerAlpha] == 1f })
|
||||
val visibleCopy = nodes.count { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") && it.config[HeroLayerAlpha] > 0f }
|
||||
assertTrue("Sequential copy cannot paint overlapping titles, including interruptions", visibleCopy <= 1)
|
||||
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]
|
||||
|
|
@ -229,10 +252,10 @@ class DiscoverHeroSequenceTest {
|
|||
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-outgoing" && alpha > 0f) assertEquals("Visible 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)
|
||||
if (alpha > 0f && previousAlpha > 0f) assertTrue("Visible copy translation cannot snap backwards", translation <= previousTranslation + .001f)
|
||||
}
|
||||
}
|
||||
current[key] = alpha to translation
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ class HeroMotionTest {
|
|||
val anchor = rule.onNodeWithText(first.title).fetchSemanticsNode().boundsInRoot
|
||||
val actionAnchor = rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot
|
||||
update { selected.value = next }
|
||||
repeat(8) {
|
||||
repeat(WolfskinTokens.Motion.heroCopyOut / 16) {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
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.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.saveable.rememberSaveableStateHolder
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
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
|
||||
|
||||
/** Real Home hero entry, interrupted fade-through and saved remount sampled at native 16 ms steps. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class HomeHeroSequenceTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private val first = HomeTitle("first", "Short title", "movie", backdrop = "red")
|
||||
private val next = HomeTitle("next", "A much longer title that must wrap onto a second line in this hero", "movie", backdrop = "blue")
|
||||
private val third = HomeTitle("third", "The final selected title", "movie", backdrop = "green")
|
||||
private val selected = mutableStateOf(first)
|
||||
private val reduced = mutableStateOf(false)
|
||||
private val mounted = mutableStateOf(true)
|
||||
private val samples = JSONArray()
|
||||
private lateinit var directory: File
|
||||
private lateinit var actionBounds: Rect
|
||||
|
||||
@Test fun initialSequentialBurstAndRemountKeepHomeCopyReadable() {
|
||||
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
|
||||
directory = File(System.getProperty("wolfskin.captureDirectory"), "home-hero-copy").apply { mkdirs() }
|
||||
val art = mapOf("red" to android.graphics.Color.RED, "blue" to android.graphics.Color.BLUE, "green" to android.graphics.Color.GREEN).mapValues { (_, color) ->
|
||||
Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(color) }.asImageBitmap()
|
||||
}
|
||||
rule.setContent { WolfskinTheme {
|
||||
val saved = rememberSaveableStateHolder()
|
||||
CompositionLocalProvider(LocalReducedMotion provides reduced.value, LocalTitleBitmaps provides art) {
|
||||
Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).background(WolfskinTokens.Color.bg)) {
|
||||
if (mounted.value) saved.SaveableStateProvider("home-hero") {
|
||||
HomeHero(HomeRow("hero", HomeRowKind.Hero, cards = listOf(selected.value)), true, 0, emptyList(), {}, {})
|
||||
}
|
||||
}
|
||||
}
|
||||
} }
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
actionBounds = rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot
|
||||
sample("initial", 0)
|
||||
assertEquals(0f, copy().config[HeroLayerAlpha], .001f)
|
||||
assertEquals(with(rule.density) { WolfskinTokens.Motion.copyEntryRise.toPx() }, copy().config[HeroLayerTranslation], .001f)
|
||||
frames("entry", 16)
|
||||
assertEquals(1f, copy().config[HeroLayerAlpha], .001f)
|
||||
rule.runOnIdle { selected.value = next }
|
||||
frames("handoff", 26)
|
||||
assertEquals(next.id, copy().config[HeroLayerIdentity])
|
||||
assertEquals(1f, copy().config[HeroLayerAlpha], .001f)
|
||||
repeat(24) { index ->
|
||||
rule.runOnIdle { selected.value = if (index % 2 == 0) first else third }
|
||||
frames("burst-$index", 1)
|
||||
}
|
||||
frames("settled", 28)
|
||||
assertEquals(third.id, copy().config[HeroLayerIdentity])
|
||||
assertEquals(1f, copy().config[HeroLayerAlpha], .001f)
|
||||
rule.runOnIdle { reduced.value = true; selected.value = next }
|
||||
frames("reduced", 20)
|
||||
assertEquals(next.id, copy().config[HeroLayerIdentity])
|
||||
assertEquals(1f, copy().config[HeroLayerAlpha], .001f)
|
||||
rule.runOnIdle { mounted.value = false }
|
||||
frames("absent", 1)
|
||||
rule.runOnIdle { mounted.value = true }
|
||||
repeat(8) { frame ->
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
sample("remount", frame)
|
||||
assertEquals("Returning Home cannot replay the initial entrance", 1f, copy().config[HeroLayerAlpha], .001f)
|
||||
assertEquals(0f, copy().config[HeroLayerTranslation], .001f)
|
||||
}
|
||||
File(directory, "series.json").writeText(samples.toString(2))
|
||||
}
|
||||
|
||||
private fun frames(phase: String, count: Int) = repeat(count) { frame -> rule.mainClock.advanceTimeBy(16); sample(phase, frame) }
|
||||
private fun copy() = rule.onAllNodes(SemanticsMatcher.keyIsDefined(HeroLayerAlpha), useUnmergedTree = true).fetchSemanticsNodes()
|
||||
.single { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") }
|
||||
|
||||
private fun sample(phase: String, frame: Int) {
|
||||
rule.waitForIdle()
|
||||
val file = File(directory, "$phase-${frame.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 nodes = rule.onAllNodes(SemanticsMatcher.keyIsDefined(HeroLayerAlpha), useUnmergedTree = true).fetchSemanticsNodes()
|
||||
val copyNodes = nodes.filter { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") }
|
||||
assertTrue("Home must never overlap visible titles, even during bursts", copyNodes.count { it.config[HeroLayerAlpha] > 0f } <= 1)
|
||||
if (mounted.value) {
|
||||
assertTrue("Home artwork retains an opaque base", nodes.any { it.config[SemanticsProperties.TestTag] == "hero-incoming" && it.config[HeroLayerAlpha] == 1f })
|
||||
assertEquals("Copy motion cannot move Home's controls", actionBounds, rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot)
|
||||
}
|
||||
val layers = JSONArray()
|
||||
nodes.forEach { node ->
|
||||
val translation = node.config[HeroLayerTranslation]
|
||||
if (reduced.value) assertEquals(0f, translation, .001f)
|
||||
layers.put(JSONObject().put("kind", node.config[SemanticsProperties.TestTag]).put("identity", node.config[HeroLayerIdentity])
|
||||
.put("layerId", node.id).put("alpha", node.config[HeroLayerAlpha]).put("translationY", translation)
|
||||
.put("x", node.boundsInRoot.left).put("y", node.boundsInRoot.top))
|
||||
}
|
||||
samples.put(JSONObject().put("phase", phase).put("frame", frame).put("timeMs", rule.mainClock.currentTime)
|
||||
.put("mounted", mounted.value).put("selection", selected.value.id).put("reduced", reduced.value)
|
||||
.put("rgb", JSONArray(listOf(android.graphics.Color.red(pixel), android.graphics.Color.green(pixel), android.graphics.Color.blue(pixel))))
|
||||
.put("actionX", actionBounds.left).put("actionY", actionBounds.top).put("layers", layers))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue