Refine late metadata fades and synchronize loading motion
All checks were successful
review/code-claude PASS on 16684454, delta fdff85c8 reviewed
review/design-claude PASS on 16684454, delta fdff85c8 reviewed
All checks were successful
review/code-claude PASS on 16684454, delta fdff85c8 reviewed
review/design-claude PASS on 16684454, delta fdff85c8 reviewed
This commit is contained in:
parent
1668445418
commit
fdff85c8a1
7 changed files with 149 additions and 15 deletions
|
|
@ -5,6 +5,7 @@ import androidx.compose.animation.core.tween
|
|||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
|
|
@ -22,13 +23,16 @@ fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier =
|
|||
titleStyle: TextStyle = WolfskinTokens.Type.button, factsStyle: TextStyle = WolfskinTokens.Type.caption,
|
||||
titleOverflow: TextOverflow = TextOverflow.Ellipsis) {
|
||||
val alpha = remember(title, facts) { Animatable(0f) }
|
||||
val entryProgress = remember(title, facts) { mutableFloatStateOf(0f) }
|
||||
val reduced = reducedMotion()
|
||||
val rise = with(androidx.compose.ui.platform.LocalDensity.current) { WolfskinTokens.Motion.captionRise.toPx() }
|
||||
LaunchedEffect(alpha, visible, reduced) {
|
||||
if (visible && !reduced) kotlinx.coroutines.delay(WolfskinTokens.Motion.captionDelay.toLong())
|
||||
alpha.animateTo(if (visible) 1f else 0f, tween(motionDuration(if (visible) WolfskinTokens.Motion.focusMove else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut))
|
||||
alpha.animateTo(if (visible) 1f else 0f, tween(motionDuration(if (visible) WolfskinTokens.Motion.focusMove else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut)) {
|
||||
if (visible) entryProgress.floatValue = value
|
||||
}
|
||||
}
|
||||
Column(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - alpha.value) }) {
|
||||
Column(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - entryProgress.floatValue) }) {
|
||||
Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = titleOverflow)
|
||||
CaptionFacts(facts, accentFirstFact, accentLastFact, factsStyle)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,16 @@ import androidx.compose.animation.core.rememberInfiniteTransition
|
|||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
|
@ -28,14 +38,40 @@ fun reducedMotion(): Boolean = LocalReducedMotion.current ||
|
|||
/** Short fades remain available when movement and continuous animation are disabled. */
|
||||
fun motionDuration(duration: Int, reduced: Boolean): Int = if (reduced) minOf(duration, M.reducedFade) else duration
|
||||
|
||||
@Stable
|
||||
private class SkeletonPulseClock {
|
||||
var subscribers by mutableIntStateOf(0)
|
||||
val idle: State<Float> = mutableFloatStateOf(M.skeletonLowAlpha)
|
||||
var alpha by mutableStateOf(idle)
|
||||
}
|
||||
|
||||
private val LocalSkeletonPulseClock = compositionLocalOf<SkeletonPulseClock> {
|
||||
error("Skeleton loading requires WolfskinTheme")
|
||||
}
|
||||
|
||||
/** Share phase across shelves, including late arrivals; stop the clock when no shelf needs it. */
|
||||
@Composable
|
||||
internal fun SkeletonPulseScope(content: @Composable () -> Unit) {
|
||||
val clock = remember { SkeletonPulseClock() }
|
||||
val reduced = reducedMotion()
|
||||
val alpha = if (clock.subscribers > 0 && !reduced) {
|
||||
rememberInfiniteTransition(label = "skeleton-screen").animateFloat(M.skeletonLowAlpha, 1f,
|
||||
infiniteRepeatable(tween(M.skeletonCycle / 2, easing = M.breatheEase), RepeatMode.Reverse), label = "breathe")
|
||||
} else clock.idle
|
||||
SideEffect { clock.alpha = alpha }
|
||||
CompositionLocalProvider(LocalSkeletonPulseClock provides clock, content = content)
|
||||
}
|
||||
|
||||
/** One alpha layer owns all shapes in a shelf; individual placeholders never animate. */
|
||||
@Composable
|
||||
fun Modifier.skeletonPulse(loading: Boolean = true): Modifier {
|
||||
if (!loading || reducedMotion()) return this
|
||||
val clock = rememberInfiniteTransition(label = "skeleton-shelf")
|
||||
val alpha = clock.animateFloat(M.skeletonLowAlpha, 1f,
|
||||
infiniteRepeatable(tween(M.skeletonCycle / 2, easing = M.breatheEase), RepeatMode.Reverse), label = "breathe")
|
||||
return graphicsLayer { this.alpha = alpha.value }
|
||||
val clock = LocalSkeletonPulseClock.current
|
||||
DisposableEffect(clock) {
|
||||
clock.subscribers++
|
||||
onDispose { clock.subscribers-- }
|
||||
}
|
||||
return graphicsLayer { alpha = clock.alpha.value }
|
||||
}
|
||||
|
||||
/** Content enters on its row layer, never one animation per card or a full-screen transition. */
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ fun TitleRatings(ratings: List<LibraryRating>, modifier: Modifier = Modifier, st
|
|||
val numberStyle = remember(textStyle) { textStyle.copy(fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold) }
|
||||
val gap = if (plate) with(density) { (style.fontSize * P.plateGapEm).toDp() } else D.ratingGap
|
||||
val markGap = if (plate) with(density) { (style.fontSize * P.markGapEm).toDp() } else D.ratingMarkGap
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(if (plate) modifier else modifier.lateLineFade(ratings), horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) {
|
||||
ratings.forEach { rating ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
val resource = when (rating.source) {
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
|||
if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
||||
if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
||||
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(item.ratings)
|
||||
if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
||||
if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body).lateLineFade(item.genres))
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(if (item.kind == "tv" || state.data.requestedStage != null) WolfskinTokens.Motion.readinessSlot else WolfskinTokens.Shell.zero).testTag("title-readiness-slot")) {
|
||||
state.data.requestedStage?.let { stage ->
|
||||
|
|
@ -69,7 +69,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
|||
}
|
||||
}
|
||||
}
|
||||
Text((episode ?: item).overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview"))
|
||||
Text((episode ?: item).overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).lateLineFade((episode ?: item).overview).testTag("title-overview"))
|
||||
if (!item.held && state.data.connection != TitleConnection.SignedIn) {
|
||||
Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.",
|
||||
color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap))
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ fun WolfskinTheme(content: @Composable () -> Unit) {
|
|||
val reduced = reducedMotion()
|
||||
androidx.compose.runtime.SideEffect { NetworkArtTransition.reduced = reduced }
|
||||
androidx.compose.material3.MaterialTheme(colorScheme = WolfskinColors.darkSchemeMaterial) {
|
||||
MaterialTheme(colorScheme = WolfskinColors.darkScheme, content = content)
|
||||
MaterialTheme(colorScheme = WolfskinColors.darkScheme) {
|
||||
SkeletonPulseScope(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import android.os.StrictMode
|
|||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
|
|
@ -19,6 +20,7 @@ import androidx.compose.ui.input.key.Key
|
|||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
|
|
@ -114,6 +116,91 @@ class MotionSurfaceTest {
|
|||
} finally { directory.deleteRecursively() }
|
||||
}
|
||||
|
||||
@Test fun shelvesJoiningAnActiveLoadShareTheSameBreathingPhase() {
|
||||
rule.mainClock.autoAdvance = false
|
||||
val second = mutableStateOf(false)
|
||||
rule.setContent { WolfskinTheme {
|
||||
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
|
||||
Box(Modifier.size(200.dp, 140.dp).skeletonPulse().background(WolfskinTokens.Color.surface2))
|
||||
if (second.value) Box(Modifier.offset(y = 200.dp).size(200.dp, 140.dp).skeletonPulse().background(WolfskinTokens.Color.surface2))
|
||||
}
|
||||
} }
|
||||
rule.mainClock.advanceTimeBy(600)
|
||||
update { second.value = true }
|
||||
val directory = kotlin.io.path.createTempDirectory("wolfskin-shared-pulse-").toFile()
|
||||
try {
|
||||
var previous: Int? = null
|
||||
repeat(2) { sample ->
|
||||
val file = File(directory, "$sample.png")
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||
try {
|
||||
val first = bitmap.getPixel(100, 100)
|
||||
assertEquals("Late shelves must join the existing phase", first, bitmap.getPixel(100, 500))
|
||||
if (previous != null) assertNotEquals("The shared clock must still breathe", previous, first)
|
||||
previous = first
|
||||
} finally { bitmap.recycle() }
|
||||
rule.mainClock.advanceTimeBy(450)
|
||||
}
|
||||
} finally { directory.deleteRecursively() }
|
||||
}
|
||||
|
||||
@Test fun captionExitKeepsTranslationFixedAtRestAndDuringInterruptedEntry() {
|
||||
rule.mainClock.autoAdvance = false
|
||||
val title = mutableStateOf("Settled caption")
|
||||
val visible = mutableStateOf(true)
|
||||
rule.setContent { WolfskinTheme { Caption(title.value, "Facts", visible.value, Modifier.offset(40.dp, 80.dp)) } }
|
||||
rule.mainClock.advanceTimeBy(400)
|
||||
fun top() = rule.onNodeWithText(title.value).fetchSemanticsNode().boundsInRoot.top
|
||||
val settled = top()
|
||||
update { visible.value = false }
|
||||
rule.mainClock.advanceTimeBy(48)
|
||||
assertEquals("Exit cannot sink a settled caption", settled, top(), 0.01f)
|
||||
update { title.value = "Interrupted caption"; visible.value = true }
|
||||
rule.mainClock.advanceTimeBy(112)
|
||||
val interrupted = top()
|
||||
assertTrue("Entry must still be partway through its rise", interrupted > settled)
|
||||
update { visible.value = false }
|
||||
val exitTop = top()
|
||||
assertTrue("Exit must retain the unfinished rise, not snap to its baseline", exitTop > settled)
|
||||
rule.mainClock.advanceTimeBy(48)
|
||||
assertEquals("An interrupted entry must fade exactly where it stopped", exitTop, top(), 0.01f)
|
||||
}
|
||||
|
||||
@Test fun lateRatingsGenresAndOverviewFadeInsideTheirExistingSlots() {
|
||||
rule.mainClock.autoAdvance = false
|
||||
val series = TitleItem("series", "A series", "tv", held = true)
|
||||
val state = TitleState(TitlePageData(series, connection = TitleConnection.SignedIn), viewer) {}
|
||||
val shell = shell()
|
||||
rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } }
|
||||
rule.mainClock.advanceTimeBy(500)
|
||||
val actions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
|
||||
update { state.update(state.data.copy(item = series.copy(ratings = listOf(LibraryRating("rt", 88.0)), genres = listOf("Drama"), overview = "Late overview"))) }
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
val regions = listOf("88%", "Drama", "Late overview").map { rule.onNodeWithText(it).fetchSemanticsNode().boundsInRoot }
|
||||
val directory = kotlin.io.path.createTempDirectory("wolfskin-late-fade-").toFile()
|
||||
fun brightness(name: String): List<Long> {
|
||||
val file = File(directory, name)
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||
return try { regions.map { bounds ->
|
||||
var sum = 0L
|
||||
for (y in bounds.top.toInt() until bounds.bottom.toInt()) for (x in bounds.left.toInt() until bounds.right.toInt()) {
|
||||
val pixel = bitmap.getPixel(x, y)
|
||||
sum += android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
|
||||
}
|
||||
sum
|
||||
} } finally { bitmap.recycle() }
|
||||
}
|
||||
try {
|
||||
val entering = brightness("entering.png")
|
||||
rule.mainClock.advanceTimeBy(240)
|
||||
brightness("settled.png").zip(entering).forEach { (settled, early) -> assertTrue("Late text must fade rather than pop", settled > early) }
|
||||
assertEquals(regions, listOf("88%", "Drama", "Late overview").map { rule.onNodeWithText(it).fetchSemanticsNode().boundsInRoot })
|
||||
assertEquals(actions, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot)
|
||||
} finally { directory.deleteRecursively() }
|
||||
}
|
||||
|
||||
@Test fun animatingSkeletonDoesNotPerformMainThreadDiskOrNetworkWork() {
|
||||
val loading = mutableStateOf(false)
|
||||
rule.setContent { WolfskinTheme { Box(Modifier.size(200.dp, 140.dp).skeletonPulse(loading.value).background(WolfskinTokens.Color.surface2)) } }
|
||||
|
|
@ -128,6 +215,11 @@ class MotionSurfaceTest {
|
|||
finally { rule.runOnIdle { StrictMode.setThreadPolicy(requireNotNull(previous)) } }
|
||||
}
|
||||
|
||||
private fun update(block: () -> Unit) {
|
||||
rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
}
|
||||
|
||||
private fun shell() = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/series") }
|
||||
private fun bounds() = listOf("title-overview", "title-actions", "title-shelf:episodes").map { rule.onNodeWithTag(it).fetchSemanticsNode().boundsInRoot }
|
||||
private fun pixel(file: File): Int {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue