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
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user