iOS SwiftUI Expert Skill
Core Rules
- Use @Observable (iOS 17+) over ObservableObject — fine-grained property tracking, better performance, simpler syntax.
- Use @State to own @Observable instances, NOT @StateObject.
@StateObject is the pre-iOS 17 pattern.
- Use NavigationStack (not NavigationView) with value-based
NavigationLink + .navigationDestination.
- Never nest NavigationStack inside NavigationStack — causes double navigation bars and broken behavior.
- Use LazyVStack/LazyHStack inside ScrollView for large collections. Use
List for very large datasets (cell reuse + prefetching).
- Keep
body pure — no data processing, network calls, or side effects. Use .task modifier for async work.
- Avoid
AnyView — it destroys view identity and kills diffing performance. Use @ViewBuilder or Group instead.
- Preserve view identity — use ternary operators (
condition ? viewA : viewB), not if/else that changes the view type in ways that break animations.
- Break large views into small subviews — SwiftUI re-evaluates
body often; smaller views = smaller re-evaluation scope.
- Use
.equatable() on expensive views to skip unnecessary re-renders.
Decision Tables
State Management — What to Use When
| Scenario |
iOS 17+ |
Pre-iOS 17 |
| Simple value owned by view |
@State |
@State |
| Pass value down for read/write |
@Binding |
@Binding |
| Reference-type model owned by view |
@State + @Observable |
@StateObject + ObservableObject |
| Reference-type model passed in |
just pass it (auto-tracked) |
@ObservedObject |
| Shared model via environment |
@Environment + custom key |
@EnvironmentObject |
| Create binding to @Observable property |
@Bindable |
N/A (use @Published + $) |
| System environment values |
@Environment(\.colorScheme) |
same |
Key insight: With @Observable, SwiftUI tracks which properties a view actually reads in body. With ObservableObject, ANY @Published change triggers ALL observing views to re-evaluate.
Layout — What Container to Use
| Need |
Use |
Why |
| Small fixed list of items |
VStack / HStack |
All children measured upfront, correct sizing |
| Large scrollable list (100+) |
LazyVStack in ScrollView |
Creates views on demand |
| Very large list (1000+) with editing |
List |
Cell reuse, prefetching, swipe actions |
| 2D grid layout |
LazyVGrid / LazyHGrid |
Flexible column/row definitions |
| Aligned rows + columns (small data) |
Grid + GridRow (iOS 16+) |
Alignment across rows |
| Responsive layout |
ViewThatFits (iOS 16+) |
Picks first child that fits |
Navigation — Which Pattern
| Need |
Use |
| Linear drill-down (push/pop) |
NavigationStack with path |
| Master-detail (iPad) |
NavigationSplitView |
| Tab-based app |
TabView (iOS 18: Tab items) |
| Modal presentation |
.sheet, .fullScreenCover |
| Programmatic deep linking |
NavigationStack(path:) + NavigationPath |
Presentation — Sheets vs Alerts vs Popovers
| Need |
Use |
| Complex form/detail |
.sheet or .fullScreenCover |
| Simple yes/no question |
.alert |
| Choose from options |
.confirmationDialog |
| Contextual info (iPad) |
.popover |
| Half-height sheet |
.sheet + .presentationDetents([.medium]) |
| Non-dismissable sheet |
.interactiveDismissDisabled(true) |
Quick Patterns
Creating an @Observable Model (iOS 17+)
@Observable
class UserViewModel {
var name = ""
var email = ""
var isLoading = false
@ObservationIgnored // not tracked
var internalCache: [String: Any] = [:]
func load() async {
isLoading = true
defer { isLoading = false }
// fetch data...
}
}
struct UserView: View {
@State private var viewModel = UserViewModel()
var body: some View {
Form {
TextField("Name", text: $viewModel.name) // needs @Bindable or use @State
}
.task { await viewModel.load() }
}
}
Note: To get $viewModel.name binding from @State, you can access it directly. If the model is passed as a parameter (not @State), wrap with @Bindable:
struct EditView: View {
@Bindable var viewModel: UserViewModel
var body: some View {
TextField("Name", text: $viewModel.name)
}
}
Navigation Stack with Programmatic Navigation
struct ContentView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
List(items) { item in
NavigationLink(value: item) {
Text(item.title)
}
}
.navigationDestination(for: Item.self) { item in
DetailView(item: item, path: $path)
}
.navigationTitle("Items")
}
}
}
Custom ViewModifier
struct CardModifier: ViewModifier {
func body(content: Content) -> some View {
content
.padding()
.background(.background, in: .rect(cornerRadius: 12))
.shadow(color: .black.opacity(0.1), radius: 4, y: 2)
}
}
extension View {
func cardStyle() -> some View {
modifier(CardModifier())
}
}
Async Data Loading
struct PostListView: View {
@State private var posts: [Post] = []
@State private var error: Error?
var body: some View {
List(posts) { post in
Text(post.title)
}
.overlay {
if posts.isEmpty && error == nil {
ProgressView()
}
if let error {
ContentUnavailableView("Error", systemImage: "exclamationmark.triangle",
description: Text(error.localizedDescription))
}
}
.task {
do {
posts = try await api.fetchPosts()
} catch {
self.error = error
}
}
}
}
Reference File Routing
Use this table to decide which reference file to read for a given task:
| Task / Question |
Read |
| Building layouts, stacks, grids |
references/layout.md |
| ScrollView, List, ForEach, lazy containers |
references/layout.md |
| @State, @Binding, @Observable, @Environment |
references/state.md |
| ObservableObject, @Published, migration to @Observable |
references/state.md |
| NavigationStack, NavigationSplitView, deep linking |
references/navigation.md |
| Sheets, alerts, popovers, modals |
references/navigation.md |
| TabView, toolbar, searchable |
references/navigation.md |
| Animations, transitions, springs |
references/animation.md |
| matchedGeometryEffect, hero animations |
references/animation.md |
| PhaseAnimator, KeyframeAnimator |
references/animation.md |
| Symbol effects, haptics |
references/animation.md |
| ViewModifier, @ViewBuilder, PreferenceKey |
references/patterns.md |
| GeometryReader, coordinate spaces |
references/patterns.md |
| App lifecycle, scenePhase, .task, .onAppear |
references/patterns.md |
| UIKit interop, UIViewRepresentable |
references/patterns.md |
| Performance optimization |
references/patterns.md |
| AsyncImage, #Preview, ContentUnavailableView |
references/patterns.md |
Common Anti-Patterns to Avoid
Using @StateObject with @Observable — @StateObject is for ObservableObject only. Use @State with @Observable.
Using @ObservedObject with @Observable — just pass the object directly; observation is automatic.
Nesting NavigationStack inside another NavigationStack — causes double nav bars and broken navigation.
Using NavigationView — deprecated since iOS 16. Use NavigationStack or NavigationSplitView.
Heavy work in body — body is called frequently. Move computation to .task, .onAppear, or the model.
Using AnyView — type-erases the view, preventing SwiftUI from diffing efficiently. Use @ViewBuilder or Group.
Using GeometryReader for simple layouts — it proposes all available space to its child. Use proper stack alignment, .frame(), or containerRelativeFrame instead.
Forgetting .id() on ForEach items — causes incorrect diffing, wrong animations, and state bugs. Always use Identifiable or explicit id:.
Using .onAppear for async work — use .task instead; it auto-cancels when the view disappears.
Creating @State from init parameters — @State initializes once. If you need to react to parameter changes, use .onChange(of:) or derive state differently.
iOS Version Feature Matrix
| Feature |
Minimum iOS |
@Observable, @Bindable |
17 |
NavigationStack, NavigationSplitView |
16 |
Grid, GridRow |
16 |
ViewThatFits |
16 |
PhaseAnimator, KeyframeAnimator |
17 |
sensoryFeedback |
17 |
SymbolEffect |
17 |
ContentUnavailableView |
17 |
#Preview macro |
17 |
containerRelativeFrame |
17 |
scrollPosition, scrollTargetBehavior |
17 |
withAnimation completion |
17 |
navigationDestination(item:) |
17 |
Tab type in TabView |
18 |
@Entry macro for Environment |
18 |
.presentationSizing |
18 |
MeshGradient |
18 |
Project Structure Convention
MyApp/
├── MyAppApp.swift // @main App struct
├── Models/ // Data models, @Observable classes
├── Views/
│ ├── Components/ // Reusable small views
│ ├── Screens/ // Full-screen views
│ └── Modifiers/ // Custom ViewModifiers
├── Services/ // Network, persistence, etc.
├── Extensions/ // View+, Color+, etc.
└── Resources/ // Assets, Localizable
Naming Conventions
- Views: noun or noun phrase (
ProfileView, SettingsScreen, UserRow)
- ViewModels:
<Feature>ViewModel with @Observable
- Modifiers: adjective or style name (
CardModifier, PrimaryButtonStyle)
- Use
some View return type, never concrete view types in public API
Related Skills
swiftui-navigation — navigation patterns
swiftui-performance-audit — performance
swiftdata-expert — data persistence
GitNexus Index
This skill is indexed by GitNexus for knowledge graph traversal.
Index path: /Users/localuser/.claude/skills/ios-swiftui-expert/.gitnexus
Last indexed: 2026-05-23
1---2name: ios-swiftui3description: Expert SwiftUI development skill for building iOS apps. Covers layout system (VStack/HStack/ZStack/Grid/LazyStacks), state management (@State/@Binding/@Observable/@Environment), navigation (NavigationStack/NavigationSplitView), animations (springs/transitions/matchedGeometryEffect/PhaseAnimator/KeyframeAnimator), lists and scroll views, sheets/alerts/popovers, custom ViewModifiers and ViewBuilders, SwiftUI lifecycle, performance optimization, and UIKit interop. Use this skill whenever the user builds SwiftUI views, layouts, navigation, animations, or asks about SwiftUI state management, view lifecycle, or performance. Triggers on any SwiftUI-related work including: SwiftUI, View, @State, @Binding, @Observable, NavigationStack, List, ScrollView, sheet, alert, animation, transition, ViewModifier, @ViewBuilder, GeometryReader, LazyVStack, TabView, toolbar, searchable, AsyncImage, or any iOS UI development with Swift.4---56# iOS SwiftUI Expert Skill78## Core Rules9101. **Use @Observable (iOS 17+) over ObservableObject** — fine-grained property tracking, better performance, simpler syntax.112. **Use @State to own @Observable instances**, NOT @StateObject. `@StateObject` is the pre-iOS 17 pattern.123. **Use NavigationStack** (not NavigationView) with value-based `NavigationLink` + `.navigationDestination`.134. **Never nest NavigationStack inside NavigationStack** — causes double navigation bars and broken behavior.145. **Use LazyVStack/LazyHStack inside ScrollView** for large collections. Use `List` for very large datasets (cell reuse + prefetching).156. **Keep `body` pure** — no data processing, network calls, or side effects. Use `.task` modifier for async work.167. **Avoid `AnyView`** — it destroys view identity and kills diffing performance. Use `@ViewBuilder` or `Group` instead.178. **Preserve view identity** — use ternary operators (`condition ? viewA : viewB`), not `if/else` that changes the view type in ways that break animations.189. **Break large views into small subviews** — SwiftUI re-evaluates `body` often; smaller views = smaller re-evaluation scope.1910. **Use `.equatable()`** on expensive views to skip unnecessary re-renders.2021---2223## Decision Tables2425### State Management — What to Use When2627| Scenario | iOS 17+ | Pre-iOS 17 |28|---|---|---|29| Simple value owned by view | `@State` | `@State` |30| Pass value down for read/write | `@Binding` | `@Binding` |31| Reference-type model owned by view | `@State` + `@Observable` | `@StateObject` + `ObservableObject` |32| Reference-type model passed in | just pass it (auto-tracked) | `@ObservedObject` |33| Shared model via environment | `@Environment` + custom key | `@EnvironmentObject` |34| Create binding to @Observable property | `@Bindable` | N/A (use `@Published` + `$`) |35| System environment values | `@Environment(\.colorScheme)` | same |3637**Key insight:** With `@Observable`, SwiftUI tracks which properties a view *actually reads* in `body`. With `ObservableObject`, ANY `@Published` change triggers ALL observing views to re-evaluate.3839### Layout — What Container to Use4041| Need | Use | Why |42|---|---|---|43| Small fixed list of items | `VStack` / `HStack` | All children measured upfront, correct sizing |44| Large scrollable list (100+) | `LazyVStack` in `ScrollView` | Creates views on demand |45| Very large list (1000+) with editing | `List` | Cell reuse, prefetching, swipe actions |46| 2D grid layout | `LazyVGrid` / `LazyHGrid` | Flexible column/row definitions |47| Aligned rows + columns (small data) | `Grid` + `GridRow` (iOS 16+) | Alignment across rows |48| Responsive layout | `ViewThatFits` (iOS 16+) | Picks first child that fits |4950### Navigation — Which Pattern5152| Need | Use |53|---|---|54| Linear drill-down (push/pop) | `NavigationStack` with path |55| Master-detail (iPad) | `NavigationSplitView` |56| Tab-based app | `TabView` (iOS 18: `Tab` items) |57| Modal presentation | `.sheet`, `.fullScreenCover` |58| Programmatic deep linking | `NavigationStack(path:)` + `NavigationPath` |5960### Presentation — Sheets vs Alerts vs Popovers6162| Need | Use |63|---|---|64| Complex form/detail | `.sheet` or `.fullScreenCover` |65| Simple yes/no question | `.alert` |66| Choose from options | `.confirmationDialog` |67| Contextual info (iPad) | `.popover` |68| Half-height sheet | `.sheet` + `.presentationDetents([.medium])` |69| Non-dismissable sheet | `.interactiveDismissDisabled(true)` |7071---7273## Quick Patterns7475### Creating an @Observable Model (iOS 17+)7677```swift78@Observable79class UserViewModel {80 var name = ""81 var email = ""82 var isLoading = false8384 @ObservationIgnored // not tracked85 var internalCache: [String: Any] = [:]8687 func load() async {88 isLoading = true89 defer { isLoading = false }90 // fetch data...91 }92}9394struct UserView: View {95 @State private var viewModel = UserViewModel()9697 var body: some View {98 Form {99 TextField("Name", text: $viewModel.name) // needs @Bindable or use @State100 }101 .task { await viewModel.load() }102 }103}104```105106Note: To get `$viewModel.name` binding from `@State`, you can access it directly. If the model is passed as a parameter (not `@State`), wrap with `@Bindable`:107108```swift109struct EditView: View {110 @Bindable var viewModel: UserViewModel111112 var body: some View {113 TextField("Name", text: $viewModel.name)114 }115}116```117118### Navigation Stack with Programmatic Navigation119120```swift121struct ContentView: View {122 @State private var path = NavigationPath()123124 var body: some View {125 NavigationStack(path: $path) {126 List(items) { item in127 NavigationLink(value: item) {128 Text(item.title)129 }130 }131 .navigationDestination(for: Item.self) { item in132 DetailView(item: item, path: $path)133 }134 .navigationTitle("Items")135 }136 }137}138```139140### Custom ViewModifier141142```swift143struct CardModifier: ViewModifier {144 func body(content: Content) -> some View {145 content146 .padding()147 .background(.background, in: .rect(cornerRadius: 12))148 .shadow(color: .black.opacity(0.1), radius: 4, y: 2)149 }150}151152extension View {153 func cardStyle() -> some View {154 modifier(CardModifier())155 }156}157```158159### Async Data Loading160161```swift162struct PostListView: View {163 @State private var posts: [Post] = []164 @State private var error: Error?165166 var body: some View {167 List(posts) { post in168 Text(post.title)169 }170 .overlay {171 if posts.isEmpty && error == nil {172 ProgressView()173 }174 if let error {175 ContentUnavailableView("Error", systemImage: "exclamationmark.triangle",176 description: Text(error.localizedDescription))177 }178 }179 .task {180 do {181 posts = try await api.fetchPosts()182 } catch {183 self.error = error184 }185 }186 }187}188```189190---191192## Reference File Routing193194Use this table to decide which reference file to read for a given task:195196| Task / Question | Read |197|---|---|198| Building layouts, stacks, grids | `references/layout.md` |199| ScrollView, List, ForEach, lazy containers | `references/layout.md` |200| @State, @Binding, @Observable, @Environment | `references/state.md` |201| ObservableObject, @Published, migration to @Observable | `references/state.md` |202| NavigationStack, NavigationSplitView, deep linking | `references/navigation.md` |203| Sheets, alerts, popovers, modals | `references/navigation.md` |204| TabView, toolbar, searchable | `references/navigation.md` |205| Animations, transitions, springs | `references/animation.md` |206| matchedGeometryEffect, hero animations | `references/animation.md` |207| PhaseAnimator, KeyframeAnimator | `references/animation.md` |208| Symbol effects, haptics | `references/animation.md` |209| ViewModifier, @ViewBuilder, PreferenceKey | `references/patterns.md` |210| GeometryReader, coordinate spaces | `references/patterns.md` |211| App lifecycle, scenePhase, .task, .onAppear | `references/patterns.md` |212| UIKit interop, UIViewRepresentable | `references/patterns.md` |213| Performance optimization | `references/patterns.md` |214| AsyncImage, #Preview, ContentUnavailableView | `references/patterns.md` |215216---217218## Common Anti-Patterns to Avoid2192201. **Using `@StateObject` with `@Observable`** — `@StateObject` is for `ObservableObject` only. Use `@State` with `@Observable`.2212222. **Using `@ObservedObject` with `@Observable`** — just pass the object directly; observation is automatic.2232243. **Nesting `NavigationStack`** inside another `NavigationStack` — causes double nav bars and broken navigation.2252264. **Using `NavigationView`** — deprecated since iOS 16. Use `NavigationStack` or `NavigationSplitView`.2272285. **Heavy work in `body`** — `body` is called frequently. Move computation to `.task`, `.onAppear`, or the model.2292306. **Using `AnyView`** — type-erases the view, preventing SwiftUI from diffing efficiently. Use `@ViewBuilder` or `Group`.2312327. **Using `GeometryReader` for simple layouts** — it proposes all available space to its child. Use proper stack alignment, `.frame()`, or `containerRelativeFrame` instead.2332348. **Forgetting `.id()` on ForEach items** — causes incorrect diffing, wrong animations, and state bugs. Always use `Identifiable` or explicit `id:`.2352369. **Using `.onAppear` for async work** — use `.task` instead; it auto-cancels when the view disappears.23723810. **Creating `@State` from init parameters** — `@State` initializes once. If you need to react to parameter changes, use `.onChange(of:)` or derive state differently.239240---241242## iOS Version Feature Matrix243244| Feature | Minimum iOS |245|---|---|246| `@Observable`, `@Bindable` | 17 |247| `NavigationStack`, `NavigationSplitView` | 16 |248| `Grid`, `GridRow` | 16 |249| `ViewThatFits` | 16 |250| `PhaseAnimator`, `KeyframeAnimator` | 17 |251| `sensoryFeedback` | 17 |252| `SymbolEffect` | 17 |253| `ContentUnavailableView` | 17 |254| `#Preview` macro | 17 |255| `containerRelativeFrame` | 17 |256| `scrollPosition`, `scrollTargetBehavior` | 17 |257| `withAnimation` completion | 17 |258| `navigationDestination(item:)` | 17 |259| `Tab` type in `TabView` | 18 |260| `@Entry` macro for Environment | 18 |261| `.presentationSizing` | 18 |262| `MeshGradient` | 18 |263264---265266## Project Structure Convention267268```269MyApp/270├── MyAppApp.swift // @main App struct271├── Models/ // Data models, @Observable classes272├── Views/273│ ├── Components/ // Reusable small views274│ ├── Screens/ // Full-screen views275│ └── Modifiers/ // Custom ViewModifiers276├── Services/ // Network, persistence, etc.277├── Extensions/ // View+, Color+, etc.278└── Resources/ // Assets, Localizable279```280281## Naming Conventions282283- Views: noun or noun phrase (`ProfileView`, `SettingsScreen`, `UserRow`)284- ViewModels: `<Feature>ViewModel` with `@Observable`285- Modifiers: adjective or style name (`CardModifier`, `PrimaryButtonStyle`)286- Use `some View` return type, never concrete view types in public API287288## Related Skills289- `swiftui-navigation` — navigation patterns290- `swiftui-performance-audit` — performance291- `swiftdata-expert` — data persistence292293## GitNexus Index294This skill is indexed by GitNexus for knowledge graph traversal.295Index path: /Users/localuser/.claude/skills/ios-swiftui-expert/.gitnexus296Last indexed: 2026-05-23