diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift index 23a3de33..ab4e7d13 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift @@ -59,7 +59,10 @@ struct GamepadCarousel: View where Item.ID: Hash /// 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 - @ViewBuilder let card: (Item) -> Card + /// 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) @@ -127,18 +130,14 @@ struct GamepadCarousel: View where Item.ID: Hash // 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: { - // Entrance INSIDE the fixed frame: the scroll target's own - // geometry stays exactly `itemWidth`, whatever the card is doing. - card(item) - .modifier(entrance(idx)) + card(item, entrance(idx)) .frame(width: itemWidth) } .buttonStyle(ConsoleBareButtonStyle()) .focused($focusedID, equals: item.id) .id(item.id) #else - card(item) - .modifier(entrance(idx)) + card(item, entrance(idx)) .frame(width: itemWidth) .contentShape(Rectangle()) .onTapGesture { tap(item) } @@ -436,11 +435,17 @@ struct GamepadCarousel: View where Item.ID: Hash /// 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 every bit of travel for a plain, unstaggered -/// cross-fade. -private struct CardEntrance: ViewModifier, Animatable { +/// ⚠️ 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 @@ -482,21 +487,13 @@ private struct CardEntrance: ViewModifier, Animatable { let away = reduceMotion ? 0 : 1 - travel return content .opacity(reduceMotion ? raw : fade) - // EVERY transform here stays inside the card's own footprint, and that is a - // correctness requirement, not taste: the caller's `.scrollTransition` reads the - // geometry of the view underneath these, so a card shoved 58 pt down and hinged on - // its leading edge spent the travel reported as "far from centre" — its phase pinned - // at fully-receded — and only collapsed to identity as the card came home. That was - // the focused card jumping into its correct state at the end. Hence: rotation about - // the CENTRE (the card turns in place rather than swinging sideways) and a rise small - // enough to stay within the strip's own slack. .scaleEffect(1 - 0.26 * away) .rotation3DEffect( .degrees(side * -64 * away), axis: (x: 0, y: 1, z: 0), anchor: .center, perspective: 0.65) - .offset(y: 16 * away) + .offset(y: 34 * away) } /// `1 - (1-t)³`, with a small overshoot past 1 before it settles. diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 8a826ad1..94d3bab4 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift index 0d0b55be..509d3fc8 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift @@ -110,8 +110,8 @@ struct LibraryCoverflowView: View { shoulderJump: 5, isActive: controllerActive, contentReady: contentReady - ) { game in - cover(game, width: coverWidth, height: coverHeight) + ) { game, entrance in + cover(game, width: coverWidth, height: coverHeight, entrance: entrance) } .frame(height: coverHeight + 44) } @@ -120,7 +120,9 @@ 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 { + 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 }) @@ -136,6 +138,8 @@ struct LibraryCoverflowView: View { .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))