Skip to main content
One of Wave’s most powerful features is its ability to seamlessly integrate gesture-driven interactions with spring animations. By injecting the velocity from a gesture recognizer into an animation, you create natural, physics-based motion that feels responsive and fluid.

Why velocity matters

When a user drags a view and releases it, the animation should continue with the momentum from the gesture. Without velocity injection, the view would abruptly start from zero velocity, creating a jarring, unnatural feel. Wave solves this by accepting gesture velocities and using them as the initial velocity for spring animations.

Block-based API with gestures

The Wave.animate(withSpring:) method accepts a gestureVelocity parameter:
Wave.animate(
    withSpring: spring,
    gestureVelocity: velocity,  // CGPoint from gesture recognizer
    animations: {
        view.animator.center = targetPosition
    }
)

Complete pan gesture example

Here’s a full implementation of a draggable Picture-in-Picture view from Wave’s sample app:
class PictureInPictureViewController: UIViewController {
    let pipView = UIView(frame: CGRect(x: 0, y: 0, width: 120, height: 80))
    
    // Tight spring for tracking finger movement
    let interactiveSpring = Spring(dampingRatio: 0.8, response: 0.26)
    
    // Looser spring for final animation
    let animatedSpring = Spring(dampingRatio: 0.68, response: 0.8)
    
    private var initialTouchLocation: CGPoint = .zero
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let panGesture = UIPanGestureRecognizer(
            target: self,
            action: #selector(handlePan(sender:))
        )
        pipView.addGestureRecognizer(panGesture)
        view.addSubview(pipView)
    }
    
    @objc
    private func handlePan(sender: UIPanGestureRecognizer) {
        let touchLocation = sender.location(in: view)
        let touchTranslation = sender.translation(in: view)
        let touchVelocity = sender.velocity(in: view)
        
        switch sender.state {
        case .began:
            initialTouchLocation = pipView.center
            
        case .changed:
            let targetCenter = CGPoint(
                x: initialTouchLocation.x + touchTranslation.x,
                y: initialTouchLocation.y + touchTranslation.y
            )
            
            // Use interactive spring for tight tracking
            Wave.animate(withSpring: interactiveSpring) {
                pipView.animator.center = targetCenter
            }
            
        case .ended, .cancelled:
            let destination = calculateDestination(
                position: touchLocation,
                velocity: touchVelocity
            )
            
            // Inject velocity for smooth handoff
            Wave.animate(
                withSpring: animatedSpring,
                gestureVelocity: touchVelocity
            ) {
                pipView.animator.center = destination
            }
            
        default:
            break
        }
    }
    
    private func calculateDestination(
        position: CGPoint,
        velocity: CGPoint
    ) -> CGPoint {
        // Your custom logic to determine final position
        // For example, snapping to screen corners
        return snapToNearestCorner(position: position, velocity: velocity)
    }
}

Property-based API with gestures

With SpringAnimator, set the velocity property before starting:
let animator = SpringAnimator<CGPoint>(
    spring: Spring(dampingRatio: 0.8, response: 0.7),
    value: view.center,
    target: destinationPoint
)

// Inject gesture velocity
animator.velocity = gestureVelocity

animator.valueChanged = { [weak view] position in
    view?.center = position
}

animator.start()

Sheet presentation example

Here’s a complete sheet presentation controller from Wave’s sample app:
class SheetViewController: UIViewController {
    let sheetView = SheetView()
    let interactiveSpring = Spring(dampingRatio: 0.8, response: 0.2)
    let animatedSpring = Spring(dampingRatio: 0.68, response: 0.8)
    
    lazy var sheetPresentationAnimator = SpringAnimator<CGFloat>(
        spring: animatedSpring
    )
    
    var sheetPresentationProgress: CGFloat = 0 {
        didSet {
            layoutSheet(withPresentationProgress: sheetPresentationProgress)
        }
    }
    
    var initialGestureAnimationProgress: CGFloat = 0
    
    var fullyCollapsedHeight: CGFloat { 200 }
    var fullyPresentedHeight: CGFloat { view.bounds.height * 0.9 }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.addSubview(sheetView)
        
        let panGesture = UIPanGestureRecognizer(
            target: self,
            action: #selector(handlePan(sender:))
        )
        sheetView.addGestureRecognizer(panGesture)
        
        sheetPresentationProgress = 0
        sheetPresentationAnimator.value = 0.0
        
        sheetPresentationAnimator.valueChanged = { [weak self] progress in
            self?.sheetPresentationProgress = progress
        }
    }
    
    @objc
    private func handlePan(sender: UIPanGestureRecognizer) {
        let translation = sender.translation(in: view)
        let velocity = sender.velocity(in: view)
        
        switch sender.state {
        case .began:
            initialGestureAnimationProgress = sheetPresentationProgress
            
        case .changed:
            // Use non-animated spring to track gesture
            sheetPresentationAnimator.spring = .defaultNonAnimated
            
            let targetProgress = progressForTranslation(translation.y)
            sheetPresentationAnimator.target = targetProgress
            sheetPresentationAnimator.start()
            
        case .ended, .cancelled:
            // Convert velocity to normalized space
            let normalizedVelocity = -velocity.y / (
                fullyPresentedHeight - fullyCollapsedHeight
            )
            
            // Project final position based on velocity
            let projectedTranslation = project(
                value: translation.y,
                velocity: velocity.y
            )
            let projectedProgress = progressForTranslation(projectedTranslation)
            let targetProgress = (projectedProgress > 0.5) ? 1.0 : 0.0
            
            // Animate with injected velocity
            sheetPresentationAnimator.spring = Spring(
                dampingRatio: 0.8,
                response: 0.7
            )
            sheetPresentationAnimator.target = targetProgress
            sheetPresentationAnimator.velocity = normalizedVelocity
            sheetPresentationAnimator.start()
            
        default:
            break
        }
    }
    
    func progressForTranslation(_ translation: CGFloat) -> CGFloat {
        let range = fullyPresentedHeight - fullyCollapsedHeight
        return (-translation / range) + initialGestureAnimationProgress
    }
    
    func layoutSheet(withPresentationProgress progress: CGFloat) {
        let sheetHeight = mapRange(
            progress, 0, 1,
            fullyCollapsedHeight, fullyPresentedHeight
        )
        
        let collapsedMargin = 80.0
        let presentedMargin = 40.0
        
        let sheetOriginX = mapRange(
            progress, 0, 1,
            collapsedMargin / 2.0, presentedMargin / 2.0
        )
        let sheetWidth = mapRange(
            progress, 0, 1,
            view.bounds.width - collapsedMargin,
            view.bounds.width - presentedMargin
        )
        
        sheetView.frame = CGRect(
            x: sheetOriginX,
            y: view.bounds.height - sheetHeight,
            width: sheetWidth,
            height: sheetHeight
        )
    }
}

Understanding velocity

Gesture velocity units

Gesture recognizers return velocity in points per second:
let velocity = panGesture.velocity(in: view)
// velocity.x: horizontal points per second
// velocity.y: vertical points per second

Normalizing velocity

When animating normalized values (like progress from 0 to 1), convert the velocity to the same space:
// Gesture velocity is in points per second
let gestureVelocity = panGesture.velocity(in: view).y

// Convert to normalized velocity (0-1 per second)
let range = maximumValue - minimumValue
let normalizedVelocity = gestureVelocity / range

animator.velocity = normalizedVelocity

Direction matters

Pay attention to velocity direction. For vertical gestures, you may need to negate the y-component:
// Dragging down is positive y, but might decrease your progress value
let normalizedVelocity = -velocity.y / range

Two-spring pattern

The most common pattern uses two different springs:
1

Interactive spring (tight)

Use a low response value (0.2-0.3) for tracking finger movement. This makes the view follow closely without lag.
let interactiveSpring = Spring(dampingRatio: 0.8, response: 0.2)
2

Animated spring (loose)

Use a higher response value (0.6-0.8) for the final animation after gesture ends. This creates satisfying, natural motion.
let animatedSpring = Spring(dampingRatio: 0.68, response: 0.8)
switch panGesture.state {
case .changed:
    // Tight spring for tracking
    Wave.animate(withSpring: interactiveSpring) {
        view.animator.center = targetPosition
    }
    
case .ended:
    // Loose spring with velocity for final animation
    Wave.animate(
        withSpring: animatedSpring,
        gestureVelocity: velocity
    ) {
        view.animator.center = finalPosition
    }
}

Velocity projection

Wave provides utility functions for projecting where a value will end up based on velocity:
// Project single value
let projectedY = project(
    value: currentY,
    velocity: velocityY
)

// Project point
let projectedPoint = project(
    point: currentPoint,
    velocity: velocityPoint
)
Use projection to determine final snap positions:
case .ended:
    let projectedPosition = project(
        point: currentPosition,
        velocity: gestureVelocity
    )
    
    // Determine which corner to snap to based on projected position
    let destination = nearestCorner(to: projectedPosition)
    
    Wave.animate(
        withSpring: animatedSpring,
        gestureVelocity: gestureVelocity
    ) {
        view.animator.center = destination
    }

Instant pan gesture recognizer

Wave’s sample app includes InstantPanGestureRecognizer, which begins immediately instead of waiting for minimum movement:
let instantPan = InstantPanGestureRecognizer(
    target: self,
    action: #selector(handlePan)
)
view.addGestureRecognizer(instantPan)
This creates more responsive interactions, especially for small draggable elements.

Retargeting during gestures

Wave automatically handles retargeting when the user changes direction mid-gesture:
case .changed:
    // Each frame potentially retargets the animation
    Wave.animate(withSpring: interactiveSpring) {
        view.animator.center = followPoint
    }
    // If followPoint changes direction, Wave smoothly retargets
No special code needed - Wave handles this automatically.

Multiple properties

You can inject velocity into multiple properties, but ensure each velocity is in the correct coordinate space:
case .ended:
    let velocity = panGesture.velocity(in: view)
    
    Wave.animate(
        withSpring: animatedSpring,
        gestureVelocity: velocity
    ) {
        // Position gets the full velocity
        view.animator.center = destination
        
        // Other properties animated without velocity
        view.animator.scale = CGPoint(x: 1.0, y: 1.0)
        view.layer.animator.cornerRadius = 16
    }
The gestureVelocity parameter only affects properties that use CGPoint values (like center, origin, etc.).

Best practices

1

Use two springs

One tight spring for tracking, one loose spring for the final animation.
2

Store initial state

Capture the starting position/progress in .began to calculate deltas correctly.
3

Normalize velocities

Convert gesture velocity to match your animation value’s coordinate space.
4

Project final positions

Use velocity projection to determine intelligent snap targets.
5

Mind the direction

Pay attention to whether increasing values move up or down, left or right.
6

Test on device

Gesture interactions feel different on real hardware vs. simulator.

Common patterns

Draggable card

@objc private func handlePan(sender: UIPanGestureRecognizer) {
    let translation = sender.translation(in: view)
    let velocity = sender.velocity(in: view)
    
    switch sender.state {
    case .changed:
        let targetCenter = CGPoint(
            x: initialCenter.x + translation.x,
            y: initialCenter.y + translation.y
        )
        Wave.animate(withSpring: interactiveSpring) {
            cardView.animator.center = targetCenter
        }
        
    case .ended:
        Wave.animate(
            withSpring: animatedSpring,
            gestureVelocity: velocity
        ) {
            cardView.animator.center = originalPosition
        }
    }
}

Dismissible modal

@objc private func handlePan(sender: UIPanGestureRecognizer) {
    let translation = sender.translation(in: view)
    let velocity = sender.velocity(in: view)
    
    switch sender.state {
    case .changed:
        let offset = max(0, translation.y)  // Only allow downward drag
        Wave.animate(withSpring: interactiveSpring) {
            modalView.animator.origin = CGPoint(x: 0, y: offset)
        }
        
    case .ended:
        let shouldDismiss = translation.y > 100 || velocity.y > 500
        let destination = shouldDismiss 
            ? CGPoint(x: 0, y: view.bounds.height)
            : CGPoint(x: 0, y: 0)
        
        Wave.animate(
            withSpring: animatedSpring,
            gestureVelocity: velocity
        ) {
            modalView.animator.origin = destination
        } completion: { finished, _ in
            if finished && shouldDismiss {
                self.dismiss(animated: false)
            }
        }
    }
}

Next steps

Block-based animations

Master the Wave.animate API

Property-based animations

Use SpringAnimator for custom animations

Completion handlers

Handle animation lifecycle events

Spring configuration

Choose the right spring parameters

Build docs developers (and LLMs) love