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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue