Add the shared polish tokens and motion helpers
This commit is contained in:
parent
5f5b5ffd54
commit
74b737fb52
9 changed files with 180 additions and 6 deletions
|
|
@ -0,0 +1,10 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/** A shell surface draws one shared card focus ring above its content and below the nav and sheets. */
|
||||
@Composable
|
||||
fun FocusRingHost(content: @Composable () -> Unit) {
|
||||
content()
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
// 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 androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
|
||||
|
||||
/** One pending playback start per surface; the player opens only after the page has faded to black. */
|
||||
@Stable
|
||||
class PlaybackStart {
|
||||
/** The control that reads "Starting…" while the stream is prepared, or null. */
|
||||
var starting by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
/** Set once the player is ready; the veil runs it exactly once, when the screen is black. */
|
||||
var launch by mutableStateOf<(() -> Unit)?>(null)
|
||||
private set
|
||||
|
||||
fun begin(control: String) { starting = control; launch = null }
|
||||
fun fail() { starting = null; launch = null }
|
||||
fun ready(open: () -> Unit) { launch = open }
|
||||
internal fun finish() { starting = null; launch = null }
|
||||
}
|
||||
|
||||
/** The hand-off is one black rectangle drawn over the page, never a second screen layer. */
|
||||
@Composable
|
||||
fun PlaybackHandoffVeil(start: PlaybackStart, modifier: Modifier = Modifier) {
|
||||
val pending = start.launch
|
||||
val reduced = reducedMotion()
|
||||
val alpha = remember { Animatable(0f) }
|
||||
LaunchedEffect(pending, reduced) {
|
||||
if (pending == null) {
|
||||
alpha.snapTo(0f)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
alpha.animateTo(1f, tween(motionDuration(M.base, reduced), easing = M.ease))
|
||||
start.finish()
|
||||
pending()
|
||||
}
|
||||
if (pending != null || alpha.value > 0f) Box(modifier.fillMaxSize().drawBehind { drawRect(Color.Black.copy(alpha = alpha.value)) })
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
|
||||
|
||||
/** Side panels enter over sheetIn and leave over sheetOut on the shared ease-out; reduced motion caps both at the fade limit. */
|
||||
@Composable
|
||||
internal fun rememberSheetFade(visible: Boolean, initial: Float = 0f): State<Float> {
|
||||
val reduced = reducedMotion()
|
||||
val progress = remember { Animatable(initial) }
|
||||
LaunchedEffect(visible, reduced) {
|
||||
progress.animateTo(if (visible) 1f else 0f,
|
||||
tween(motionDuration(if (visible) M.sheetIn else M.sheetOut, reduced), easing = M.easeOut))
|
||||
}
|
||||
return progress.asState()
|
||||
}
|
||||
|
||||
/** The scrim stays a draw-only colour, so a fading sheet never adds a full-screen layer. */
|
||||
internal fun Modifier.sheetScrim(progress: () -> Float): Modifier = drawBehind {
|
||||
val scrim = WolfskinTokens.Color.sheetScrim
|
||||
drawRect(scrim.copy(alpha = scrim.alpha * progress()))
|
||||
}
|
||||
|
||||
/** The bounded panel modulates each draw, so its outside shadow fades with it and no offscreen buffer is allocated. */
|
||||
internal fun Modifier.sheetPanelFade(progress: () -> Float): Modifier = graphicsLayer {
|
||||
alpha = progress()
|
||||
compositingStrategy = CompositingStrategy.ModulateAlpha
|
||||
}
|
||||
|
||||
/** Focus and selection colours retarget over focusMove on the ease-out; reduced motion caps the fade. */
|
||||
@Composable
|
||||
internal fun animatedFocusColor(target: Color, label: String = "focus-colour"): Color {
|
||||
val reduced = reducedMotion()
|
||||
return animateColorAsState(target, tween(motionDuration(M.focusMove, reduced), easing = M.easeOut), label = label).value
|
||||
}
|
||||
|
||||
/** A pending action keeps its button's resting width: the resting content fades out while the busy label fades in over base. */
|
||||
@Composable
|
||||
internal fun BusyLabelSwap(busy: Boolean, busyLabel: String, color: Color, style: TextStyle, content: @Composable () -> Unit) {
|
||||
val reduced = reducedMotion()
|
||||
val progress = remember { Animatable(if (busy) 1f else 0f) }
|
||||
LaunchedEffect(busy, reduced) {
|
||||
progress.animateTo(if (busy) 1f else 0f, tween(motionDuration(M.base, reduced), easing = M.ease))
|
||||
}
|
||||
val showLabel = busy || progress.value > 0f
|
||||
Layout(content = {
|
||||
Box(Modifier.graphicsLayer { alpha = 1f - progress.value }.then(if (busy) Modifier.clearAndSetSemantics { } else Modifier)) { content() }
|
||||
if (showLabel) Text(busyLabel, Modifier.graphicsLayer { alpha = progress.value }, color = color, style = style, maxLines = 1, softWrap = false)
|
||||
}) { measurables, constraints ->
|
||||
val resting = measurables[0].measure(constraints)
|
||||
val label = measurables.getOrNull(1)?.measure(Constraints())
|
||||
layout(resting.width, resting.height) {
|
||||
resting.place(0, 0)
|
||||
if (label != null) label.place((resting.width - label.width) / 2, (resting.height - label.height) / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,24 @@
|
|||
// 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 androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.dropShadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.shadow.Shadow
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
|
|
@ -16,14 +26,31 @@ import androidx.tv.material3.Text
|
|||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
|
||||
|
||||
/** Callers pass their current message; a cleared message fades out with its last text instead of vanishing. */
|
||||
@Composable
|
||||
fun RecoveryToastHost(message: String?, modifier: Modifier = Modifier) {
|
||||
val reduced = reducedMotion()
|
||||
var last by remember { mutableStateOf(message) }
|
||||
val opacity = remember { Animatable(0f) }
|
||||
SideEffect { if (message != null) last = message }
|
||||
LaunchedEffect(message != null, reduced) {
|
||||
opacity.animateTo(if (message != null) 1f else 0f, tween(motionDuration(M.base, reduced), easing = M.ease))
|
||||
}
|
||||
val text = message ?: last ?: return
|
||||
if (message == null && opacity.value == 0f) return
|
||||
RecoveryToast(text, modifier) { opacity.value }
|
||||
}
|
||||
|
||||
/** Recovery messages remain transient and never take focus from the action that failed. */
|
||||
@Composable
|
||||
fun RecoveryToast(message: String, modifier: Modifier = Modifier) {
|
||||
fun RecoveryToast(message: String, modifier: Modifier = Modifier, opacity: () -> Float = { 1f }) {
|
||||
Box(modifier.fillMaxSize()) {
|
||||
val shape = androidx.compose.runtime.remember { RoundedCornerShape(WolfskinTokens.Radius.panel) }
|
||||
val shape = remember { RoundedCornerShape(WolfskinTokens.Radius.panel) }
|
||||
Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, H.toastTop)
|
||||
.widthIn(max = H.toastWidth)
|
||||
.graphicsLayer { alpha = opacity(); compositingStrategy = CompositingStrategy.ModulateAlpha }
|
||||
.dropShadow(shape, Shadow(radius = H.toastShadowBlur, color = Color.Black.copy(alpha = H.toastShadowOpacity),
|
||||
offset = DpOffset(WolfskinTokens.Shell.zero, H.toastShadowY)))
|
||||
.background(C.surface2, shape)
|
||||
|
|
|
|||
|
|
@ -229,6 +229,8 @@ object WolfskinTokens {
|
|||
const val focusMove = 180
|
||||
const val captionDelay = 60
|
||||
const val captionOut = 100
|
||||
const val sheetIn = 160
|
||||
const val sheetOut = 100
|
||||
const val artReveal = 160
|
||||
const val heroFade = 320
|
||||
const val heroCopyOut = 120
|
||||
|
|
@ -319,6 +321,7 @@ object WolfskinTokens {
|
|||
val receiptPaddingX = 15.0.dp
|
||||
val receiptGap = 5.0.dp
|
||||
val receiptGlyph = 18.0.dp
|
||||
val thumbGlyph = 20.0.dp
|
||||
val buttonMargin = 6.0.dp
|
||||
val skeletonTitleWidth = 180.0.dp
|
||||
val skeletonTitleHeight = 17.0.dp
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue