swiftui-patterns

SKILL.md

SwiftUI Patterns — Expert Decisions

Expert decision frameworks for SwiftUI choices that require experience. Claude knows SwiftUI syntax — this skill provides the judgment calls that prevent subtle bugs.


Decision Trees

Property Wrapper Selection

Who creates the object?
├─ This view creates it
│  └─ Is it a value type (struct, primitive)?
│     ├─ YES → @State
│     └─ NO (class/ObservableObject)
│        └─ iOS 17+?
│           ├─ YES → @Observable class + var (no wrapper)
│           └─ NO → @StateObject
└─ Parent passes it down
   └─ Is it an ObservableObject?
      ├─ YES → @ObservedObject
      └─ NO
         └─ Need two-way binding?
            ├─ YES → @Binding
            └─ NO → Regular parameter

The @StateObject vs @ObservedObject trap: Using @ObservedObject for a locally-created object causes recreation on EVERY view update. State vanishes randomly.

// ❌ BROKEN — viewModel recreated on parent rerender
struct BadView: View {
    @ObservedObject var viewModel = UserViewModel()  // WRONG
}

// ✅ CORRECT — viewModel survives view updates
struct GoodView: View {
    @StateObject private var viewModel = UserViewModel()
}

Navigation Pattern Selection

How many columns needed?
├─ 1 column (stack-based)
│  └─ NavigationStack with .navigationDestination
├─ 2 columns (list → detail)
│  └─ NavigationSplitView (2 column)
│     └─ iPad: sidebar + detail
│     └─ iPhone: collapses to stack
└─ 3 columns (sidebar → list → detail)
   └─ NavigationSplitView (3 column)
      └─ Mail/Files app pattern

NavigationStack gotcha: navigationDestination(for:) must be attached to a view INSIDE the NavigationStack, not on the NavigationStack itself. Wrong placement = silent failure.

// ❌ WRONG — destination outside stack hierarchy
NavigationStack {
    ContentView()
}
.navigationDestination(for: Item.self) { ... } // Never triggers!

// ✅ CORRECT — destination inside stack
NavigationStack {
    ContentView()
        .navigationDestination(for: Item.self) { item in
            DetailView(item: item)
        }
}

Sheet vs FullScreenCover vs NavigationLink

Is it a modal workflow? (user must complete or cancel)
├─ YES
│  └─ Should user see parent context?
│     ├─ YES → .sheet (can dismiss by swiping)
│     └─ NO → .fullScreenCover (must tap button)
└─ NO (progressive disclosure in same flow)
   └─ NavigationLink / .navigationDestination

Sheet state gotcha: Sheet content is created BEFORE presentation. @StateObject inside sheet reinitializes on each presentation.

// ❌ PROBLEM — viewModel resets each time sheet opens
.sheet(isPresented: $show) {
    SheetView()  // New @StateObject created each time
}

// ✅ SOLUTION — pass data or use item binding
.sheet(item: $selectedItem) { item in
    SheetView(item: item)  // Item drives content
}

NEVER Do

View Identity & Performance

NEVER use AnyView unless absolutely necessary:

// ❌ Destroys view identity — state lost, animations break
func makeView() -> AnyView {
    if condition {
        return AnyView(ViewA())
    } else {
        return AnyView(ViewB())
    }
}

// ✅ Use @ViewBuilder — preserves identity
@ViewBuilder
func makeView() -> some View {
    if condition {
        ViewA()
    } else {
        ViewB()
    }
}

NEVER compute expensive values in body:

// ❌ Recomputed on EVERY view update
var body: some View {
    let processed = expensiveComputation(data)  // Runs constantly
    Text(processed)
}

// ✅ Use .task or computed property with caching
var body: some View {
    Text(cachedResult)
        .task(id: data) {
            cachedResult = await expensiveComputation(data)
        }
}

NEVER change view identity during animation:

// ❌ Animation breaks — different views
if isExpanded {
    ExpandedCard()  // One view
} else {
    CompactCard()   // Different view
}

// ✅ Same view, different state — smooth animation
CardView(isExpanded: isExpanded)
    .animation(.spring(), value: isExpanded)

State Management

NEVER mutate @Published from background thread:

// ❌ Undefined behavior — sometimes works, sometimes crashes
Task.detached {
    viewModel.items = newItems  // Background thread!
}

// ✅ Always MainActor for @Published
Task { @MainActor in
    viewModel.items = newItems
}
// Or mark entire ViewModel as @MainActor

NEVER use .onAppear for async data loading:

// ❌ No cancellation, runs multiple times
.onAppear {
    Task { await loadData() }  // Not cancelled on disappear
}

// ✅ Use .task — automatic cancellation
.task {
    await loadData()  // Cancelled when view disappears
}

NEVER store derived state that should be computed:

// ❌ State duplication — can become inconsistent
@State private var items: [Item] = []
@State private var itemCount: Int = 0  // Derived from items!

// ✅ Compute derived values
@State private var items: [Item] = []
var itemCount: Int { items.count }

Lists & ForEach

NEVER use array index as id:

// ❌ Bugs when array changes — wrong rows update
ForEach(items.indices, id: \.self) { index in
    ItemRow(item: items[index])
}

// ✅ Use stable identifier
ForEach(items) { item in  // Requires Identifiable
    ItemRow(item: item)
}
// Or explicit id
ForEach(items, id: \.stableId) { item in ... }

NEVER put List inside ScrollView:

// ❌ Double scrolling, broken behavior
ScrollView {
    List(items) { ... }
}

// ✅ List handles its own scrolling
List(items) { item in
    ItemRow(item: item)
}

iOS/tvOS Platform Patterns

tvOS Focus System

#if os(tvOS)
struct TVCardView: View {
    @Environment(\.isFocused) var isFocused

    var body: some View {
        VStack {
            Image(item.image)
            Text(item.title)
        }
        .scaleEffect(isFocused ? 1.1 : 1.0)
        .animation(.easeInOut(duration: 0.15), value: isFocused)
        // tvOS: 10ft viewing distance = larger touch targets
        .frame(width: 300, height: 400)
    }
}

struct TVRowView: View {
    @FocusState private var focusedIndex: Int?

    var body: some View {
        ScrollView(.horizontal) {
            HStack(spacing: 48) {  // tvOS needs larger spacing
                ForEach(items.indices, id: \.self) { index in
                    TVCardView(item: items[index])
                        .focusable()
                        .focused($focusedIndex, equals: index)
                }
            }
            .padding(.horizontal, 90)  // Safe area for overscan
        }
        .onAppear { focusedIndex = 0 }
    }
}
#endif

tvOS gotcha: Focus system REQUIRES explicit .focusable() on custom views. Without it, remote navigation skips the view entirely.

Adaptive Layout Decision

struct AdaptiveView: View {
    @Environment(\.horizontalSizeClass) var sizeClass

    var body: some View {
        // iPhone portrait: compact, iPad/iPhone landscape: regular
        if sizeClass == .compact {
            VStack { content }
        } else {
            HStack { sidebar; content }
        }
    }
}

// Or use ViewThatFits for automatic selection
ViewThatFits {
    HStack { wideContent }  // Try first
    VStack { narrowContent } // Fallback
}

Performance Patterns

Preventing Unnecessary Redraws

// ✅ Equatable conformance for diffing
struct ItemRow: View, Equatable {
    let item: Item

    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.item.id == rhs.item.id &&
        lhs.item.name == rhs.item.name
    }

    var body: some View {
        Text(item.name)
    }
}

// ✅ Extract child views to isolate updates
struct ParentView: View {
    @StateObject var viewModel = ParentViewModel()

    var body: some View {
        VStack {
            // Only rerenders when header data changes
            HeaderView(title: viewModel.title)
            // Only rerenders when items change
            ItemList(items: viewModel.items)
        }
    }
}

Lazy Loading Patterns

// ✅ LazyVStack for large lists — views created on demand
ScrollView {
    LazyVStack {
        ForEach(items) { item in
            ItemRow(item: item)  // Created when scrolled into view
        }
    }
}

// ✅ task(id:) for dependent async work
.task(id: searchQuery) {
    // Automatically cancels previous task when searchQuery changes
    results = await search(searchQuery)
}

Common Gotchas

Sheet/Alert Binding Timing

// ❌ PROBLEM — item is nil when sheet renders
@State var selectedItem: Item?

.sheet(isPresented: Binding(
    get: { selectedItem != nil },
    set: { if !$0 { selectedItem = nil } }
)) {
    ItemDetail(item: selectedItem!)  // Crash! nil during transition
}

// ✅ SOLUTION — use item binding
.sheet(item: $selectedItem) { item in
    ItemDetail(item: item)  // item guaranteed non-nil
}

GeometryReader Sizing

// ❌ GeometryReader expands to fill available space
VStack {
    GeometryReader { geo in
        Text("Small text")  // But GeometryReader takes ALL space
    }
    Text("Never visible")  // Pushed off screen
}

// ✅ Wrap in fixed-size container or use sparingly
VStack {
    Text("Visible")
    Text("Also visible")
}
.background(
    GeometryReader { geo in
        Color.clear.onAppear { size = geo.size }
    }
)

Animation + State Change Timing

// ❌ State change THEN animation — no animation occurs
showDetail = true
withAnimation { }  // Nothing to animate

// ✅ State change INSIDE withAnimation
withAnimation(.spring()) {
    showDetail = true  // This change gets animated
}

Quick Reference

Property Wrapper Cheat Sheet

Wrapper Creates Survives Update Use Case
@State View-local value types
@StateObject View-owned ObservableObject
@ObservedObject Parent-passed ObservableObject
@Binding N/A Two-way value connection
@EnvironmentObject N/A App-wide shared state

Modifier Order Matters

// Different results!
Text("A").padding().background(.red)  // Red includes padding
Text("B").background(.red).padding()  // Red only behind text

Common order: content modifiers → padding → background → frame → position

Weekly Installs
14
GitHub Stars
6
First Seen
Jan 25, 2026
Installed on
claude-code12
opencode12
gemini-cli11
codex10
antigravity10
github-copilot9