Use Wave’s spring animations in SwiftUI views with property-based animators and gesture handling
While Wave is primarily a UIKit/AppKit animation engine, it integrates seamlessly with SwiftUI through property-based animators. This approach gives you Wave’s retargetable spring physics in declarative SwiftUI code.
Here’s a full implementation of a draggable box that snaps back to center:
SwiftUIView.swift
import SwiftUIimport Wavestruct SwiftUIView: View { let offsetAnimator = SpringAnimator<CGPoint>(spring: Spring(dampingRatio: 0.72, response: 0.7)) @State var boxOffset: CGPoint = .zero var body: some View { let size = 80.0 ZStack { RoundedRectangle(cornerRadius: size * 0.22, style: .continuous) .fill(.blue) .frame(width: size, height: size) VStack { Text("SwiftUI") .foregroundColor(.white) } }.onAppear { offsetAnimator.value = .zero // The offset animator's callback will update the `offset` state variable. offsetAnimator.valueChanged = { newValue in boxOffset = newValue } } .offset(x: boxOffset.x, y: boxOffset.y) .gesture( DragGesture() .onChanged { value in // Update the animator's target to the new drag translation. offsetAnimator.target = CGPoint(x: value.translation.width, y: value.translation.height) // Don't animate the box's position when we're dragging it. offsetAnimator.mode = .nonAnimated offsetAnimator.start() } .onEnded { value in // Animate the box to its original location (i.e. with zero translation). offsetAnimator.target = .zero // We want the box to animate to its original location, so use an `animated` mode. offsetAnimator.mode = .animated // Take the velocity of the gesture, and give it to the animator. // This makes the throw animation feel natural and continuous. offsetAnimator.velocity = CGPoint(x: value.velocity.width, y: value.velocity.height) offsetAnimator.start() } ) }}struct SwiftUIView_Previews: PreviewProvider { static var previews: some View { SwiftUIView() }}
Initialize a SpringAnimator with your desired spring configuration:
let offsetAnimator = SpringAnimator<CGPoint>( spring: Spring(dampingRatio: 0.72, response: 0.7))
The generic type <CGPoint> determines what type of value the animator will produce.
2
Set up state binding
Create a @State variable and bind it to the animator’s output:
@State var boxOffset: CGPoint = .zerovar body: some View { // Your view... .onAppear { offsetAnimator.value = .zero // Update state whenever animator produces new values offsetAnimator.valueChanged = { newValue in boxOffset = newValue } } .offset(x: boxOffset.x, y: boxOffset.y)}
The valueChanged callback is called on every frame of the animation, updating your @State variable and triggering view re-renders.
3
Handle drag gestures
Use SwiftUI’s DragGesture to drive the animator:
.gesture( DragGesture() .onChanged { value in offsetAnimator.target = CGPoint( x: value.translation.width, y: value.translation.height ) offsetAnimator.mode = .nonAnimated offsetAnimator.start() } .onEnded { value in offsetAnimator.target = .zero offsetAnimator.mode = .animated offsetAnimator.velocity = CGPoint( x: value.velocity.width, y: value.velocity.height ) offsetAnimator.start() })
4
Extract gesture velocity
SwiftUI’s DragGesture.Value doesn’t expose velocity directly. Use this extension:
DragGesture+Extensions.swift
import SwiftUIextension DragGesture.Value { internal var velocity: CGSize { let valueMirror = Mirror(reflecting: self) for valueChild in valueMirror.children { if valueChild.label == "velocity" { let velocityMirror = Mirror(reflecting: valueChild.value) for velocityChild in velocityMirror.children { if velocityChild.label == "valuePerSecond" { if let velocity = velocityChild.value as? CGSize { return velocity } } } } } fatalError("Unable to retrieve velocity from \(Self.self)") }}
This uses reflection to access internal velocity values. While not ideal, it’s necessary until SwiftUI officially exposes gesture velocity.
Only make values @State if they trigger view updates:
@State var visibleOffset: CGPoint = .zero // ✅ Used in viewlet animator = SpringAnimator<CGPoint>() // ✅ Not state, doesn't change
Reuse animators
Create animators outside of the view body:
struct MyView: View { let animator = SpringAnimator<CGFloat>(spring: .defaultAnimated) // ✅ Created once var body: some View { // NOT: let animator = SpringAnimator<CGFloat>() // ❌ Created every render }}