Have you ever opened an app where the transitions between screens look like the interface was thrown together an hour before the deadline? The screen flashes, elements jump around, animations stutter – and you instinctively want to close this mess. The problem isn’t that the developer can’t do animations. The problem is they don’t understand how user perception works.
In this article, I’ll break down how to build transitions in iOS apps that don’t annoy users, don’t break flow, and don’t make them think “something went wrong.” You’ll learn why 60 FPS isn’t always smooth, how to avoid common bugs like jank and flashing, how to properly use matchedGeometryEffect, write custom transitions without hacks, and debug everything to perfection. At the end, we’ll walk through a real-world case: transitioning from a list to a detail page without a single screen flicker.
If you’re building apps in SwiftUI or UIKit and want your animations to look like Apple’s – not like a startup that hired the first junior dev they could find – read on.
What “Smoothness” Actually Means (Perceived vs Actual Performance)
When I ask colleagues what a “smooth animation” is, most answer: “60 FPS.” And that’s true. But only half the truth.
The thing is, the human brain isn’t a profiler. It doesn’t count frames per second. It evaluates how logical and predictable the interface behaves. You can render a transition at a stable 60 FPS, but if an element appears somewhere unexpected, or the animation starts with a 100-millisecond delay – the user will feel discomfort. They won’t say “oh, the frame rate dropped here.” They’ll say “something’s lagging.”
Perceived performance is how fast the app appears to the user. And here’s what’s interesting: you can make an app feel faster without touching the code at all. Add a skeleton screen instead of a spinner – and the transition will feel instant, even if data loads for a second. Start the card expansion animation before content loads – and the user won’t notice the delay.
On the other hand, actual performance is real performance. 60 FPS on an iPhone 15 Pro and 60 FPS on an iPhone XR are different things. On older devices, animations can drop frames due to complex shadows, blur, or heavy transformations. And no UX trick will help here – you need to optimize rendering.
I’ve noticed that the smoothest apps (Apple Music, Things, Overcast) do two things simultaneously:
- Minimize delay between user action and animation start.
- Use the simplest transformations possible: scale, translate, opacity. No complex 3D rotations or custom shapes mid-transition.
Simple test: record your screen in slow-mo and watch when exactly the animation starts after the tap. If there are more than 2-3 frames between tap and movement – you have a latency problem. The user won’t see this in real-time, but they’ll feel that “something’s off.”
Common Transition Mistakes: Freezes, Flashing, Broken Layouts
Let’s be honest: most animation bugs aren’t because the code is complex. They’re because we forget about edge cases.
Jank (micro-freezes)
Jank is when an animation runs smoothly, then freezes for a moment, then continues. This usually happens because of:
- Heavy computations on the main thread during animation.
- Layout recalculation mid-transition.
- Loading images that weren’t cached.
I once built a gallery screen where transitioning to fullscreen view smoothly scaled up the image. Everything worked great until I tested on a real device with slow internet. Turns out, the animation would start, but the high-quality version of the image was loading right during the transition – and the animation would stutter for half a second.
The fix was simple: start prefetching the high-quality image early, when the user does a long press on the preview. By the time they release their finger, the image is already cached.
// Bad: loading during animation
.onTapGesture {
withAnimation {
isFullscreen = true
}
loadHighResImage() // <- this will kill the animation
}
// Good: load on long press
.onLongPressGesture(minimumDuration: 0.3, pressing: { isPressing in
if isPressing {
loadHighResImage() // prefetch
}
}, perform: {})
Flashing
The most annoying problem. The screen flashes white (or black) between transitions. This usually happens when:
- You use
.sheet()or.fullScreenCover()with a custom background but forget.background()on the.sheet()itself. - SwiftUI recreates the view hierarchy during animation.
- You have different
colorSchemes on two screens, and the system switches themes mid-transition.
Classic example:
.sheet(isPresented: $showDetails) {
DetailView()
// Forgot to add background - will flash white
}
// Correct:
.sheet(isPresented: $showDetails) {
DetailView()
.background(Color(.systemBackground))
}
Another source of flashing is when you change a view’s id during animation. SwiftUI perceives this as removing the old view and creating a new one. There won’t be any smooth transition – just an abrupt swap.
Layout Shifts (jumping elements)
You open a product card, and the title first appears at the top, then jumps 20 pixels down. Or the “Buy” button first renders somewhere in the corner, then teleports to the right place.
This happens because SwiftUI (or UIKit) calculates layout after the animation has already started. The system doesn’t know the final sizes of elements and is forced to recalculate them on the fly.
Solution: explicitly set frames or use .layoutPriority() so SwiftUI understands which elements are more important.
// Bad: size calculated dynamically
Text(product.title)
.font(.largeTitle)
// Good: fix the height
Text(product.title)
.font(.largeTitle)
.frame(height: 40, alignment: .leading)
I try to avoid situations where content determines container size during animation. Better to fix sizes upfront – even if it means a bit more code.
Using matchedGeometryEffect Properly
matchedGeometryEffect is probably the coolest SwiftUI feature for transitions. But also the most overrated. Yes, it lets you animate the same element between different screens, and yes, it looks magical. But it doesn’t solve all problems.
How It Works
You mark two views with the same id within a single @Namespace, and SwiftUI automatically interpolates position, size, and shape between them.
@Namespace private var animation
var body: some View {
if showDetail {
DetailView(item: selectedItem)
.matchedGeometryEffect(id: selectedItem.id, in: animation)
} else {
ListView()
.matchedGeometryEffect(id: item.id, in: animation)
}
}
Sounds simple. In practice – tons of pitfalls.
Problem #1: IDs Must Be Unique
If you have a list of 100 items and use matchedGeometryEffect on each, make sure the ids are actually unique. I once carelessly passed not item.id but just the string "card" as the id. Result: all cards animated simultaneously to one point. Looked like a bug in The Matrix.
Problem #2: Namespace Must Live Higher in the Hierarchy
If you create a @Namespace inside a view that disappears during animation, SwiftUI will lose the connection between elements.
// Bad: namespace dies with ListView
struct ListView: View {
@Namespace private var animation // <- Won't survive the animation
...
}
// Good: namespace lives at container level
struct ContentView: View {
@Namespace private var animation
...
}
Problem #3: Not Everything Can Be Animated
matchedGeometryEffect only works with geometry: position, size, shape. It does not animate view content. If you have text in a card and its size changes – SwiftUI won’t smoothly interpolate each letter. It’ll just switch from one text to another.
For such cases, you need to combine matchedGeometryEffect with regular .opacity() and .scaleEffect().
When NOT to Use matchedGeometryEffect
If elements on two screens look similar but are semantically different – don’t try to link them through matchedGeometryEffect. For example, if you have a photo preview in a list and the same photo on the detail page but in a different aspect ratio (crop), the animation will look weird: the image will stretch, then crop, then return to form.
Better to use a regular fade + scale:
.transition(.asymmetric(
insertion: .scale.combined(with: .opacity),
removal: .opacity
))
I’ve noticed that the best apps use matchedGeometryEffect very sparingly: only for elements that truly “move” between screens. Everything else – fade in/out.
Custom Transitions with AnimatablePair and GeometryReader
Sometimes SwiftUI’s built-in transitions aren’t enough. You want a card to not just slide up from the bottom, but expand from the tap point. Or you want list elements to appear one by one with a cascading delay.
For this, there are custom Transitions and the AnimatableModifier protocol.
Simple Example: ScaleAndFade
Let’s say you want an element to appear with simultaneous scaling and fade-in. The built-in .scale works, but doesn’t give control over the pivot point (the point relative to which scaling happens).
struct ScaleAndFade: ViewModifier {
var progress: Double // 0 = invisible, 1 = fully visible
func body(content: Content) -> some View {
content
.scaleEffect(0.8 + progress * 0.2) // From 0.8 to 1.0
.opacity(progress)
}
}
extension AnyTransition {
static var scaleAndFade: AnyTransition {
.modifier(
active: ScaleAndFade(progress: 0),
identity: ScaleAndFade(progress: 1)
)
}
}
Usage:
if showCard {
CardView()
.transition(.scaleAndFade)
}
AnimatablePair: When You Need to Animate Multiple Values
Let’s say you want to animate both scale and offset simultaneously. AnimatablePair lets you combine two Animatable values into one.
struct ScaleAndOffset: ViewModifier, Animatable {
var scale: CGFloat
var offsetY: CGFloat
var animatableData: AnimatablePair<CGFloat, CGFloat> {
get { AnimatablePair(scale, offsetY) }
set {
scale = newValue.first
offsetY = newValue.second
}
}
func body(content: Content) -> some View {
content
.scaleEffect(scale)
.offset(y: offsetY)
}
}
Now you can smoothly animate a transition from scale: 0.5, offsetY: 100 to scale: 1.0, offsetY: 0.
GeometryReader: Adaptive Transitions
The most interesting animations are those that respond to context. For example, if a user taps on a card in the top-left corner of the screen, it should expand from that point. If they tap bottom-right – from there.
For this, you need GeometryReader:
struct ExpandFromTap: ViewModifier {
var tapLocation: CGPoint
var progress: Double
func body(content: Content) -> some View {
GeometryReader { geometry in
let centerX = geometry.size.width / 2
let centerY = geometry.size.height / 2
let offsetX = (tapLocation.x - centerX) * (1 - progress)
let offsetY = (tapLocation.y - centerY) * (1 - progress)
content
.scaleEffect(0.1 + progress * 0.9, anchor: .center)
.offset(x: offsetX, y: offsetY)
.opacity(progress)
}
}
}
The idea is that at the start of the animation (progress = 0), the element is at the tap location with a scale of 0.1. As progress increases, it moves to the center and grows to full size.
The problem with GeometryReader is that it requires an additional render and can slow down animation if you use it inside a List or ScrollView. I try to use it only where it’s truly needed.
Avoid Excessive Animation
This is the case where “we can” doesn’t mean “we should.”
I’ve seen projects where every little thing was accompanied by an animation. Pressed a button – it bounced. Opened a menu – it flew out with three different easings. Switched tabs – icons spun. And you know what, after 30 seconds it starts to annoy.
The rule is simple: animation should explain what happened. If it doesn’t convey information – it’s unnecessary.
When Animation Is Mandatory
- Context change: transition between screens, opening a modal, expanding a card. Without animation, the user doesn’t understand where the previous content went.
- Feedback: button pressed, switch toggled, item deleted. Animation confirms the action.
- Loading: skeleton screen, spinner, progress bar. Shows that the app is working, not frozen.
When Animation Is Excessive
- Micro-effects: button slightly changes shade when pressed. The user won’t even notice this. Animations should be noticeable but not intrusive.
- Decorative movements: text smoothly slides in from the left, though it could just appear. This looks nice the first 2 times, then it’s annoying.
- Cascading delays: 10 elements appear one by one with 0.1-second intervals. That’s 1 second until the screen fully loads. Too slow.
I follow the rule: if an animation lasts longer than 0.3 seconds – it should be interactive (i.e., the user can interrupt it with a gesture). If longer than 0.5 seconds – it’s definitely unnecessary.
Easing: Not Everything Should Be a Spring
SwiftUI uses spring animation by default for everything. This is good for interactive elements (drag-and-drop, pull-to-refresh) but bad for simple transitions.
// Good for gestures
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: offset)
// Bad for fade-in
.animation(.spring(), value: isVisible) // Why a spring here?
// Better:
.animation(.easeOut(duration: 0.2), value: isVisible)
Spring looks beautiful in marketing videos, but in real use it often makes animations longer and less predictable.
Debugging Tools: Slow Animations, Core Animation Logs
When an animation doesn’t work, the first instinct is to add print() and see what’s happening. But print() won’t show you FPS drops, render delays, or layer conflicts.
Slow Animations (Debug Mode)
The easiest way to understand what’s wrong is to slow down the animation.
In Simulator: Debug → Slow Animations (or Cmd + T).
Everything will run 10 times slower. This lets you see:
- Moments when the animation stutters (jank).
- Places where elements appear from unexpected locations.
- Layout shifts that are invisible at normal speed.
I always enable slow animations before handing a feature off to QA. If something looks weird in slow motion – it’ll be even worse at real speed.
Core Animation Instrument
When you need to find the real cause of FPS drops – use Instruments.
- Launch the app through Product → Profile (
Cmd + I). - Select Core Animation.
- Enable Debug Options → Color Blended Layers.
Now the screen will be colored:
- Green: opaque layer, renders fast.
- Red: layer with alpha-blending, renders slow.
If the whole screen is red – you have a problem. SwiftUI has to blend layers on every frame, and this kills performance.
Typical causes:
.background(Color.white.opacity(0.5))– semi-transparent backgrounds..shadow()– shadows require additional compositing.- Nested
ZStacks with.opacity().
Solution: if you don’t need transparency – don’t use .opacity(). If you need a shadow – try using a pre-made image with a shadow instead of .shadow().
Color Misaligned Layers
Another useful option in Core Animation Instrument. Shows where layers are rendered off pixel boundaries.
When an element is positioned at fractional coordinates (e.g., x: 12.5, y: 34.3), the system is forced to use antialiasing. This isn’t a bug, but on older devices it slows rendering.
If you see lots of yellow areas – check if you’re using .offset() or .position() with non-integer values.
View Hierarchy Debugger
When it’s unclear why an element isn’t animating or is animating incorrectly, open Debug → View Debugging → Capture View Hierarchy.
You’ll see a 3D breakdown of all layers. Sometimes it turns out your element is completely hidden behind another layer, or has the wrong zIndex, or is in a completely different place in the hierarchy than you thought.
I once spent an hour debugging an animation that “didn’t work.” Turns out SwiftUI was creating two instances of the same view, and I was animating the wrong one.
List to Detail Navigation Without Flashing
Now let’s put it all together with a real example.
Task: We have a product list (grid of cards). When tapping a card, it expands into a fullscreen detail page. Requirements:
- No flashing.
- No jumping elements.
- Animation should feel natural.
- Must work on iPhone SE (2020) without stuttering.
Step 1: Data Structure
struct Product: Identifiable {
let id: UUID
let title: String
let price: String
let imageName: String
}
Step 2: List (Grid)
struct ProductGrid: View {
let products: [Product]
@Binding var selectedProduct: Product?
@Namespace private var animation
var body: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))], spacing: 16) {
ForEach(products) { product in
ProductCard(product: product)
.matchedGeometryEffect(id: product.id, in: animation)
.onTapGesture {
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
selectedProduct = product
}
}
}
}
.padding()
}
.overlay {
if let product = selectedProduct {
ProductDetail(product: product, namespace: animation) {
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
selectedProduct = nil
}
}
.transition(.opacity)
}
}
}
}
Step 3: Product Card
struct ProductCard: View {
let product: Product
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Image(product.imageName)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 200)
.clipped()
.cornerRadius(12)
Text(product.title)
.font(.headline)
.lineLimit(2)
Text(product.price)
.font(.subheadline)
.foregroundColor(.secondary)
}
.background(Color(.systemBackground))
}
}
Step 4: Detail Page
struct ProductDetail: View {
let product: Product
let namespace: Namespace.ID
let onClose: () -> Void
var body: some View {
ZStack(alignment: .topTrailing) {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Image(product.imageName)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity)
.frame(height: 400)
.clipped()
.matchedGeometryEffect(id: product.id, in: namespace)
VStack(alignment: .leading, spacing: 12) {
Text(product.title)
.font(.largeTitle)
.fontWeight(.bold)
Text(product.price)
.font(.title2)
.foregroundColor(.secondary)
Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.")
.font(.body)
.foregroundColor(.primary)
}
.padding()
}
}
.background(Color(.systemBackground))
.edgesIgnoringSafeArea(.all)
Button(action: onClose) {
Image(systemName: "xmark.circle.fill")
.font(.title)
.foregroundColor(.secondary)
.padding()
}
}
}
}
Problems I Solved Along the Way
Problem 1: Image flashed during transition.
Cause: SwiftUI was reloading the image when creating ProductDetail.
Solution: Used image caching. Added prefetch on long press.
Problem 2: Text on detail page appeared abruptly.
Cause: matchedGeometryEffect only animates geometry, not content.
Solution: Added .transition(.opacity) to text blocks.
Text(product.title)
.transition(.opacity.animation(.easeIn(duration: 0.3).delay(0.2)))
Problem 3: Animation stuttered on iPhone SE.
Cause: Too complex shadows on cards + semi-transparent background.
Solution: Removed .shadow(), added a thin border. Replaced semi-transparent background with solid.
// Was:
.background(Color.white.opacity(0.95))
.shadow(radius: 10)
// Became:
.background(Color(.systemBackground))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.gray.opacity(0.2), lineWidth: 1))
Problem 4: Scroll position reset when returning to list.
Cause: SwiftUI was recreating ScrollView.
Solution: Used a stable .id() for ScrollView so it wouldn’t be recreated.
ScrollView {
// ...
}
.id("product_grid") // Fixed ID
Final Result
Animation takes 0.4 seconds. The card smoothly scales from its position in the grid to fullscreen. The image stays in place (thanks to matchedGeometryEffect), text appears with a slight delay. No flashing, no jumps. On iPhone SE, runs at a stable 60 FPS.
Key points:
matchedGeometryEffectonly on the image, not the whole view.- Simple easings:
springwithdampingFraction: 0.8. - Minimal visual effects: no shadows, no blur, no semi-transparency.
- Prefetch images before animation starts.
Conclusions
Smooth transitions aren’t about cramming animation everywhere you can. They’re about making users not notice transitions at all. The interface should flow, not jump.
Three main rules I’ve learned over years of working with iOS:
- Perceived performance matters more than actual performance. Start the animation instantly, even if data is still loading. Users won’t notice a 100ms delay after movement starts, but they will notice a 100ms delay before it starts.
- Less is more. One well-thought-out animation is better than ten mediocre ones. If you’re not sure whether animation is needed – it probably isn’t.
- Test on old devices. If animation stutters on iPhone XR – simplify. No beautiful effects are worth jank.
And remember: ultimately, the best animation is one the user doesn’t notice. Because it just works.
