fix(client/apple): the library actually plays its entrance, and it swings in 3D
ci / bun-nix (pull_request) Successful in 54s
ci / docs-site (pull_request) Successful in 1m12s
ci / rust-arm64 (pull_request) Successful in 1m28s
apple / swift (pull_request) Successful in 1m28s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m56s
ci / rust (pull_request) Successful in 4m21s

The strip entrance never ran in the library, for two reasons:

- The trigger was lost. Flipping the state inside onAppear puts the
  change in the SAME transaction as the view's insertion, where SwiftUI
  runs with animations disabled. The launcher got away with it; the
  library's strip mounts late - only once the fetch lands - and lost
  every time. The flip now defers one runloop turn, so it is an ordinary
  animated state change.

- The art snapped in behind it. Covers hard-swapped from grey
  placeholder to image, so even a working entrance was followed by a run
  of cards popping to artwork after the strip had settled. PosterImage
  cross-fades now (the touch grid inherits it).

And the entrance is 3D: a card starts turned away on the drum, small,
low and invisible, then swings flat, grows and rises on an overshooting
spring. Cards left of the anchor hinge on their trailing edge and cards
right of it on their leading one, so the strip FANS OPEN from the cursor
instead of sweeping past it - the same hinge-and-perspective language
the coverflow's own recede speaks, so arriving and scrolling read as one
object. Reduce Motion still drops every bit of travel.
This commit is contained in:
2026-08-07 15:10:33 +02:00
parent cf68d33e2c
commit 2fb80073e6
2 changed files with 38 additions and 13 deletions
@@ -181,7 +181,12 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
if isActive { input.start() }
// After `reconcile`, so the fan-out anchors on the seeded/restored cursor.
entranceAnchor = cursor
appeared = true
// Deferred one runloop turn ON PURPOSE. A state change made inside `onAppear` lands
// in the same transaction as the insertion, where SwiftUI runs animations disabled
// so the cards just WERE there, no entrance (the library, whose strip mounts late
// when the fetch lands, showed this every time). One hop later it is an ordinary
// state change and every card's `.animation(_:value:)` picks it up.
DispatchQueue.main.async { appeared = true }
}
.onDisappear {
input.stop()
@@ -222,14 +227,15 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
// MARK: - Entrance
/// The card's share of the strip's entrance: it rises into place out of a fade, the anchored
/// card landing first and its neighbours following outward.
/// 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 {
CardEntrance(
shown: appeared,
// Capped so a several-hundred-title library never queues a card behind a visibly long
// wait everything past the cap lands together, well off-screen anyway.
delay: min(0.3, Double(abs(idx - entranceAnchor)) * 0.05),
delay: min(0.36, Double(abs(idx - entranceAnchor)) * 0.055),
side: idx == entranceAnchor ? 0 : (idx < entranceAnchor ? -1 : 1),
reduceMotion: reduceMotion)
}
@@ -377,30 +383,42 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
}
}
/// How a card arrives when its strip does: it rises a little, growing out of a fade, on a spring
/// soft enough to overshoot by a hair so the launcher's hosts and the library's covers assemble
/// themselves around the cursor instead of simply being there. Each card carries its own delay
/// (see `entrance(_:)`), which is what makes the strip read as one gesture rather than a
/// simultaneous flash.
/// 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.
///
/// Transforms only nothing here touches layout, so the scroll view's snapping, the caller's
/// `.scrollTransition` (whose scale/rotation simply multiply with these) and the tvOS focus
/// engine are all untouched. Reduce Motion drops the travel entirely for a plain, unstaggered
/// engine are all untouched. Reduce Motion drops every bit of travel for a plain, unstaggered
/// cross-fade.
private struct CardEntrance: ViewModifier {
let shown: Bool
let delay: 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), 0 for the anchor card, which never turns.
let side: Double
let reduceMotion: Bool
func body(content: Content) -> some View {
let away = !shown && !reduceMotion
content
.scaleEffect(away ? 0.82 : 1)
.rotation3DEffect(
.degrees(away ? side * 72 : 0),
axis: (x: 0, y: 1, z: 0),
anchor: side < 0 ? .trailing : .leading,
perspective: 0.6)
.offset(y: away ? 44 : 0)
.opacity(shown ? 1 : 0)
.scaleEffect(shown || reduceMotion ? 1 : 0.88)
.offset(y: shown || reduceMotion ? 0 : 34)
.animation(
reduceMotion
? .easeOut(duration: 0.25)
: .spring(response: 0.52, dampingFraction: 0.78).delay(delay),
: .spring(response: 0.58, dampingFraction: 0.74).delay(delay),
value: shown)
}
}
@@ -80,12 +80,19 @@ 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() }