Gamepad UI (iOS): the field pass — a linear drum, no close chrome, a heading that leads, and strips that assemble themselves #93
@@ -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, 0…1.
|
||||
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 0…1, 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² − (r−y)²)` 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.
|
||||
|
||||
Reference in New Issue
Block a user