Back to Articles
SwiftUI 240fps Guide: ProMotion Performance Deep Dive

SwiftUI 240fps Guide: ProMotion Performance Deep Dive

Reality check: No current iPhone or iPad supports 240Hz. The max is 120Hz (ProMotion). 240Hz is only available on Mac Studio (M4 Max/M4 Pro) over HDMI to external 4K displays. This guide covers every optimization needed to hit 120fps on ProMotion and prepares for hypothetical 240Hz (4.17ms frame budget).


Table of Contents

  1. Hardware & Frame Budgets
  2. Enabling High Refresh Rates
  3. The SwiftUI Render Loop
  4. AttributeGraph: The Engine Under SwiftUI
  5. View Identity & the Diffing Algorithm
  6. Reducing Body Recomputation
  7. @Observable vs @ObservedObject
  8. EquatableView & Custom Diffing
  9. Metal GPU Acceleration & drawingGroup()
  10. TimelineView + Canvas for High-FPS Rendering
  11. Metal Shaders in SwiftUI
  12. Scroll Performance: List vs LazyVStack
  13. AnyView Cost & Structural Identity
  14. CADisplayLink & Frame Rate Control
  15. Metal + SwiftUI Integration
  16. SpriteKit + SwiftUI
  17. Core Animation Layer Optimizations
  18. View Decomposition Patterns
  19. Conditional Views & Modifiers
  20. Opacity vs Hidden vs If
  21. Overlay vs ZStack
  22. .task vs .onAppear
  23. Image Caching
  24. Environment & Data Flow
  25. @State vs @StateObject
  26. @Bindable vs @Binding
  27. GeometryReader & Modern Alternatives
  28. Custom Layout Protocol
  29. visualEffect Modifier
  30. Animation Performance
  31. Copy-on-Write & Value Types
  32. Concurrency & Threading
  33. Preference Keys
  34. Scroll Target Behavior
  35. Lazy Navigation
  36. Apple Vision Pro Rendering
  37. Profiling & Debugging
  38. iOS 26 / WWDC 2025 Improvements
  39. The 120fps Checklist
  40. The 240fps Approach
  41. Sources

1. Hardware & Frame Budgets

DeviceMax Refresh RateFrame Budget
Standard iPhone/iPad60 Hz16.67 ms
iPhone 13 Pro+ (ProMotion)120 Hz8.33 ms
iPad Pro (ProMotion)120 Hz8.33 ms
Apple Vision Pro (M2)90/96/100 Hz10-11.1 ms
Apple Vision Pro (M5)120 Hz8.33 ms
Mac Studio 4K HDMI240 Hz4.17 ms

ProMotion Discrete Refresh Rates

iPhone 13 Pro+ and iPad Pro ProMotion displays only support these specific rates:

120Hz (8.3ms), 80Hz (12ms), 60Hz (16.6ms), 48Hz (20.8ms), 40Hz (25ms), 30Hz (33.3ms), 24Hz (41.6ms), 20Hz (50ms), 16Hz (62.5ms), 15Hz (66.6ms), 12Hz (83.3ms), 10Hz (100ms)

Key Constraints

  • A “hitch” occurs when the main thread cannot complete all work within the frame budget
  • SwiftUI is 100% main-thread-dependent for body evaluation, layout, and diffing
  • Only the final GPU composition step is offloaded
  • At 120Hz, usable budget is ~5ms after system overhead
  • At hypothetical 240Hz, the budget is 4.17ms — requiring sub-millisecond view body evaluations
  • Low Power Mode caps display at 60Hz regardless of hardware

2. Enabling High Refresh Rates

Info.plist Configuration (Required for iPhone)

<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>

This is the single most important configuration for high frame rates on iPhone. Without it, all third-party app animations on iPhone are capped at 60fps even on ProMotion hardware.

  • Required on iPhone to unlock >60Hz for CADisplayLink callbacks and CAAnimation animations
  • NOT required on iPad Pro — ProMotion works without this key on iPad
  • Standard system UI (scrolling, keyboard) gets ProMotion benefits regardless

For Mac:

<key>CADisableMinimumFrameDuration</key>
<true/>

What Works Automatically (No Code Changes)

  • Standard SwiftUI animations (withAnimation, .animation(), transitions)
  • UIKit UIView.animate animations
  • System scrolling
  • SpriteKit, CAAnimation (with Info.plist key on iPhone)

What Requires Manual Configuration

  • Custom CADisplayLink-driven drawing
  • Metal MTKView rendering
  • Explicit CAAnimation objects targeting >60Hz
  • Custom TimelineView + Canvas animations

How Apple Decides Refresh Rate

Core Animation dynamically selects the refresh rate. Your preferredFrameRateRange is a hint, not a guarantee. The system considers:

  • Battery level and Low Power Mode
  • Thermal state
  • Other animations running concurrently
  • Content complexity

3. The SwiftUI Render Loop

The render pipeline operates on CFRunLoop:

  1. Event Reception — RunLoop receives touches, timers, display refresh signals
  2. State Updates — User actions trigger @State, @ObservedObject, publisher changes
  3. View Invalidation — Changed state marks views as needing re-evaluation
  4. Body Re-evaluation — Scheduled at the RunLoop’s beforeWaiting stage (NOT immediately)
  5. Handler ExecutiononChange, onPreferenceChange, onAppear callbacks fire
  6. Layer Updates — Built-in views make CALayer changes, starting an implicit CATransaction
  7. GPU Composition — Transaction commits, executing CPU rendering + GPU composition
  8. Display Update — Frame appears on next display refresh

Critical Performance Implications

  • Batching: If a view is invalidated twice in the same RunLoop cycle, it is evaluated only once. Multiple state changes in a single user action produce only one body evaluation and one render pass.
  • Safe double evaluation: If onChange/onAppear handlers cause additional invalidations, a second body evaluation occurs. The first result is never rendered to screen.
  • Body evaluation is distinct from rendering: A body can be evaluated multiple times during a frame without drawing to screen each time. Actual rendering only occurs at CATransaction commit.
  • Infinite loop protection: If handlers cause runaway invalidations, SwiftUI temporarily disables invalidation and warns: "onChange(of:) action tried to update multiple times per frame."

RunLoop Modes and Scrolling

When scrolling, the RunLoop switches from .default to .tracking mode:

ModeWhen Active
.defaultNormal app state, no active scrolling
.trackingActive scroll gesture / drag
.commonPseudo-mode that includes both .default and .tracking
// Timer that fires during scrolling
let timer = Timer(timeInterval: 1.0, repeats: true) { _ in print("tick") }
RunLoop.main.add(timer, forMode: .common)

// CADisplayLink that fires during scrolling
let displayLink = CADisplayLink(target: self, selector: #selector(update))
displayLink.add(to: .main, forMode: .common) // NOT .default

4. AttributeGraph: The Engine Under SwiftUI

AttributeGraph is a private C++ library that is the actual engine running SwiftUI. It provides a graph of attributes that tracks data dependencies.

How the Dependency Graph Works

  • Node structure: Each SwiftUI view gets a body node in the graph. @State properties become their own nodes. Every view body that reads a state property creates a dynamic dependency edge.
  • Dependency tracking via access: State.wrappedValue’s getter records reads. SwiftUI infers dependencies from access patterns — if a state property is not accessed during body evaluation, no dependency is created.
  • Pull-based (lazy) evaluation: When an input changes, the graph marks dependents as “potentially dirty” but does nothing else immediately. Values are only recomputed when actually requested.
  • Environment dependencies: Reading from the environment creates a dependency; modifying the environment creates a new node in the chain.

How SwiftUI Discovers State

SwiftUI uses reflection to discover @State members and installs _location on them for dependency tracking. For nested types, DynamicProperty conformance is needed.

Debugging the Attribute Graph

  • AG::Graph::print_stack() — prints information on which nodes are being updated
  • AG::Subgraph::print(int) and AG::Graph::print_attribute() — inspect specific subgraphs
  • AGGraphArchiveJSON — exports the entire attribute graph to JSON for analysis
  • Symbolic breakpoint on AG::Graph::print_cycle — catches cycle detection

Actionable Techniques

  • Minimize the number of dependency edges — only read state that the view actually needs in its body
  • Avoid reading environment values unnecessarily — every read creates a dependency edge
  • The graph is NOT a 1:1 representation of your code — it tracks dependencies, not structure
  • "AttributeGraph: cycle detected" warnings indicate circular dependencies causing stuttering

5. View Identity & the Diffing Algorithm

Two Types of Identity

  • Structural identity: Determined by the view’s type and position in the view hierarchy. SwiftUI uses ConditionalContent behind the scenes for if/else branches.
  • Explicit identity: Assigned via Identifiable protocol, .id() modifier, or ForEach loops. Must be stable, unique, and not change over time.

How Diffing Works

SwiftUI uses a reflection-based diffing algorithm. For each view’s stored properties:

  1. Equatable types: Compared using their == implementation
  2. Value types (structs): Recursively compared property-by-property
  3. Reference types (classes): Compared by reference identity (pointer equality)
  4. Closures: Attempted identity comparison, but most non-trivial closures cannot be compared reliably

If any single property is non-diffable, the entire view becomes non-diffable (per Airbnb’s findings). This is the single most impactful discovery for performance optimization.

The View Graph Only Changes in Four Ways

  1. _ConditionalView switches between true/false branches
  2. Optional<View> toggles visibility
  3. ForEach data updates (child types remain constant)
  4. AnyView content type changes

Static Type System Advantage

Unlike React, SwiftUI uses static typing for unambiguous diffing. The type of old and new body is always the same, so SwiftUI does not need Myers’ shortest-edit-script algorithm for most changes. ForEach is the exception.

Composed View Skipping

If an invalidated parent view contains a child composed view whose properties haven’t changed, SwiftUI skips evaluating the child’s body entirely. This is why view decomposition is so powerful.

Stable Identity Rules

  • Unstable id values cause SwiftUI to treat the view as new (destroy + rebuild)
  • .id() modifier forces full view recreation — use only for intentional state resets
  • Prefer Identifiable conformance over .self for ForEach
  • Use stable, persistent identifiers (database IDs, UUIDs) not indices
  • Never use .id(UUID()) — it destroys identity every frame

6. Reducing Body Recomputation

Sources of Truth (What Triggers Updates)

Three categories trigger view updates:

  1. DynamicProperty wrappers: @State, @Binding, @Environment, @ObservedObject, @StateObject
  2. Construction parameters: Changes detected via reflection-based diffing
  3. Event sources: onReceive, onChange, onOpenURL

@State and @Environment values do NOT participate in the diffing algorithm — they trigger updates through separate mechanisms.

Common Causes of Redundant Computation

Unused property wrappers:

// BAD: unused @Environment triggers updates when myValue changes
struct SubView: View {
    @Environment(\.myValue) var myValue // never used in body!
    var body: some View { Text("Static") }
}

// GOOD: remove the declaration entirely
struct SubView: View {
    var body: some View { Text("Static") }
}

Over-inclusive parameters:

// BAD: entire Student triggers update on any property change
SubView(student: student)

// GOOD: only name changes trigger update
SubView(name: student.name)

Unstable constructor values:

// BAD: new Date instance every time = always "changed"
struct MyView: View {
    let today = Date()  // Triggers recomputation every time
}

Closure captures:

// BAD: new closure instance each time
CellView(id: i) { store.sendID(i) }

// Better: pass method reference directly
CellView(id: i, action: store.sendID)

Key Strategies

  1. Decompose into small subviews — Each subview creates a diffing boundary
  2. Pass only needed data — Minimize construction parameters
  3. Keep body lightweight — No network calls, no expensive computation. Use .task {} or onAppear
  4. Computed properties are NOT diffing boundaries — SwiftUI inlines them at runtime. Extract into separate View structs
  5. Cache expensive computations:
    // BAD: creates formatter every body evaluation
    var body: some View {
        Text(DateFormatter().string(from: date))
    }
    
    // GOOD: cached formatter
    private static let formatter = DateFormatter()
    var body: some View {
        Text(Self.formatter.string(from: date))
    }
  6. Use @State for local values only — Not for heavy objects
  7. Avoid conditional view creation — Prefer parameter conditionals:
    // BAD: destroys and recreates views, loses state
    if condition { ViewA() } else { ViewB() }
    
    // GOOD: preserves identity
    MyView(value: condition ? a : b)

7. @Observable vs @ObservedObject

@ObservedObject / ObservableObject (Combine-based, iOS 13+)

  • Uses objectWillChange publisher
  • Any @Published property change invalidates every view observing that object
  • Push-based invalidation model (broadcasts generic change signals)
  • Severe performance degradation proportional to number of subscribed views

@Observable (Swift 5.9, iOS 17+)

  • Uses ObservationRegistrar with KeyPath-based tracking
  • Only views reading specific changed properties recompute
  • Pull-based, access-tracked invalidation model
  • Four internal methods: access(), willSet(), didSet(), withMutation()
  • Dramatically reduces unnecessary view invalidations

Migration Pattern

// Old
final class ViewModel: ObservableObject {
    @Published var count: Int = 0
    @Published var unrelated: String = ""  // Changes here update ALL observers
}
// Usage: @ObservedObject var vm: ViewModel

// New
@Observable
final class ViewModel {
    var count: Int = 0
    var unrelated: String = ""  // Changes here only update views reading 'unrelated'
}
// Usage: @State var vm = ViewModel()  // Note: @State not @StateObject

Granular Data Dependencies

// BAD: changing one item's favorite status updates ALL views
@Observable class Store {
    var items: [Item] = []
}

// GOOD: each item is independently observable
@Observable class ItemModel {
    var isFavorite: Bool = false
}

8. EquatableView & Custom Diffing

How It Works

When a view conforms to Equatable and uses .equatable() modifier, SwiftUI uses your == implementation instead of reflection-based diffing. If == returns true, body evaluation is skipped entirely.

The _isPOD() Function

SwiftUI uses _isPOD() (Plain Old Data) detection internally:

  • POD types (structs with only Int, Bool, etc.): Swift uses direct field-by-field comparison, ignoring custom ==
  • Non-POD types (String, classes, etc.): SwiftUI checks custom Equatable first

For POD types, you must explicitly use .equatable() to force custom == usage.

Implementation

struct ExpensiveView: View, Equatable {
    let data: ComplexModel
    let onTap: () -> Void  // Non-comparable

    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.data.id == rhs.data.id  // Only compare what matters
    }

    var body: some View { /* expensive rendering */ }
}

// Usage:
ExpensiveView(data: model, onTap: action).equatable()

Performance Benchmarks (Alexey Naumov)

ApproachFPS (1600 views, single element toggle)
ConditionalView10
AnyView10
EquatableView18 (nearly 2x improvement)

Airbnb’s Approach

Airbnb created a custom @Equatable macro that:

  • Compares all stored properties except @State and @Environment
  • Uses @SkipEquatable to exclude non-diffable properties
  • Fails the build when non-Equatable properties are added
  • Achieved 15% reduction in scroll hitches on their Search screen

The @Equatable Macro (ordo-one/equatable)

@Equatable
struct MyView: View {
    let title: String
    let count: Int
    @EquatableIgnored var onTap: () -> Void // excluded from comparison

    var body: some View { ... }
}

Limitations

  • @ObservedObject and @EnvironmentObject can still force rebuilds even when == returns true
  • In Swift 6, nonisolated conformance issues arise with @MainActor views. Swift 6.2 introduced isolated conformances to resolve this

9. Metal GPU Acceleration & drawingGroup()

What drawingGroup() Does

Composites a view’s contents into an offscreen Metal texture before final display. Flattens the entire subtree into a single CALayer.

.drawingGroup(opaque: Bool = false, colorMode: ColorRenderingMode = .nonLinear)

Setting opaque: true when the view has no transparency can improve performance.

When to Use

  • Complex view hierarchies with many overlapping layers
  • Heavy use of CoreImage blend modes
  • Deep nesting of modified views
  • Applying Metal shader effects (distortion effects require it)
  • When profiling reveals rendering bottlenecks

When NOT to Use

  • Simple views (overhead of GPU upload exceeds savings)
  • Views containing UIKit-based elements (won’t render)
  • Views using .ultraThinMaterial or blur effects (breaks visual blending)
  • Layouts exceeding 16384x16384 pixels (Metal texture limit — causes empty screen)
  • Views that change frequently — the entire offscreen texture is redrawn on any change

compositingGroup() vs drawingGroup()

AspectcompositingGroup()drawingGroup()
BackendCore Animation (CALayer tree)Metal (GPU offscreen texture)
MechanismGroups layers under common parentFlattens into single rasterized texture
InteractivityViews remain interactiveViews flattened — may lose interactivity
OverheadLower — stays within CA pipelineHigher — offscreen render pass + GPU upload
Best forOpacity/shadow/blend blending fixesComplex rendering with many gradients/shapes

Default to compositingGroup() for visual blending fixes. Escalate to drawingGroup() only when profiling shows a bottleneck.

geometryGroup() (iOS 17+)

Isolates a view’s geometry from its parent, preventing animation coalescing issues. Use when parent/child geometry animations conflict.

Performance Impact

  • At Backdrop, Metal shaders process 4K video wallpapers in real-time with 0.3% CPU usage
  • iPhone 15 Pro Max GPU: 2.15 teraflops, enabling ~5000 floating-point operations per pixel per frame

Debugging

Toggle Debug > Colour Off-Screen Rendered in the iOS Simulator to see where offscreen rendering is occurring.


10. TimelineView + Canvas for High-FPS Rendering

TimelineView Schedules

  • .animation: Updates every frame at display refresh rate. Highest FPS.
  • .periodic(from:by:): Updates at user-defined intervals.
  • .everyMinute: Updates once per minute.

Canvas: Immediate Mode Drawing

Canvas bypasses SwiftUI’s retained-mode view system entirely:

TimelineView(.animation) { timeline in
    Canvas { context, size in
        let elapsed = timeline.date.timeIntervalSinceReferenceDate
        for particle in particles {
            context.fill(
                Path(ellipseIn: particle.rect(at: elapsed)),
                with: .color(particle.color)
            )
        }
    }
}

Performance Characteristics

  • 500 Circle() views in ForEach = 12 FPS. Canvas with 500 circles = smooth 60fps.
  • Canvas is Metal-backed for GPU-accelerated rendering
  • rendersAsynchronously parameter controls whether drawing happens off the main thread
  • Canvas uses significantly less memory and CPU than equivalent Shape-based views

Cadence Awareness

if context.cadence == .live {
    // Full detail rendering
} else {
    // Simplified rendering for lower cadence
}

Particle System Architecture

struct ParticleSystem {
    var particles: [Particle] = []

    mutating func update(at time: TimeInterval) {
        particles.removeAll { $0.isDead(at: time) }
        // Update positions, apply physics
    }
}

Cache resolved symbols (images, text) outside the render loop. Reuse GraphicsContext.ResolvedImage across frames.


11. Metal Shaders in SwiftUI (iOS 17+)

Three Shader Types

Color Effect — Modifies pixel colors independently:

[[stitchable]] half4 myColor(float2 position, half4 color, float time) {
    return half4(color.r * sin(time), color.g, color.b, color.a);
}

Distortion Effect — Remaps pixel positions (requires .drawingGroup()):

[[stitchable]] float2 myDistortion(float2 position, float time) {
    return float2(position.x + sin(position.y / 20 + time) * 5, position.y);
}

Layer Effect — Samples from rendered view layer:

#include <SwiftUI/SwiftUI_Metal.h>
[[stitchable]] half4 myLayer(float2 position, SwiftUI::Layer layer, float size) {
    float2 snapped = size * round(position / size);
    return layer.sample(snapped);
}

[[stitchable]] Attribute

Required for all SwiftUI-compatible shaders. Enables runtime composition and makes functions visible to the Metal Framework API.

Integration Pattern

struct ShaderView: ViewModifier {
    private let startDate = Date()
    func body(content: Content) -> some View {
        TimelineView(.animation) { _ in
            content.visualEffect { content, proxy in
                content.colorEffect(ShaderLibrary.myShader(
                    .float2(proxy.size),
                    .float(startDate.timeIntervalSinceNow)
                ))
            }
        }
    }
}

Optimization Techniques

  • Avoid branching: Use mix() and smoothstep() instead of if/else
  • Use half precision: half (16-bit) for color values; float (32-bit) only for positions
  • Pre-compute values: Pass constants as uniforms rather than recalculating per-pixel
  • Pre-compile shaders (iOS 18+): Call .compile() early to avoid first-use compilation stutter
  • Budget: ~5000 floating-point operations per pixel per frame at typical resolutions

12. Scroll Performance: List vs LazyVStack

Benchmark Results (iPhone 15 Pro, iOS 18.3.2)

MetricListLazyVStackVStack
Scroll-to-bottom (1000 items)5.53s52.3sFreeze
Hangs4.678N/A
Memory after scrolling128.9 MB149 MBMassive spike
View recyclingYes (UICollectionView)No (retains offscreen)No
FPS under loadConsistent 60fpsDegrades with fast scrollUnusable

Key Details

  • VStack in ScrollView: Loads entire hierarchy simultaneously. Unusable for >100 items.
  • LazyVStack: Defers creation until about-to-appear but retains views in memory after first creation. iOS 18+ improved bidirectional memory management.
  • List: Built on UICollectionView (native cell recycling). Smooth even under extreme conditions.

Recommendations

  • Use List for datasets > ~1000 items
  • Use LazyVStack only when you need custom scroll effects (iOS 17+ scroll APIs)
  • onAppear in ScrollView + VStack fires for ALL items immediately
  • In LazyVStack, nested horizontal ScrollView forces eager loading of all horizontal content

Infinite Scroll Pattern

LazyVStack {
    ForEach(items) { item in
        RowView(item: item)
            .onAppear {
                if item == items.suffix(5).first {
                    loadMoreItems()
                }
            }
    }
}

13. AnyView Cost & Structural Identity

Why AnyView is Costly

  • Type erasure hides view structure from the compiler
  • SwiftUI cannot efficiently compute diffing — must redraw the entire view
  • Benchmarks: ~10% slower for browsing, ~17% slower with data changes
  • FPS drops below 50 with frequent updates
  • Inside List/ForEach: forces all views to be created in advance

Alternatives

  1. @ViewBuilder: Constructs tuples of views with full type information
  2. Group: Wraps conditional content without type erasure
  3. Generics: Make container views generic over their content type
  4. some View: The compiler knows the exact type

Inert Modifiers Are Free

padding(0), opacity(1), offset(.zero) are recognized as inert — SwiftUI skips them entirely.


Core API

let displayLink = CADisplayLink(target: self, selector: #selector(step))
displayLink.preferredFrameRateRange = CAFrameRateRange(
    minimum: 80,
    maximum: Float(UIScreen.main.maximumFramesPerSecond),
    preferred: Float(UIScreen.main.maximumFramesPerSecond)
)
displayLink.add(to: .main, forMode: .common)

Key Timing Properties

  • timestamp — time of the last displayed frame
  • targetTimestamp — time of the next frame to display
  • duration — constant interval between frames

Mid-Frame Time Budget Pattern

@objc func step(displaylink: CADisplayLink) {
    for item in workItems {
        if CACurrentMediaTime() >= displayLink.targetTimestamp {
            break  // Stop if time budget exceeded
        }
        // process item
    }
}

Since SwiftUI has no native API to request elevated frame rates:

class FrameRateRequest {
    private let frameRateRange: CAFrameRateRange
    private let duration: Double

    init(preferredFrameRate: Float, duration: Double) {
        frameRateRange = CAFrameRateRange(
            minimum: 30,
            maximum: Float(UIScreen.main.maximumFramesPerSecond),
            preferred: preferredFrameRate
        )
        self.duration = duration
    }

    func perform() {
        let displayLink = CADisplayLink(target: self, selector: #selector(dummyFunction))
        displayLink.preferredFrameRateRange = frameRateRange
        displayLink.add(to: .current, forMode: .common)
        DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
            displayLink.remove(from: .current, forMode: .common)
        }
    }

    @objc private func dummyFunction() {}
}

// Usage: fire just before your SwiftUI animation
let request = FrameRateRequest(preferredFrameRate: 120, duration: 0.4)
request.perform()
withAnimation(.spring()) { /* state change */ }

UIScreen.maximumFramesPerSecond

let maxFPS = UIScreen.main.maximumFramesPerSecond  // 60 or 120
  • Returns 120 on ProMotion devices, 60 on standard devices
  • Available from iOS 10.3+

15. Metal + SwiftUI Integration

UIViewRepresentable MTKView Wrapper

import SwiftUI
import MetalKit

struct MetalView: UIViewRepresentable {
    func makeCoordinator() -> Renderer { Renderer() }

    func makeUIView(context: Context) -> MTKView {
        let view = MTKView()
        view.device = MTLCreateSystemDefaultDevice()
        view.delegate = context.coordinator
        view.preferredFramesPerSecond = UIScreen.main.maximumFramesPerSecond
        view.isPaused = false
        view.enableSetNeedsDisplay = false  // Timer-driven mode
        view.framebufferOnly = true          // Performance optimization
        return view
    }

    func updateUIView(_ uiView: MTKView, context: Context) {}

    class Renderer: NSObject, MTKViewDelegate {
        var commandQueue: MTLCommandQueue?

        override init() {
            super.init()
            guard let device = MTLCreateSystemDefaultDevice() else { return }
            commandQueue = device.makeCommandQueue()
        }

        func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {}

        func draw(in view: MTKView) {
            guard let drawable = view.currentDrawable,
                  let descriptor = view.currentRenderPassDescriptor,
                  let commandBuffer = commandQueue?.makeCommandBuffer(),
                  let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor)
            else { return }

            encoder.endEncoding()
            commandBuffer.present(drawable)
            commandBuffer.commit()
        }
    }
}

MTKView Drawing Modes

  • Timer-driven (isPaused = false, enableSetNeedsDisplay = false): redraws at preferredFramesPerSecond
  • Notification-driven (enableSetNeedsDisplay = true): redraws only on setNeedsDisplay() calls

Metal Best Practices

  • Reuse MTLCommandQueue (create once)
  • Set framebufferOnly = true when you don’t need to read back
  • Create pipeline states and buffers at initialization, not per-frame
  • Use triple buffering for CPU/GPU parallelism

16. SpriteKit + SwiftUI

struct GameView: View {
    var scene: SKScene = {
        let scene = MyGameScene()
        scene.scaleMode = .resizeFill
        return scene
    }()

    var body: some View {
        SpriteView(scene: scene, preferredFramesPerSecond: 120)
    }
}

SKView Performance Tips

  • Set ignoresSiblingOrder = true for GPU-friendly Z-sorting
  • Use texture atlases to minimize state changes
  • Enable showsFPS, showsDrawCount, showsNodeCount for profiling
  • On ProMotion: 8.3ms per frame budget at 120fps
  • didMove(to:) doesn’t fire reliably in SwiftUI — use didChangeSize(_:) instead

17. Core Animation Layer Optimizations

Shadow Optimization (50-80% improvement)

layer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: 12).cgPath

Never let Core Animation infer the shadow path.

Opacity

layer.isOpaque = true  // Eliminates alpha blending cost

Rasterization (use carefully)

layer.shouldRasterize = true
layer.rasterizationScale = UIScreen.main.scale

Only helps if the layer content is static. For animated content, it makes performance worse.

Async Drawing

layer.drawsAsynchronously = true  // Draw on background thread

GPU-Heavy Modifiers to Use Sparingly

.shadow, .blur, .opacity, .mask are GPU-intensive. In scrolling lists:

  • Combine similar effects into a single .overlay
  • Consider pre-rendering to images
  • Use .drawingGroup() if layering many effects

18. View Decomposition Patterns

Why It Helps

SwiftUI re-evaluates the entire body of a view when its state changes. By splitting into smaller view structs, each child gets its own independent comparison check.

Zero overhead: SwiftUI flattens its view hierarchy internally.

// BAD: monolithic view, everything recomputes together
struct FeedView: View {
    @State var items: [Item]
    @State var selectedTab: Tab

    var body: some View {
        VStack {
            TabBar(selected: selectedTab)  // Recomputes when items change!
            ForEach(items) { item in
                ItemView(item: item)       // ALL recompute when selectedTab changes!
            }
        }
    }
}

// GOOD: decomposed, each subview is a diffing boundary
struct FeedView: View {
    var body: some View {
        VStack {
            TabBarView()      // Own state, own diffing boundary
            ItemListView()    // Own state, own diffing boundary
        }
    }
}

Isolate Event Sources

// BAD: onReceive on parent invalidates entire tree
struct ParentView: View {
    var body: some View {
        VStack {
            ExpensiveContent()
        }
        .onReceive(timer) { /* update */ }
    }
}

// GOOD: isolate the event-driven part
struct ParentView: View {
    var body: some View {
        VStack {
            ExpensiveContent()
            TimerView()  // only this recomputes on timer
        }
    }
}

Three Extraction Methods

  1. Separate View structs (best for reuse and independent updates)
  2. @ViewBuilder functions (better than AnyView but not reusable across files)
  3. Xcode’s “Extract Subview” refactoring (Cmd-click, choose Extract Subview)

19. Conditional Views & Modifiers

The Danger of applyIf / .if Extensions

// DANGEROUS: creates _ConditionalContent -- two different view types
extension View {
    @ViewBuilder func applyIf<T: View>(_ condition: Bool, transform: (Self) -> T) -> some View {
        if condition { transform(self) } else { self }
    }
}

When the condition changes:

  • SwiftUI tears down the entire subgraph and rebuilds the other branch
  • All @State and @StateObject are lost
  • Unexpected transition animations fire
  • onAppear/onDisappear fire on both branches

The Solution: Inert Modifiers + Ternary Operators

// GOOD: same view identity, no teardown/rebuild
.padding(isCompact ? 8 : 16)
.background(isHighlighted ? Color.blue : Color.clear)
.opacity(isVisible ? 1 : 0)

When Conditional Modifiers Are Acceptable

Only when the condition is static (platform check, one-time configuration that never changes at runtime).


20. Opacity vs Hidden vs If

ApproachLayout SpaceState PreservedAnimationPerformance
.opacity(cond ? 0 : 1)PreservedYesExcellentGood
.hidden()PreservedYesNo bool argSimilar to opacity
if/elseRemovedLost on toggleNeeds transitionsExpensive
  • .opacity(0): SwiftUI auto-disables hit testing. opacity(1.0) is recognized as inert (no-op).
  • .hidden(): Does not accept a boolean, making conditional use awkward.
  • if/else: Creates _ConditionalContent — destroys structural identity and state.

Best Practice: Use .opacity() with ternary for toggling visibility when state preservation matters.


21. Overlay vs ZStack

AspectZStackoverlay/background
RelationshipSiblingsParent-child
SizingLargest childPrimary view dictates
Layout impactAll children sized independentlyOverlay constrained to primary
PerformanceCan cause unnecessary recalculationsCleaner view tree

Rule: Use overlay/background when content has a dependency relationship. Use ZStack when views are independent peers.


22. .task vs .onAppear

Feature.task.onAppear
Async supportNative async/awaitRequires manual Task {}
Auto-cancellationYes, on view disappearManual
Re-trigger on value.task(id: value)Not built-in
First-frame renderingMay show placeholderCan update before first frame
Min iOS1513

Critical Rendering Difference

.onAppear can execute before the first frame renders — state changes inside it are reflected in the initial render. .task always renders at least one frame before executing.

Rules

  • Use .task for network requests and anything needing auto-cancellation
  • Use .onAppear for synchronous state setup needed in the first frame
  • Use .task(id:) to auto-cancel and restart work when a dependency changes
  • Never put expensive work in view constructors

23. Image Caching

The Problem

AsyncImage does NOT cache images between screen loads.

In-Memory with NSCache

Thread-safe, auto-evicts under memory pressure. Good for most apps.

On-Disk with URLCache

Persists across app launches. Respects HTTP cache headers.

Key Optimization Patterns

  • Resize before caching: Remote images may be much larger than display size
  • Deduplicate active requests: Multiple views requesting the same URL should share a single publisher/task
  • Use @StateObject for image loaders: Prevents re-initialization on view redraws
  • Consider third-party libraries (Nuke, Kingfisher) over AsyncImage

24. Environment & Data Flow

EnvironmentObject vs Environment with @Observable

Aspect@EnvironmentObject (legacy)@Environment + @Observable
GranularityWhole-object observationProperty-level observation
Re-rendersAll subscribers on any changeOnly views using changed property
SafetyCrashes if not injectedRequires default value
Multiple instancesOne per typeMultiple via different keys

Best Practices

  • Migrate to @Observable + @Environment for iOS 17+ targets
  • Avoid storing frequently changing values in environment (geometry, timers)
  • Large environment objects invalidate entire subtrees
  • Split into multiple smaller environment objects to isolate update domains

25. @State vs @StateObject

Critical Difference

  • @State: For value types. SwiftUI owns storage, preserved across body re-evaluations.
  • @StateObject: For reference types (ObservableObject). Creates once, preserves for view’s lifetime.
// BAD: @State with a class -- recreated on every parent re-render
@State var viewModel = FeedViewModel() // class instance

// GOOD: @StateObject preserves the class
@StateObject var viewModel = FeedViewModel()

With @Observable (iOS 17+)

Use @State (not @StateObject) with @Observable types. The initializer runs every time, but SwiftUI stores and reuses the value.


26. @Bindable vs @Binding

  • @Binding: Two-way binding for value types. Works with both observation systems.
  • @Bindable: Creates bindings to @Observable class properties. Needed for $property syntax with controls.

Only 3 wrappers needed: @State, @Bindable, @Environment.

If passing @Binding through 5+ levels (prop drilling), switch to @Environment.


27. GeometryReader & Modern Alternatives

Problems with GeometryReader

  • Recalculates size on every re-render
  • “Greedy” layout behavior — expands to fill available space
  • Breaks declarative programming model
  • Significant resource consumption

Modern Alternatives

AlternativeiOSBest For
onGeometryChange16+Monitoring geometry without layout side effects
visualEffect17+Applying geometry-dependent visual effects
ViewThatFits16+Adaptive content based on available space
containerRelativeFrame17+Sizing relative to container dimensions
Custom Layout protocol16+Complex custom layouts
PreferenceKey13+Tracking offsets in ScrollView

If You Must Use GeometryReader

  • Attach as .background or .overlay — not as a direct container
  • Minimize dependent views
  • Avoid for layout when Layout protocol or containerRelativeFrame can serve

28. Custom Layout Protocol

Performance Advantages Over GeometryReader

  • No extra view hierarchy overhead
  • Precise control over every view position
  • Reusable layout logic
  • Automatic animation support

Caching (Critical Optimization)

sizeThatFits and placeSubviews are called multiple times per layout pass:

struct MyLayout: Layout {
    func makeCache(subviews: Subviews) -> MyCache {
        // Pre-compute column heights, spacing, etc.
    }

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout MyCache) -> CGSize {
        // Use cache instead of recomputing
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout MyCache) {
        // Use cache for placement
    }
}

Layout calculations may run on background threads. Sendable conformance is required for captured data.


29. visualEffect Modifier

Why It’s Performant

visualEffect applies changes after layout is complete. It reads geometry without a GeometryReader, meaning it does not affect layout or trigger recalculations.

What It Can Modify

Anything visual that does not affect layout: offset, scale, opacity, blur, rotation, color effects.

Background Threading

Closures may run on background threads. Captured values must be Sendable:

.visualEffect { [scale] content, proxy in
    content.scaleEffect(scale)
}

Best Practices

  • Combine multiple effects into a single visualEffect call
  • Use Instruments to measure actual impact
  • Respect accessibilityReduceMotion preferences

30. Animation Performance

withAnimation vs .animation()

  • .animation(): Scoped to the specific view branch. More efficient for localized effects.
  • withAnimation: Dispatched from root to all affected branches. Better for coordinating cross-view animations.

Prefer .animation() when the same effect can be achieved.

Critical Anti-Pattern

// BAD: triggers state update on EVERY scroll offset change
.onPreferenceChange(ScrollOffsetKey.self) { value in
    withAnimation { showHeader = value > threshold }
}

// GOOD: guard against redundant updates
.onPreferenceChange(ScrollOffsetKey.self) { value in
    let shouldShow = value > threshold
    guard shouldShow != showHeader else { return }
    withAnimation { showHeader = shouldShow }
}

General Rules

  • Animate transforms, not layout — opacity, scale, offset, rotation are cheap. Frame sizes and padding are expensive.
  • Disable animations for bulk data changes: withAnimation(.none) { items = newItems }
  • Never use .id(UUID()) during animation
  • Use tracksVelocity (iOS 17+) for smoother gesture-to-animation handoffs
  • Use withTransaction for fine-grained animation control per subtree
  • Place .animation modifiers as close to the animatable component as possible

31. Copy-on-Write & Value Types

Built-in CoW Types

Array, Dictionary, Set, String — assignment is O(1) until mutation.

Custom Structs Do NOT Get Automatic CoW

var copy = myLargeStruct  // full copy immediately

Manual CoW Implementation

final class Storage<T> {
    var value: T
    init(_ value: T) { self.value = value }
}

struct CoWWrapper<T> {
    private var storage: Storage<T>
    var value: T {
        get { storage.value }
        set {
            if !isKnownUniquelyReferenced(&storage) {
                storage = Storage(newValue)
            } else {
                storage.value = newValue
            }
        }
    }
}

Swift-CowBox Macro

Apply @CowBox to structs for automatic CoW. Large structs used as @State trigger copies on every state change — CoW mitigates this.


32. Concurrency & Threading

SwiftUI’s Threading Model

  • All SwiftUI views are implicitly @MainActor isolated
  • Task {} inside a @MainActor context runs on the main thread
  • Background work requires explicit Task.detached

What SwiftUI Runs on Background Threads

  • Shape protocol path calculations during animations
  • visualEffect closure processing
  • Layout protocol calculations (sizeThatFits, placeSubviews)
  • onGeometryChange closures

Correct Pattern

func loadData() async {
    let result = await Task.detached { fetchExpensiveData() }.value
    await MainActor.run { self.items = result }
}

Context Switching Overhead

Excessive switching between actors (e.g., per-item in a loop) is expensive. Batch work on one executor, then switch once for the result.


33. Preference Keys

Performance Characteristics

  • Preference keys are only resolved when actively observed (via onPreferenceChange or overlayPreferenceValue)
  • If no one is reading a preference key, SwiftUI ignores it completely — zero overhead
  • The reduce method uses lazy evaluation via closures
  • Dynamic siblings (AnyView, ForEach, views with .id()) trigger extra reduce calls

Best Practices

  • Preference keys are efficient by design — don’t avoid them for performance reasons
  • Keep reduce implementations cheap (simple merge operations)

34. Scroll Target Behavior

Built-in Behaviors

  • .paging: Snaps based on container size
  • .viewAligned: Snaps to individual child views (requires .scrollTargetLayout())

Performance Optimization

// BAD: heavy calculations inside updateTarget
struct MyBehavior: ScrollTargetBehavior {
    func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
        let step = computeExpensiveStep()  // called frequently during scrolling
    }
}

// GOOD: pre-calculate at init time
struct MyBehavior: ScrollTargetBehavior {
    let step: CGFloat  // pre-calculated
    func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
        // fast arithmetic only
    }
}

35. Lazy Navigation

Prevent preloading of destination views:

struct LazyView<Content: View>: View {
    let build: () -> Content
    init(_ build: @autoclosure @escaping () -> Content) { self.build = build }
    var body: some View { build() }
}

NavigationLink { LazyView(HeavyDetailView(item: item)) } label: { Text("Open") }

36. Apple Vision Pro Rendering

Display Refresh Rates

  • Vision Pro (M2): 90Hz, 96Hz, 100Hz (adaptive)
  • Vision Pro (M5, Oct 2025): Up to 120Hz

Render Pipeline Architecture

App (main thread) --> Render Server (Core Animation + RealityKit) --> Compositor --> Display
  • The compositor always runs at the display refresh rate
  • Missing the render server deadline delays content by one frame
  • Severe stalls can cause app termination

Metal via Compositor Services (Low-Latency Path)

App (Metal) --> Compositor Services (LayerRenderer) --> Compositor --> Display

Skips the render server entirely for lower latency.

Foveated Rendering

Vision Pro tracks eye position and renders at full resolution only where the user looks, significantly reducing GPU load.


37. Profiling & Debugging

Self._printChanges() (Debug Only)

var body: some View {
    let _ = Self._printChanges()
    // ... view code
}

Output markers:

  • @self — view value itself changed
  • @identity — view identity changed
  • Property names — specific properties that changed

Self._logChanges() (Xcode 15.1+)

Logs to com.apple.SwiftUI subsystem at info level.

Random Background Color Trick

.background(Color(hue: Double.random(in: 0...1), saturation: 1, brightness: 1))

Makes redraws visually obvious.

Instruments 26 (WWDC 2025)

New SwiftUI instrument with four tracking lanes:

  • Update Groups: When SwiftUI is actively working
  • Long View Body Updates: Bodies taking excessive time (color-coded orange/red)
  • Long Representable Updates: Slow UIKit/AppKit bridges
  • Other Long Updates: Additional issues

Cause & Effect Graph: Visualizes the chain from user interaction to state change to view body update.

Other Instruments

  • Animation Hitches instrument
  • VSync trace analysis
  • Core Animation Commits
  • Time Profiler
  • Memory Allocations
  • RealityKit Trace (visionOS)

Critical Rules

  • Never profile on Simulator — always use a real device
  • Make one change at a time when optimizing
  • Target: keep view body evaluation under 8ms for 120fps
  • Key scenarios: cold launch, scrolling long lists, navigation push/pop, tab switching, background-foreground transitions, orientation changes

38. iOS 26 / WWDC 2025 Improvements

Performance

  • Lists of 100K+ items load 6x faster, update 16x faster
  • Improved scheduling of UI updates reduces frame drops at high frame rates
  • Ice Cubes (Mastodon app) saw substantial drop in scroll hitch rate

New APIs

  • @IncrementalState: Tracks fine-grained changes so only affected parts update. Used with .incrementalID(item.id) — “buttery smooth, even with 1000+ items”
  • SwiftUI Instrument in Instruments 26: Cause & Effect Graph for visualizing update cascades
  • Implicit RealityKit animation: SwiftUI animation APIs can now animate RealityKit component changes

New Profiling

Dedicated SwiftUI instrument makes it significantly easier to identify root causes of performance issues.


39. The 120fps Checklist

  1. Add CADisableMinimumFrameDurationOnPhone = true to Info.plist
  2. Use @Observable instead of ObservableObject for granular invalidation
  3. Decompose views into small subviews (each is a diffing boundary)
  4. Conform complex views to Equatable with .equatable() modifier
  5. Pass minimum data to subviews (individual properties, not whole objects)
  6. Use List for infinite scrolling (not LazyVStack)
  7. Keep body free of computation (no formatters, no data processing)
  8. Use Canvas + TimelineView(.animation) for custom high-fps drawing
  9. Apply .drawingGroup() only where profiling shows rendering bottlenecks
  10. Profile with Instruments 26’s SwiftUI instrument before and after changes
  11. Cache expensive computations in static properties
  12. Avoid AnyView — use @ViewBuilder and generics
  13. Use .opacity() instead of if/else for toggling visibility
  14. Replace GeometryReader with visualEffect, onGeometryChange, containerRelativeFrame
  15. Animate transforms (offset, scale, rotation) not layout (frame, padding)
  16. Debounce rapidly changing state (search text, scroll offsets)
  17. Batch list updates: withAnimation(nil) { items.append(contentsOf: newItems) }
  18. Remove unused @Environment / @EnvironmentObject declarations
  19. Use stable identifiers for ForEach (never .id(UUID()))
  20. Explicitly set shadow paths on CALayers

40. The 240fps Approach

For a hypothetical 240Hz target (4.17ms frame budget):

  1. Metal shaders for all visual effects (zero CPU rendering)
  2. Canvas-only rendering (bypass SwiftUI’s view tree entirely)
  3. Pre-computed layouts (zero dynamic measurement)
  4. Background thread preparation with MainActor dispatch of only final state
  5. Aggressive caching of all resolved graphics resources
  6. Minimal view tree depth (fewer diffing nodes)
  7. A dummy CADisplayLink with preferredFrameRateRange set to 240Hz
  8. Triple-buffered Metal rendering via UIViewRepresentable + MTKView
  9. Foveated rendering where applicable (visionOS)
  10. @IncrementalState (iOS 26) for sub-item-level update granularity
  11. All state mutations batched into single RunLoop cycles
  12. Pre-compile all Metal shaders at app launch
  13. Use half precision (16-bit) in all shaders where possible
  14. Eliminate all GeometryReader usage
  15. Zero allocations in the render path

41. Sources

Apple Official

Community Deep Dives

Libraries