feat(client/apple): the strips assemble themselves, and the store chip stops flashing
ci / bun-nix (pull_request) Successful in 38s
ci / web (pull_request) Successful in 1m7s
ci / rust-arm64 (pull_request) Successful in 1m23s
apple / swift (pull_request) Successful in 1m27s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m48s
ci / rust (pull_request) Canceled after 6m16s

Two more from the on-glass pass:

- The coverflow's store/source chip only showed its background on the
  centred cover. Same mechanism as the tray blur: a card rides a
  scrollTransition that composites it with opacity < 1 and a 3D
  rotation, and a material cannot sample a backdrop through an offscreen
  composite - so the frost stayed blank everywhere except the one card
  sitting at exactly full opacity. The coverflow's chip is a flat wash
  now (StoreBadge gains `solid`), which has no backdrop to sample and
  is therefore simply always there. The touch grid keeps its material -
  its cards carry no transform, so its frost samples fine.

- Host cards and library covers now arrive with the strip instead of
  being there: each card rises out of a fade on a lightly overshooting
  spring, delayed by its distance from the cursor, so the strip
  assembles outward from where the eye already is. Implemented once in
  GamepadCarousel, so the launcher and the coverflow inherit it
  together. Transforms only - snapping, the callers' own
  scrollTransition and the tvOS focus engine are untouched - and Reduce
  Motion drops the travel for a plain unstaggered cross-fade.
This commit is contained in:
2026-08-07 15:03:54 +02:00
parent 70de58f446
commit cf68d33e2c
3 changed files with 79 additions and 6 deletions
@@ -83,6 +83,16 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
/// confirm and end-stop events (moves trigger on `cursor`).
@State private var activateTick = 0
@State private var boundaryTick = 0
/// The strip's entrance (see `CardEntrance`): false for exactly one frame after mount, then
/// the cards rise in. Never reset a strip that re-played its entrance every time a screen
/// popped off the top of it would be noise, and the shell's push/pop carries that motion
/// already. So it plays when a screen is entered: the launcher when the gamepad UI comes up,
/// the coverflow each time the library opens (its layer mounts fresh).
@State private var appeared = false
/// Which card the entrance fans out from the cursor as it stood at mount, so a restored
/// selection assembles around where the eye already is instead of sweeping in from the left.
@State private var entranceAnchor = 0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// Read-back from a touch drag is honoured only once the gamepad has been quiet this long
/// (longer than a move animation, so overlapping held-stick moves never let it through).
@@ -94,7 +104,9 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
ScrollViewReader { proxy in
ScrollView(.horizontal) {
HStack(spacing: spacing) {
ForEach(items) { item in
// Enumerated for the entrance stagger only identity stays `item.id`,
// which is what `.scrollTargetLayout()` and `scrollPosition` key on.
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
#if os(tvOS)
// A focusable Button per card: the focus engine does the navigating
// (remote swipes and pad dpad alike), select activates. The bare style
@@ -103,6 +115,7 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
Button { activate(item) } label: {
card(item)
.frame(width: itemWidth)
.modifier(entrance(idx))
}
.buttonStyle(ConsoleBareButtonStyle())
.focused($focusedID, equals: item.id)
@@ -110,6 +123,7 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
#else
card(item)
.frame(width: itemWidth)
.modifier(entrance(idx))
.contentShape(Rectangle())
.onTapGesture { tap(item) }
#endif
@@ -165,6 +179,9 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
reconcile()
wire()
if isActive { input.start() }
// After `reconcile`, so the fan-out anchors on the seeded/restored cursor.
entranceAnchor = cursor
appeared = true
}
.onDisappear {
input.stop()
@@ -203,6 +220,19 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
}
}
// MARK: - Entrance
/// The card's share of the strip's entrance: it rises into place out of a fade, the anchored
/// card landing first and its neighbours following outward.
private func entrance(_ idx: Int) -> CardEntrance {
CardEntrance(
shown: appeared,
// Capped so a several-hundred-title library never queues a card behind a visibly long
// wait everything past the cap lands together, well off-screen anyway.
delay: min(0.3, Double(abs(idx - entranceAnchor)) * 0.05),
reduceMotion: reduceMotion)
}
// MARK: - Input wiring
private func wire() {
@@ -346,4 +376,32 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
withAnimation(.spring(response: 0.34, dampingFraction: 0.7).delay(0.1)) { bumpOffset = 0 }
}
}
/// How a card arrives when its strip does: it rises a little, growing out of a fade, on a spring
/// soft enough to overshoot by a hair so the launcher's hosts and the library's covers assemble
/// themselves around the cursor instead of simply being there. Each card carries its own delay
/// (see `entrance(_:)`), which is what makes the strip read as one gesture rather than a
/// simultaneous flash.
///
/// Transforms only nothing here touches layout, so the scroll view's snapping, the caller's
/// `.scrollTransition` (whose scale/rotation simply multiply with these) and the tvOS focus
/// engine are all untouched. Reduce Motion drops the travel entirely for a plain, unstaggered
/// cross-fade.
private struct CardEntrance: ViewModifier {
let shown: Bool
let delay: Double
let reduceMotion: Bool
func body(content: Content) -> some View {
content
.opacity(shown ? 1 : 0)
.scaleEffect(shown || reduceMotion ? 1 : 0.88)
.offset(y: shown || reduceMotion ? 0 : 34)
.animation(
reduceMotion
? .easeOut(duration: 0.25)
: .spring(response: 0.52, dampingFraction: 0.78).delay(delay),
value: shown)
}
}
#endif
@@ -107,7 +107,9 @@ struct LibraryCoverflowView: View {
.frame(width: width, height: height)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(alignment: .topLeading) {
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
// `solid`: a frosted chip can't sample a backdrop through this card's own
// composited transform, so it would only show up on the centred card.
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher, solid: true)
}
.overlay {
RoundedRectangle(cornerRadius: 16, style: .continuous)
@@ -17,16 +17,29 @@ struct StoreBadge: View {
/// A launcher entry (design D4) gets the brand fill, so "opens Steam" is legible at poster size
/// without reading the title.
var isLauncher: Bool = false
/// Fill the chip with a flat wash instead of a frosted material.
///
/// The coverflow MUST pass true. Its cards ride a `.scrollTransition` that composites them
/// with `opacity < 1` and a 3D rotation, and a material cannot sample a backdrop through an
/// offscreen composite so the frost stayed blank on every card and only appeared on the one
/// card sitting at exactly full opacity in the centre, reading as a flash on focus. A flat
/// wash has no backdrop to sample: it is simply always there. (Deliberately black, not
/// palette ink: the chip sits on cover art, whose colours the palette has no business
/// fighting.)
var solid: Bool = false
private var fill: AnyShapeStyle {
if isLauncher { return AnyShapeStyle(Color.brand) }
return solid ? AnyShapeStyle(Color.black.opacity(0.58)) : AnyShapeStyle(.ultraThinMaterial)
}
var body: some View {
Text(label)
.font(.geist(11, .semibold, relativeTo: .caption2))
.foregroundStyle(isLauncher ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
.foregroundStyle(isLauncher || solid ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
isLauncher ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.ultraThinMaterial),
in: Capsule())
.background(fill, in: Capsule())
.padding(6)
}
}