fix(apple): the host strip stopped flashing as it settles
Reported from the iPad: the cards arrive, come to rest, and then flash. They did — and it was not the motion. Every card sat at the WRONG fill for as long as the entrance animation ran, then snapped to its real one in a single frame once SwiftUI tore the animation down, roughly a third of a second AFTER the last card had stopped moving. `rotation3DEffect` was the cause. A real 3D transform renders its subtree through an offscreen layer, and these cards carry translucent glass, which resolves differently in there — so the strip showed the aurora through itself for the duration and went opaque the instant the layer was dropped. Measured on an iPad Pro 13", sampling the centred tile: it held #4a3d87 across twelve frames in which nothing moved, then stepped to #423970 — 23/255 of blue in one frame, on every card at once, while the backdrop beside them stayed exactly #713fa0 throughout. Stretching the timeline from 1.02 s to 2.82 s moved the step from 0.70 s to 2.50 s after launch: the same 0.32 s before the end in both runs, which is what proves it is the animation ENDING and not any state the tiles were waiting on. Deleting the rotation deleted the step outright. `compositingGroup()` above and below the transforms each changed nothing, so this is the 3D layer specifically, not compositing in general. The turn is now projected instead of rendered: cos(angle) as a horizontal squeeze IS the orthographic projection of a Y-axis rotation, hinged on the edge the card fans from — which also puts the code behind the doc comment that already described the fan that way (the sign of a rotation carried it before; cos is even, so the anchor has to). Affine, so there is no offscreen pass and no layer to drop. What's lost is the perspective trapezoid, which at card size was never what sold the motion; the fan-out, the stagger and the overshoot are untouched. Also drops the temporary PUNKTFUNK_DIAG_OPEN hook that gated each step of this branch in the simulator. 284 tests pass; macOS + tvOS typecheck; entrance re-recorded on the iPad Pro simulator — the tile now settles monotonically (#4153cf → #383c77 → #39396e → #393a6e) and holds, with the fan-out still reading as a turn.
This commit is contained in:
@@ -432,8 +432,8 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
/// 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.
|
||||
/// 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
|
||||
@@ -442,6 +442,23 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
/// 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.
|
||||
@@ -485,14 +502,15 @@ struct CardEntrance: ViewModifier, Animatable {
|
||||
// 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)
|
||||
.rotation3DEffect(
|
||||
.degrees(side * -64 * away),
|
||||
axis: (x: 0, y: 1, z: 0),
|
||||
anchor: .center,
|
||||
perspective: 0.65)
|
||||
.scaleEffect(x: turn, y: 1, anchor: side < 0 ? .trailing : .leading)
|
||||
.offset(y: 34 * away)
|
||||
}
|
||||
|
||||
|
||||
@@ -156,15 +156,7 @@ struct GamepadHomeView: 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()
|
||||
.onAppear {
|
||||
discovery.start()
|
||||
// TEMPORARY verification hook (PUNKTFUNK_DIAG_OPEN=settings). Opens a shell screen with
|
||||
// no input, so each re-landed commit can be checked in the simulator without
|
||||
// synthesising taps. Removed before this work is committed.
|
||||
if ProcessInfo.processInfo.environment["PUNKTFUNK_DIAG_OPEN"] == "settings" {
|
||||
showSettings = true
|
||||
}
|
||||
}
|
||||
.onAppear { discovery.start() }
|
||||
.onDisappear { discovery.stop() }
|
||||
// Reachability sweep (mDNS-independent) so routed/VPN hosts that never advertise still show
|
||||
// Online — the console mirror of HomeView's `.task`; cancelled on disappear.
|
||||
|
||||
Reference in New Issue
Block a user