Files
punktfunk/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift
T
enricobuehler bac63059a9 feat(apple): drive the console UI from a hardware keyboard
Asked for by a field user: "make the iPadOS client compatible with keyboard to
select games with keyboard arrows, enter to launch". An iPad on a Magic
Keyboard and a couch Mac are the same situation the console layout was built
for — a screen driven from a distance with a fixed set of directional inputs —
and the cursor/confirm/back model already exists here for the pad. A keyboard
is a third input onto it, not a new navigation scheme: arrows move, Return and
Space activate, Esc backs out, everywhere the controller already worked
(carousel, menu lists, prompts) plus the plain poster grid.

`active` mirrors each caller's existing controller gate rather than being a
second, parallel notion of "who has input". Without that, a launcher sitting
under an open screen would keep eating key presses and navigate behind it —
the same defect the pad gate exists to prevent. Esc returns `.ignored` when a
screen has no back action, so it still reaches the `.cancelAction` shortcut
that closes a macOS sheet.

The plain grid needed real arithmetic rather than a flat index. It renders up
to TWO `LazyVGrid` sections (launchers above titles), so a flat index steps by
the wrong amount at the boundary whenever the first section's last row is
partial — up from the titles' first row lands mid-launcher-row instead of
above. `LibraryGridNav` moves within a section and hands off at its edges
preserving the column, clamping into partial rows. It lives in PunktfunkKit
because it is edge-case arithmetic and that is the target tests can reach; 12
cases cover the partial row, the hand-off, a stale cursor, an empty grid and a
zero column count.

The column count comes from the grid's MEASURED width run through
`.adaptive`'s own fitting rule, so up/down move exactly one visual row instead
of a guess that drifts with window size. Measured via a background
GeometryReader — a sibling inside a ScrollView would claim the whole viewport.

The grid cursor starts nil and only appears on the first arrow press, so a
touch user is never shown a selection they didn't ask for.

tvOS is excluded throughout: its focus engine already routes hardware arrows,
and these screens hand it navigation authority deliberately.

17 PunktfunkKit tests pass; macOS + tvOS typecheck; launcher and settings
verified rendering and navigating in the iPad Pro 13" simulator.
2026-08-10 09:59:28 +02:00

540 lines
29 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The one piece of gamepad-menu machinery shared by the host launcher (GamepadHomeView) and the
// library coverflow (LibraryCoverflowView): a horizontal, center-snapping carousel driven entirely
// by a controller (iOS/iPadOS/macOS) — or, on tvOS, by the NATIVE FOCUS ENGINE: every card is a
// focusable Button, so the Siri Remote and a game controller both navigate through the system
// (dpad/swipe moves focus, select activates, Menu backs out at the presentation level), and the
// cursor/scroll chase the focused card instead of the poll. The poll still runs on tvOS but
// carries ONLY the Y/X actions (library/settings) — buttons the focus engine has no concept of.
// The iOS/macOS poll-driven behavior is untouched by the tvOS mode.
//
// The scrolling is pure native SwiftUI — `.scrollTargetLayout()` + `.scrollTargetBehavior(.viewAligned)`
// snap exactly one item to center, and symmetric `.safeAreaPadding(.horizontal)` (sized off the live
// container width, so it's correct in an iPad split view too) lets the first and last item reach the
// middle. The CALLER owns each card's look, including its own `.scrollTransition` — this component
// deliberately applies none, so a screen can chain the VisualEffect-only transition modifiers without
// the generic wrapper here pushing the type-checker onto an overload it can't satisfy.
//
// Navigation authority: an internal `cursor` (an index), NOT the scroll-position binding, is the
// source of truth for where the gamepad is. `.scrollPosition(id:)` is a two-way binding and the
// scroll view WRITES intermediate ids into it while a programmatic animation is in flight — so
// reading the "current" item back out of it to compute the next one desyncs badly on a fast held
// stick (each move reads a lagging value and the cursor stalls before the last item). Instead a move
// advances `cursor` synchronously and points the scroll view at `items[cursor]`; scroll read-back is
// only allowed to move the cursor when the gamepad hasn't driven recently (i.e. a touch drag).
//
// Feedback is dual-channel by design: `.sensoryFeedback` ticks the DEVICE Taptic engine (for a
// handheld/touch user) and `MenuHaptics` ticks the CONTROLLER (for a couch user holding the pad).
// Both fire on a move, on confirm, and — for a non-wrapping list — a duller bump plus a short visual
// recoil when a move is refused at either end.
import PunktfunkKit
import SwiftUI
#if os(iOS) || os(macOS) || os(tvOS)
struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hashable {
let items: [Item]
/// Output only: the carousel WRITES the focused item's id here for the caller's detail panel.
/// It is deliberately not what drives the scroll (see the file header).
@Binding var selection: Item.ID?
/// Every card is laid out at this fixed width so `.viewAligned` snapping + symmetric side
/// insets center exactly one at a time.
let itemWidth: CGFloat
let spacing: CGFloat
/// A → activate the centered item.
let onActivate: (Item) -> Void
/// Y → the screen's secondary action (e.g. open a host's library); nil disables it.
var onSecondary: (() -> Void)?
/// X → the screen's tertiary action (e.g. open settings); nil disables it.
var onTertiary: (() -> Void)?
/// B → back/dismiss; nil disables it (e.g. the root launcher has nowhere to go back to).
var onBack: (() -> Void)?
/// L1/R1 → jump this many items at once (clamped to the ends); 0 disables the shoulders.
var shoulderJump: Int = 0
/// Whether this carousel currently owns controller input. A presenting screen (e.g. the host
/// launcher) stays mounted behind a presented one (e.g. the library), and both carousels would
/// 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
/// 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)
#if os(tvOS)
/// tvOS: the focus engine is the navigation authority — `cursor`/`scrolledID` chase this,
/// never the other way around (mirroring the poll's cursor-first discipline).
@FocusState private var focusedID: Item.ID?
#endif
/// Authoritative gamepad cursor (index into `items`). Never assigned from scroll read-back
/// while the gamepad is driving — that's the whole desync fix.
@State private var cursor = 0
/// The id the scroll view is aligned to — its own two-way `.scrollPosition` state.
@State private var scrolledID: Item.ID?
/// When the gamepad last moved the cursor; gates scroll read-back so a mid-animation write can't
/// drag the cursor backward during a fast held direction.
@State private var lastNav = Date.distantPast
/// True while a programmatic scroll animation is in flight. `.scrollPosition(id:)` DROPS a new
/// write that lands mid-animation — the scroll view stays stuck on the old item even though the
/// binding updated — so we never issue one until the previous animation reports complete, then
/// `commitScroll` re-targets the current cursor (coalescing a fast burst; see `commitScroll`).
@State private var isScrolling = false
/// A short horizontal recoil when a move is refused at a list end.
@State private var bumpOffset: CGFloat = 0
/// `.sensoryFeedback` fires on a change of its trigger; counters request a device tick for the
/// 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).
private let navSettle: TimeInterval = 0.4
var body: some View {
GeometryReader { geo in
let inset = max(0, (geo.size.width - itemWidth) / 2)
ScrollViewReader { proxy in
ScrollView(.horizontal) {
HStack(spacing: spacing) {
// 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, entrance(idx))
.frame(width: itemWidth)
}
.buttonStyle(ConsoleBareButtonStyle())
.focused($focusedID, equals: item.id)
.id(item.id)
#else
card(item, entrance(idx))
.frame(width: itemWidth)
.contentShape(Rectangle())
.onTapGesture { tap(item) }
.id(item.id) // explicit scroll-target identity for scrollPosition
#endif
}
}
.frame(height: geo.size.height) // fill so shorter cards center vertically
.scrollTargetLayout()
}
// The two-way `.scrollPosition` + snap machinery serves the POLL/touch platforms.
// Not on tvOS: that binding DROPS a write landing mid-animation (the very desync
// the poll's cursor design exists to avoid — see the header), and on tvOS the
// focus engine's own reveal-scrolls are always in flight, so drops were routine
// ("navigation not reflected in the scroll view"). tvOS scrolls imperatively
// below instead — scrollTo RE-TARGETS mid-animation (the GamepadMenuList pattern).
#if !os(tvOS)
.scrollPosition(id: $scrolledID)
.scrollTargetBehavior(.viewAligned)
#endif
// .never, not .hidden — macOS's "always show scroll bars" setting overrides .hidden
// and paints a scroller across the console strip.
.scrollIndicators(.never)
.scrollClipDisabled() // let the focused card scale up past the strip bounds
.safeAreaPadding(.horizontal, inset)
.offset(x: bumpOffset)
#if os(tvOS)
// Land initial focus on the first card (the launcher's first host / the coverflow's
// first title) instead of wherever the engine guesses.
.defaultFocus($focusedID, items.first?.id)
// Focus moved (remote swipe / pad dpad) — chase it: cursor, detail selection,
// controller detent, and an imperative center scroll.
.onChange(of: focusedID) { _, newValue in
guard let idx = index(of: newValue), idx != cursor else { return }
cursor = idx
lastNav = Date()
haptics.move()
selection = newValue
withAnimation(.easeOut(duration: scrollAnim)) {
proxy.scrollTo(newValue, anchor: .center)
}
}
// The list changed under a stable focus (discovered hosts prepend tiles): the
// content shifted but no focus change fires above — re-center the focused card.
.onChange(of: items.map(\.id)) { _, _ in
if let id = focusedID { proxy.scrollTo(id, anchor: .center) }
}
#endif
}
}
.sensoryFeedback(.selection, trigger: cursor)
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
#if os(iOS) || os(macOS)
// A hardware keyboard drives the same cursor as the pad — arrows step, Return activates,
// Esc backs out (iPad on a Magic Keyboard, couch Mac). tvOS routes arrows through the
// focus engine instead, which owns navigation there.
.gamepadKeyNavigation(
active: isActive,
onMove: { move($0) },
onConfirm: { activate() },
onBack: onBack)
#endif
.onAppear {
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()
}
// Hand controller input to/from a screen presented on top (see `isActive`): a covered
// carousel stops polling so it can't navigate behind the front-most one.
.onChange(of: isActive) { _, active in
if active {
wire()
input.start()
} else {
input.stop()
haptics.stop()
}
}
// A touch drag settles the scroll onto a new id: adopt it as the cursor. Ignored while a
// programmatic scroll is animating (its own intermediate id write-backs would regress the
// cursor) and briefly after a gamepad move (the same reason), so only a genuine touch drag
// — which never sets `isScrolling` — moves the cursor here. Not on tvOS: there is no touch
// drag, and the focus engine's own reveal-scrolls must never steal the cursor from focus.
#if !os(tvOS)
.onChange(of: scrolledID) { _, newValue in
guard !isScrolling, Date().timeIntervalSince(lastNav) > navSettle else { return }
guard let idx = index(of: newValue), idx != cursor else { return }
cursor = idx
selection = newValue
}
#endif
// Re-seed a dropped/changed selection AND re-wire the input callbacks so they capture the
// current `items` value (a plain array — unlike an observed object it would otherwise go
// stale in the closures stored on `input`).
.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() {
#if os(tvOS)
// The focus engine owns move/confirm/back on tvOS (that's what keeps the Siri Remote
// working on this screen — and what routes Menu through the system's back semantics).
// The poll carries only the buttons focus has no concept of: Y/X, the screen actions.
input.onSecondary = onSecondary
input.onTertiary = onTertiary
#else
input.onMove = { move($0) }
input.onConfirm = { activate() }
input.onSecondary = onSecondary
input.onTertiary = onTertiary
input.onBack = onBack
input.onShoulder = shoulderJump > 0 ? { shoulder(right: $0) } : nil
#endif
}
private func move(_ direction: GamepadMenuInput.Direction) {
let forward = direction == .right || direction == .down
step(by: forward ? 1 : -1, clampAtEnds: false)
}
private func shoulder(right: Bool) {
step(by: right ? shoulderJump : -shoulderJump, clampAtEnds: true)
}
/// Advance the cursor by `delta`. A single move (`clampAtEnds: false`) that would leave the list
/// recoils + bumps; a shoulder jump (`clampAtEnds: true`) lands on the end item, bumping only if
/// already there. The cursor is the authority — the scroll view is pointed at it, never read for it.
private func step(by delta: Int, clampAtEnds: Bool) {
guard !items.isEmpty else { return }
var target = cursor + delta
if target < 0 || target >= items.count {
guard clampAtEnds else { return boundaryBump(forward: delta > 0) }
target = min(max(target, 0), items.count - 1)
}
guard target != cursor else { return boundaryBump(forward: delta > 0) }
cursor = target
lastNav = Date()
haptics.move()
selection = items[target].id // text/detail updates immediately; the scroll chases
commitScroll()
}
private let scrollAnim: TimeInterval = 0.24
/// A hair past `scrollAnim` — long enough that the scroll has actually settled before the next
/// write, short enough to stay responsive.
private var scrollSettle: TimeInterval { scrollAnim + 0.05 }
/// Drive the scroll toward the current cursor, one honoured write at a time. `.scrollPosition(id:)`
/// DROPS a write that lands while a scroll is still animating, so we issue at most one at a time and
/// re-target the LATEST cursor once it settles — coalescing a fast burst (hold OR quick flicks) and
/// always converging on the final item, instead of getting stuck on the old card.
///
/// The settle is timed by a plain timer rather than `withAnimation`'s completion: `scrolledID` is a
/// discrete id, not an animatable value, so `withAnimation` has no tracked animation to fire a
/// reliable completion against (it can fire early — which is exactly what let quick flicks slip a
/// write through mid-scroll and stick). `asyncAfter` always fires, so `isScrolling` can never latch.
private func commitScroll() {
guard !isScrolling, cursor >= 0, cursor < items.count else { return }
let id = items[cursor].id
guard scrolledID != id else { return }
isScrolling = true
withAnimation(.easeOut(duration: scrollAnim)) { scrolledID = id }
DispatchQueue.main.asyncAfter(deadline: .now() + scrollSettle) {
MainActor.assumeIsolated {
isScrolling = false
commitScroll() // the cursor may have advanced while this scroll ran — chase it
}
}
}
private func activate() {
guard cursor >= 0, cursor < items.count else { return }
activate(items[cursor])
}
/// Shared confirm tail — the poll activates the cursor's item, a tvOS Button its own.
private func activate(_ item: Item) {
activateTick &+= 1
haptics.confirm()
onActivate(item)
}
/// Touch fallback matching the rest of the app: tapping the centered card activates it, tapping
/// any other re-centers on it.
private func tap(_ item: Item) {
if let idx = index(of: item.id), idx == cursor {
activate()
} else if let idx = index(of: item.id) {
cursor = idx
lastNav = Date()
haptics.move()
selection = item.id
commitScroll()
}
}
// MARK: - Selection housekeeping
private func index(of id: Item.ID?) -> Int? {
guard let id else { return nil }
return items.firstIndex { $0.id == id }
}
/// Keep `cursor`/`scrolledID`/`selection` consistent with `items`: seed on appear, and on a list
/// change keep the same focused item when it survives, else clamp the cursor into range.
private func reconcile() {
guard !items.isEmpty else {
cursor = 0
if scrolledID != nil { scrolledID = nil }
if selection != nil { selection = nil }
return
}
if let sid = scrolledID, let idx = index(of: sid) {
cursor = idx
if selection != sid { selection = sid }
} else {
let idx = min(max(cursor, 0), items.count - 1)
cursor = idx
let id = items[idx].id
scrolledID = id
selection = id
}
#if os(tvOS)
// Keep real focus on the reconciled item when its old target vanished from the list —
// the engine would otherwise pick a neighbour by geometry and drag the cursor with it.
if focusedID == nil || index(of: focusedID) == nil, cursor < items.count {
focusedID = items[cursor].id
}
#endif
}
private func boundaryBump(forward: Bool) {
boundaryTick &+= 1
haptics.boundary()
let recoil: CGFloat = forward ? -16 : 16
withAnimation(.spring(response: 0.16, dampingFraction: 0.42)) { bumpOffset = recoil }
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 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.
///
/// ⚠️ NO `rotation3DEffect` HERE, however much the drum language invites one. It was the cause of
/// the strip's "flash as the cards settle": a real 3D transform renders the card through an
/// offscreen layer, and a card carries translucent glass, which resolves differently in there —
/// so every card sat at the wrong fill for as long as the master animation ran and then snapped
/// to its true one in a SINGLE frame the moment SwiftUI dropped that layer.
///
/// Measured on an iPad Pro 13": the centred tile held #4a3d87 for twelve frames in which nothing
/// moved, then stepped to #423970 (23 blue) in one. It is the ANIMATION ending, not the motion:
/// stretching the timeline from 1.02 s to 2.82 s moved the step from 0.70 s to 2.50 s after the
/// launcher appeared — the same 0.32 s before the end both times. Removing the rotation removed
/// the step outright; `compositingGroup()` above or below the transforms did nothing.
///
/// So the turn is PROJECTED instead: `cos(angle)` as a horizontal squeeze is exactly the
/// orthographic projection of a Y-axis rotation, hinged on the edge the card fans from. Affine,
/// so no offscreen pass and no layer to drop — and it reads as the same gesture, losing only the
/// perspective trapezoid, which at these card sizes was never what sold the motion.
///
/// 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
// The turn, projected rather than rendered in 3D — see the type's note on the flash.
// `cos` of the angle IS the orthographic projection of a Y-axis rotation, and hinging it
// on the edge the card fans from restores the direction that the rotation's sign carried
// (cos is even, so the sign alone would read the same both ways).
let turn = cos(Angle.degrees(64 * away).radians)
return content
.opacity(reduceMotion ? raw : fade)
.scaleEffect(1 - 0.26 * away)
.scaleEffect(x: turn, y: 1, anchor: side < 0 ? .trailing : .leading)
.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