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