From 9582da07b48c208113e0ae9cec23e39e16243203 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 15:38:38 +0200 Subject: [PATCH] fix(client/apple): the entrance goes underneath the scroll transition Structural, because tuning the transform values was treating a symptom. A scroll transition derives its phase from the geometry of the view it wraps, and the entrance was wrapping each card on the OUTSIDE - so it moved the very thing the transition measures. Every card read as far from centre for the whole travel, phase pinned at fully receded, and the centred card only collapsed into its focused look as the entrance ended. That collapse was the jump; shrinking the offset last round only made it smaller. The card builder now hands each caller its own CardEntrance and both the launcher and the coverflow apply it BENEATH their .scrollTransition. The transition measures a card that never moves and composes its scale and rotation on top of the entrance's, so the two can no longer fight - and the fuller travel is back (34 pt rise) now that the geometry constraint that forced it down to 16 is gone. --- .../Home/GamepadCarousel.swift | 39 +++++++++---------- .../Home/GamepadHomeView.swift | 10 +++-- .../Home/LibraryCoverflowView.swift | 10 +++-- 3 files changed, 32 insertions(+), 27 deletions(-) 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))