Default SwiftUI animations look like 2003 Keynote. You slap .animation(.easeInOut) on something, it slides, it works, you ship it. The app feels cheap and you don't know why.
The answer: real-world motion doesn't move in straight lines. It doesn't start and stop instantly. It has weight, it overshoots, it settles. SwiftUI gives you the tools to do this, almost nobody uses them.
Here's how I make SwiftUI animations feel premium, not PowerPoint.
The single biggest upgrade: springs, not easing curves
// Cheap animation
withAnimation(.easeInOut(duration: 0.3)) {
isExpanded.toggle()
}
// Premium animation
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
isExpanded.toggle()
}That's it. That's the whole trick. .easeInOut reads as a web transition. .spring reads as a native Apple animation because every iOS system animation uses springs internally.
Tune the parameters:
response, how long the animation takes to settle. 0.3-0.5 covers most UI. Higher = slower, floatier.dampingFraction, how much it overshoots. 1.0 = no overshoot (stiff). 0.7 = gentle overshoot. Below 0.6 = bouncy, playful.
For a button press: response: 0.2, dampingFraction: 0.7.
For a sheet presentation: response: 0.5, dampingFraction: 0.9.
For a playfulness pop: response: 0.3, dampingFraction: 0.6.
Animate the right property, or it will jank
This is the silent killer. Animating the wrong property is why most SwiftUI animations jank on older devices.
// WRONG: triggers layout every frame
withAnimation {
frame = CGRect(x: 0, y: offset, width: 100, height: 100)
}
// RIGHT: GPU-composited, stays at 120fps
withAnimation {
offset = 100
}The properties you should animate:
opacityscaleEffectoffsetrotationEffect
The properties you should NOT animate:
- Anything inside
frame() paddingposition(useoffsetinstead)- Layout-affecting modifiers
GPU-composited properties don't trigger layout passes. The view stays where it is and the GPU just moves, scales, or fades the texture. That's why Apple's own apps stay smooth on old devices.
withAnimation vs .animation, the actual difference
These are not interchangeable. Using the wrong one is the most common SwiftUI bug I see.
// withAnimation: wraps a STATE CHANGE
// Use when: user taps a button, toggles a sheet, switches tabs
withAnimation(.spring()) {
isSelected.toggle()
}
// .animation: binds to a VALUE
// Use when: dragging, scrubbing, continuous input
.offset(y: dragOffset)
.animation(.spring(), value: dragOffset)Rule: if the change comes from a user gesture that completes (tap, swipe to dismiss), use withAnimation. If the change is continuous and follows a finger, use .animation(_:value:).
The iOS 17+ alternative is the withAnimation spring parameters or the new keyframe-based APIs, but the gesture rule still applies.
Phase animations (iOS 17+) for sequences
Before iOS 17, chaining animations with delays required DispatchQueue.asyncAfter or custom TimelineView setups. Both were ugly.
MyView()
.phaseAnimator([false, true]) { content, phase in
content
.scaleEffect(phase ? 1.1 : 1.0)
.opacity(phase ? 0.9 : 1.0)
} animation: { phase in
phase ? .spring(response: 0.3, dampingFraction: 0.6)
: .spring(response: 0.4, dampingFraction: 0.8)
}This runs forever, a gentle pulse. No timers, no state. The system handles the loop.
For one-shot sequences, use KeyframeAnimator:
MyView()
.keyframeAnimator(initialValue: .zero) { content, value in
content.offset(value.offset)
} keyframes { _ in
KeyframeTrack(\.offset) {
CubicKeyframe(0, duration: 0.2)
SpringKeyframe(40, duration: 0.3)
CubicKeyframe(0, duration: 0.4)
}
}Real choreography. Real Apple-feeling animation.
matchedGeometryEffect, the hero transition
This is the one that makes people say "how did they do that?"
@Namespace private var namespace
if !isExpanded {
SmallView()
.matchedGeometryEffect(id: "hero", in: namespace)
.onTapGesture { withAnimation(.spring()) { isExpanded = true } }
} else {
LargeView()
.matchedGeometryEffect(id: "hero", in: namespace)
.onTapGesture { withAnimation(.spring()) { isExpanded = false } }
}The system animates the view from its small frame to its large frame. No manual math. No transition coordinator. It just works (mostly, read on).
The catch: matchedGeometryEffect is finicky. The two views can't be in different scroll views. The namespace has to match. Switching between if and if-else won't work the same way. When it breaks it falls back to a crossfade, still works, just not the hero effect.
The workflow I actually use
- Replace every
.easeInOutwith.spring(response: 0.4, dampingFraction: 0.8) - Audit every animation, am I animating
frameoroffset? Switch to GPU-composited. - Use
withAnimationfor taps,.animation(_:value:)for drags. - Reach for
matchedGeometryEffectfor hero transitions between list and detail. - Use phase animations for ambient motion (loading, success states).
Steps 1 and 2 are 80% of the win. The rest is polish that pays off when you have time.
SwiftUI animations don't need to be clever. They need to use springs instead of lines, and animate offset instead of frame. Everything else is garnish.
Want a SwiftUI app that feels like Apple built it?
I build iOS apps with the animation polish users feel without thinking about. Let's talk.