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
|
|
@ -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