All skills
avdlee avatar

/swiftui-expert-skill

@1e522cf

Use when writing, reviewing, or refactoring SwiftUI code for iOS or macOS, including state and `@Observable` data flow, view composition, resizable layouts, safe areas, display scale, performance, lists, environment, localization, animation, Liquid Glass, and API migration. Also use for iPhone Duo, foldable, or large-display layouts (`NavigationSplitView` on large displays, two-column reflow, foldable grids, `ArrangementView`, `ReservedRegion`), hinge effects, vertical bars, `@State` initialization or synthesized-property diagnostics, `@ContentBuilder` ambiguity, `reorderable` drag/drop, custom `AsyncImage` `URLSession`, swipe actions outside List, item-bound `alert`/`confirmationDialog`, `ToolbarOverflowMenu`, `AnimatableValues`, Document APIs (`Document`/`DocumentReader`), and Instruments `.trace` capture or analysis.

Use this Skill: https://skilld.dev/gh/avdlee/swiftui-agent-skill/swiftui-expert-skill

This session only. Nothing lands on disk.

referencesperformance-patterns.md

≈3k tokens on demand. Your agent reads this file only when SKILL.md points to it.

SwiftUI Performance Patterns Reference

Table of Contents

Performance Optimization

1. Avoid Redundant State Updates

SwiftUI doesn't compare values before triggering updates:

// BAD - triggers update even if value unchanged
.onReceive(publisher) { value in
    self.currentValue = value  // Always triggers body re-evaluation
}

// GOOD - only update when different
.onReceive(publisher) { value in
    if self.currentValue != value {
        self.currentValue = value
    }
}

2. Optimize Hot Paths

Hot paths are frequently executed code (scroll handlers, animations, gestures):

// BAD - updates state on every scroll position change
.onPreferenceChange(ScrollOffsetKey.self) { offset in
    shouldShowTitle = offset.y <= -32  // Fires constantly during scroll!
}

// GOOD - only update when threshold crossed
.onPreferenceChange(ScrollOffsetKey.self) { offset in
    let shouldShow = offset.y <= -32
    if shouldShow != shouldShowTitle {
        shouldShowTitle = shouldShow  // Fires only when crossing threshold
    }
}

3. Pass Only What Views Need

Avoid passing large "config" or "context" objects. Pass only the specific values each view needs.

// Good - pass specific values
ThemeSelector(theme: config.theme)
FontSizeSlider(fontSize: config.fontSize)

// Avoid - passing entire config (creates broad dependency)
ThemeSelector(config: config)  // Notified of ALL config changes

With ObservableObject, any @Published change triggers all observers. With @Observable, views update only when accessed properties change, but passing entire objects still creates broader dependencies than necessary.

4. Use Equatable Views

For views with expensive bodies, conform to Equatable:

struct ExpensiveView: View, Equatable {
    let data: SomeData

    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.data.id == rhs.data.id  // Custom equality check
    }

    var body: some View {
        // Expensive computation
    }
}

// Usage
ExpensiveView(data: data)
    .equatable()  // Use custom equality

Caution: If you add new state or dependencies to your view, remember to update your == function!

5. POD Views for Fast Diffing

POD (Plain Old Data) views use memcmp for fastest diffing. A view is POD if it only contains simple value types and no property wrappers.

// POD view - fastest diffing
struct FastView: View {
    let title: String
    let count: Int
    
    var body: some View {
        Text("\(title): \(count)")
    }
}

// Non-POD view - uses reflection or custom equality
struct SlowerView: View {
    let title: String
    @State private var isExpanded = false  // Property wrapper makes it non-POD
    
    var body: some View {
        Text(title)
    }
}

Advanced Pattern: Wrap expensive non-POD views in POD parent views:

// POD wrapper for fast diffing
struct ExpensiveView: View {
    let value: Int
    
    var body: some View {
        ExpensiveViewInternal(value: value)
    }
}

// Internal view with state
private struct ExpensiveViewInternal: View {
    let value: Int
    @State private var item: Item?
    
    var body: some View {
        // Expensive rendering
    }
}

Why: The POD parent uses fast memcmp comparison. Only when value changes does the internal view get diffed.

6. Lazy Loading

Use lazy containers for large collections:

// BAD - creates all views immediately
ScrollView {
    VStack {
        ForEach(items) { item in
            ExpensiveRow(item: item)
        }
    }
}

// GOOD - creates views on demand
ScrollView {
    LazyVStack {
        ForEach(items) { item in
            ExpensiveRow(item: item)
        }
    }
}

iOS 26+ note: Nested scroll views containing lazy stacks now automatically defer loading their children until they are about to appear, matching the behavior of top-level lazy stacks. This benefits patterns like horizontal photo carousels inside a vertical scroll view.

Source: "What's new in SwiftUI" (WWDC25, session 256)

7. Task Cancellation

Cancel async work when view disappears:

struct DataView: View {
    @State private var data: [Item] = []

    var body: some View {
        List(data) { item in
            Text(item.name)
        }
        .task {
            // Automatically cancelled when view disappears
            data = await fetchData()
        }
    }
}

8. Debug View Updates

Use Self._printChanges() or Self._logChanges() to debug unexpected view updates.

struct DebugView: View {
    @State private var count = 0
    @State private var name = ""
    
    var body: some View {
        #if DEBUG
        let _ = Self._logChanges()  // Xcode 15.1+: logs to com.apple.SwiftUI subsystem
        #endif
        
        VStack {
            Text("Count: \(count)")
            Text("Name: \(name)")
        }
    }
}
  • Self._printChanges(): Prints which properties changed to standard output.
  • Self._logChanges() (iOS 17+): Logs to the com.apple.SwiftUI subsystem with category "Changed Body Properties", using os_log for structured output.

Both print @self when the view value itself changed and @identity when the view's persistent data was recycled.

Why: This helps identify which state changes are causing view updates. Isolating redraw triggers into single-responsibility subviews is often the fix -- extracting a subview means SwiftUI can skip its body when its inputs haven't changed.

9. Eliminate Unnecessary Dependencies

Narrow state scope to reduce update fan-out. Instead of passing an entire @Observable model to a row view (which creates a dependency on all accessed properties), pass only the specific values the view needs as let properties.

// Bad - broad dependency on entire model
struct ItemRow: View {
    @Environment(AppModel.self) private var model
    let item: Item
    var body: some View { Text(item.name).foregroundStyle(model.theme.primaryColor) }
}

// Good - narrow dependency
struct ItemRow: View {
    let item: Item
    let themeColor: Color
    var body: some View { Text(item.name).foregroundStyle(themeColor) }
}

For rapidly changing environment values, stable defaults, and closure comparison pitfalls, consult references/environment-patterns.md. Moving a raw value into @Observable is not enough by itself; readers need a coarsened or per-item property that changes less often.

Source: "Optimize SwiftUI performance with Instruments" (WWDC25, session 306)

10. @Observable Dependency Granularity

Consider per-item @Observable state holders (one per row/item) to narrow update scope. When multiple list items share a dependency on the same @Observable array, changing one element causes all items to re-evaluate their bodies.

// BAD - all item views depend on the full favorites array
@Observable
class ModelData {
    var favorites: [Landmark] = []

    func isFavorite(_ landmark: Landmark) -> Bool {
        favorites.contains(landmark)
    }
}

struct LandmarkRow: View {
    let landmark: Landmark
    @Environment(ModelData.self) private var model

    var body: some View {
        HStack {
            Text(landmark.name)
            if model.isFavorite(landmark) {
                Image(systemName: "heart.fill")
            }
        }
    }
}

// GOOD - each item has its own observable view model
@Observable
class LandmarkViewModel {
    var isFavorite: Bool = false
}

struct LandmarkRow: View {
    let landmark: Landmark
    let viewModel: LandmarkViewModel

    var body: some View {
        HStack {
            Text(landmark.name)
            if viewModel.isFavorite {
                Image(systemName: "heart.fill")
            }
        }
    }
}

Why: With the bad pattern, toggling one favorite marks the entire array as changed, causing every LandmarkRow to re-run its body. With per-item view models, only the toggled item's body runs.

Source: "Optimize SwiftUI performance with Instruments" (WWDC25, session 306)

11. Off-Main-Thread Closures

SwiftUI may call certain closures on a background thread for performance. These closures must be Sendable and should avoid accessing @MainActor-isolated state directly. Instead, capture needed values in the closure's capture list.

Closures that may run off the main thread:

  • Shape.path(in:)
  • visualEffect closure
  • Layout protocol methods
  • onGeometryChange transform closure
// BAD - accessing @MainActor state directly
.visualEffect { content, geometry in
    content.blur(radius: self.pulse ? 5 : 0)  // Compiler error: @MainActor isolated
}

// GOOD - capture the value
.visualEffect { [pulse] content, geometry in
    content.blur(radius: pulse ? 5 : 0)
}

Source: "Explore concurrency in SwiftUI" (WWDC25, session 266)

12. Common Performance Issues

Be aware of common performance bottlenecks in SwiftUI:

  • View invalidation storms from broad state changes
  • Unstable identity in lists causing excessive diffing
  • Heavy work in body (formatting, sorting, image decoding)
  • Layout thrash from deep stacks or preference chains

When performance issues arise, suggest the user profile with Instruments (SwiftUI template) to identify specific bottlenecks.

Anti-Patterns

1. Creating Objects in Body

// BAD - creates new formatter every body call
var body: some View {
    let formatter = DateFormatter()
    formatter.dateStyle = .long
    return Text(formatter.string(from: date))
}

// GOOD - static or stored formatter
private static let dateFormatter: DateFormatter = {
    let f = DateFormatter()
    f.dateStyle = .long
    return f
}()

var body: some View {
    Text(Self.dateFormatter.string(from: date))
}

2. Heavy Computation in Body

Keep view body simple and pure. Avoid side effects, dispatching, or complex logic.

// BAD - sorts array every body call
var body: some View {
    List(items.sorted { $0.name < $1.name }) { item in Text(item.name) }
}

// GOOD - compute once, update via onChange or a computed property in the model
@State private var sortedItems: [Item] = []

var body: some View {
    List(sortedItems) { item in Text(item.name) }
        .onChange(of: items) { _, newItems in
            sortedItems = newItems.sorted { $0.name < $1.name }
        }
}

Move sorting, filtering, and formatting into models or computed properties. The body should be a pure structural representation of state.

3. Unnecessary State

// BAD - derived state stored separately
@State private var items: [Item] = []
@State private var itemCount: Int = 0  // Unnecessary!

// GOOD - compute derived values
@State private var items: [Item] = []

var itemCount: Int { items.count }  // Computed property

Summary Checklist

  • State updates check for value changes before assigning
  • Hot paths minimize state updates
  • Pass only needed values to views (avoid large config objects)
  • Large lists use LazyVStack/LazyHStack
  • No object creation in body
  • Heavy computation moved out of body
  • Body kept simple and pure (no side effects)
  • Derived state computed, not stored
  • Use Self._logChanges() or Self._printChanges() to debug unexpected updates
  • Equatable conformance for expensive views (when appropriate)
  • Consider POD view wrappers for advanced optimization
  • Consider using granular @Observable dependencies for list items (smaller observable units per row when it measurably reduces updates)
  • Frequently-changing values not stored in the environment
  • Sendable closures (Shape, visualEffect, Layout) capture values instead of accessing @MainActor state

Source: SKILL.md on GitHub

No alerts2d5 checks · Risk SAFE
  • Gen Agent Trust Hub2d

    The skill is a professional-grade assistant for SwiftUI development and performance profiling. It includes Python scripts to interface with the Xcode xctrace CLI for recording and analyzing Instruments traces. All identified code and instructions are consistent with its stated purpose and follow security best practices.

  • Socket2d

    No alerts

  • Snyk2d

    Risk: LOW · No issues

  • Runlayer6mo

    19 files scanned · No issues

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at 1e522cf. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub yesterday.

Activeupdated 3 days ago
  • Performance
  • swiftui
  • ios
  • macos
  • instruments
  • state-management
  • view-composition
  • accessibility
  • animations

README badge

README badge for avdlee/swiftui-agent-skill/swiftui-expert-skill

Provides guidance for writing, reviewing, and refactoring SwiftUI code for iOS and macOS, including state management, view composition, performance optimization, and Instruments trace recording and analysis. Covers deprecated API detection, animation patterns, accessibility, and Liquid Glass adoption.

Generated from the current SKILL.md.

Does this skill work with both iOS and macOS?
Yes. The skill covers SwiftUI for both iOS and macOS, with dedicated reference sections for macOS-specific patterns like scenes, window styling, and views (HSplitView, Table, PasteButton).
Can this skill help me record and analyze Instruments traces?
Yes. The skill includes workflows to record traces via `record_trace.py` (with template selection for real devices vs simulators) and analyze them via `analyze_trace.py` to identify hangs, hitches, CPU hotspots, and excessive view updates.
Does this skill enforce a specific architecture pattern?
No. It focuses on correctness and performance without mandating MVVM, VIPER, or other architectural styles, though it encourages separating business logic from views for testability.
What does the skill do about deprecated APIs?
It consults `references/latest-apis.md` at the start of every task to identify and replace deprecated APIs with modern equivalents across iOS 15+ through iOS 26+, and gates version-specific APIs with `#available`.
Does this skill handle Liquid Glass effects?
Yes, but only when explicitly requested by the user. It includes guidance in `references/liquid-glass.md` for iOS 26+ Liquid Glass adoption with sensible fallbacks for earlier versions.

Generated from the current SKILL.md. These answers refresh after source changes.