Hold the full painted backdrop until incoming hero art is ready
This commit is contained in:
parent
60b0c307aa
commit
9988a193de
4 changed files with 24 additions and 9 deletions
|
|
@ -73,7 +73,7 @@ Home heroes and shelves, Library rows, Discover hero/lanes, title headers, episo
|
||||||
| Horizontal / vertical focus-follow scroll | 160 ms ease / 180 ms ease-out | Existing scroll geometry |
|
| 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 |
|
| 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 lower backdrop changes 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.
|
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 **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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ fun <T> HeroArtCrossfade(identity: Any, target: T, height: Dp, modifier: Modifie
|
||||||
layers.forEach { if (it !== selected) it.outgoing = true }
|
layers.forEach { if (it !== selected) it.outgoing = true }
|
||||||
}
|
}
|
||||||
Box(modifier) {
|
Box(modifier) {
|
||||||
layers.forEachIndexed { index, layer -> key(layer) {
|
layers.forEach { layer -> key(layer) {
|
||||||
SideEffect { if (layer.identity == identity) layer.target = target }
|
SideEffect { if (layer.identity == identity) layer.target = target }
|
||||||
LaunchedEffect(layer.outgoing, reduced) {
|
LaunchedEffect(layer.outgoing, reduced) {
|
||||||
if (layer.outgoing) {
|
if (layer.outgoing) {
|
||||||
|
|
@ -86,7 +86,7 @@ fun <T> HeroArtCrossfade(identity: Any, target: T, height: Dp, modifier: Modifie
|
||||||
layers.remove(layer)
|
layers.remove(layer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val outgoing = index != 0
|
val outgoing = layer.outgoing
|
||||||
val onReady = remember(layer) { { layer.ready = true } }
|
val onReady = remember(layer) { { layer.ready = true } }
|
||||||
val bounds = if (outgoing) Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, height) else Modifier
|
val bounds = if (outgoing) Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, height) else Modifier
|
||||||
Box(bounds.graphicsLayer { alpha = layer.alpha.value; clip = outgoing }
|
Box(bounds.graphicsLayer { alpha = layer.alpha.value; clip = outgoing }
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ class DiscoverHeroSequenceTest {
|
||||||
private val artAvailable = mutableStateOf(true)
|
private val artAvailable = mutableStateOf(true)
|
||||||
private val reduced = mutableStateOf(false)
|
private val reduced = mutableStateOf(false)
|
||||||
private var previousPixel: Int? = null
|
private var previousPixel: Int? = null
|
||||||
|
private var lowerPixel = 0
|
||||||
private var largestJump = 0
|
private var largestJump = 0
|
||||||
private var previousLayers = emptyMap<String, Pair<Float, Float>>()
|
private var previousLayers = emptyMap<String, Pair<Float, Float>>()
|
||||||
private val previousDotWidths = mutableMapOf<Int, Float>()
|
private val previousDotWidths = mutableMapOf<Int, Float>()
|
||||||
|
|
@ -99,9 +100,14 @@ class DiscoverHeroSequenceTest {
|
||||||
launch("slow")
|
launch("slow")
|
||||||
sample("settled", 0)
|
sample("settled", 0)
|
||||||
val original = previousPixel
|
val original = previousPixel
|
||||||
|
val originalLower = lowerPixel
|
||||||
rule.runOnIdle { artAvailable.value = false }
|
rule.runOnIdle { artAvailable.value = false }
|
||||||
press(Key.DirectionRight)
|
press(Key.DirectionRight)
|
||||||
frames("pending", 24)
|
repeat(24) { frame ->
|
||||||
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
sample("pending", frame)
|
||||||
|
assertEquals("Pending art cannot expose a placeholder beneath the hero", originalLower, lowerPixel)
|
||||||
|
}
|
||||||
assertEquals("Pending art must not replace the painted backdrop", original, previousPixel)
|
assertEquals("Pending art must not replace the painted backdrop", original, previousPixel)
|
||||||
assertEquals(state.heroes.first().key, copyIdentities().single())
|
assertEquals(state.heroes.first().key, copyIdentities().single())
|
||||||
rule.runOnIdle { artAvailable.value = true }
|
rule.runOnIdle { artAvailable.value = true }
|
||||||
|
|
@ -169,7 +175,10 @@ class DiscoverHeroSequenceTest {
|
||||||
val file = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
|
val file = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
|
||||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||||
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||||
val pixel = try { bitmap.getPixel(1600, 200) } finally { bitmap.recycle() }
|
val pixel = try {
|
||||||
|
lowerPixel = bitmap.getPixel(1600, 800)
|
||||||
|
bitmap.getPixel(1600, 200)
|
||||||
|
} finally { bitmap.recycle() }
|
||||||
val channels = channels(pixel)
|
val channels = channels(pixel)
|
||||||
previousPixel?.let { old -> largestJump = maxOf(largestJump, channels.zip(channels(old)).maxOf { (a, b) -> kotlin.math.abs(a - b) }) }
|
previousPixel?.let { old -> largestJump = maxOf(largestJump, channels.zip(channels(old)).maxOf { (a, b) -> kotlin.math.abs(a - b) }) }
|
||||||
previousPixel = pixel
|
previousPixel = pixel
|
||||||
|
|
@ -197,7 +206,7 @@ class DiscoverHeroSequenceTest {
|
||||||
} }
|
} }
|
||||||
previousSelection = state.slide
|
previousSelection = state.slide
|
||||||
samples.put(JSONObject().put("phase", phase).put("frame", index).put("timeMs", rule.mainClock.currentTime)
|
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))
|
.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))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun layerSamples(): JSONArray {
|
private fun layerSamples(): JSONArray {
|
||||||
|
|
|
||||||
|
|
@ -62,16 +62,22 @@ class HeroMotionTest {
|
||||||
val probe = BitmapFactory.decodeFile(probeFile.absolutePath)
|
val probe = BitmapFactory.decodeFile(probeFile.absolutePath)
|
||||||
try {
|
try {
|
||||||
for (y in listOf(380, 400, 420, 600, 800)) {
|
for (y in listOf(380, 400, 420, 600, 800)) {
|
||||||
assertEquals("The outgoing hero must stop at its 400px boundary: y=$y",
|
assertEquals("Pending artwork cannot replace any part of the painted backdrop: y=$y",
|
||||||
(if (y < 400) Color.Red else Color.Blue).toArgb(), probe.getPixel(100, y))
|
Color.Red.toArgb(), probe.getPixel(100, y))
|
||||||
}
|
}
|
||||||
assertEquals(400f, rule.onNodeWithTag("hero-outgoing").fetchSemanticsNode().boundsInRoot.height, .01f)
|
|
||||||
} finally { probe.recycle() }
|
} finally { probe.recycle() }
|
||||||
update { ready.value = true }
|
update { ready.value = true }
|
||||||
rule.mainClock.advanceTimeBy(176)
|
rule.mainClock.advanceTimeBy(176)
|
||||||
val mixed = pixel(directory, 100)
|
val mixed = pixel(directory, 100)
|
||||||
assertNotEquals(Color.Red.toArgb(), mixed)
|
assertNotEquals(Color.Red.toArgb(), mixed)
|
||||||
assertNotEquals(Color.Blue.toArgb(), mixed)
|
assertNotEquals(Color.Blue.toArgb(), mixed)
|
||||||
|
val blendedProbe = BitmapFactory.decodeFile(probeFile.absolutePath)
|
||||||
|
try {
|
||||||
|
assertEquals("Only the outgoing blend is clipped at its 400px boundary",
|
||||||
|
400f, rule.onNodeWithTag("hero-outgoing").fetchSemanticsNode().boundsInRoot.height, .01f)
|
||||||
|
assertEquals(Color.Blue.toArgb(), blendedProbe.getPixel(100, 400))
|
||||||
|
assertEquals(Color.Blue.toArgb(), blendedProbe.getPixel(100, 800))
|
||||||
|
} finally { blendedProbe.recycle() }
|
||||||
rule.mainClock.advanceTimeBy(400)
|
rule.mainClock.advanceTimeBy(400)
|
||||||
assertEquals(Color.Blue.toArgb(), pixel(directory, 100))
|
assertEquals(Color.Blue.toArgb(), pixel(directory, 100))
|
||||||
update { selected.value = "green"; ready.value = false }
|
update { selected.value = "green"; ready.value = false }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue