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. matchedGeometryEffect 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. Perceived performance 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. actual performance 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. 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. 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: {}) // 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. You use .sheet() or .fullScreenCover() with a custom background but forget .background() on the .sheet() itself. .sheet() .fullScreenCover() .background() .sheet() SwiftUI recreates the view hierarchy during animation. You have different colorSchemes on two screens, and the system switches themes mid-transition. colorScheme Classic example: .sheet(isPresented: $showDetails) { DetailView() // Forgot to add background - will flash white } // Correct: .sheet(isPresented: $showDetails) { DetailView() .background(Color(.systemBackground)) } .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. id 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. .layoutPriority() // Bad: size calculated dynamically Text(product.title) .font(.largeTitle) // Good: fix the height Text(product.title) .font(.largeTitle) .frame(height: 40, alignment: .leading) // 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. matchedGeometryEffect 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. id @Namespace @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) } } @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. matchedGeometryEffect id item.id "card" id 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. @Namespace // 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 ... } // 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. matchedGeometryEffect not animate For such cases, you need to combine matchedGeometryEffect with regular .opacity() and .scaleEffect(). matchedGeometryEffect .opacity() .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. matchedGeometryEffect Better to use a regular fade + scale: .transition(.asymmetric( insertion: .scale.combined(with: .opacity), removal: .opacity )) .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. matchedGeometryEffect 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. Transition AnimatableModifier 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). .scale 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) ) } } 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) } 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. AnimatablePair Animatable 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) } } 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. scale: 0.5, offsetY: 100 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: 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) } } } 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. progress = 0 progress 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. GeometryReader List ScrollView 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. Context change: transition between screens, opening a modal, expanding a card. Without animation, the user doesn't understand where the previous content went. Context change Feedback: button pressed, switch toggled, item deleted. Animation confirms the action. Feedback Loading: skeleton screen, spinner, progress bar. Shows that the app is working, not frozen. Loading 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. Micro-effects: button slightly changes shade when pressed. The user won't even notice this. Animations should be noticeable but not intrusive. Micro-effects 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. Decorative movements Cascading delays: 10 elements appear one by one with 0.1-second intervals. That's 1 second until the screen fully loads. Too slow. Cascading delays 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. spring // 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) // 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. print() print() 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). Debug → Slow Animations 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. 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. Launch the app through Product → Profile (Cmd + I). Product → Profile Cmd + I Select Core Animation. Core Animation Enable Debug Options → Color Blended Layers. Debug Options → Color Blended Layers Now the screen will be colored: Green: opaque layer, renders fast. Red: layer with alpha-blending, renders slow. Green: opaque layer, renders fast. Green Red: layer with alpha-blending, renders slow. Red 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(). .background(Color.white.opacity(0.5)) - semi-transparent backgrounds. .background(Color.white.opacity(0.5)) .shadow() - shadows require additional compositing. .shadow() Nested ZStacks with .opacity(). ZStack .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(). .opacity() .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. x: 12.5, y: 34.3 If you see lots of yellow areas - check if you're using .offset() or .position() with non-integer values. .offset() .position() View Hierarchy Debugger When it's unclear why an element isn't animating or is animating incorrectly, open Debug → View Debugging → Capture View Hierarchy. 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. zIndex 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: Task No flashing. No jumping elements. Animation should feel natural. Must work on iPhone SE (2020) without stuttering. 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 } 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) } } } } 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)) } } 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() } } } } 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. Problem 1 Cause: SwiftUI was reloading the image when creating ProductDetail. Cause ProductDetail Solution: Used image caching. Added prefetch on long press. Solution Problem 2: Text on detail page appeared abruptly. Problem 2 Cause: matchedGeometryEffect only animates geometry, not content. Cause matchedGeometryEffect Solution: Added .transition(.opacity) to text blocks. Solution .transition(.opacity) Text(product.title) .transition(.opacity.animation(.easeIn(duration: 0.3).delay(0.2))) Text(product.title) .transition(.opacity.animation(.easeIn(duration: 0.3).delay(0.2))) Problem 3: Animation stuttered on iPhone SE. Problem 3 Cause: Too complex shadows on cards + semi-transparent background. Cause Solution: Removed .shadow(), added a thin border. Replaced semi-transparent background with solid. Solution .shadow() // 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)) // 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. Problem 4 Cause: SwiftUI was recreating ScrollView. Cause ScrollView Solution: Used a stable .id() for ScrollView so it wouldn't be recreated. Solution .id() ScrollView ScrollView { // ... } .id("product_grid") // Fixed ID 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. matchedGeometryEffect Key points: matchedGeometryEffect only on the image, not the whole view. Simple easings: spring with dampingFraction: 0.8. Minimal visual effects: no shadows, no blur, no semi-transparency. Prefetch images before animation starts. matchedGeometryEffect only on the image, not the whole view. matchedGeometryEffect Simple easings: spring with dampingFraction: 0.8. spring dampingFraction: 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. 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. Perceived performance matters more than actual performance 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. Less is more Test on old devices. If animation stutters on iPhone XR - simplify. No beautiful effects are worth jank. Test on old devices And remember: ultimately, the best animation is one the user doesn't notice. Because it just works.