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
- Hardware & Frame Budgets
- Enabling High Refresh Rates
- The SwiftUI Render Loop
- AttributeGraph: The Engine Under SwiftUI
- View Identity & the Diffing Algorithm
- Reducing Body Recomputation
- @Observable vs @ObservedObject
- EquatableView & Custom Diffing
- Metal GPU Acceleration & drawingGroup()
- TimelineView + Canvas for High-FPS Rendering
- Metal Shaders in SwiftUI
- Scroll Performance: List vs LazyVStack
- AnyView Cost & Structural Identity
- CADisplayLink & Frame Rate Control
- Metal + SwiftUI Integration
- SpriteKit + SwiftUI
- Core Animation Layer Optimizations
- View Decomposition Patterns
- Conditional Views & Modifiers
- Opacity vs Hidden vs If
- Overlay vs ZStack
- .task vs .onAppear
- Image Caching
- Environment & Data Flow
- @State vs @StateObject
- @Bindable vs @Binding
- GeometryReader & Modern Alternatives
- Custom Layout Protocol
- visualEffect Modifier
- Animation Performance
- Copy-on-Write & Value Types
- Concurrency & Threading
- Preference Keys
- Scroll Target Behavior
- Lazy Navigation
- Apple Vision Pro Rendering
- Profiling & Debugging
- iOS 26 / WWDC 2025 Improvements
- The 120fps Checklist
- The 240fps Approach
- Sources
1. Hardware & Frame Budgets
| Device | Max Refresh Rate | Frame Budget |
|---|---|---|
| Standard iPhone/iPad | 60 Hz | 16.67 ms |
| iPhone 13 Pro+ (ProMotion) | 120 Hz | 8.33 ms |
| iPad Pro (ProMotion) | 120 Hz | 8.33 ms |
| Apple Vision Pro (M2) | 90/96/100 Hz | 10-11.1 ms |
| Apple Vision Pro (M5) | 120 Hz | 8.33 ms |
| Mac Studio 4K HDMI | 240 Hz | 4.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
CADisplayLinkcallbacks andCAAnimationanimations - 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.animateanimations - System scrolling
- SpriteKit, CAAnimation (with Info.plist key on iPhone)
What Requires Manual Configuration
- Custom
CADisplayLink-driven drawing - Metal
MTKViewrendering - Explicit
CAAnimationobjects targeting >60Hz - Custom
TimelineView+Canvasanimations
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:
- Event Reception — RunLoop receives touches, timers, display refresh signals
- State Updates — User actions trigger
@State,@ObservedObject, publisher changes - View Invalidation — Changed state marks views as needing re-evaluation
- Body Re-evaluation — Scheduled at the RunLoop’s
beforeWaitingstage (NOT immediately) - Handler Execution —
onChange,onPreferenceChange,onAppearcallbacks fire - Layer Updates — Built-in views make
CALayerchanges, starting an implicitCATransaction - GPU Composition — Transaction commits, executing CPU rendering + GPU composition
- 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/onAppearhandlers 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
CATransactioncommit. - 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:
| Mode | When Active |
|---|---|
.default | Normal app state, no active scrolling |
.tracking | Active scroll gesture / drag |
.common | Pseudo-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.
@Stateproperties 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 updatedAG::Subgraph::print(int)andAG::Graph::print_attribute()— inspect specific subgraphsAGGraphArchiveJSON— 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
ConditionalContentbehind the scenes forif/elsebranches. - Explicit identity: Assigned via
Identifiableprotocol,.id()modifier, orForEachloops. 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:
- Equatable types: Compared using their
==implementation - Value types (structs): Recursively compared property-by-property
- Reference types (classes): Compared by reference identity (pointer equality)
- 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
_ConditionalViewswitches between true/false branchesOptional<View>toggles visibilityForEachdata updates (child types remain constant)AnyViewcontent 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
idvalues cause SwiftUI to treat the view as new (destroy + rebuild) .id()modifier forces full view recreation — use only for intentional state resets- Prefer
Identifiableconformance over.selfforForEach - 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:
- DynamicProperty wrappers:
@State,@Binding,@Environment,@ObservedObject,@StateObject - Construction parameters: Changes detected via reflection-based diffing
- 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
- Decompose into small subviews — Each subview creates a diffing boundary
- Pass only needed data — Minimize construction parameters
- Keep body lightweight — No network calls, no expensive computation. Use
.task {}oronAppear - Computed properties are NOT diffing boundaries — SwiftUI inlines them at runtime. Extract into separate
Viewstructs - 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)) } - Use
@Statefor local values only — Not for heavy objects - 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
objectWillChangepublisher - Any
@Publishedproperty 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
ObservationRegistrarwith 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
Equatablefirst
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)
| Approach | FPS (1600 views, single element toggle) |
|---|---|
| ConditionalView | 10 |
| AnyView | 10 |
| EquatableView | 18 (nearly 2x improvement) |
Airbnb’s Approach
Airbnb created a custom @Equatable macro that:
- Compares all stored properties except
@Stateand@Environment - Uses
@SkipEquatableto 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
@ObservedObjectand@EnvironmentObjectcan still force rebuilds even when==returnstrue- In Swift 6,
nonisolatedconformance issues arise with@MainActorviews. 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
.ultraThinMaterialor 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()
| Aspect | compositingGroup() | drawingGroup() |
|---|---|---|
| Backend | Core Animation (CALayer tree) | Metal (GPU offscreen texture) |
| Mechanism | Groups layers under common parent | Flattens into single rasterized texture |
| Interactivity | Views remain interactive | Views flattened — may lose interactivity |
| Overhead | Lower — stays within CA pipeline | Higher — offscreen render pass + GPU upload |
| Best for | Opacity/shadow/blend blending fixes | Complex 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
rendersAsynchronouslyparameter 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()andsmoothstep()instead ofif/else - Use
halfprecision: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)
| Metric | List | LazyVStack | VStack |
|---|---|---|---|
| Scroll-to-bottom (1000 items) | 5.53s | 52.3s | Freeze |
| Hangs | 4.6 | 78 | N/A |
| Memory after scrolling | 128.9 MB | 149 MB | Massive spike |
| View recycling | Yes (UICollectionView) | No (retains offscreen) | No |
| FPS under load | Consistent 60fps | Degrades with fast scroll | Unusable |
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
Listfor datasets > ~1000 items - Use
LazyVStackonly when you need custom scroll effects (iOS 17+ scroll APIs) onAppearinScrollView+VStackfires for ALL items immediately- In
LazyVStack, nested horizontalScrollViewforces 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
@ViewBuilder: Constructs tuples of views with full type informationGroup: Wraps conditional content without type erasure- Generics: Make container views generic over their content type
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.
14. CADisplayLink & Frame Rate Control
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 frametargetTimestamp— time of the next frame to displayduration— 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
}
}
The “Dummy DisplayLink” Trick for SwiftUI
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 atpreferredFramesPerSecond - Notification-driven (
enableSetNeedsDisplay = true): redraws only onsetNeedsDisplay()calls
Metal Best Practices
- Reuse
MTLCommandQueue(create once) - Set
framebufferOnly = truewhen 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 = truefor GPU-friendly Z-sorting - Use texture atlases to minimize state changes
- Enable
showsFPS,showsDrawCount,showsNodeCountfor profiling - On ProMotion: 8.3ms per frame budget at 120fps
didMove(to:)doesn’t fire reliably in SwiftUI — usedidChangeSize(_:)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
- Separate
Viewstructs (best for reuse and independent updates) @ViewBuilderfunctions (better thanAnyViewbut not reusable across files)- 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
@Stateand@StateObjectare lost - Unexpected transition animations fire
onAppear/onDisappearfire 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
| Approach | Layout Space | State Preserved | Animation | Performance |
|---|---|---|---|---|
.opacity(cond ? 0 : 1) | Preserved | Yes | Excellent | Good |
.hidden() | Preserved | Yes | No bool arg | Similar to opacity |
if/else | Removed | Lost on toggle | Needs transitions | Expensive |
.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
| Aspect | ZStack | overlay/background |
|---|---|---|
| Relationship | Siblings | Parent-child |
| Sizing | Largest child | Primary view dictates |
| Layout impact | All children sized independently | Overlay constrained to primary |
| Performance | Can cause unnecessary recalculations | Cleaner 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 support | Native async/await | Requires manual Task {} |
| Auto-cancellation | Yes, on view disappear | Manual |
| Re-trigger on value | .task(id: value) | Not built-in |
| First-frame rendering | May show placeholder | Can update before first frame |
| Min iOS | 15 | 13 |
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
.taskfor network requests and anything needing auto-cancellation - Use
.onAppearfor 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
@StateObjectfor 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 |
|---|---|---|
| Granularity | Whole-object observation | Property-level observation |
| Re-renders | All subscribers on any change | Only views using changed property |
| Safety | Crashes if not injected | Requires default value |
| Multiple instances | One per type | Multiple via different keys |
Best Practices
- Migrate to
@Observable+@Environmentfor 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@Observableclass properties. Needed for$propertysyntax with controls.
Apple’s Recommended Set (iOS 17+)
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
| Alternative | iOS | Best For |
|---|---|---|
onGeometryChange | 16+ | Monitoring geometry without layout side effects |
visualEffect | 17+ | Applying geometry-dependent visual effects |
ViewThatFits | 16+ | Adaptive content based on available space |
containerRelativeFrame | 17+ | Sizing relative to container dimensions |
Custom Layout protocol | 16+ | Complex custom layouts |
PreferenceKey | 13+ | Tracking offsets in ScrollView |
If You Must Use GeometryReader
- Attach as
.backgroundor.overlay— not as a direct container - Minimize dependent views
- Avoid for layout when
Layoutprotocol orcontainerRelativeFramecan 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
visualEffectcall - Use Instruments to measure actual impact
- Respect
accessibilityReduceMotionpreferences
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
withTransactionfor fine-grained animation control per subtree - Place
.animationmodifiers 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
@MainActorisolated Task {}inside a@MainActorcontext runs on the main thread- Background work requires explicit
Task.detached
What SwiftUI Runs on Background Threads
Shapeprotocolpathcalculations during animationsvisualEffectclosure processingLayoutprotocol calculations (sizeThatFits,placeSubviews)onGeometryChangeclosures
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
onPreferenceChangeoroverlayPreferenceValue) - If no one is reading a preference key, SwiftUI ignores it completely — zero overhead
- The
reducemethod uses lazy evaluation via closures - Dynamic siblings (
AnyView,ForEach, views with.id()) trigger extrareducecalls
Best Practices
- Preference keys are efficient by design — don’t avoid them for performance reasons
- Keep
reduceimplementations 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
- Add
CADisableMinimumFrameDurationOnPhone = trueto Info.plist - Use
@Observableinstead ofObservableObjectfor granular invalidation - Decompose views into small subviews (each is a diffing boundary)
- Conform complex views to
Equatablewith.equatable()modifier - Pass minimum data to subviews (individual properties, not whole objects)
- Use
Listfor infinite scrolling (notLazyVStack) - Keep
bodyfree of computation (no formatters, no data processing) - Use
Canvas+TimelineView(.animation)for custom high-fps drawing - Apply
.drawingGroup()only where profiling shows rendering bottlenecks - Profile with Instruments 26’s SwiftUI instrument before and after changes
- Cache expensive computations in static properties
- Avoid
AnyView— use@ViewBuilderand generics - Use
.opacity()instead ofif/elsefor toggling visibility - Replace
GeometryReaderwithvisualEffect,onGeometryChange,containerRelativeFrame - Animate transforms (offset, scale, rotation) not layout (frame, padding)
- Debounce rapidly changing state (search text, scroll offsets)
- Batch list updates:
withAnimation(nil) { items.append(contentsOf: newItems) } - Remove unused
@Environment/@EnvironmentObjectdeclarations - Use stable identifiers for
ForEach(never.id(UUID())) - Explicitly set shadow paths on CALayers
40. The 240fps Approach
For a hypothetical 240Hz target (4.17ms frame budget):
- Metal shaders for all visual effects (zero CPU rendering)
- Canvas-only rendering (bypass SwiftUI’s view tree entirely)
- Pre-computed layouts (zero dynamic measurement)
- Background thread preparation with
MainActordispatch of only final state - Aggressive caching of all resolved graphics resources
- Minimal view tree depth (fewer diffing nodes)
- A dummy
CADisplayLinkwithpreferredFrameRateRangeset to 240Hz - Triple-buffered Metal rendering via
UIViewRepresentable+MTKView - Foveated rendering where applicable (visionOS)
@IncrementalState(iOS 26) for sub-item-level update granularity- All state mutations batched into single RunLoop cycles
- Pre-compile all Metal shaders at app launch
- Use
halfprecision (16-bit) in all shaders where possible - Eliminate all
GeometryReaderusage - Zero allocations in the render path
41. Sources
Apple Official
- Demystify SwiftUI — WWDC21
- Demystify SwiftUI Performance — WWDC23
- Optimize SwiftUI Performance with Instruments — WWDC25
- What’s New in SwiftUI — WWDC25
- Understanding and Improving SwiftUI Performance — Apple Docs
- Optimizing ProMotion Refresh Rates
- Optimize for Variable Refresh Rate Displays — WWDC21
- CADisplayLink Documentation
- Understanding the visionOS Render Pipeline
- Improving RealityKit Performance
- Drawing Fully Immersive Content Using Metal
- Controlling Animation Timing
- Creating Performant Scrollable Stacks
Community Deep Dives
- SwiftUI Scroll Performance: The 120FPS Challenge — Jacob Bartlett
- Metal in SwiftUI: How to Write Shaders — Jacob Bartlett
- The SwiftUI Render Loop — Rens Breur
- SwiftUI’s Diffing Algorithm — Rens Breur
- Untangling the AttributeGraph — Rens Breur
- Understanding and Improving SwiftUI Performance — Airbnb Engineering
- How to Avoid Repeating SwiftUI View Updates — Fatbobman
- List or LazyVStack — Fatbobman
- Tips for Lazy Containers — Fatbobman
- GeometryReader: Blessing or Curse — Fatbobman
- Mastering Transactions — Fatbobman
- Mastering containerRelativeFrame — Fatbobman
- Deep Dive into Observation — Fatbobman
- The Mystery Behind View Equality — SwiftUI Lab
- SwiftUI Layout Protocol — SwiftUI Lab
- Performance Battle: AnyView vs Group — Alexey Naumov
- @Observable Macro Performance — SwiftLee
- Debugging SwiftUI Views — SwiftLee
- Optimizing Views with EquatableView — Swift with Majid
- Structural Identity — Swift with Majid
- Making Friends with AttributeGraph — Saagar Jha
- Conditional View Modifiers Are Evil — Phil Z
- Why Conditional View Modifiers Are a Bad Idea — objc.io
- SwiftUI Performance Deep Dive — DEV Community