Restore sequential hero copy and first-appearance entry
All checks were successful
review/code-claude Code APPROVE: sequential copy hand-off, no blocker
review/design-claude Design PASS: copy hand-off matches approved frames/tokens

This commit is contained in:
justinzeus 2026-11-07 18:44:00 +01:00
parent 7bfa431350
commit 79f64f6e18
5 changed files with 226 additions and 53 deletions

View file

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