swiftui-patterns

SwiftUI Patterns — Expert Decisions

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "swiftui-patterns" with this command: npx skills add kaakati/rails-enterprise-dev/kaakati-rails-enterprise-dev-swiftui-patterns

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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

flutter conventions & best practices

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

getx state management patterns

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

ruby oop patterns

No summary provided by upstream source.

Repository SourceNeeds Review