diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift index 64369f6d..5e51d648 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift @@ -83,6 +83,16 @@ struct GamepadCarousel: 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: 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: 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: 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: 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: 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: 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 diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift index bff15cce..5c5e06a3 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift @@ -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) diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift index 79d2219d..37f5be75 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift @@ -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) } }