Give TV cards roomy spacing and edge-straddling focus rings
Some checks failed
review/code-gpt Spacing review: progress-bar inset regression; rail transition focus invalidation
Some checks failed
review/code-gpt Spacing review: progress-bar inset regression; rail transition focus invalidation
This commit is contained in:
parent
9e15dbee8c
commit
c17447c528
18 changed files with 210 additions and 142 deletions
|
|
@ -23,7 +23,7 @@ fun CatalogueRecoveryCard(retry: Boolean, focused: Boolean, onClick: () -> Unit,
|
|||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
||||
Column(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)
|
||||
.focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, straddle = true)
|
||||
.background(C.surface, shape).border(G.borderWidth, C.border, shape).clickable(onClick = onClick).padding(G.tailPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(G.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Glyph(if (retry) "alert" else "down", if (retry) C.warn else C.accentText, Modifier.size(G.tailIcon))
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
|
|||
modifier = Modifier.offset(y = L.rowHeadingBand - K.posterGap).height(height + K.posterGap * 2)) {
|
||||
if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service ->
|
||||
Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card)
|
||||
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, straddle = true)
|
||||
.background(Brush.linearGradient(listOf(Color.hsl(service.hue, .30f, .26f), Color.hsl(service.hue, .30f, .12f))), RoundedCornerShape(WolfskinTokens.Radius.card))
|
||||
.border(WolfskinTokens.Library.borderWidth, C.border.copy(alpha = D.entryBorderOpacity), RoundedCornerShape(WolfskinTokens.Radius.card))
|
||||
.clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.BottomStart) {
|
||||
|
|
|
|||
|
|
@ -16,11 +16,13 @@ import androidx.compose.ui.draw.dropShadow
|
|||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
/** Only transforms animate: focus never changes measured geometry. */
|
||||
|
|
@ -37,9 +39,9 @@ fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier {
|
|||
/** Content lifts are distinct from the white person/gear rings. */
|
||||
enum class FocusLift { None, Card, Button }
|
||||
|
||||
/** Draw the ring outside the slot; neither the gap nor the ring obscures content. */
|
||||
/** Card rings straddle the art edge above content; control rings retain their outside gap. */
|
||||
@Composable
|
||||
fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: Color, lift: FocusLift = FocusLift.None): Modifier {
|
||||
fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: Color, lift: FocusLift = FocusLift.None, straddle: Boolean = false): Modifier {
|
||||
val ringAlpha by animateFloatAsState(if (focused) 1f else 0f,
|
||||
tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring")
|
||||
val shadow = if (lift == FocusLift.None) this else {
|
||||
|
|
@ -54,6 +56,17 @@ fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color:
|
|||
alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity
|
||||
}
|
||||
}
|
||||
if (straddle) return shadow.drawWithCache {
|
||||
val stroke = Stroke(width.toPx())
|
||||
val corner = CornerRadius(radius.toPx())
|
||||
onDrawWithContent {
|
||||
drawContent()
|
||||
if (ringAlpha > 0f) {
|
||||
drawRoundRect(color.copy(alpha = color.alpha * ringAlpha),
|
||||
cornerRadius = corner, style = stroke)
|
||||
}
|
||||
}
|
||||
}
|
||||
return shadow.drawBehind {
|
||||
if (ringAlpha > 0f) {
|
||||
val outer = (gap + width).toPx()
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ class LibraryGridState(
|
|||
var inRail by mutableStateOf(false)
|
||||
var railLetter by mutableStateOf('#')
|
||||
private var railReturn = 0
|
||||
val columns = WolfskinTokens.Behaviour.gridColumns.toInt()
|
||||
val columns: Int get() = WolfskinTokens.Behaviour.gridColumns.toInt() - if (railVisible) 1 else 0
|
||||
val mixed: Boolean get() = catalogue && source.any { it.type == "movie" } && source.any { it.type == "tv" }
|
||||
val hasToolbar: Boolean get() = !catalogue || mixed
|
||||
val items: List<LibraryTitle> by derivedStateOf {
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ fun LibraryScreen(
|
|||
if (grid.toolbar || row == 0 && active) {
|
||||
animateLibraryScroll(view, headerCount, 0f, density)
|
||||
} else if (row > 0) {
|
||||
val target = with(density) { (L.row_lhead + (if (headerCount == 2) L.row_chips else WolfskinTokens.Shell.zero) + rowHeight * row - L.pinnedLine).toPx() }
|
||||
val target = with(density) { (L.row_lhead + (if (headerCount == 2) L.row_chipsGrid else WolfskinTokens.Shell.zero) + rowHeight * row - L.pinnedLine).toPx() }
|
||||
animateLibraryScroll(view, headerCount, target, density)
|
||||
}
|
||||
}
|
||||
|
|
@ -186,7 +186,7 @@ fun LibraryHeader(person: ShellPerson, title: String, subtitle: String = "", sav
|
|||
|
||||
@Composable
|
||||
private fun LibraryToolbar(grid: LibraryGridState, active: Boolean) {
|
||||
Row(Modifier.fillMaxWidth().height(L.row_chips).padding(start = L.safeSide, top = G.chipTop), horizontalArrangement = Arrangement.spacedBy(G.chipGap)) {
|
||||
Row(Modifier.fillMaxWidth().height(L.row_chipsGrid).padding(start = L.safeSide, top = G.chipTop), horizontalArrangement = Arrangement.spacedBy(G.chipGap)) {
|
||||
val labels = if (grid.catalogue) listOf("All", "Movies", "Series") else listOf("Sort ${listOf("A–Z", "Recently added", "Rating")[grid.sort]}", if (grid.unwatched) "Unwatched" else "All")
|
||||
labels.forEachIndexed { index, label ->
|
||||
val focused = active && grid.toolbar && grid.tool == index
|
||||
|
|
@ -266,7 +266,7 @@ private suspend fun animateLibraryScroll(view: RememberedView, headerCount: Int,
|
|||
val current = with(density) {
|
||||
(if (first == 0) WolfskinTokens.Shell.zero else L.row_lhead +
|
||||
if (first < headerCount) WolfskinTokens.Shell.zero else
|
||||
(if (headerCount == 2) L.row_chips else WolfskinTokens.Shell.zero) + rowHeight * (first - headerCount)).toPx()
|
||||
(if (headerCount == 2) L.row_chipsGrid else WolfskinTokens.Shell.zero) + rowHeight * (first - headerCount)).toPx()
|
||||
} + view.listState.firstVisibleItemScrollOffset
|
||||
animateFrameScroll(view.listState, target - current)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = Wolfs
|
|||
height: Dp = WolfskinTokens.Card.posterH, content: @Composable BoxScope.() -> Unit) {
|
||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
||||
Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent, FocusLift.Card)) {
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent, FocusLift.Card, straddle = true)) {
|
||||
Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,9 +51,10 @@ fun TitleCast(state: TitleState, active: Boolean) {
|
|||
val focusOwner = LocalTitleFocusOwner.current
|
||||
val pitch = with(LocalDensity.current) { WolfskinTokens.Card.personPitch.toPx() }
|
||||
LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch) }
|
||||
Column(Modifier.height(D.castHeight)) {
|
||||
Box(Modifier.height(D.castHeight)) {
|
||||
Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
|
||||
LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) {
|
||||
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2),
|
||||
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) {
|
||||
if (state.data.castStatus == TitleReadStatus.Loading) {
|
||||
items(WolfskinTokens.Card.personSlots, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) {
|
||||
Box(Modifier.size(L.castCircle).background(C.surface, CircleShape))
|
||||
|
|
@ -63,7 +64,7 @@ fun TitleCast(state: TitleState, active: Boolean) {
|
|||
Box(Modifier.size(WolfskinTokens.Card.personW, WolfskinTokens.Card.personH).zIndex(if (focused) 1f else 0f)
|
||||
.onGloballyPositioned { state.recordCenter("cast", index, it.boundsInRoot().center.x) }
|
||||
.titlePointerFocus { state.focus("cast", index) }.focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { focusOwner?.invoke(); state.click("cast", index) }.testTag("title-cast-${person.id}")) {
|
||||
PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).clip(CircleShape))
|
||||
PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, straddle = true).clip(CircleShape))
|
||||
Column(Modifier.offset(x = -D.castNameSide, y = D.castNameTop).wrapContentWidth(Alignment.Start, unbounded = true).width(L.castCircle + D.castNameSide * 2), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(person.name, color = C.fg, style = TitleTextStyles.castName, modifier = Modifier.exactLineBox(TitleTextStyles.castName), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
|
||||
Text(person.character, color = C.muted, style = TitleTextStyles.caption, modifier = Modifier.exactLineBox(TitleTextStyles.caption), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
|
||||
|
|
@ -85,9 +86,11 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
|
|||
val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots
|
||||
val pitch = with(LocalDensity.current) { (if (episodes) WolfskinTokens.Card.tilePitch else WolfskinTokens.Card.posterPitch).toPx() }
|
||||
LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, visible, pitch) }
|
||||
Column(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) {
|
||||
Box(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) {
|
||||
Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
|
||||
LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
|
||||
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter)
|
||||
.height(height + (if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter * 2),
|
||||
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
|
||||
if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) {
|
||||
items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) {
|
||||
Box(Modifier.size(width, height).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
||||
|
|
@ -112,7 +115,7 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
|
|||
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue,
|
||||
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
|
||||
} else Box(Modifier.size(WolfskinTokens.Card.tileW, WolfskinTokens.Card.tileH).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, straddle = true)
|
||||
.clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) {
|
||||
TitleTileArtwork(item)
|
||||
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity))))
|
||||
|
|
|
|||
|
|
@ -112,9 +112,9 @@ object WolfskinTokens {
|
|||
val navSolidAfter = 20.0.dp
|
||||
val pinnedLine = 84.0.dp
|
||||
val windowAhead = 350.0.dp
|
||||
val rowHeadingBand = 30.0.dp
|
||||
val rowGapAfter = 22.0.dp
|
||||
val gridRowGap = 14.0.dp
|
||||
val rowHeadingBand = 42.0.dp
|
||||
val rowGapAfter = 34.0.dp
|
||||
val gridRowGap = 22.0.dp
|
||||
val row_hero = 410.0.dp
|
||||
val row_phero = 370.0.dp
|
||||
val row_section = 75.0.dp
|
||||
|
|
@ -146,7 +146,7 @@ object WolfskinTokens {
|
|||
val settingsRowWidth = 650.0.dp
|
||||
val settingsRowHeight = 52.0.dp
|
||||
val stateWidth = 550.0.dp
|
||||
val captionOffset = 9.0.dp
|
||||
val captionOffset = 19.0.dp
|
||||
val badge = 24.0.dp
|
||||
val badgeInset = 7.0.dp
|
||||
val progressBar = 4.0.dp
|
||||
|
|
@ -159,36 +159,37 @@ object WolfskinTokens {
|
|||
val personPortrait = 120.0.dp
|
||||
val thumbWidth = 48.0.dp
|
||||
val thumbHeight = 72.0.dp
|
||||
val row_chipsGrid = 68.0.dp
|
||||
}
|
||||
object Card {
|
||||
val posterW = 128.0.dp
|
||||
val posterH = 192.0.dp
|
||||
val posterGap = 12.0.dp
|
||||
val posterPitch = 140.0.dp
|
||||
val posterCaptionBand = 38.0.dp
|
||||
val posterGap = 20.0.dp
|
||||
val posterPitch = 148.0.dp
|
||||
val posterCaptionBand = 48.0.dp
|
||||
const val posterSlots = 6
|
||||
val tileW = 208.0.dp
|
||||
val tileH = 117.0.dp
|
||||
val tileGap = 12.0.dp
|
||||
val tileW = 200.0.dp
|
||||
val tileH = 112.5.dp
|
||||
val tileGap = 20.0.dp
|
||||
val tilePitch = 220.0.dp
|
||||
val tileCaptionBand = 0.0.dp
|
||||
const val tileSlots = 4
|
||||
val wideW = 280.0.dp
|
||||
val wideH = 157.5.dp
|
||||
val wideGap = 12.0.dp
|
||||
val wideW = 272.0.dp
|
||||
val wideH = 153.0.dp
|
||||
val wideGap = 20.0.dp
|
||||
val widePitch = 292.0.dp
|
||||
val wideCaptionBand = 0.0.dp
|
||||
const val wideSlots = 3
|
||||
val entryW = 128.0.dp
|
||||
val entryH = 72.0.dp
|
||||
val entryGap = 12.0.dp
|
||||
val entryPitch = 140.0.dp
|
||||
val entryGap = 20.0.dp
|
||||
val entryPitch = 148.0.dp
|
||||
val entryCaptionBand = 0.0.dp
|
||||
const val entrySlots = 6
|
||||
val personW = 100.0.dp
|
||||
val personH = 145.0.dp
|
||||
val personGap = 14.0.dp
|
||||
val personPitch = 114.0.dp
|
||||
val personH = 155.0.dp
|
||||
val personGap = 22.0.dp
|
||||
val personPitch = 122.0.dp
|
||||
val personCaptionBand = 0.0.dp
|
||||
const val personSlots = 7
|
||||
}
|
||||
|
|
@ -196,7 +197,7 @@ object WolfskinTokens {
|
|||
const val scaleCard = 1.08f
|
||||
const val scaleControl = 1.05f
|
||||
const val scaleRow = 1.02f
|
||||
val ringCardGap = 2.0.dp
|
||||
val ringCardGap = 0.0.dp
|
||||
val ringCardRing = 2.0.dp
|
||||
val ringButtonGap = 2.0.dp
|
||||
val ringButtonRing = 2.0.dp
|
||||
|
|
@ -429,8 +430,8 @@ object WolfskinTokens {
|
|||
val progressBottom = 15.0.dp
|
||||
val progressGap = 9.0.dp
|
||||
val progressLift = -7.0.dp
|
||||
val castHeight = 197.0.dp
|
||||
val castNameTop = 108.0.dp
|
||||
val castHeight = 231.0.dp
|
||||
val castNameTop = 118.0.dp
|
||||
val castNameSide = 6.0.dp
|
||||
val personTop = 85.0.dp
|
||||
val personGap = 24.0.dp
|
||||
|
|
@ -456,7 +457,7 @@ object WolfskinTokens {
|
|||
val infoRowGap = 3.0.dp
|
||||
val infoColumnGap = 10.0.dp
|
||||
val infoKey = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
|
||||
val gridHeight = 244.0.dp
|
||||
val gridHeight = 262.0.dp
|
||||
val minimumEndPadding = 40.0.dp
|
||||
val ratingGap = 10.0.dp
|
||||
val ratingMarkGap = 4.0.dp
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue