SwiftUI Views skill for Claude Code

Clear state ownership and navigation that scales. Claude builds SwiftUI screens with one owner per piece of state, choosing between @State, @Bindable, @Environment and plain properties from a decision table rather than habit. Models are MainActor @Observable classes, loading runs in .task so it cancels when the view leaves, navigation uses typed routes on NavigationStack, and every screen ships with previews for loading, empty, error and large text.

  • nameswiftui-views
  • size234 lines · ≈2,450 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Builds SwiftUI screens with correct state ownership using the Observation framework (@Observable, @State, @Bindable, @Environment), value-based NavigationStack routing, composable layout, previews with #Preview and @Previewable, and async work through .task with cancellation under Swift 6 concurrency. Use when writing or refactoring SwiftUI views, migrating from ObservableObject and NavigationView, fixing views that do not update or update too often, handling navigation and sheets, or loading data in a view.

Try asking

  • Migrate this screen from ObservableObject and NavigationView to @Observable and NavigationStack
  • My SwiftUI list does not update after the model changes
  • Build an orders screen with search, pull to refresh, empty and error states

Use it when

  • Writing or refactoring SwiftUI views and their models
  • Views fail to update or update far too often
  • Setting up programmatic or deep-link navigation
  • Loading async data inside a view

Skip it when

How to check the result

The project builds with no new warnings, previews render every state, and on device the screen survives deep links, backgrounding, rotation and the largest text size without stray updates.

The common mistake it prevents

Expecting @State to pick up a new value passed into init, when SwiftUI only uses the initial value and keeps the stale one.

~/.claude/skills/swiftui-views/SKILL.md

--- name: swiftui-views description: Builds SwiftUI screens with correct state ownership using the Observation framework (@Observable, @State, @Bindable, @Environment),… license: MIT ---

A good SwiftUI screen has one clear owner for each piece of state, passes models down as plain references or bindings, keeps views small enough that invalidation stays local, does async work in .task so it cancels automatically, routes with value-based navigation, and has previews for every meaningful state.

Before changing anything

  • Check the deployment target (Xcode project → target → Minimum Deployments, or IPHONEOS_DEPLOYMENT_TARGET). @Observable needs iOS 17 / macOS 14; @Previewable needs Xcode 16; below iOS 17 you are on ObservableObject.
  • Check the Swift language mode and concurrency settings (Swift 6 strict checking, default actor isolation). New Xcode 26 projects default to MainActor isolation.
  • Build once and note existing warnings so you do not add new ones: xcodebuild -scheme App -destination 'platform=iOS Simulator,name=iPhone 16' build | xcpretty (or plain xcodebuild output if xcpretty is not installed).

Workflow

- [ ] 1. Identify state and its owner (view, model, environment, persistence)
- [ ] 2. Model with @Observable classes (UI logic + async loading), views stay declarative
- [ ] 3. Choose the property wrapper per relationship (table below)
- [ ] 4. Compose layout from small subviews; avoid GeometryReader unless needed
- [ ] 5. Navigation with NavigationStack + typed routes; sheets via item binding
- [ ] 6. Async loading via .task / .task(id:) / .refreshable
- [ ] 7. Previews for loading, empty, error, populated, dark mode, large type
- [ ] 8. Build with no new warnings; run on device for performance

Property wrapper decision table (iOS 17+)

RelationshipWrapper
Value-type state owned by this view (toggle, text field)@State private var
Reference model created and owned by this view@State private var model = Model()
Model passed in, read onlylet model: Model (plain property; tracking still works)
Model passed in, need $model.field bindings@Bindable var model: Model
Shared model injected high in the tree.environment(model) + @Environment(Model.self) private var model
Value owned by parent, edited here@Binding var value
System values@Environment(\.dismiss), \.colorScheme, \.dynamicTypeSize
Persisted small preference@AppStorage("key")
SwiftData query@Query

Legacy mapping when migrating: @StateObject → @State, @ObservedObject → plain let or @Bindable, @EnvironmentObject → @Environment(Type.self), @Published → plain stored property.

Model and view

import Observation

@MainActor
@Observable
final class OrdersModel {
    enum Phase { case idle, loading, loaded([Order]), failed(String) }
    private(set) var phase: Phase = .idle
    var query = ""
    @ObservationIgnored private let api: OrdersAPI

    init(api: OrdersAPI) { self.api = api }

    func load() async {
        phase = .loading
        do {
            let orders = try await api.orders(matching: query)
            try Task.checkCancellation()
            phase = .loaded(orders)
        } catch is CancellationError {
            // view disappeared or query changed; keep previous state
        } catch {
            phase = .failed(error.localizedDescription)
        }
    }
}

struct OrdersScreen: View {
    @State private var model: OrdersModel

    init(api: OrdersAPI) { _model = State(initialValue: OrdersModel(api: api)) }

    var body: some View {
        List {
            switch model.phase {
            case .idle, .loading:
                ProgressView().frame(maxWidth: .infinity)
            case .loaded(let orders) where orders.isEmpty:
                ContentUnavailableView.search(text: model.query)
            case .loaded(let orders):
                ForEach(orders) { order in
                    NavigationLink(value: Route.order(order.id)) { OrderRow(order: order) }
                }
            case .failed(let message):
                ContentUnavailableView("Could not load orders", systemImage: "exclamationmark.triangle", description: Text(message))
            }
        }
        .searchable(text: $model.query)
        .task(id: model.query) {                       // restarts and cancels when query changes
            try? await Task.sleep(for: .milliseconds(300)) // debounce
            await model.load()
        }
        .refreshable { await model.load() }
        .navigationTitle("Orders")
    }
}

Rules:

  • @State initial values are used once. Passing a new value into init later does not reset it; use .id(newValue) on the view or move the state up.
  • Mark UI models @MainActor; do networking in async functions that suspend, not on the main thread synchronously.
  • Prefer .task over onAppear { Task { ... } } — .task is cancelled when the view disappears; a bare Task keeps running and can write to a dead screen.
  • Never put expensive work in body or computed properties read by body; it runs on every invalidation.
enum Route: Hashable { case order(Order.ID), settings, profile(userID: String) }

struct RootView: View {
    @State private var path: [Route] = []

    var body: some View {
        NavigationStack(path: $path) {
            OrdersScreen(api: .live)
                .navigationDestination(for: Route.self) { route in
                    switch route {
                    case .order(let id): OrderDetail(orderID: id)
                    case .settings: SettingsView()
                    case .profile(let userID): ProfileView(userID: userID)
                    }
                }
        }
        .onOpenURL { url in if let route = Route(url: url) { path.append(route) } }
    }
}
  • NavigationView is deprecated; use NavigationStack (phone) or NavigationSplitView (iPad/Mac sidebars).
  • Register navigationDestination once per type, on a view inside the stack, never inside a lazy container like List rows.
  • Programmatic navigation = mutate path. Pop to root = path.removeAll().
  • Present sheets with .sheet(item: $selectedOrder) (Identifiable item) rather than a Bool plus a separate optional, which can show stale content.
  • Deep links map URL → Route in one place (see deep-links).

Layout

  • Build from VStack/HStack/ZStack, Grid, LazyVStack/LazyVGrid for long content; List for platform-standard rows with swipe actions.
  • Modifier order matters: .padding().background(.blue) pads inside the color; reversed, the color does not include the padding.
  • .frame(maxWidth: .infinity, alignment: .leading) instead of Spacer() hacks for alignment.
  • ViewThatFits for “horizontal if it fits, else vertical” (important at large Dynamic Type sizes).
  • GeometryReader takes all offered space and complicates layout; prefer containerRelativeFrame, onGeometryChange, or a custom Layout when you need sizes.
  • Backgrounds may extend under safe areas (.ignoresSafeArea() on the background only), content should not. Use .safeAreaInset(edge: .bottom) for pinned bars.
  • Use semantic fonts (.font(.headline)) and system colors so Dynamic Type and dark mode work by default. When built with the iOS 26 SDK, standard bars and controls adopt the Liquid Glass design automatically; custom chrome can opt in with .glassEffect() — test legibility over content.

Identity and performance

  • ForEach needs stable identity: Identifiable models with persistent IDs. id: \.self on mutable values or array indices causes wrong rows to animate or reuse state.
  • Avoid AnyView and large conditional branches that swap view types; prefer modifiers that change values (.opacity, .disabled).
  • Extract subviews so only the part reading a changed property re-evaluates. With @Observable, a view depends only on properties it actually reads in body.
  • Debug unexpected updates with let _ = Self._printChanges() at the top of body (remove before commit) and the SwiftUI template in Instruments.

Previews

#Preview("Loaded") {
    NavigationStack { OrdersScreen(api: .preview(orders: .sample)) }
}

#Preview("Empty, large type", traits: .sizeThatFitsLayout) {
    OrdersScreen(api: .preview(orders: []))
        .environment(\.dynamicTypeSize, .accessibility3)
}

#Preview("Toggle") {
    @Previewable @State var isOn = true
    Toggle("Notifications", isOn: $isOn).padding()
}
  • Inject dependencies through protocols or closures so previews never hit the network.
  • Cover loading, empty, error, populated, dark mode, and an accessibility text size.

Gotchas

  • Updating @Observable state from a background task without @MainActor triggers runtime warnings and races; keep UI models on the main actor.
  • .onChange(of:) uses the two-parameter closure { old, new in } (iOS 17+) or the zero-parameter form; the single-parameter form is deprecated.
  • List selection and NavigationLink(value:) in the same list can conflict on iPhone; test on compact width.
  • Text fields bound directly to a model property that triggers network calls on every keystroke: debounce with .task(id:) as shown.
  • Alerts and sheets attached to views inside ForEach rows present unreliably; attach them at the screen level with an item binding.

Verification

  1. Build with zero new warnings (including concurrency warnings).
  2. Previews render for every state listed above.
  3. On device: navigate deep, background the app, return; push via deep link; rotate (iPad); enable the largest accessibility text size and dark mode.
  4. Leave a screen while it loads — no state updates or logs after it disappears (cancellation works).
  5. Unit-test models with Swift Testing (@Test, #expect) using fake APIs; reserve XCUITest for critical flows.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc