Implementing Spring Animations (Physics-Based) in iOS Applications
Spring animations make interfaces feel alive—elements don't just move, they "bounce," slightly overshooting their target and returning. iOS uses them everywhere: icons on long press, cards in App Store, keyboard keys. The difference between "system-made" and "almost system-made" lies in correct physical model parameters.
UIKit: UISpringTimingParameters and UIViewPropertyAnimator
Before iOS 10, spring animations used UIView.animate(withDuration:delay:usingSpringWithDamping:initialSpringVelocity:). The dampingRatio (0–1) and initialVelocity parameters work, but it's a simplified model—not true spring physics.
From iOS 10 onwards, use UISpringTimingParameters with mass, stiffness, and damping:
let timingParams = UISpringTimingParameters(
mass: 1.0,
stiffness: 170,
damping: 26,
initialVelocity: CGVector(dx: 0, dy: 0)
)
let animator = UIViewPropertyAnimator(duration: 0, timingParameters: timingParams)
animator.addAnimations {
self.cardView.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
}
animator.startAnimation()
duration: 0—with spring timing, duration is ignored; the animation lasts as long as the spring needs to settle. This is correct—don't set duration for springs.
Parameter presets for typical cases: stiffness: 300, damping: 30 is a quick, elastic animation (tap feedback). stiffness: 120, damping: 14 is a slow, soft spring (bottom sheet appearance). stiffness: 400, damping: 40 is stiff without overshoot (toggle).
UIViewPropertyAnimator supports isInterruptible = true—stop and redirect the animation mid-flight. Critical for gesture-driven UI: if a user starts dragging a card down then changes mind, the animation smoothly reverses from current velocity.
SwiftUI: spring() and .interpolatingSpring()
SwiftUI offers several options:
// Simple spring with dampingFraction
withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
isExpanded.toggle()
}
// Physical model via interpolatingSpring
withAnimation(.interpolatingSpring(stiffness: 170, damping: 26)) {
offset = targetOffset
}
// iOS 17+: new Spring type
withAnimation(.spring(.bouncy(duration: 0.4, extraBounce: 0.1))) {
scale = 1.0
}
.spring(response:dampingFraction:) is simpler: response is approximate duration (not strict), dampingFraction 1.0 is critical damping with no overshoot, below 1.0 is overshoot. For most UI: response: 0.3–0.5, dampingFraction: 0.7–0.85.
iOS 17 brought named spring presets: .bouncy, .smooth, .snappy—useful for quick prototyping, but for final product, specify parameters explicitly.
Velocity matching on interruption: when gesture interrupts animation, the new spring should start from current velocity. In SwiftUI, use @GestureState and withAnimation with correct initialVelocity. In UIKit, use UIViewPropertyAnimator.fractionComplete and continueAnimation(withTimingParameters:durationFactor:).
Gesture-Driven Spring: UIPanGestureRecognizer + Spring
The liveliest case is a card you can drag and it springs back or flies to the next position:
@objc func handlePan(_ gesture: UIPanGestureRecognizer) {
let translation = gesture.translation(in: view)
switch gesture.state {
case .changed:
cardView.transform = CGAffineTransform(translationX: 0, y: translation.y)
case .ended:
let velocity = gesture.velocity(in: view)
let velocityVector = CGVector(dx: 0, dy: velocity.y / 1000) // normalization
let timingParams = UISpringTimingParameters(
mass: 1, stiffness: 200, damping: 28,
initialVelocity: velocityVector
)
let animator = UIViewPropertyAnimator(duration: 0, timingParameters: timingParams)
animator.addAnimations {
self.cardView.transform = .identity
}
animator.startAnimation()
default: break
}
}
Take initialVelocity from gesture velocity, normalize by dividing by ~1000 (UISpringTimingParameters scale differs from points/second gesture velocity).
Timeline
Adding spring animations to existing UI components (2–4 elements) takes 1–2 days with device testing. Gesture-driven interactive screen with spring physics takes 2–3 days. Cost is calculated individually.







