Gamepad UI (iOS): the field pass — a linear drum, no close chrome, a heading that leads, and strips that assemble themselves #93

Merged
enricobuehler merged 10 commits from worktree-gamepad-ios-polish-2 into main 2026-08-07 14:33:19 +00:00
11 changed files with 444 additions and 175 deletions
@@ -53,26 +53,24 @@ struct GamepadAddHostView: View {
}
.frame(maxWidth: .infinity)
.safeAreaInset(edge: .top, spacing: 0) {
VStack(spacing: gamepadHeaderSpacing(compact: compact)) {
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
// Leading, like every gamepad heading and no close chrome (B is the exit).
Text("Add Host")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
.frame(maxWidth: .infinity)
// On the title row itself (not the header block) so it rides the title's own
// top padding the same anchoring the settings screen's close button uses.
.overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) }
if !compact {
Text("Hosts on this network appear automatically — add one by address "
+ "for everything else.")
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
.foregroundStyle(ink.fg(0.55))
.multilineTextAlignment(.center)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72)
.multilineTextAlignment(.leading)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72, alignment: .leading)
}
}
.padding(.horizontal, 24)
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
.frame(maxWidth: .infinity)
.frame(maxWidth: .infinity, alignment: .leading)
.background { GamepadTrayScrim(edge: .top) }
}
.safeAreaInset(edge: .bottom, spacing: 0) {
@@ -95,6 +93,18 @@ struct GamepadAddHostView: View {
.onChange(of: port) { _, value in
if value.count > 5 { port = String(value.prefix(5)) }
}
#if !os(tvOS)
// The visible close is gone (a gamepad UI exits with B) this keeps a hardware
// keyboard's Esc and the macOS sheet's cancel working without chrome.
.background {
Button("Cancel") { performClose() }
.keyboardShortcut(.cancelAction)
.buttonStyle(.plain)
.frame(width: 0, height: 0)
.opacity(0)
.accessibilityHidden(true)
}
#endif
#if os(tvOS)
// tvOS types with the SYSTEM fullscreen keyboard (TVTextEntry) instead of the custom
// tray the remote and the pad both drive it natively. Same `editing` state as the
@@ -161,24 +171,6 @@ struct GamepadAddHostView: View {
if let close { close() } else { dismiss() }
}
/// Touch/click fallback for closing the controller path is B, a hardware keyboard's Esc
/// rides the cancel action.
private var closeButton: some View {
Button { performClose() } label: {
Image(systemName: "xmark")
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
.foregroundStyle(ink.fg)
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
.consoleGlassBackground(Circle(), interactive: true)
.contentShape(Circle())
}
.buttonStyle(.plain)
#if !os(tvOS)
.keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there)
#endif
.accessibilityLabel("Cancel")
}
// MARK: - Rows
private struct Row: Identifiable {
@@ -55,7 +55,14 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
/// otherwise poll the SAME controller at once driving both. The parent sets this false while
/// something is presented on top so only the front-most carousel consumes the gamepad.
var isActive: Bool = true
@ViewBuilder let card: (Item) -> Card
/// Whether the cards are worth showing off yet the entrance holds until this is true. The
/// library passes "the first covers have their artwork" (see LibraryCoverflowView); anything
/// whose cards are ready the moment they mount leaves it alone.
var contentReady: Bool = true
/// Builds one card. The `CardEntrance` handed along is the card's share of the strip's
/// arrival, and the caller MUST apply it (`.modifier(entrance)`) *underneath* its own
/// `.scrollTransition` see `CardEntrance` for why that placement is load-bearing.
@ViewBuilder let card: (Item, CardEntrance) -> Card
@State private var input = GamepadMenuInput(manager: .shared)
@State private var haptics = MenuHaptics(manager: .shared)
@@ -83,6 +90,26 @@ 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, as ONE timeline: 0 = every card still away, 1 = every card landed
/// (see `CardEntrance`, which slices its own window out of this). Animated exactly once per
/// mount 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).
///
/// One animated Double rather than a Bool behind per-card `.animation(_:value:)` modifiers,
/// because those modifiers wrap the caller's card INCLUDING its `.scrollTransition` and a
/// delayed spring flipping while the scroll view was still settling captured the transition's
/// own per-frame phase updates, stranding the centred card in a half-receded state until the
/// next scroll re-drove it. Nothing here wraps the card in an animation at all.
@State private var entranceProgress: Double = 0
/// Which card the entrance fans out from the cursor as it stood when the strip was armed,
/// so a restored selection assembles around where the eye already is instead of sweeping in
/// from the left.
@State private var entranceAnchor = 0
/// The entrance has been scheduled; it plays exactly once per mount.
@State private var entranceArmed = false
@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,24 +121,27 @@ 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
// below keeps the tile's own look the `.scrollTransition` center pop
// is the focus treatment, since focus and center track each other.
Button { activate(item) } label: {
card(item)
card(item, entrance(idx))
.frame(width: itemWidth)
}
.buttonStyle(ConsoleBareButtonStyle())
.focused($focusedID, equals: item.id)
.id(item.id)
#else
card(item)
card(item, entrance(idx))
.frame(width: itemWidth)
.contentShape(Rectangle())
.onTapGesture { tap(item) }
.id(item.id) // explicit scroll-target identity for scrollPosition
#endif
}
}
@@ -165,7 +195,10 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
reconcile()
wire()
if isActive { input.start() }
armEntrance()
}
// The cards became worth showing (the library's covers got their art) play now.
.onChange(of: contentReady) { _, _ in armEntrance() }
.onDisappear {
input.stop()
haptics.stop()
@@ -200,9 +233,55 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
.onChange(of: items.map(\.id)) { _, _ in
reconcile()
wire()
// A strip that mounted empty (its content arrived after) still gets its entrance.
armEntrance()
}
}
// MARK: - Entrance
/// Run the entrance, once, as soon as the strip is mounted AND its cards are worth showing.
///
/// Deferred one runloop turn ON PURPOSE: a state change made inside `onAppear` lands in the
/// same transaction as the view's insertion, where SwiftUI runs with animations disabled so
/// the cards would simply BE there. Note the failure mode is benign either way: progress
/// reaching 1 without animating leaves every card at exact identity, never stranded.
private func armEntrance() {
guard !entranceArmed, contentReady, !items.isEmpty else { return }
entranceArmed = true
// After `reconcile`, so the fan-out anchors on the seeded/restored cursor.
entranceAnchor = cursor
// Not just the next runloop turn (a change made inside `onAppear` lands in the
// insertion's transaction, where animations are disabled) but a couple of frames: the
// GeometryReader's first pass can report no width at all, so the strip has to lay out
// for real and the scroll view has to centre itself on the cursor before this starts.
// Cards are invisible until then (progress 0 opacity 0), so the wait never shows.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
// Linear on purpose: the master timeline is a clock, and each card eases its OWN
// slice of it (see `CardEntrance`) a spring here would warp every card's curve.
withAnimation(
reduceMotion ? .easeOut(duration: 0.28) : .linear(duration: CardEntrance.total)
) {
entranceProgress = 1
}
}
}
/// The card's share of the strip's entrance: it swings in on the drum, the anchored card
/// landing first and its neighbours fanning outward to either side.
private func entrance(_ idx: Int) -> CardEntrance {
// 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.
let delay = min(CardEntrance.maxDelay, Double(abs(idx - entranceAnchor)) * 0.07)
return CardEntrance(
progress: entranceProgress,
start: delay / CardEntrance.total,
// Never zero: the anchor is the card the eye is ON, so it must swing like the rest
// giving it "no rotation" left the one card you actually watch merely sliding up.
side: idx < entranceAnchor ? -1 : 1,
reduceMotion: reduceMotion)
}
// MARK: - Input wiring
private func wire() {
@@ -346,4 +425,87 @@ 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: turned away on the drum, small, low and invisible
/// then it swings flat, grows and rises into place on a spring soft enough to overshoot. Cards to
/// the left of the anchor hinge on their trailing edge and cards to its right on their leading
/// one, so the strip FANS OPEN from the cursor rather than sweeping past it; the anchor card
/// itself only grows, since it is already facing you. Each card carries its own delay (see
/// `entrance(_:)`) that stagger is what makes the strip read as one gesture instead of a
/// simultaneous flash, and it is the same hinge/perspective language the coverflow's own recede
/// speaks, so the arrival and the scrolling feel like one object.
///
/// APPLY THIS UNDERNEATH THE CARD'S OWN `.scrollTransition`, never around it. A scroll
/// transition derives its phase from the geometry of the view it wraps, so an entrance layered
/// on the OUTSIDE moves the very thing the transition is measuring: every card read as far from
/// centre for the whole travel, its phase pinned at fully-receded, and the centred card only
/// collapsed into its focused look as the entrance ended arriving as a jump. Underneath, the
/// transition measures a card that never moves and simply composes its own scale/rotation on top.
///
/// Transforms only nothing here touches layout, so the scroll view's snapping and the tvOS
/// focus engine are untouched either. Reduce Motion drops every bit of travel for a plain,
/// unstaggered cross-fade.
struct CardEntrance: ViewModifier, Animatable {
/// How long ONE card takes to travel, and the most any card waits before it starts.
static let perCard: Double = 0.6
static let maxDelay: Double = 0.42
/// The master timeline the carousel animates 0 1.
static var total: Double { perCard + maxDelay }
/// The interpolated master progress. `Animatable` is the whole point: SwiftUI hands this
/// modifier a fresh value every frame and re-runs `body`, so the card's transforms are a pure
/// FUNCTION of the clock. No `.animation` modifier wraps the card, so nothing here can catch
/// the caller's `.scrollTransition` mid-scroll and strand it.
var progress: Double
/// Where this card's window opens on that timeline, 01.
let start: Double
/// Which way the card swings in: -1 hinged on its trailing edge (it sits left of the anchor),
/// +1 hinged on its leading edge (right of it). Never 0 every card turns, including the
/// centred one.
let side: Double
let reduceMotion: Bool
var animatableData: Double {
get { progress }
set { progress = newValue }
}
func body(content: Content) -> some View {
// This card's own 01, sliced out of the master clock.
let span = Self.perCard / Self.total
let raw = min(max((progress - start) / span, 0), 1)
// The travel eases out with a whisker of overshoot, so a card settles rather than stops.
let travel = Self.easeOutBack(raw)
// The fade is FAR quicker than the travel it finishes in the first third of the window.
// Sharing one curve meant the card spent its whole swing at near-zero opacity and only
// the last few degrees ever showed, which is why this read as a small slide.
let fade = Self.easeOut(min(raw / 0.34, 1))
// Deep turn, well down, well shrunk the card is genuinely edge-on and travelling. The
// sign matches the coverflow's own recede (right of centre turns negative about its
// leading edge), so the arrival deepens the turn the card wears at rest and unwinds into
// it instead of swinging the opposite way.
let away = reduceMotion ? 0 : 1 - travel
return content
.opacity(reduceMotion ? raw : fade)
.scaleEffect(1 - 0.26 * away)
.rotation3DEffect(
.degrees(side * -64 * away),
axis: (x: 0, y: 1, z: 0),
anchor: .center,
perspective: 0.65)
.offset(y: 34 * away)
}
/// `1 - (1-t)³`, with a small overshoot past 1 before it settles.
private static func easeOutBack(_ t: Double) -> Double {
let c1 = 1.2, c3 = c1 + 1
let u = t - 1
return 1 + c3 * u * u * u + c1 * u * u
}
private static func easeOut(_ t: Double) -> Double {
let u = 1 - t
return 1 - u * u * u
}
}
#endif
@@ -32,7 +32,7 @@ func gamepadTitleTopPadding(compact: Bool) -> CGFloat {
#elseif os(tvOS)
24
#else
compact ? 10 : 18
compact ? 18 : 28
#endif
}
@@ -57,12 +57,13 @@ func gamepadHeaderSpacing(compact: Bool) -> CGFloat {
}
/// Point size for a gamepad screen's pinned title: TV-large on tvOS (read from the couch), the
/// in-hand compact-aware sizes elsewhere.
/// in-hand compact-aware sizes elsewhere. Sized as a proper screen heading the field verdict
/// on the smaller first cut was "way too small" once the title moved off-centre.
func gamepadTitleSize(compact: Bool) -> CGFloat {
#if os(tvOS)
44
#else
compact ? 20 : 30
compact ? 24 : 34
#endif
}
@@ -82,8 +83,6 @@ enum GamepadFormMetrics {
static let rowCorner: CGFloat = 18
static let rowMaxWidth: CGFloat = 920
static let detailFont: CGFloat = 19
static let closeFont: CGFloat = 20
static let closeSide: CGFloat = 48
static let bandWidth: CGFloat = 380
#else
static let headerFont: CGFloat = 12
@@ -97,8 +96,6 @@ enum GamepadFormMetrics {
static let rowCorner: CGFloat = 14
static let rowMaxWidth: CGFloat = 620
static let detailFont: CGFloat = 13
static let closeFont: CGFloat = 14
static let closeSide: CGFloat = 34
/// The option band's (GamepadOptionBand) fixed stage inside a choice row.
static let bandWidth: CGFloat = 240
#endif
@@ -383,20 +380,39 @@ struct GamepadTrayScrim: View {
// to keep the pinned title legible, so it has to frost dark under white ink and
// light under dark ink.
.environment(\.colorScheme, ink.isLight ? .light : .dark)
// Fade the whole blur out toward the content so it dissolves rather than ending on a line.
// Sink the material's grey luminance lift toward the palette's shade (black on a
// dark field field ask: the frost read GREY over the aurora). Inside the mask, so
// the tint dissolves with the blur.
.overlay(ink.shade(0.35))
// Fade the whole blur out toward the content so it dissolves rather than ending on a
// line. The strong region sits deep (0.65) because the first stretch of the gradient
// now runs over the fixed 80 pt outer overhang below.
.mask {
LinearGradient(
stops: [
.init(color: .black, location: 0),
.init(color: .black.opacity(0.9), location: 0.5),
.init(color: .black.opacity(0.92), location: 0.65),
.init(color: .clear, location: 1),
],
startPoint: fromEdge, endPoint: toContent)
}
// Grow past the tray so the fade-to-clear happens OUTSIDE its bounds the tray's own
// text always sits on the strong part, rows blur out before they reach it.
.padding(edge == .top ? .bottom : .top, -32)
.ignoresSafeArea()
// text always sits on the strong part, rows blur out before they reach it. The bottom
// gets the longer runway: its tray sits over SCROLLING rows plus the detail line, and
// the field verdict on the short reach was rows colliding visibly with the legend.
.padding(edge == .top ? .bottom : .top, edge == .top ? -44 : -72)
// Full-bleed by LAYOUT, not by `.ignoresSafeArea()`: safe-area expansion resolves a
// beat after insertion (outside any geometry group and outside this view's own
// transaction), which is exactly the pop the field kept seeing vertically first,
// then, once the vertical runway became padding, on the X axis alone (the landscape
// side insets). 80 pt clears every inset on every device; backgrounds never clip,
// so the overhang simply draws.
.padding(edge == .top ? .top : .bottom, -80)
.padding(.horizontal, -80)
// And the shape must NEVER animate: mounted inside a pushed shell layer, any late
// geometry would ride the push's transaction and visibly grow into place. The
// layer's own fade/slide still carries the scrim; only its SHAPE is pinned.
.transaction { $0.animation = nil }
}
}
@@ -124,6 +124,11 @@ struct GamepadHomeView: View {
#if os(iOS)
if let screen = topScreen {
screenLayer(screen)
// Settle the screen's internal layout before the insertion animates, so
// descendants never lerp from a half-resolved first frame. (Not sufficient
// for the tray blurs on its own safe-area expansion resolves outside a
// geometry group; GamepadTrayScrim pins its own geometry too.)
.geometryGroup()
.zIndex(1)
.id(screen.id)
.transition(.gamepadScreen(slide: GamepadShellMotion.slide(compact: compact)))
@@ -322,32 +327,27 @@ struct GamepadHomeView: View {
// MARK: - Chrome
private var titleBar: some View {
// The chip used to be a trailing `.overlay`, which reserves no width: on a portrait phone
// it sat directly on top of the centred title ("Select a Host" ran straight into the pad
// name). Laying it out as a row with a hidden mirror on the leading side keeps the title
// optically centred AND clear of the chip at every width; the title shrinks a little
// before it would ever truncate.
// Leading title (a console heading, not a floating label field ask), chip trailing.
// The old hidden-mirror trick existed only to keep a CENTRED title clear of the chip;
// a leading title needs none of it the flexible frame keeps the two apart, and the
// title shrinks a little before it would ever truncate.
HStack(spacing: 12) {
statusChip(hidden: true)
Text("Select a Host")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
.lineLimit(1)
.minimumScaleFactor(0.75)
.frame(maxWidth: .infinity)
statusChip(hidden: false)
.frame(maxWidth: .infinity, alignment: .leading)
statusChip
}
.padding(.horizontal, 20)
.padding(.horizontal, 24)
}
/// Which pad is driving this UI (name + battery) quiet, and only where there's room; a
/// compact-height phone gives the pixels to the carousel instead. `hidden` renders the same
/// chip purely as a width reserve.
@ViewBuilder private func statusChip(hidden: Bool) -> some View {
/// compact-height phone gives the pixels to the carousel instead.
@ViewBuilder private var statusChip: some View {
if !compact, let active = gamepads.active {
ControllerStatusChip(controller: active)
.opacity(hidden ? 0 : 1)
.accessibilityHidden(hidden)
}
}
@@ -371,8 +371,8 @@ struct GamepadHomeView: View {
onSecondary: { openLibraryForSelected() },
onTertiary: { showSettings = true },
isActive: homeOwnsController
) { tile in
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
) { tile, entrance in
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight), entrance: entrance)
}
.frame(height: cardHeight + 40)
}
@@ -381,8 +381,12 @@ struct GamepadHomeView: View {
/// per-frame `phase` (real distance-from-centered), so the look always matches what's on screen
/// mid-scroll. `.shadow`/`.overlay` aren't part of `VisualEffect`, so the focus pop is scale +
/// brightness/saturation + a depth blur on the recessed neighbors.
private func hostCard(_ tile: HomeTile, size: CGSize) -> some View {
private func hostCard(
_ tile: HomeTile, size: CGSize, entrance: CardEntrance
) -> some View {
GamepadHostTile(tile: tile, size: size)
// Beneath the scroll transition, never around it see CardEntrance.
.modifier(entrance)
.scrollTransition { content, phase in
let d = CGFloat(min(abs(phase.value), 1))
let scale = 1 - d * 0.12
@@ -27,35 +27,29 @@ struct GamepadLibraryScreen: View {
store: store, host: host, onLaunch: onLaunch,
onClose: close, controllerActive: controllerActive)
.safeAreaInset(edge: .top, spacing: 0) {
// Leading, like every gamepad heading no close chrome, B is the exit (the
// coverflow's, or LibraryView's own back-catcher before the coverflow exists).
Text("\(host.displayName) — Library")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
.lineLimit(1)
.minimumScaleFactor(0.75)
.frame(maxWidth: .infinity)
.overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) }
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
.background { GamepadTrayScrim(edge: .top) }
}
// A hardware keyboard's Esc still closes, without chrome.
.background {
Button("Close") { close() }
.keyboardShortcut(.cancelAction)
.buttonStyle(.plain)
.frame(width: 0, height: 0)
.opacity(0)
.accessibilityHidden(true)
}
.gamepadPaletteInk()
}
/// Touch/click fallback for closing the controller path is B (the coverflow's onDismiss),
/// and it also covers the loading/error/empty states, which the coverflow (and its B) never
/// mounts under. A hardware keyboard's Esc rides the cancel action.
private var closeButton: some View {
Button { close() } label: {
Image(systemName: "xmark")
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
.foregroundStyle(ink.fg)
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
.consoleGlassBackground(Circle(), interactive: true)
.contentShape(Circle())
}
.buttonStyle(.plain)
.keyboardShortcut(.cancelAction)
.accessibilityLabel("Close library")
}
}
#endif
@@ -41,6 +41,18 @@ struct LibraryCoverflowView: View {
private let compact = false // no size classes on macOS
#endif
@State private var selection: String?
/// How many covers have settled (art loaded, or every candidate exhausted).
@State private var artSettled = 0
/// The backstop below has fired: play the entrance regardless of what the art is doing.
@State private var artWaitOver = false
/// Whether the strip may play its entrance yet. Cards swinging in as grey placeholders and
/// then filling with artwork afterwards is the whole effect wasted, so the entrance waits for
/// the first few covers every poster is fetched in parallel, so those land together and
/// cover the visible strip. The wait is capped: a slow or artless library still animates.
private var contentReady: Bool {
artWaitOver || artSettled >= min(4, games.count)
}
var body: some View {
GeometryReader { geo in
@@ -59,6 +71,11 @@ struct LibraryCoverflowView: View {
// Publish the palette's ink to this screen (text, glass, accent, scrims) a
// pale palette flips all of them, and no leaf should have to read the setting.
.gamepadPaletteInk()
// The entrance's backstop (see `contentReady`).
.task {
try? await Task.sleep(for: .milliseconds(700))
artWaitOver = true
}
}
@ViewBuilder private func content(for size: CGSize) -> some View {
@@ -91,9 +108,10 @@ struct LibraryCoverflowView: View {
onActivate: { onLaunch?($0.id) },
onBack: { onDismiss?() },
shoulderJump: 5,
isActive: controllerActive
) { game in
cover(game, width: coverWidth, height: coverHeight)
isActive: controllerActive,
contentReady: contentReady
) { game, entrance in
cover(game, width: coverWidth, height: coverHeight, entrance: entrance)
}
.frame(height: coverHeight + 44)
}
@@ -102,18 +120,26 @@ struct LibraryCoverflowView: View {
/// per-frame `phase` (real distance-from-centered), so the tilt tracks what's actually on screen
/// mid-scroll. `.shadow` isn't a `VisualEffect`, so it's baked constant into the card; the
/// scale/rotation/opacity ramp already makes the centered cover prominent.
private func cover(_ game: GameEntry, width: CGFloat, height: CGFloat) -> some View {
PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession)
private func cover(
_ game: GameEntry, width: CGFloat, height: CGFloat, entrance: CardEntrance
) -> some View {
PosterImage(
candidates: game.art.posterCandidates, title: game.title, session: imageSession,
onLoaded: { artSettled += 1 })
.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)
.strokeBorder(ink.fg(0.12), lineWidth: 1)
}
.shadow(color: ink.shadow(0.5), radius: 16, y: 12)
// Beneath the scroll transition, never around it see CardEntrance.
.modifier(entrance)
.scrollTransition { content, phase in
let v = phase.value
let d = CGFloat(min(abs(v), 1))
@@ -65,6 +65,17 @@ struct LibraryView: View {
imageSession?.finishTasksAndInvalidate()
imageSession = nil
}
#if os(iOS) || os(macOS)
// B closes the library even before the coverflow exists (loading / error / empty):
// the coverflow's carousel owns B once games render; until then this zero-size
// listener does without it a controller-only user is trapped on an error screen
// (the gamepad screens carry no close chrome).
.background {
if gamepadUIActive && games.isEmpty {
LibraryBackCatcher(active: controllerActive) { (onClose ?? { dismiss() })() }
}
}
#endif
}
@ViewBuilder private var content: some View {
@@ -210,6 +221,30 @@ struct LibraryView: View {
}
}
#if os(iOS) || os(macOS)
/// Zero-size controller listener for the library's pre-coverflow states B backs out. The same
/// shape as ConnectOverlay's `ConnectControllerInput`; `GamepadMenuInput.needsSnapshot` swallows
/// the held press that opened the screen. Unmounts the moment the coverflow (and its own B) is up.
private struct LibraryBackCatcher: View {
let active: Bool
let onBack: () -> Void
@State private var input = GamepadMenuInput(manager: .shared)
var body: some View {
Color.clear
.frame(width: 0, height: 0)
.onAppear {
input.onBack = onBack
if active { input.start() }
}
.onChange(of: active) { _, nowActive in
if nowActive { input.start() } else { input.stop() }
}
.onDisappear { input.stop() }
}
}
#endif
/// One poster tile. Steam vs custom is marked with a badge; the art walks the candidate URLs
/// (portrait header hero) and finally a text placeholder.
private struct GameCard: View {
@@ -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)
}
}
@@ -58,6 +71,10 @@ struct PosterImage: View {
let candidates: [URL]
let title: String
let session: URLSession?
/// Fires once this poster has settled art loaded, or every candidate exhausted and the
/// placeholder is what it will be. The gamepad coverflow waits on a few of these before
/// playing its entrance, so the cards swing in carrying artwork rather than grey rectangles.
var onLoaded: (() -> Void)?
@State private var index = 0
@State private var image: PlatformImage?
@@ -67,19 +84,30 @@ struct PosterImage: View {
Image(platformImage: image)
.resizable()
.scaledToFill()
.transition(.opacity)
} else if index < candidates.count {
ZStack { placeholder; ProgressView() }
.transition(.opacity)
} else {
placeholder
.transition(.opacity)
}
}
// Art crosses over its placeholder instead of replacing it between two frames. Cover
// fetches land one by one, so without this a freshly opened library is a run of cards
// visibly snapping from grey to artwork after the strip has already settled.
.animation(.easeOut(duration: 0.3), value: image != nil)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
.task(id: index) { await loadCurrent() }
}
private func loadCurrent() async {
guard index < candidates.count else { return }
// Past the end: the placeholder IS the final look, so this poster has settled.
guard index < candidates.count else {
onLoaded?()
return
}
guard let session, let data = try? await session.data(from: candidates[index]).0,
let loaded = PlatformImage(data: data)
else {
@@ -87,6 +115,7 @@ struct PosterImage: View {
return
}
image = loaded
onLoaded?()
}
private var placeholder: some View {
@@ -224,9 +224,18 @@ struct StreamHUDView: View {
/// The card's inner content padding. Roomier on tvOS the stat text auto-scales for the
/// couch (relative system styles), so the card's chrome must keep pace or it reads cramped.
///
/// On iOS it also has to CLEAR THE CORNER. A rounded corner of radius `r` pulls the card's
/// edge inward by `r (r² (ry)²)` at a distance `y` below the top, so the first and last
/// lines of a padded stack sit inside the arc unless the padding keeps pace with the radius.
/// At `0.45 · r` that intrusion stays well inside the padding across the whole range this
/// card can wear (4.6 pt of arc against 12.6 pt of padding at the 28 pt cap), so no line
/// ever runs into the curve.
private var cardPadding: CGFloat {
#if os(tvOS)
return 16
#elseif os(iOS)
return max(10, cardCornerRadius * 0.45)
#else
return 10
#endif
@@ -246,13 +255,20 @@ struct StreamHUDView: View {
#endif
}
/// The card's corner radius. On iOS it's concentric with the physical display corner
/// `displayCornerRadius edgeInset`, so the gap to the screen edge stays uniform right around the
/// corner instead of a small-radius card cutting into the very rounded glass. Clamped so a
/// flat-cornered device (or a hidden radius) still gets a sensibly rounded card.
/// The card's corner radius. On iOS it aims to be concentric with the physical display
/// corner `displayCornerRadius edgeInset`, so the gap to the screen edge stays uniform
/// right around the corner instead of a small-radius card cutting into the very rounded
/// glass but that aim is BOUNDED by what a card this small can actually carry.
///
/// Unbounded, a modern phone (~62 pt of display radius) asked for a 48 pt corner on a card
/// whose lines sit 10 pt from the edge: the arc reaches ~19 pt inward at the first line, so
/// the top and bottom lines rendered INSIDE the curve. Concentricity is only a virtue while
/// the radius is small next to the card; past that it is just a blob eating its own text.
/// 28 pt is the most this card's stack can wear (with `cardPadding` scaling alongside), and
/// devices whose display radius asks for less than that still get a truly concentric corner.
private var cardCornerRadius: CGFloat {
#if os(iOS)
return max(12, DeviceMetrics.displayCornerRadius - edgeInset)
return min(28, max(12, DeviceMetrics.displayCornerRadius - edgeInset))
#elseif os(tvOS)
return 16 // scales with the roomier padding
#else
@@ -1,10 +1,19 @@
// The gamepad settings' "select" value as a REAL band: every option sits on a drum rotating
// about a vertical axis the current one faces you flat, its neighbours curve away with
// perspective, shrinking and fading toward the edges. The old presentation animated a single
// Text keyed by its value (an old-out/new-in crossfade that merely implied motion), which fell
// apart under fast repeated steps: each press restarted the fade. Here the drum's position is
// one continuous value driven by a spring, and SwiftUI's spring retargeting preserves velocity
// rapid presses accumulate into one accelerating spin instead of five restarted crossfades.
// The gamepad settings' "select" value as a REAL band: the options sit side by side on a drum
// segment curving about a vertical axis the current one faces you flat, and a step rotates the
// next one in with perspective. The old presentation animated a single Text keyed by its value
// (an old-out/new-in crossfade that merely implied motion), which fell apart under fast repeated
// steps: each press restarted the fade. Here the drum's position is one continuous value driven
// by a spring, and SwiftUI's spring retargeting preserves velocity rapid presses accumulate
// into one accelerating travel instead of five restarted crossfades.
//
// The band is LINEAR, not a ring (field verdict on the first cut): a ring showed the first
// option waiting to the right of the last one, which left/right can't reach (adjust clamps)
// a promise the navigation doesn't keep. And on a 2-option ring the unselected option flipped
// sides with every step. So positions are fixed: option i sits i steps from the start, the ends
// are the ends, and A's wrap from the last option travels BACK across the list to the first.
// Options other than the facing one exist only while the drum is actually moving at rest a row
// shows exactly its value (a resting neighbour under a long label rendered as overlapping,
// unreadable text).
//
// The band is purely presentational: stepping semantics (left/right clamps with a boundary thud,
// A cycles forward wrapping, disabled rows refuse input) stay in GamepadSettingsView's row
@@ -28,9 +37,8 @@ struct GamepadOptionBand: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// Where the drum rests, in option steps UNBOUNDED: a forward wrap keeps adding 1, never
/// modded back, so the ring distance below is what brings option 0 around from the right.
/// Rendering only ever reads it modulo `options.count`.
/// Where the drum rests, in option steps always chasing `Double(selection)`; only the
/// spring's interpolation ever puts it between integers.
@State private var drumPosition: Double
init(options: [String], selection: Int, focused: Bool, width: CGFloat) {
@@ -56,7 +64,6 @@ struct GamepadOptionBand: View {
Drum(
options: options,
rotation: drumPosition,
neighborGate: focused ? 1 : 0,
target: drumPosition,
// Puts the ±1 neighbour ~40 % of the band off-centre, curling to the edge.
radius: width * 0.72)
@@ -77,8 +84,8 @@ struct GamepadOptionBand: View {
}
.onChange(of: selection) { old, new in step(from: old, to: new) }
// The options list itself can mutate under the drum (a custom resolution appears, a
// controller connects, the buffer options re-derive from a new refresh rate) the ring
// math is only valid while drumPosition selection (mod count), so re-seat without a spin.
// controller connects, the buffer options re-derive from a new refresh rate) re-seat
// without a travel.
.onChange(of: options.count) { _, _ in snap() }
// One element to VoiceOver the neighbour texts are rendering, not content.
.accessibilityElement(children: .ignore)
@@ -89,22 +96,15 @@ struct GamepadOptionBand: View {
options.indices.contains(selection) ? options[selection] : ""
}
/// One step spins the drum; anything else (an external write from the touch settings, a
/// re-derived options list) re-seats it a spin to a value the user didn't step to would
/// read as the UI acting on its own.
/// A step (or A's wrap which on a linear band is a fast travel back to the start) springs
/// the drum; anything else (an external write from the touch settings, a re-derived options
/// list) re-seats it a travel to a value the user didn't step to would read as the UI
/// acting on its own.
private func step(from old: Int, to new: Int) {
let n = options.count
let raw = new - old
let delta: Int? = if n > 1 && old == n - 1 && new == 0 {
1 // A's wrap from the last option: keep spinning FORWARD, the way the thumb pressed.
} else if abs(raw) == 1 {
raw
} else {
nil
}
guard let delta, !reduceMotion else { return snap() }
let wrapped = options.count > 1 && old == options.count - 1 && new == 0
guard (abs(new - old) == 1 || wrapped), !reduceMotion else { return snap() }
withAnimation(.spring(response: 0.32, dampingFraction: 0.78)) {
drumPosition += Double(delta)
drumPosition = Double(new)
}
}
@@ -117,51 +117,44 @@ struct GamepadOptionBand: View {
/// The rotating drum itself. `Animatable` so SwiftUI re-evaluates the body with the INTERPOLATED
/// rotation every frame of the spring each option's offset/scale/opacity follows the real arc,
/// and options more than one step away genuinely enter and leave mid-spin. (A plain `.animation`
/// on independent modifiers can't do that: each modifier would lerp its own endpoints and the
/// and options along the travel genuinely enter and leave mid-flight. (A plain `.animation` on
/// independent modifiers can't do that: each modifier would lerp its own endpoints and the
/// in-between options would never appear.)
private struct Drum: View, Animatable {
let options: [String]
/// The interpolated drum position, in option steps.
var rotation: Double
/// 1 while the row is focused the resting drum shows its neighbours only under focus (an
/// unfocused row is one flat Text, visually and costwise what it was before the band).
var neighborGate: Double
/// Where the spring is headed (jumps instantly on a step; only `rotation` chases it). The
/// distance between them is "how mid-flight are we" it keeps the neighbours visible while
/// an unfocused drum finishes settling, fading them continuously as it lands.
/// distance between them is "how mid-flight are we" the neighbours exist exactly as long
/// as the drum is moving, fading continuously as it lands, so a resting row is one flat
/// Text and a long label never sits under a resting neighbour.
let target: Double
/// Drum radius in points (from the band width see the caller).
let radius: Double
var animatableData: AnimatablePair<Double, Double> {
get { AnimatablePair(rotation, neighborGate) }
set {
rotation = newValue.first
neighborGate = newValue.second
}
var animatableData: Double {
get { rotation }
set { rotation = newValue }
}
/// Angular pitch between adjacent options on the drum.
private static let stepAngle = 34.0 * .pi / 180.0
var body: some View {
let n = options.count
let flight = min(1, abs(rotation - target) * 3)
let content = ZStack {
ForEach(0..<n, id: \.self) { i in
// Signed ring distance to the drum position, wrapped into (-n/2, n/2] the
// whole wrap story: a monotonically grown position brings option 0 around from
// the right of the last option with no special casing.
let d = (Double(i) - rotation).remainder(dividingBy: Double(n))
if abs(d) <= 2.5 {
option(i, distance: d, gate: max(neighborGate, flight))
ForEach(0..<options.count, id: \.self) { i in
// Plain signed distance the band is linear, so option i has ONE home and the
// ends are the ends (nothing waits beyond the last option).
let d = Double(i) - rotation
if abs(d) < 0.5 || (flight > 0.001 && abs(d) <= 2.5) {
option(i, distance: d, gate: flight)
}
}
}
#if os(tvOS)
// Flatten the transform stack while spinning the 10-foot GPU already made these rows
// drop Liquid Glass, and five projected texts per step is the same class of cost.
// Flatten the transform stack while travelling the 10-foot GPU already made these
// rows drop Liquid Glass, and five projected texts per step is the same class of cost.
content.drawingGroup()
#else
content
@@ -171,7 +164,7 @@ private struct Drum: View, Animatable {
@ViewBuilder private func option(_ i: Int, distance d: Double, gate: Double) -> some View {
let angle = d * Self.stepAngle
let depth = cos(angle)
// The facing option never gates: an unfocused row still shows its value.
// The facing option never gates: a resting row still shows its value.
let alpha = pow(max(depth, 0), 3) * (abs(d) < 0.5 ? 1 : gate)
Text(options[i])
.lineLimit(1)
@@ -144,12 +144,14 @@ struct GamepadSettingsView: View {
}
.frame(maxWidth: .infinity)
.safeAreaInset(edge: .top, spacing: 0) {
VStack(spacing: gamepadHeaderSpacing(compact: compact)) {
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
// Leading, like a console section heading centred read as a floating label,
// and a gamepad UI needs no close chrome next to it (B is the exit).
Text(title)
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
.frame(maxWidth: .infinity)
.overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) }
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
// The picker is one layer deeper its rows aren't sections of anything, so the
// strip would be a control that does nothing while it's up.
if pinTarget == nil { tabStrip }
@@ -191,6 +193,18 @@ struct GamepadSettingsView: View {
gamepads.startDiscovery()
}
.onDisappear { gamepads.stopDiscovery() }
#if !os(tvOS)
// The visible close is gone (a gamepad UI exits with B) this keeps a hardware
// keyboard's Esc and the macOS sheet's cancel working without chrome.
.background {
Button("Close") { performClose() }
.keyboardShortcut(.cancelAction)
.buttonStyle(.plain)
.frame(width: 0, height: 0)
.opacity(0)
.accessibilityHidden(true)
}
#endif
}
/// The section switcher. Horizontally scrollable so a narrow phone in landscape never has to
@@ -243,10 +257,12 @@ struct GamepadSettingsView: View {
.padding(.vertical, 7)
.background {
// One shared capsule that MOVES between pills, rather than one per pill fading
// in and out the highlight travels the way the press did.
// in and out the highlight travels the way the press did. A Liquid Glass
// surface (accent-tinted through consoleGlass), so the strip wears the same
// material language as the rows it sits above.
if selected {
Capsule()
.fill(ink.accent(0.85))
Color.clear
.consoleGlass(Capsule(), tint: ink.accent(0.85))
.matchedGeometryEffect(id: "tab", in: tabHighlight)
}
}
@@ -294,24 +310,6 @@ struct GamepadSettingsView: View {
if let close { close() } else { dismiss() }
}
/// Touch/click fallback for closing the controller path is B, a hardware keyboard's Esc
/// rides the cancel action.
private var closeButton: some View {
Button { performClose() } label: {
Image(systemName: "xmark")
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
.foregroundStyle(ink.fg)
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
.consoleGlassBackground(Circle(), interactive: true)
.contentShape(Circle())
}
.buttonStyle(.plain)
#if !os(tvOS)
.keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there)
#endif
.accessibilityLabel("Close settings")
}
/// "Settings", or "Pin Work" while the pin picker is up the title is what says which
/// layer the row list currently is.
private var title: String {
@@ -391,12 +389,10 @@ struct GamepadSettingsView: View {
.font(.geist(m.valueFont, .medium, relativeTo: .callout))
.foregroundStyle(focused ? ink.fg : ink.fg(0.6))
} else {
// Toggles and the flat rows keep the quiet slip: keyed by the value so
// a change slides the new string in following the user's motion (a
// right-step enters from the right), crossfading over ~14 pt. The
// ZStack is the stable home the removed/inserted texts transition
// within. (A two-position switch on a drum would read as a coin flip
// both sibling clients keep toggles a different control, too.)
// The flat rows (profile pin counts, placeholders) keep the quiet slip:
// keyed by the value so a change slides the new string in following the
// user's motion, crossfading over ~14 pt. The ZStack is the stable home
// the removed/inserted texts transition within.
let slide: CGFloat = lastAdjustDelta >= 0 ? 14 : -14
ZStack {
Text(row.value)
@@ -771,6 +767,8 @@ struct GamepadSettingsView: View {
value: pinned ? "Pinned" : "Off",
detail: "A pinned profile appears as its own card on the host — one press "
+ "connects with it.",
optionLabels: ["Off", "Pinned"],
selectedIndex: pinned ? 1 : 0,
adjust: { delta in
let target = delta > 0
guard pinned != target else { return false }
@@ -868,6 +866,10 @@ struct GamepadSettingsView: View {
id: id, tab: tab, icon: icon, label: label,
value: value.wrappedValue ? "On" : "Off",
detail: detail,
// Toggles ride the band too (field ask): Off sits left of On, matching the
// directional semantics below, so a right-step slides On in from the right.
optionLabels: ["Off", "On"],
selectedIndex: value.wrappedValue ? 1 : 0,
enabled: enabled,
adjust: { delta in
// Directional semantics: left = off, right = on; a no-op reads as a boundary.