| name | swiftui-patterns |
| description | Expert SwiftUI decisions: property wrapper selection (@State vs @StateObject vs @ObservedObject gotchas), navigation architecture (NavigationStack vs NavigationSplitView), performance traps (body recomputation, identity), and platform-specific patterns for tvOS focus. Use when building SwiftUI views for iOS/tvOS, debugging view update issues, or choosing navigation patterns. Trigger keywords: SwiftUI, @State, @StateObject, @ObservedObject, NavigationStack, sheet, animation, tvOS focus, view identity, body |
| version | 3.0.0 |
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.
struct BadView: View {
@ObservedObject var viewModel = UserViewModel()
}
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.
NavigationStack {
ContentView()
}
.navigationDestination(for: Item.self) { ... }
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.
.sheet(isPresented: $show) {
SheetView()
}
.sheet(item: $selectedItem) { item in
SheetView(item: item)
}
NEVER Do
View Identity & Performance
NEVER use AnyView unless absolutely necessary:
func makeView() -> AnyView {
if condition {
return AnyView(ViewA())
} else {
return AnyView(ViewB())
}
}
@ViewBuilder
func makeView() -> some View {
if condition {
ViewA()
} else {
ViewB()
}
}
NEVER compute expensive values in body:
var body: some View {
let processed = expensiveComputation(data)
Text(processed)
}
var body: some View {
Text(cachedResult)
.task(id: data) {
cachedResult = await expensiveComputation(data)
}
}
NEVER change view identity during animation:
if isExpanded {
ExpandedCard()
} else {
CompactCard()
}
CardView(isExpanded: isExpanded)
.animation(.spring(), value: isExpanded)
State Management
NEVER mutate @Published from background thread:
Task.detached {
viewModel.items = newItems
}
Task { @MainActor in
viewModel.items = newItems
}
NEVER use .onAppear for async data loading:
.onAppear {
Task { await loadData() }
}
.task {
await loadData()
}
NEVER store derived state that should be computed:
@State private var items: [Item] = []
@State private var itemCount: Int = 0
@State private var items: [Item] = []
var itemCount: Int { items.count }
Lists & ForEach
NEVER use array index as id:
ForEach(items.indices, id: \.self) { index in
ItemRow(item: items[index])
}
ForEach(items) { item in
ItemRow(item: item)
}
ForEach(items, id: \.stableId) { item in ... }
NEVER put List inside ScrollView:
ScrollView {
List(items) { ... }
}
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)
.frame(width: 300, height: 400)
}
}
struct TVRowView: View {
@FocusState private var focusedIndex: Int?
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: 48) {
ForEach(items.indices, id: \.self) { index in
TVCardView(item: items[index])
.focusable()
.focused($focusedIndex, equals: index)
}
}
.padding(.horizontal, 90)
}
.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 {
if sizeClass == .compact {
VStack { content }
} else {
HStack { sidebar; content }
}
}
}
ViewThatFits {
HStack { wideContent }
VStack { narrowContent }
}
Performance Patterns
Preventing Unnecessary Redraws
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)
}
}
struct ParentView: View {
@StateObject var viewModel = ParentViewModel()
var body: some View {
VStack {
HeaderView(title: viewModel.title)
ItemList(items: viewModel.items)
}
}
}
Lazy Loading Patterns
ScrollView {
LazyVStack {
ForEach(items) { item in
ItemRow(item: item)
}
}
}
.task(id: searchQuery) {
results = await search(searchQuery)
}
Common Gotchas
Sheet/Alert Binding Timing
@State var selectedItem: Item?
.sheet(isPresented: Binding(
get: { selectedItem != nil },
set: { if !$0 { selectedItem = nil } }
)) {
ItemDetail(item: selectedItem!)
}
.sheet(item: $selectedItem) { item in
ItemDetail(item: item)
}
GeometryReader Sizing
VStack {
GeometryReader { geo in
Text("Small text")
}
Text("Never visible")
}
VStack {
Text("Visible")
Text("Also visible")
}
.background(
GeometryReader { geo in
Color.clear.onAppear { size = geo.size }
}
)
Animation + State Change Timing
showDetail = true
withAnimation { }
withAnimation(.spring()) {
showDetail = true
}
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
Text("A").padding().background(.red)
Text("B").background(.red).padding()
Common order: content modifiers → padding → background → frame → position