
Ios Animation Code Review
- 46 installs
- 74 repo stars
- Updated July 21, 2026
- existential-birds/beagle
Helps with ai & agent building tasks.
About
ios-animation-code-review is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- ios-animation-code-review
- AI & Agent Building
- AI-coding skill
Ios Animation Code Review by the numbers
- 46 all-time installs (skills.sh)
- Ranked #7,568 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/existential-birds/beagle --skill ios-animation-code-reviewAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 46 |
|---|---|
| repo stars | ★ 74 |
| Last updated | July 21, 2026 |
| Repository | existential-birds/beagle ↗ |
What it does
Helps with ai & agent building tasks.
Files
iOS Animation Code Review
Quick Reference
| Issue Type | Reference |
|---|---|
| Spring parameters, withAnimation misuse, phase/keyframe bugs | references/swiftui-animation-patterns.md |
| Frame drops, offscreen rendering, main thread blocking | references/performance.md |
| Reduce Motion, VoiceOver, motion sensitivity | references/accessibility.md |
| Transition protocol, matchedGeometryEffect, navigation transitions | references/transitions.md |
Hard gates (sequence)
Complete in order for the files in scope. If a step fails, omit the finding, re-anchor, or downgrade to a question—do not ship accusations without meeting the pass condition.
| Step | What you do | Pass condition (objective) |
|---|---|---|
| 1. Inventory | List each file under review and where animation APIs appear (line ranges or symbol names: withAnimation, .animation, matchedGeometryEffect, PhaseAnimator, UIKit/CA animators, etc.). | A written list exists; files with no animation APIs are explicitly marked out of scope. |
| 2. Anchor | Re-read the cited region in the current file or diff hunk before naming an issue. | Each [FILE:LINE] still shows the behavior; stale line numbers are fixed or the finding is dropped. |
| 3. Evidence | For framework-specific claims (spring curves, Transition conformance, Reduce Motion), cross-check the matching row in Quick Reference against references/*.md. | The finding’s detail names the reference file used, or states inline-only (structural/readability with no framework rule). |
| 4. Report | Emit findings using Output Format. | Headers match [FILE:LINE] ISSUE_TITLE; checklist items below are applied only where gates 1–2 covered that code. |
Output Format
Report each finding as:
[FILE:LINE] ISSUE_TITLEExample: [AnimatedCard.swift:42] Missing Reduce Motion fallback for spring animation
All details, code suggestions, and rationale follow after the header line.
Review Checklist
- [ ]
@Environment(\.accessibilityReduceMotion)checked — animations have Reduce Motion fallback - [ ] Animation is not the sole feedback channel — important state changes pair with haptics (
.sensoryFeedback) or audio - [ ] Custom animation isn't duplicating system-provided motion (standard nav transitions, sheet presentation, SF Symbol effects)
- [ ] Animations on frequent interactions are brief and unobtrusive — or absent (system handles it)
- [ ] All animations are interruptible — user is never forced to wait for completion before interacting
- [ ] Spring animations use
duration/bounceparameters (not raw mass/stiffness/damping unless UIKit/CA) - [ ] No deprecated
.animation()withoutvalue:parameter - [ ]
withAnimationwraps state changes, not view declarations - [ ]
matchedGeometryEffectIDs are stable and unique within the namespace - [ ]
geometryGroup()used when parent geometry animates with child views appearing - [ ] Looping animations (
PhaseAnimator,symbolEffect) have finite phases or appropriate trigger - [ ] No
CATransaction.setAnimationDuration()in UIView-backed layers (use UIView.animate instead) - [ ] Interactive animations handle interruption (re-trigger mid-flight doesn't break state)
- [ ] Shadow animations provide explicit
shadowPath(avoids per-frame recalculation) - [ ] Gesture-driven animations preserve velocity on release for natural completion
- [ ] Gesture-driven feedback follows spatial expectations (dismiss direction matches reveal direction)
- [ ] No animation of
.id()modifier (destroys view identity — usetransitionormatchedGeometryEffectinstead)
When to Load References
- Incorrect spring setup or
withAnimationscope issues → swiftui-animation-patterns.md - Hitches, dropped frames, or expensive animations in scroll views → performance.md
- Missing Reduce Motion handling or motion accessibility → accessibility.md
matchedGeometryEffectglitches or customTransitionbugs → transitions.md
Review Questions
1. Does every animation have a Reduce Motion fallback that preserves the information conveyed? Is animation the only feedback channel, or are haptics/audio supplementing it? 2. Is this custom animation necessary, or does the system already provide it (standard transitions, SF Symbol effects, Liquid Glass)? 3. Could this animation cause frame drops — is it animating expensive properties (blur, shadow without path, mask) in a list or scroll view? 4. Are all animations interruptible? Can the user act without waiting for completion? Does gesture-driven feedback follow spatial expectations? 5. Is withAnimation scoped to the minimal state change needed, or is it wrapping unrelated mutations? 6. For matchedGeometryEffect — are source and destination using the same ID and namespace, and is only one visible at a time?
Animation Accessibility
Core HIG Principle
Apple's Human Interface Guidelines state: "Make motion optional. Not everyone can or wants to experience the motion in your app or game, so it's essential to avoid using it as the only way to communicate important information. To help everyone enjoy your app or game, supplement visual feedback by also using alternatives like haptics and audio to communicate."
This means two things: (1) every animation needs a Reduce Motion fallback, and (2) animation should never be the sole feedback channel for important state changes.
Reduce Motion
Users with vestibular disorders, motion sensitivity, or preference for reduced visual complexity enable Settings → Accessibility → Motion → Reduce Motion. This is not a niche setting — a meaningful percentage of users have it enabled.
Every animation must have a Reduce Motion path. Not "should have" — must have.
SwiftUI
@Environment(\.accessibilityReduceMotion) private var reduceMotion
// Pattern 1: Skip animation
withAnimation(reduceMotion ? .none : .spring()) {
isExpanded.toggle()
}
// Pattern 2: Simplified animation (crossfade instead of movement)
.animation(reduceMotion ? .easeOut(duration: 0.15) : .spring(duration: 0.5, bounce: 0.3), value: isActive)
// Pattern 3: Conditional modifier
.modifier(ReduceMotionModifier(
standard: .offset(y: isVisible ? 0 : 20).combined(with: .opacity),
reduced: .opacity
))UIKit
if UIAccessibility.isReduceMotionEnabled {
// Instant or simple fade
UIView.animate(withDuration: 0.15) {
view.alpha = targetAlpha
}
} else {
// Full spring animation
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.7, initialSpringVelocity: 0) {
view.alpha = targetAlpha
view.transform = targetTransform
}
}
// Listen for changes
NotificationCenter.default.addObserver(
forName: UIAccessibility.reduceMotionStatusDidChangeNotification,
object: nil, queue: .main
) { _ in
// Update running animations
}What to Reduce
Not all animation needs removal with Reduce Motion. The guideline is: remove vestibular-triggering motion while preserving state communication.
| Keep | Remove/Simplify |
|---|---|
| Opacity changes (fade in/out) | Large positional movement (slide, fly) |
| Instant state transitions | Zoom/scale transitions |
| Brief, small-scale changes | Bouncing, wobbling, shaking |
| User-controlled gestures | Auto-playing motion (parallax, ambient) |
| Progress indicators (non-bouncing) | Spring overshoot (bounce parameter) |
Reduce Motion Replacement Strategies
| Original Animation | Reduced Alternative |
|---|---|
| Slide in from edge | Crossfade (opacity 0→1) |
| Spring with bounce | Linear ease-out, 0.15s |
| Zoom navigation transition | System handles (crossfade) |
| Parallax scroll effect | Static (no parallax) |
| Staggered item entrance | All items appear simultaneously, fade |
| Rotation/flip | Crossfade |
| Animated gradient (MeshGradient) | Static gradient |
| Pulsing indicator | Static indicator at full opacity |
Missing Reduce Motion — Review Signals
Code patterns that likely need Reduce Motion handling:
// Any of these without @Environment(\.accessibilityReduceMotion) is a flag:
withAnimation(.spring(duration: 0.5, bounce: 0.3)) { }
.phaseAnimator(phases) { }
.keyframeAnimator(initialValue: values) { }
.matchedGeometryEffect(id: "hero", in: namespace)
.scrollTransition { content, phase in }
.offset(y: isVisible ? 0 : 50)
.scaleEffect(isActive ? 1 : 0.5)
.rotationEffect(.degrees(isFlipped ? 180 : 0))Exceptions (these don't need Reduce Motion handling):
.contentTransition(.numericText())— system handles it.symbolEffect— system respects Reduce Motion automatically.navigationTransition(.zoom)— system manages.sensoryFeedback— haptics, not visual motion
VoiceOver and Animation
State Change Announcements
If an animation communicates a state change, VoiceOver users need an equivalent announcement.
// Visual-only feedback (VoiceOver users miss this)
withAnimation(.bouncy) {
isLiked.toggle()
}
// With announcement
withAnimation(.bouncy) {
isLiked.toggle()
}
AccessibilityNotification.Announcement(isLiked ? "Added to favorites" : "Removed from favorites").post()Animation Blocking Interaction
Animations that block touch interaction (e.g., a loading overlay that animates in) must be announced to VoiceOver so users understand why controls became unresponsive.
Focus Management After Transition
After animated navigation transitions, verify VoiceOver focus moves to the appropriate element in the new view.
.accessibilityFocused($isFocused)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
isFocused = true
}
}Dynamic Type Considerations
Animations involving layout (position, size, offset) may need adjustment for larger text sizes. Fixed pixel offsets that look right at default text size may be too small or too large at accessibility sizes.
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
// Scale animation distances with type size
let slideDistance: CGFloat = dynamicTypeSize.isAccessibilitySize ? 40 : 20Prefer Cross-Dissolve for Reduce Motion
When in doubt about what to use as a Reduce Motion replacement, a simple opacity cross-dissolve at 0.15–0.2s is almost always appropriate. It communicates change without triggering vestibular response.
Critical Anti-Patterns
| Pattern | Issue |
|---|---|
No @Environment(\.accessibilityReduceMotion) with spring/bounce | Motion-sensitive users see full animation |
| Reduce Motion check only at top level | Individual animated components also need checks |
| Removing all animation with Reduce Motion | Over-correction — keep fades and instant transitions |
| Animated state change with no VoiceOver announcement | VoiceOver users miss the state change |
| Fixed offset values that don't scale with Dynamic Type | Animation looks wrong at accessibility sizes |
Review Questions
1. Does every spring/bounce/movement animation check accessibilityReduceMotion? 2. Is the Reduce Motion fallback appropriate (crossfade, not just removal of all animation)? 3. Are animated state changes communicated to VoiceOver via announcements? 4. Do gesture-driven animations still work correctly with VoiceOver (or provide an accessible alternative)? 5. Are fixed animation distances reasonable at larger Dynamic Type sizes?
Animation Performance
Frame Budget
iOS targets 60fps (16.67ms per frame) or 120fps on ProMotion devices (8.33ms). Animations that exceed the frame budget cause visible hitches — dropped frames where the UI visibly stutters.
Offscreen Rendering Triggers
These properties force the GPU to render to an offscreen buffer before compositing, doubling the rendering cost:
| Property | When Expensive |
|---|---|
cornerRadius + masksToBounds | Always — clips content to rounded rect |
shadow without explicit shadowPath | Every frame — system must compute shadow from layer alpha |
.blur() in SwiftUI | Always — Gaussian blur is computationally expensive |
mask / .mask() modifier | Always — requires compositing pass |
shouldRasterize = true on changing layer | Cache invalidation every frame defeats the purpose |
Shadow Path Fix
// BAD — shadow path recalculated every frame
layer.shadowOpacity = 0.3
layer.shadowRadius = 10
// No shadowPath set
// GOOD — explicit path, cached by GPU
layer.shadowPath = UIBezierPath(roundedRect: layer.bounds, cornerRadius: 12).cgPathIn SwiftUI, .shadow() doesn't expose path control. For animated shadows in lists, consider using a separate RoundedRectangle with .shadow() behind the content rather than applying shadow to the content itself.
Animations in Scroll Views and Lists
Per-item animations in scrolling containers are the most common source of hitches.
// BAD — blur per cell in a list
ForEach(items) { item in
ItemView(item: item)
.blur(radius: 5) // Offscreen render per cell, every frame while scrolling
}
// BAD — shadow without path per cell
ForEach(items) { item in
ItemView(item: item)
.shadow(radius: 10) // Recomputed per cell
}
// GOOD — use opacity or overlay for visual depth instead
ForEach(items) { item in
ItemView(item: item)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.shadow(.drop(radius: 5)))
)
}View Identity and Animation Cost
Changing a view's structural identity (.id() modifier, conditional if/else) creates a new view from scratch. This is far more expensive than animating an existing view's properties.
// EXPENSIVE — destroys and recreates view
if isExpanded {
ExpandedView().id("expanded")
} else {
CompactView().id("compact")
}
// CHEAPER — animate properties of the same view
ContentView()
.frame(height: isExpanded ? 400 : 100)
.animation(.spring(), value: isExpanded)When you must swap views (different content), use transition instead of relying on implicit animation of id changes.
geometryGroup() for Layout Anomalies
When a parent's geometry changes (position, size) are animated and new child views appear during that animation, the children can render at incorrect positions. geometryGroup() fixes this by resolving the parent's geometry before passing it to children.
// Without geometryGroup — children may appear at wrong position during parent animation
VStack {
if showContent {
ContentView() // May animate from wrong origin
}
}
.frame(height: expanded ? 400 : 200)
.animation(.spring(), value: expanded)
// With geometryGroup — parent geometry resolved first
VStack {
if showContent {
ContentView()
}
}
.frame(height: expanded ? 400 : 200)
.geometryGroup()
.animation(.spring(), value: expanded)drawingGroup() for Complex View Hierarchies
Flattens a SwiftUI view hierarchy into a single Metal-rendered layer. Reduces compositing overhead for complex but static-ish view trees.
// Complex animated overlay — flatten to single texture
ZStack {
ForEach(particles) { particle in
Circle()
.fill(particle.color)
.frame(width: particle.size)
.offset(particle.offset)
}
}
.drawingGroup() // Rendered as single Metal textureUse when: many overlapping views with shared animation. Don't use when: views need independent hit testing or accessibility.
Looping Animation Cost
PhaseAnimator without a trigger, .symbolEffect(.pulse), and repeatForever animations run continuously. Each consumes render cycles even when the view isn't visible (unless the view is removed from the hierarchy).
// This runs forever, even if scrolled off screen in a LazyVStack
// (LazyVStack keeps some buffer views alive)
.phaseAnimator([false, true]) { content, phase in
content.opacity(phase ? 1 : 0.5)
}For items in scroll views, prefer triggered animations (.symbolEffect(.bounce, value: trigger)) over continuous ones.
Profiling Tools
| Tool | What It Shows |
|---|---|
| Instruments → Animation Hitches | Frame drops with call stacks |
| Instruments → Core Animation | FPS, offscreen rendering, blending |
| Debug → Color Blended Layers | Green = normal, red = blended (expensive) |
| Debug → Color Offscreen-Rendered | Yellow highlight on offscreen renders |
| Xcode GPU Report | Frame time breakdown |
Critical Anti-Patterns
| Pattern | Issue |
|---|---|
.blur() in ForEach/List | Offscreen render per cell |
Shadow without shadowPath | Path recalculated every frame |
Continuous PhaseAnimator in scroll cells | Battery drain, hitch source |
Animating .id() change | View recreation instead of property animation |
shouldRasterize on frequently changing layers | Cache invalidation overhead |
drawingGroup() on interactive views | Breaks hit testing and accessibility |
Review Questions
1. Are blur, shadow, or mask applied to views inside a scroll view or list? 2. Do shadow layers have an explicit shadowPath? 3. Are looping animations (PhaseAnimator, repeatForever) appropriate for this context — or should they be triggered? 4. Could geometryGroup() fix layout anomalies where children appear at wrong positions during parent geometry animation? 5. Are expensive animations profiled with Instruments Animation Hitches template?
SwiftUI Animation Patterns
withAnimation Scope Issues
withAnimation should wrap only the state mutation that drives the animation, not unrelated logic.
// BAD — animation wraps network call and unrelated state
withAnimation {
Task { await viewModel.fetchData() }
selectedTab = .home
showBanner = false
}
// GOOD — only the visual state change is animated
Task { await viewModel.fetchData() }
withAnimation(.snappy) {
selectedTab = .home
}
showBanner = falseDeprecated .animation() Without Value
The parameterless .animation(_:) modifier is deprecated. It animates every state change in the view, causing unexpected animations on unrelated properties.
// BAD — deprecated, animates everything
Text(name)
.animation(.spring())
// GOOD — explicit value binding
Text(name)
.animation(.spring(), value: isExpanded)Spring Parameter Anti-Patterns
| Pattern | Problem |
|---|---|
.spring(mass:stiffness:damping:) in SwiftUI | Old API — use duration/bounce instead |
duration: 0 on a spring | Undefined behavior — use .none for instant |
bounce: 1.0 or higher | Extremely bouncy, likely unintentional — values above 0.5 are unusual |
.easeInOut(duration: 0.25) for interactive feedback | Springs feel more natural for user interactions |
PhaseAnimator Issues
Infinite Loop Without Trigger
PhaseAnimator without a trigger parameter loops continuously. This is intentional for ambient animations but a bug if used for one-shot effects.
// This loops forever — intentional?
.phaseAnimator([false, true]) { content, phase in
content.opacity(phase ? 1 : 0.5)
}
// One-shot: add trigger
.phaseAnimator([false, true], trigger: tapCount) { content, phase in
content.opacity(phase ? 1 : 0.5)
}Single Phase
A PhaseAnimator with only one phase does nothing.
// BUG — needs at least 2 phases
.phaseAnimator([Phase.idle]) { /* never transitions */ }KeyframeAnimator Issues
Mismatched Track Properties
Keyframe tracks must reference properties that exist on the initialValue type.
// BUG — if AnimationValues doesn't have .blur, this crashes
KeyframeTrack(\.blur) { ... }Missing Duration on Keyframes
Every keyframe needs a duration. Omitting it defaults to 0, making the keyframe instant.
// BAD — instant jump, probably unintentional
SpringKeyframe(1.5)
// GOOD — explicit duration
SpringKeyframe(1.5, duration: 0.3)Content Transition Misuse
.contentTransition is for content changes within a view, not view insertion/removal.
// BAD — contentTransition on a conditional view
if isVisible {
Text("Hello")
.contentTransition(.opacity) // Wrong — use .transition()
}
// GOOD — for content that changes in place
Text(count, format: .number)
.contentTransition(.numericText())Symbol Effect on Non-SF-Symbols
.symbolEffect only works with SF Symbols (system images). Applied to custom images, it silently does nothing.
// No effect — custom image, not SF Symbol
Image("custom-icon")
.symbolEffect(.bounce, value: count)
// Works — SF Symbol
Image(systemName: "bell.badge")
.symbolEffect(.bounce, value: count)Animation Identity vs. Transition
Animating the .id() modifier destroys and recreates the view. This is almost never what you want for smooth animation.
// BAD — destroys view identity, no smooth animation
Text(name)
.id(currentUser.id) // View is destroyed and recreated on change
.animation(.spring(), value: currentUser.id)
// GOOD — transition for view swap
Text(name)
.transition(.opacity)
.id(currentUser.id)
// GOOD — matchedGeometryEffect for position/size animation
Text(name)
.matchedGeometryEffect(id: "title", in: namespace)Critical Anti-Patterns
| Pattern | Issue |
|---|---|
.animation() without value: | Deprecated, animates all state changes unpredictably |
withAnimation wrapping Task { } | Async work doesn't need animation wrapping |
PhaseAnimator with 1 phase | Does nothing — needs ≥ 2 phases to transition |
.symbolEffect on custom images | Silently fails — only works with SF Symbols |
Animating .id() modifier changes | Destroys view — use transition or matchedGeometryEffect |
spring(bounce: > 0.5) | Excessively bouncy — verify this is intentional |
Review Questions
1. Is .animation() always paired with a value: parameter? 2. Does withAnimation scope include only the state mutation, not side effects? 3. Are PhaseAnimator phase counts ≥ 2 and triggered appropriately (continuous vs. one-shot)? 4. Are KeyframeAnimator track properties matching the initialValue struct? 5. Is .symbolEffect only applied to SF Symbols?
Transition Review Patterns
matchedGeometryEffect Issues
Duplicate IDs in Same Namespace
Only one view with a given ID should be in the view hierarchy at a time. Two visible views sharing an ID causes undefined geometry matching.
// BUG — both views visible with same ID
ZStack {
SourceView()
.matchedGeometryEffect(id: "card", in: namespace)
.opacity(isExpanded ? 0 : 1) // Opacity 0 still counts as "in hierarchy"
DestinationView()
.matchedGeometryEffect(id: "card", in: namespace)
.opacity(isExpanded ? 1 : 0)
}
// GOOD — conditional, only one in hierarchy
if isExpanded {
DestinationView()
.matchedGeometryEffect(id: "card", in: namespace)
} else {
SourceView()
.matchedGeometryEffect(id: "card", in: namespace)
}Unstable IDs
The matched ID must be stable across the transition. Using array indices or computed values that change during the animation breaks the match.
// BAD — index changes when array mutates
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
ItemView(item: item)
.matchedGeometryEffect(id: index, in: namespace) // Index shifts on delete
}
// GOOD — stable model ID
ForEach(items) { item in
ItemView(item: item)
.matchedGeometryEffect(id: item.id, in: namespace)
}Missing isSource
When matching position and size separately (rare but valid), one side must be isSource: true and the other isSource: false. When matching both together (common case), both default to isSource: true which works correctly.
Namespace Scope
@Namespace must be declared in the common ancestor view that contains both the source and destination. Passing namespaces between unrelated view hierarchies doesn't work.
Custom Transition Protocol
TransitionPhase Misunderstanding
TransitionPhase has three cases. .willAppear is the "before" state for insertion, .didDisappear is the "after" state for removal, and .identity is the normal presented state.
// BUG — this makes the view invisible in its identity state
struct BrokenTransition: Transition {
func body(content: Content, phase: TransitionPhase) -> some View {
content
.opacity(phase == .willAppear ? 0 : 1)
// Removal: .didDisappear gets opacity 1, then view disappears without fade
}
}
// GOOD — both insertion and removal handled
struct CorrectTransition: Transition {
func body(content: Content, phase: TransitionPhase) -> some View {
content
.opacity(phase.isIdentity ? 1 : 0) // 0 for both willAppear and didDisappear
.scaleEffect(phase.isIdentity ? 1 : 0.8)
}
}Asymmetric Behavior
If insertion and removal should look different, use .asymmetric() or check the specific phase.
struct AsymmetricSlide: Transition {
func body(content: Content, phase: TransitionPhase) -> some View {
content
.offset(
x: phase == .willAppear ? 100 : // Enter from right
phase == .didDisappear ? -100 : // Exit to left
0 // Identity
)
.opacity(phase.isIdentity ? 1 : 0)
}
}Zoom Navigation Transition (iOS 18+)
Missing matchedTransitionSource
.navigationTransition(.zoom(sourceID:in:)) on the destination requires .matchedTransitionSource(id:in:) on the source. Without it, the zoom has no origin point and falls back to a standard push.
// BUG — missing source
NavigationLink {
DetailView()
.navigationTransition(.zoom(sourceID: item.id, in: namespace))
} label: {
ItemCell()
// No .matchedTransitionSource — zoom won't work
}
// GOOD
NavigationLink {
DetailView()
.navigationTransition(.zoom(sourceID: item.id, in: namespace))
} label: {
ItemCell()
.matchedTransitionSource(id: item.id, in: namespace)
}ID Mismatch
The sourceID in .navigationTransition(.zoom) must exactly match the id in .matchedTransitionSource. Type must also match (both String, both Int, etc.).
Outside NavigationStack
Zoom transitions require NavigationStack. They don't work with the deprecated NavigationView or with custom navigation implementations.
View Transition Timing
Missing Animation Wrapper
Conditional view changes need withAnimation or .animation(_:value:) to animate transitions.
// BUG — no animation, views snap in/out
if showDetail {
DetailView()
.transition(.slide) // Transition defined but never animated
}
// GOOD — wrapped in animation
Button("Show") {
withAnimation(.spring()) {
showDetail = true
}
}
if showDetail {
DetailView()
.transition(.slide)
}Transition on Wrong View
.transition() must be on the view being inserted/removed, not the parent.
// BAD — transition on container, not on the conditional view
VStack {
if showBanner {
BannerView()
}
}
.transition(.slide) // Does nothing — VStack is always present
// GOOD — transition on the conditional view
VStack {
if showBanner {
BannerView()
.transition(.slide)
}
}NavigationStack Path Animation
Programmatic navigation changes should be wrapped in withAnimation for smooth transitions.
// No animation — instant view swap
path.append(destination)
// Animated
withAnimation(.smooth) {
path.append(destination)
}Critical Anti-Patterns
| Pattern | Issue |
|---|---|
| Two views with same matchedGeometryEffect ID visible simultaneously | Undefined geometry matching |
matchedGeometryEffect with array index as ID | ID shifts when array mutates |
.transition() on a view that's always in the hierarchy | No effect — transitions only fire on insertion/removal |
.navigationTransition(.zoom) without .matchedTransitionSource | Falls back to standard push, no zoom |
Missing withAnimation around conditional view state change | Transition defined but never animated |
Custom Transition that only handles .willAppear | Removal has no animation |
Review Questions
1. For matchedGeometryEffect — is only one view with that ID in the hierarchy at a time? 2. Does the custom Transition handle both .willAppear and .didDisappear phases? 3. For zoom navigation — does every .navigationTransition(.zoom) have a matching .matchedTransitionSource with the same ID? 4. Is the view state change that triggers the transition wrapped in withAnimation? 5. Is .transition() placed on the conditional view, not its always-present parent?