| name | programming-swift-ui |
| user-invocable | false |
| description | Internal skill invoked by /programming chain. Use when writing, reviewing, or debugging SwiftUI views, state management, navigation, animations, or performance. Use when encountering view update issues, body re-evaluation, @State/@Binding/@Observable behavior, slider/continuous control performance, or layout problems. Triggers on SwiftUI, @State, @Binding, @Observable, @Environment, @Bindable, View, body, NavigationStack, NavigationSplitView, Slider, List, ForEach, LazyVStack, LazyHStack, animation, transition, matchedGeometryEffect, TimelineView, Canvas, onAppear, onDisappear, .task, onChange, GeometryReader, EquatableView, _printChanges, view update, re-render, sheet, fullScreenCover, navigationDestination, @AppStorage, @EnvironmentObject. |
SwiftUI Expert Patterns
Comprehensive reference for SwiftUI view lifecycle, state management, rendering pipeline, performance optimization, and continuous control patterns. For macOS AppKit/SwiftUI hybrid patterns, see programming-swift § 14. For concurrency, see programming-concurrency.
1. View Lifecycle & Identity
Structural vs Explicit Identity
Views are value-type structs — recipes, not pixels. SwiftUI maintains a separate render tree (AttributeGraph). body is called when dependencies change, not every frame.
Structural identity: position in the view hierarchy. if/else produces _ConditionalContent<A, B> — switching branches destroys state.
if isLoggedIn { HomeView() }
else { LoginView() }
DetailView().opacity(showDetails ? 1 : 0)
Explicit identity: .id() modifier. Changing the id destroys all @State in the subtree — nuclear reset. Use intentionally (force ScrollView position reset), never accidentally.
AnyView
AnyView defeats structural identity, forcing slower reflection-based comparison. Use @ViewBuilder instead.
func makeView() -> AnyView { if cond { return AnyView(A()) }; return AnyView(B()) }
@ViewBuilder func makeView() -> some View { if cond { A() } else { B() } }
View Creation vs Render Node
View structs are created cheaply and often. The render node (onscreen representation) is created on first appearance, destroyed on removal. onAppear/onDisappear track render node lifetime, not struct creation.
2. State Management
@State
Stored in SwiftUI's internal graph, NOT the struct. Initial value only used on first creation. Parent passing a new value via initializer is ignored after first creation. Survives parent redraws but dies when .id() changes.
@Observable vs ObservableObject
| Aspect | @Observable (iOS 17+) | ObservableObject |
|---|
| Tracking | Per-property (pull-based) | Whole-object (push-based) |
| Invalidation | Only views reading changed property | ALL views observing the object |
| Property declaration | Plain var | @Published var |
| View wrappers | @State (owned), plain property (inject) | @StateObject (owned), @ObservedObject |
| Environment | .environment(model) + @Environment | .environmentObject + @EnvironmentObject |
| Performance at scale | Dramatically better | Degrades with model size |
Never mix @StateObject/@ObservedObject with @Observable — different tracking mechanisms.
@Bindable
Creates bindings to @Observable properties: @Bindable var model: MyModel then use $model.property.
@Environment
@Environment(\.keyPath): value types, system keys. Custom keys need EnvironmentKey + EnvironmentValues extension. Has defaultValue, never crashes.
@EnvironmentObject: reference types conforming to ObservableObject. Crashes at runtime if not injected — no compile-time check.
- iOS 17+:
@Environment(MyType.self) works with @Observable directly.
@AppStorage
Backed by UserDefaults. Only String, Int, Bool, Double, Data, URL. KVO notifications can arrive on any thread — background writes can crash. Never store secrets.
3. Rendering Pipeline
Update Cycle (State Change → Pixels)
- Event arrives (touch, timer, callback)
- State mutation (binding, @State, @Observable property)
- Invalidation — AttributeGraph marks dependent nodes dirty
- Coalescing — CFRunLoop
beforeWaiting observer batches invalidations
- Body re-evaluation — dirty views have
body computed
- Side-effect handlers —
onChange, onPreferenceChange, onAppear
- CATransaction commit — layer tree sent to render server
- GPU compositing — window server composites on next vsync
Key insight: Multiple mutations in the same run loop iteration coalesce into one body evaluation. But DispatchQueue.main.async dispatches to the next iteration — two separate .async calls = two body evaluations.
AttributeGraph (AG)
Private C++ framework — SwiftUI is a thin Swift veneer. AG tracks dependencies via property wrapper getter interception. Only properties actually read during body are registered as dependencies. Reading a property you don't use creates a spurious dependency.
Diffing Algorithm
Reflection-based comparison of view struct stored properties:
Equatable conformance → use ==
- Otherwise → recursive property comparison via reflection
- Reference types → pointer identity (
===)
- Closures → identity comparison (almost always fails)
Closures in view initializers defeat diffing: every parent evaluation creates a new closure → child always re-evaluates. Pass method references for stable identity.
CellView(id: i) { store.sendID(i) }
CellView(id: i, action: store.sendID)
Self._printChanges()
Debug-only. Prints which dynamic property triggered re-evaluation:
@self changed — view struct properties changed (look at parent)
@identity changed — view destroyed/recreated
_count changed — @State var count changed
Instruments (SwiftUI template, Xcode 26)
Four lanes: Update Groups, Long View Body Updates (orange/red = hitch), Long Representable Updates, Other Long Updates. Cause & Effect Graph shows state change → body evaluation chain.
4. Performance
What Triggers Re-Renders
@State/@Binding value changes (value equality)
ObservableObject.objectWillChange fires (ALL observers)
@Observable tracked property changes (ONLY views reading it)
@Environment value changes (views reading that key)
- Parent re-evaluation (child may or may not be called)
View Decomposition (Primary Optimization)
Break large bodies into subviews — creates diffing boundaries. If subview inputs unchanged, body skipped entirely. Extract TitleView(title:), ChartView(data:) instead of inlining all in one body.
EquatableView
Override SwiftUI's default dependency checking. If == returns true, body skipped entirely.
struct ExpensiveView: View, Equatable {
let data: LargeDataSet
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.data.id == rhs.data.id && lhs.data.version == rhs.data.version
}
var body: some View { }
}
Airbnb: 15% scroll hitch reduction on Search screen with EquatableView.
Lazy Containers
| Feature | List | LazyVStack | VStack |
|---|
| Recycling | Yes (UITableView-style) | No (retains in memory) | No |
| Lazy creation | Yes | Yes | No |
| Swipe actions | Yes | No | No |
| 100K items | Stable memory | Memory grows with scroll | All at once |
List: best for 1000+ items. LazyVStack: hundreds of items or when state preservation matters. VStack: <50 items.
.id() on subviews inside lazy containers forces ALL children to instantiate immediately, defeating laziness.
Environment Blast Radius
Changing an environment value re-evaluates ALL views in the subtree reading that value. Inject as low in hierarchy as possible.
5. Slider & Continuous Control Performance
The Problem
SwiftUI Slider fires at input event rate (60-120+ Hz). Each write triggers: binding mutation → invalidation → body evaluation → layout → diffing → CATransaction. If body takes 5ms, 120Hz drag is impossible.
macOS-specific: During drag, NSEventTrackingRunLoopMode blocks DispatchQueue.main.async, default-mode timers, and Task { @MainActor in }. Only .common mode sources fire.
Decision Tree
Expensive work from slider?
├─ No → Standard Slider is fine
└─ Yes
├─ Work can run off main thread?
│ └─ AsyncStream(.bufferingNewest(1)), process on Task.detached
├─ Live preview needed during drag?
│ ├─ No → onEditingChanged, process only on release
│ └─ Yes → Low-res preview during drag, full quality on release
└─ SwiftUI Slider itself the bottleneck?
├─ @Observable with isolated leaf views (biggest win)
├─ Canvas-drawn custom slider
└─ NSViewRepresentable + NSSlider (see swift-appkit-hybrid)
Two-Stage Binding
struct FilterView: View {
@State private var localIntensity: Double = 0.5
@Bindable var model: FilterModel
var body: some View {
Slider(value: $localIntensity, in: 0...1) { editing in
if !editing { model.applyFilter(intensity: localIntensity) }
}
.onChange(of: model.filterIntensity) { _, new in localIntensity = new }
}
}
AsyncStream Coalescing
let (stream, continuation) = AsyncStream.makeStream(
of: Double.self, bufferingPolicy: .bufferingNewest(1)
)
Task.detached {
for await value in stream {
let result = await Self.expensiveComputation(value)
await MainActor.run { self.processedResult = result }
}
}
func sliderChanged(_ v: Double) {
displayValue = v
continuation?.yield(v)
}
Disable Animations During Drag
Slider(value: Binding(
get: { model.value },
set: { new in
var t = Transaction(); t.disablesAnimations = true
withTransaction(t) { model.value = new }
}
), in: 0...1)
@Observable Isolation
Extract slider-dependent rendering into a leaf view that only reads the slider property. N views observing ObservableObject = N body evaluations per tick. N views on @Observable but only 1 reading slider = 1 evaluation. 50x reduction for complex UIs.
6. Layout
Propose/Respond Algorithm
- Parent proposes size (width/height, may be nil = ideal)
- Child responds with actual size (child chooses)
- Parent positions child
Special proposals: (0,0) = minimum, (.infinity,.infinity) = maximum, (nil,nil) = ideal.
.frame(width:height:): proposes exactly that size, reports exactly that size. Child may be smaller (centered by default).
.frame(maxWidth: .infinity): the frame expands, child is centered/aligned within it.
GeometryReader Alternatives (iOS 16+)
onGeometryChange: read size without layout disruption
ViewThatFits: adaptive layouts
containerRelativeFrame (iOS 17+): proportional sizing
visualEffect (iOS 17+): geometry-dependent visual changes
GeometryReader in ScrollView creates circular layout dependencies. Use onGeometryChange in .background instead.
7. Navigation
NavigationStack + NavigationPath
NavigationStack(path: $path) {
RootView()
.navigationDestination(for: Route.self) { route in route.view }
}
navigationDestination(for:) must be inside NavigationStack, not on it. Multiple destinations for same type: only innermost wins.
Sheet/fullScreenCover
- Creates new view hierarchy with own NavigationStack scope
- Presenting immediately after dismissing (same frame) can fail — use
onChange to detect dismissal
@EnvironmentObject passes through; NavigationPath does not
8. Animation
withAnimation vs .animation(value:)
withAnimation { state = new }: explicit — wraps state change in Transaction with animation. All affected views animate.
.animation(.spring, value: x): implicit — monitors value, animates on change. Per-view.
Never use value-less .animation(.spring) (deprecated iOS 15) — animates everything in subtree.
Transaction System
Every state change carries a Transaction (animation + flags). Ephemeral — resets after each update cycle. withTransaction overrides animation for specific state changes.
matchedGeometryEffect
Requires shared @Namespace. Both views must exist simultaneously during transition. Use isSource: true/false when one is removed and another added.
9. .task Lifecycle
.task: runs on appear, cancelled on disappear
.task(id:): also cancels and restarts when id changes — preferred over .onChange + Task {} (auto-cancellation)
- Runs on MainActor by default. Use
Task.detached inside for background work
- Cooperative cancellation — check
Task.isCancelled before late @State writes
10. Key Antipatterns
| Antipattern | Fix |
|---|
Side effects in body | Use .task, .onAppear, .onChange |
@State for reference types | Use @Observable |
| Massive view (100+ lines body) | Extract subviews (own invalidation scope) |
| Mixing ObservableObject + @Observable | Pick one. @Observable for iOS 17+ |
| Force unwrap in views | if let, guard let, ?? |
Network calls in body/onAppear | Use .task (auto-cancels) |
| GeometryReader as layout container | Use in .background; prefer onGeometryChange |
| Closures in view init | Pass method references for stable diffing identity |
.onChange + Task {} for async | .task(id:) — auto-cancels previous |
AnyView everywhere | @ViewBuilder, Group |