| 1 | import SwiftUI |
| 2 | |
| 3 | // 🤖 The jump wave, shared by both platforms: the message a quote-tap or a |
| 4 | // search hit landed on asks for attention like ONE minion in a crowd of |
| 5 | // minions — a small tiptoe and a gentle wag, not a performance. Every row |
| 6 | // runs the animator on the shared tick; non-targets wave by zero. |
| 7 | public struct JumpWave: ViewModifier { |
| 8 | let on: Bool |
| 9 | let tick: Int |
| 10 | |
| 11 | public init(on: Bool, tick: Int) { |
| 12 | self.on = on |
| 13 | self.tick = tick |
| 14 | } |
| 15 | |
| 16 | struct Pose { |
| 17 | var angle = 0.0 |
| 18 | var sway = 0.0 |
| 19 | } |
| 20 | |
| 21 | public func body(content: Content) -> some View { |
| 22 | content |
| 23 | .keyframeAnimator(initialValue: Pose(), trigger: tick) { view, pose in |
| 24 | view |
| 25 | .rotationEffect(.degrees(on ? pose.angle : 0), anchor: .bottom) |
| 26 | .offset(x: on ? pose.sway : 0) |
| 27 | } keyframes: { _ in |
| 28 | KeyframeTrack(\.angle) { // a gentle side-to-side wag… |
| 29 | CubicKeyframe(0, duration: 0.10) |
| 30 | CubicKeyframe(1.2, duration: 0.15) |
| 31 | CubicKeyframe(-1.2, duration: 0.17) |
| 32 | CubicKeyframe(0.8, duration: 0.17) |
| 33 | CubicKeyframe(-0.4, duration: 0.17) |
| 34 | CubicKeyframe(0, duration: 0.24) |
| 35 | } |
| 36 | KeyframeTrack(\.sway) { // …with the body following |
| 37 | CubicKeyframe(0, duration: 0.10) |
| 38 | CubicKeyframe(1.6, duration: 0.15) |
| 39 | CubicKeyframe(-1.6, duration: 0.17) |
| 40 | CubicKeyframe(1.0, duration: 0.17) |
| 41 | CubicKeyframe(-0.5, duration: 0.17) |
| 42 | CubicKeyframe(0, duration: 0.24) |
| 43 | } |
| 44 | } |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | extension View { |
| 49 | /// Wave when `on` (this row is the jump target) and `tick` bumps. |
| 50 | public func jumpWave(on: Bool, tick: Int) -> some View { |
| 51 | modifier(JumpWave(on: on, tick: tick)) |
| 52 | } |
| 53 | } |
| 54 | |