Build a fluid, retargetable picture-in-picture implementation with gesture-driven animations
This example demonstrates Wave’s core strength: retargetable animations that preserve velocity when interrupted. We’ll build a picture-in-picture (PiP) view that smoothly animates to screen corners, similar to iOS’s native PiP feature.
When a user drags the PiP view and changes direction mid-flight, traditional animations feel jerky. Wave animations preserve the view’s velocity and gracefully arc to the new destination, creating a fluid, natural experience.The key difference: Wave maintains momentum through interruptions, while standard UIKit animations restart from zero velocity.
First, create the view that will be dragged around:
PictureInPictureView.swift
import UIKitimport Waveclass PictureInPictureView: UIView { public override init(frame: CGRect) { super.init(frame: frame) let gradient = CAGradientLayer() gradient.colors = [UIColor.systemOrange.cgColor, UIColor.systemRed.cgColor] gradient.cornerRadius = 16 gradient.cornerCurve = .continuous gradient.frame = bounds layer.insertSublayer(gradient, at: 0) layer.shadowColor = UIColor.black.cgColor layer.shadowOpacity = 0.2 layer.shadowRadius = 5 layer.shadowOffset = .init(width: 0, height: 3) } public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }}
2
Configure spring models
Define two different springs for different interaction states:
PictureInPictureViewController.swift
class PictureInPictureViewController: UIViewController { let pipView = PictureInPictureView(frame: CGRect(x: 0, y: 0, width: 120, height: 80)) /// A tighter spring used when dragging the PiP view around. let interactiveSpring = Spring(dampingRatio: 0.8, response: 0.26) /// A looser spring used to animate the PiP view to its final location after dragging ends. let animatedSpring = Spring(dampingRatio: 0.68, response: 0.8)}
The interactiveSpring has a lower response value (0.26) to closely follow the user’s touch. The animatedSpring has a higher response (0.8) for a more natural, bouncy animation when released.
3
Handle pan gestures
Implement gesture handling with velocity injection:
PictureInPictureViewController.swift
private var initialTouchLocation: CGPoint = .zero@objcprivate func handlePan(sender: UIPanGestureRecognizer) { guard let pipView = sender.view as? PictureInPictureView else { return } let touchLocation = sender.location(in: view) let touchTranslation = sender.translation(in: view) let touchVelocity = sender.velocity(in: view) if sender.state == .began { // Mark the view's initial position. initialTouchLocation = pipView.center } switch sender.state { case .began, .changed: let targetPiPViewCenter = CGPoint( x: initialTouchLocation.x + touchTranslation.x, y: initialTouchLocation.y + touchTranslation.y ) // We want the dragged view to closely follow the user's touch (not exactly 1:1, but close). // So animating using the `interactiveSpring` that has a lower `response` value. Wave.animate(withSpring: interactiveSpring) { // Just update the view's `animator.center` – that's it! pipView.animator.center = targetPiPViewCenter } case .ended, .cancelled: // The gesture ended, so figure out where the PiP view should ultimately land. let pipViewDestination = targetCenter(currentPosition: touchLocation, velocity: touchVelocity) // The `animatedSpring` is looser and takes a longer time to settle, so use that to animate the final position. // // Finally "inject" the pan gesture's lift-off velocity into the animation. // That `gestureVelocity` parameter will affect the `center` animation. Wave.animate(withSpring: animatedSpring, gestureVelocity: touchVelocity) { pipView.animator.center = pipViewDestination } default: break }}
The gestureVelocity parameter is crucial for natural animations. It injects the pan gesture’s lift-off velocity into the spring animation, making throws feel continuous and realistic.
4
Calculate target positions
Determine which corner the PiP view should snap to:
To draw the orange path that shows the PiP view’s trajectory, use a property-based animator:
PictureInPictureViewController.swift
/// In order to draw the path that the PiP view takes when animating to its final destination,/// we need the intermediate spring values. Use a separate `CGPoint` animator to get these values.lazy var positionAnimator = SpringAnimator<CGPoint>(spring: animatedSpring)/// The view that draws the path of the PiP view.lazy var pathView = PathView(frame: view.bounds)
When the gesture ends, run the position animator in parallel:
case .ended, .cancelled: let pipViewDestination = targetCenter(currentPosition: touchLocation, velocity: touchVelocity) // Animate the view using the block-based API Wave.animate(withSpring: animatedSpring, gestureVelocity: touchVelocity) { pipView.animator.center = pipViewDestination } // This position animation runs the exact same spring animation as the above block // We run this to get the intermediate spring values, such that we can draw the view's animation path. positionAnimator.spring = animatedSpring positionAnimator.value = pipView.center // The current, presentation value of the view. positionAnimator.target = pipView.animator.center // The target center that we just set above. positionAnimator.velocity = touchVelocity positionAnimator.valueChanged = { [weak self] location in self?.pathView.add(location) } positionAnimator.start()
The positionAnimator runs the same spring physics as the view’s animation, giving us access to every intermediate position for drawing the path.