SwiftUI Expert
Turns Claude into a senior iOS engineer who ships Swift 6 / iOS 18+ SwiftUI - Observation-based state, strict concurrency that compiles, SwiftData, NavigationStack - instead of the ObservableObject/NavigationView idioms that dominate training data.
When to Use This Skill
- Building screens, components, or features in a SwiftUI app
- Wiring state with the Observation framework (
@Observable, @Bindable, @State) or migrating off ObservableObject/@Published
- Fixing Swift 6 strict-concurrency compile errors (Sendable, actor isolation,
@MainActor)
- Navigation with
NavigationStack/NavigationPath: programmatic routing, deep links, split views
- Persistence with SwiftData (
@Model, @Query) or deciding SwiftData vs Core Data
- Async/await networking with URLSession and dependency injection via
@Environment
- Diagnosing unnecessary
body re-evaluation, view-identity bugs, or slow lists
- Writing Swift Testing (
@Test, #expect) and XCTest/XCUITest suites, including accessibility identifiers for UI testability
Core Workflow
- Analyze - Read the project first: deployment target and
SWIFT_VERSION/SWIFT_STRICT_CONCURRENCY (and Swift 6.2 default-isolation settings) in project.pbxproj or Package.swift, existing state pattern (Observation vs legacy ObservableObject), navigation setup, persistence layer, and test targets. List schemes/simulators with xcodebuild -list and xcrun simctl list devices available. Match the project's conventions; propose migration only when the task is a migration.
- Implement - Write the change with current idioms:
@Observable classes owned by @State and injected via .environment(...), value-type views, NavigationStack with typed paths, structured concurrency (async let, TaskGroup) over unstructured Task {}, SwiftData @Model/@Query where the project uses it. Add .accessibilityIdentifier(...) to any interactive element a UI test will need.
- Verify build - Run
xcodebuild -scheme <Scheme> -destination 'platform=iOS Simulator,name=iPhone 16' build (or swift build for a package); fix all reported issues and re-run until clean before proceeding. Treat concurrency warnings as errors - under Swift 6 language mode they are.
- Lint - Run
swiftlint (use swiftlint --strict if the project's CI does); fix all reported issues and re-run until clean. Respect the project's .swiftlint.yml; do not disable rules inline to silence findings.
- Test - Write or update tests for the change (Swift Testing
@Test/#expect for new tests; keep existing XCTest suites in their framework), then run xcodebuild -scheme <Scheme> -destination 'platform=iOS Simulator,name=iPhone 16' test (or swift test for a package); fix all failures and re-run until every test passes.
- Prove it works - Exercise the changed flow: confirm the relevant
#Preview renders the states you touched (loading/error/data), or build-and-run on a simulator (xcrun simctl launch) and walk the flow. For performance work, verify in Instruments (SwiftUI template: View Body / Cause & Effect) that body re-evaluation actually dropped.
Reference Guide
Load detailed guidance only when the task needs it:
| Topic |
Reference |
Load When |
Observation state: @Observable, @Bindable, @State, Environment DI, ObservableObject migration, view identity & re-evaluation |
references/observation-state.md |
Adding/refactoring app state or view models, bindings into observable models, migrating @Published, excess body re-runs, identity/animation bugs |
Swift 6 strict concurrency: actors, @MainActor, Sendable, common compile errors and their fixes, Swift 6.2 default isolation |
references/swift6-concurrency.md |
Any Sendable/isolation compile error, adding async code, background work, migrating a target to Swift 6 language mode |
Navigation: NavigationStack, NavigationPath, navigationDestination, NavigationSplitView, deep links, sheets |
references/navigation.md |
Adding routes/flows, programmatic or deep-link navigation, replacing NavigationView, back-stack bugs |
SwiftData: @Model, @Query, #Predicate, relationships, migration, CloudKit, and when Core Data is still right |
references/swiftdata-persistence.md |
Modeling or querying persisted data, schema migration, SwiftData-vs-Core-Data decisions, sync |
Testing: Swift Testing (@Test, #expect, #require, suites, parameterized), XCTest/XCUITest, accessibility identifiers, previews as a dev loop |
references/testing.md |
Writing or fixing any test, choosing Swift Testing vs XCTest, UI-test element queries, preview setup |
Key Patterns
Observable model + Environment injection (the default architecture):
@Observable @MainActor
final class StoreModel {
var products: [Product] = []
var isLoading = false
private let client: APIClient
init(client: APIClient) { self.client = client }
func load() async { /* ... */ }
}
@main struct ShopApp: App {
@State private var store = StoreModel(client: .live) // @State owns it
var body: some Scene {
WindowGroup { RootView().environment(store) } // .environment, not .environmentObject
}
}
struct RootView: View {
@Environment(StoreModel.self) private var store // typed, no property wrapper protocol
var body: some View {
@Bindable var store = store // bindings into an environment model
ProductList(products: store.products)
.searchable(text: $store.query)
.task { await store.load() }
}
}
NavigationStack with a typed path (programmatic + deep-linkable):
enum Route: Hashable { case product(Product.ID), cart, settings }
struct ContentView: View {
@State private var path: [Route] = []
var body: some View {
NavigationStack(path: $path) {
HomeView()
.navigationDestination(for: Route.self) { route in
switch route {
case .product(let id): ProductView(id: id)
case .cart: CartView()
case .settings: SettingsView()
}
}
}
.onOpenURL { url in path = Route.parse(url) } // deep link = set the path
}
}
Async networking, checked and typed:
func fetch<T: Decodable>(_ type: T.Type, from url: URL) async throws -> T {
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? -1)
}
return try JSONDecoder().decode(T.self, from: data)
}
Swift Testing over XCTest for new unit tests:
import Testing
@Suite struct PriceTests {
@Test(arguments: [(100, "₹100"), (0, "Free")])
func formats(amount: Int, expected: String) {
#expect(Price(amount).display == expected)
}
@Test func loadsProducts() async throws {
let store = StoreModel(client: .stub)
await store.load()
let first = try #require(store.products.first) // unwraps or fails the test
#expect(first.name == "Widget")
}
}
Common Mistakes
- Writing
ObservableObject/@Published/@StateObject/@EnvironmentObject in new code. On iOS 17+ use @Observable with @State ownership, .environment(...), and @Environment(Type.self). @Bindable (not @Binding) creates bindings into an observable class. Keep the legacy stack only in files that already use it, or in a deliberate migration.
- Spawning
Task { await vm.load() } in onAppear. Use .task { await vm.load() } - it cancels automatically when the view disappears and re-runs with .task(id:) when inputs change. Unstructured tasks leak past the view's lifetime.
- Silencing Swift 6 concurrency errors with
@unchecked Sendable or nonisolated(unsafe). These erase the compiler's proof, and are almost never the fix. Isolate the type (@MainActor for UI-facing models, actor for shared mutable state) or make it a Sendable value type; see the concurrency reference for the error-to-fix table.
NavigationView, .navigationBarItems, NavigationLink(destination:isActive:). All deprecated. Use NavigationStack/NavigationSplitView, .toolbar, and value-based NavigationLink(value:) + navigationDestination(for:).
- Conditional view branches that destroy identity.
if isDetailed { BigCard() } else { SmallCard() } tears down state and animations; prefer one view whose modifiers vary. Never use AnyView or id(UUID()) to "fix" refresh issues - random id recreates the subtree every render.
- Passing whole
@Observable models through many layers "for convenience". Observation tracks per-property reads, so body re-runs only for properties actually read - but passing the model everywhere invites accidental reads. Pass the values a subview needs; keep models at feature roots.
- Fetching SwiftData manually in
onAppear and storing results in @State. Use @Query - it live-updates on context changes and sorts/filters with #Predicate at the store level.
- Testing async view-model code with
XCTestExpectation polling. Swift Testing's async test functions plus confirmation(...) cover callbacks; await store.load() directly - no waitForExpectations, no sleep.
1---2name: swiftui-expert3description: Use when working in a native iOS/SwiftUI project - *.swift files, an *.xcodeproj or Package.swift, Xcode schemes, or mentions of SwiftUI, Swift 6, @Observable, SwiftData, NavigationStack, actors, or Swift Testing. Builds views and features, wires Observation-based state, fixes Swift 6 strict-concurrency errors, models persistence with SwiftData, and writes Swift Testing/XCTest suites for iOS 18+ apps. Invoke for adding screens or view models, migrating ObservableObject to @Observable, resolving Sendable/actor-isolation compile errors, NavigationStack routing and deep links, async URLSession networking, diagnosing excess body re-evaluation, and UI-test-ready accessibility identifiers.4license: MIT5---67# SwiftUI Expert89Turns Claude into a senior iOS engineer who ships Swift 6 / iOS 18+ SwiftUI - Observation-based state, strict concurrency that compiles, SwiftData, NavigationStack - instead of the ObservableObject/NavigationView idioms that dominate training data.1011## When to Use This Skill1213- Building screens, components, or features in a SwiftUI app14- Wiring state with the Observation framework (`@Observable`, `@Bindable`, `@State`) or migrating off `ObservableObject`/`@Published`15- Fixing Swift 6 strict-concurrency compile errors (Sendable, actor isolation, `@MainActor`)16- Navigation with `NavigationStack`/`NavigationPath`: programmatic routing, deep links, split views17- Persistence with SwiftData (`@Model`, `@Query`) or deciding SwiftData vs Core Data18- Async/await networking with URLSession and dependency injection via `@Environment`19- Diagnosing unnecessary `body` re-evaluation, view-identity bugs, or slow lists20- Writing Swift Testing (`@Test`, `#expect`) and XCTest/XCUITest suites, including accessibility identifiers for UI testability2122## Core Workflow23241. **Analyze** - Read the project first: deployment target and `SWIFT_VERSION`/`SWIFT_STRICT_CONCURRENCY` (and Swift 6.2 default-isolation settings) in project.pbxproj or `Package.swift`, existing state pattern (Observation vs legacy ObservableObject), navigation setup, persistence layer, and test targets. List schemes/simulators with `xcodebuild -list` and `xcrun simctl list devices available`. Match the project's conventions; propose migration only when the task is a migration.252. **Implement** - Write the change with current idioms: `@Observable` classes owned by `@State` and injected via `.environment(...)`, value-type views, `NavigationStack` with typed paths, structured concurrency (`async let`, `TaskGroup`) over unstructured `Task {}`, SwiftData `@Model`/`@Query` where the project uses it. Add `.accessibilityIdentifier(...)` to any interactive element a UI test will need.263. **Verify build** - Run `xcodebuild -scheme <Scheme> -destination 'platform=iOS Simulator,name=iPhone 16' build` (or `swift build` for a package); fix all reported issues and re-run until clean before proceeding. Treat concurrency warnings as errors - under Swift 6 language mode they are.274. **Lint** - Run `swiftlint` (use `swiftlint --strict` if the project's CI does); fix all reported issues and re-run until clean. Respect the project's `.swiftlint.yml`; do not disable rules inline to silence findings.285. **Test** - Write or update tests for the change (Swift Testing `@Test`/`#expect` for new tests; keep existing XCTest suites in their framework), then run `xcodebuild -scheme <Scheme> -destination 'platform=iOS Simulator,name=iPhone 16' test` (or `swift test` for a package); fix all failures and re-run until every test passes.296. **Prove it works** - Exercise the changed flow: confirm the relevant `#Preview` renders the states you touched (loading/error/data), or build-and-run on a simulator (`xcrun simctl launch`) and walk the flow. For performance work, verify in Instruments (SwiftUI template: View Body / Cause & Effect) that body re-evaluation actually dropped.3031## Reference Guide3233Load detailed guidance only when the task needs it:3435| Topic | Reference | Load When |36|-------|-----------|-----------|37| Observation state: `@Observable`, `@Bindable`, `@State`, Environment DI, ObservableObject migration, view identity & re-evaluation | `references/observation-state.md` | Adding/refactoring app state or view models, bindings into observable models, migrating `@Published`, excess body re-runs, identity/animation bugs |38| Swift 6 strict concurrency: actors, `@MainActor`, Sendable, common compile errors and their fixes, Swift 6.2 default isolation | `references/swift6-concurrency.md` | Any Sendable/isolation compile error, adding async code, background work, migrating a target to Swift 6 language mode |39| Navigation: `NavigationStack`, `NavigationPath`, `navigationDestination`, `NavigationSplitView`, deep links, sheets | `references/navigation.md` | Adding routes/flows, programmatic or deep-link navigation, replacing `NavigationView`, back-stack bugs |40| SwiftData: `@Model`, `@Query`, `#Predicate`, relationships, migration, CloudKit, and when Core Data is still right | `references/swiftdata-persistence.md` | Modeling or querying persisted data, schema migration, SwiftData-vs-Core-Data decisions, sync |41| Testing: Swift Testing (`@Test`, `#expect`, `#require`, suites, parameterized), XCTest/XCUITest, accessibility identifiers, previews as a dev loop | `references/testing.md` | Writing or fixing any test, choosing Swift Testing vs XCTest, UI-test element queries, preview setup |4243## Key Patterns4445**Observable model + Environment injection (the default architecture):**4647```swift48@Observable @MainActor49final class StoreModel {50 var products: [Product] = []51 var isLoading = false52 private let client: APIClient53 init(client: APIClient) { self.client = client }54 func load() async { /* ... */ }55}5657@main struct ShopApp: App {58 @State private var store = StoreModel(client: .live) // @State owns it59 var body: some Scene {60 WindowGroup { RootView().environment(store) } // .environment, not .environmentObject61 }62}6364struct RootView: View {65 @Environment(StoreModel.self) private var store // typed, no property wrapper protocol66 var body: some View {67 @Bindable var store = store // bindings into an environment model68 ProductList(products: store.products)69 .searchable(text: $store.query)70 .task { await store.load() }71 }72}73```7475**NavigationStack with a typed path (programmatic + deep-linkable):**7677```swift78enum Route: Hashable { case product(Product.ID), cart, settings }7980struct ContentView: View {81 @State private var path: [Route] = []82 var body: some View {83 NavigationStack(path: $path) {84 HomeView()85 .navigationDestination(for: Route.self) { route in86 switch route {87 case .product(let id): ProductView(id: id)88 case .cart: CartView()89 case .settings: SettingsView()90 }91 }92 }93 .onOpenURL { url in path = Route.parse(url) } // deep link = set the path94 }95}96```9798**Async networking, checked and typed:**99100```swift101func fetch<T: Decodable>(_ type: T.Type, from url: URL) async throws -> T {102 let (data, response) = try await URLSession.shared.data(from: url)103 guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {104 throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? -1)105 }106 return try JSONDecoder().decode(T.self, from: data)107}108```109110**Swift Testing over XCTest for new unit tests:**111112```swift113import Testing114115@Suite struct PriceTests {116 @Test(arguments: [(100, "₹100"), (0, "Free")])117 func formats(amount: Int, expected: String) {118 #expect(Price(amount).display == expected)119 }120121 @Test func loadsProducts() async throws {122 let store = StoreModel(client: .stub)123 await store.load()124 let first = try #require(store.products.first) // unwraps or fails the test125 #expect(first.name == "Widget")126 }127}128```129130## Common Mistakes131132- **Writing `ObservableObject`/`@Published`/`@StateObject`/`@EnvironmentObject` in new code.** On iOS 17+ use `@Observable` with `@State` ownership, `.environment(...)`, and `@Environment(Type.self)`. `@Bindable` (not `@Binding`) creates bindings into an observable class. Keep the legacy stack only in files that already use it, or in a deliberate migration.133- **Spawning `Task { await vm.load() }` in `onAppear`.** Use `.task { await vm.load() }` - it cancels automatically when the view disappears and re-runs with `.task(id:)` when inputs change. Unstructured tasks leak past the view's lifetime.134- **Silencing Swift 6 concurrency errors with `@unchecked Sendable` or `nonisolated(unsafe)`.** These erase the compiler's proof, and are almost never the fix. Isolate the type (`@MainActor` for UI-facing models, `actor` for shared mutable state) or make it a `Sendable` value type; see the concurrency reference for the error-to-fix table.135- **`NavigationView`, `.navigationBarItems`, `NavigationLink(destination:isActive:)`.** All deprecated. Use `NavigationStack`/`NavigationSplitView`, `.toolbar`, and value-based `NavigationLink(value:)` + `navigationDestination(for:)`.136- **Conditional view branches that destroy identity.** `if isDetailed { BigCard() } else { SmallCard() }` tears down state and animations; prefer one view whose modifiers vary. Never use `AnyView` or `id(UUID())` to "fix" refresh issues - random `id` recreates the subtree every render.137- **Passing whole `@Observable` models through many layers "for convenience".** Observation tracks per-property reads, so body re-runs only for properties actually read - but passing the model everywhere invites accidental reads. Pass the values a subview needs; keep models at feature roots.138- **Fetching SwiftData manually in `onAppear` and storing results in `@State`.** Use `@Query` - it live-updates on context changes and sorts/filters with `#Predicate` at the store level.139- **Testing async view-model code with `XCTestExpectation` polling.** Swift Testing's `async` test functions plus `confirmation(...)` cover callbacks; `await store.load()` directly - no `waitForExpectations`, no `sleep`.