SwiftUI Views skill for Codex

One owner per state, views that adapt. Codex writes SwiftUI views as small structs over @Observable models, picks @State, @Bindable or @Environment from a single ownership table, and avoids GeometryReader and AnyView where layout primitives suffice. It checks the deployment target before using new APIs, keeps async work in .task, and verifies each screen in light and dark mode at the largest text size.

  • Mobile & Desktop
  • $swiftui-views
  • 173 lines
  • 2 files
  • ~1,800 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- swiftui-views

Installs to ~/.agents/skills/swiftui-views/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $swiftui-views, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Writes and refactors SwiftUI views for iOS and macOS - stack, grid and ViewThatFits layout, the Observation framework with @Observable, @State, @Bindable and @Environment, view identity and performance, async loading with .task, previews with #Preview, accessibility and Dynamic Type, and building with xcodebuild. Use when building a SwiftUI screen or component, migrating ObservableObject to @Observable, fixing views that re-render too much or lose state, laying out adaptive UI, or making a view accessible.

Use it when

  • Building or refactoring a SwiftUI screen or component
  • Moving from ObservableObject and @Published to the Observation framework
  • Views lose state, flicker or re-render too often
  • Layouts break on small phones, iPad or large Dynamic Type

Use something else when

  • The work is stacks, sheets, tabs or deep links: use swiftui-navigation
  • You are persisting models with @Model and @Query: use swiftdata
  • Embedding SwiftUI in an existing UIKit app: use uikit-to-swiftui

How to check the result

xcodebuild builds and tests the scheme on a simulator, and changed views were checked in light and dark mode, at the largest Dynamic Type size, and on iPhone SE and iPad widths.

The mistake it prevents

Initialising @State from an init parameter and expecting it to follow later changes from the parent; SwiftUI only reads it on first creation.

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

--- name: swiftui-views description: Writes and refactors SwiftUI views for iOS and macOS - stack, grid and ViewThatFits layout, the Observation framework with @Observable, @State,… license: MIT ---

Done well means: views are small value types driven by an @Observable model, state has exactly one owner, layout adapts from the smallest iPhone to iPad and the largest accessibility text size, every view has a #Preview, and the target builds without warnings under Swift 6 strict concurrency.

Inspect first

cat AGENTS.md 2>/dev/null
xcodebuild -list                                   # schemes and targets
grep -rE 'IPHONEOS_DEPLOYMENT_TARGET|SWIFT_VERSION' *.xcodeproj/project.pbxproj | sort -u
grep -rlE 'ObservableObject|@Observable' --include='*.swift' . | head

The deployment target decides the toolset: @Observable needs iOS 17 / macOS 14; @Entry, the Tab type and mesh gradients need iOS 18; Liquid Glass APIs (glassEffect) need iOS 26. Do not introduce APIs above the target without if #available and a fallback.

State: who owns what

DataDeclare asNotes
Value owned by this view (toggle, text)@State private varPrivate; initial value used once
Reference model owned by this view@State private var model = Model()Model is @Observable
Model passed in, need bindings@Bindable var model: Model$model.name
Model passed in, read onlylet model: ModelObservation tracks reads automatically
Shared through the tree.environment(model) / @Environment(Model.self)Crashes if missing; inject at the root and in previews
Two-way value from parent@Binding var isOn: Bool
Legacy ObservableObject@StateObject / @ObservedObjectMigrate when touched
import Observation
import SwiftUI

@MainActor @Observable
final class ProfileModel {
    var name = ""
    var isSaving = false
    private(set) var error: String?
    @ObservationIgnored private let api: ProfileAPI

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

    func save() async {
        isSaving = true
        defer { isSaving = false }
        do { try await api.update(name: name) } catch { self.error = error.localizedDescription }
    }
}

struct ProfileView: View {
    @Bindable var model: ProfileModel

    var body: some View {
        Form {
            TextField("Name", text: $model.name)
                .textContentType(.name)
            if let error = model.error {
                Text(error).foregroundStyle(.red)
            }
            Button("Save") { Task { await model.save() } }
                .disabled(model.isSaving || model.name.isEmpty)
        }
    }
}

#Preview {
    ProfileView(model: ProfileModel(api: .preview))
}

Migration from ObservableObject: drop the conformance and @Published, add @Observable, replace @StateObject with @State, @ObservedObject with a plain let or @Bindable, and @EnvironmentObject with @Environment(Type.self).

Layout

  • Compose with VStack/HStack/ZStack, Grid for aligned rows and columns, LazyVStack/LazyVGrid inside ScrollView for long content, List when you want platform list behaviour (swipe actions, separators, edit mode).
  • Adapt instead of measuring: ViewThatFits picks the first child that fits; @Environment(\.horizontalSizeClass) switches compact vs regular layouts; containerRelativeFrame sizes relative to the scroll container.
  • Avoid GeometryReader for ordinary layout; it takes all offered space and breaks intrinsic sizing. Use it (or onGeometryChange) only to read a size you truly need.
  • Write a custom Layout when stacks cannot express it (flow/tag layouts).
  • Use .frame(maxWidth: .infinity, alignment: .leading) rather than Spacer() chains for alignment.

Identity and performance

  • ForEach needs stable identity: Identifiable models with persistent IDs. Never id: \.self on mutable values or array indices for dynamic data.
  • Conditional if/else creates two different views; toggling destroys state. To change appearance, use a modifier with a conditional value (.opacity(isOn ? 1 : 0)).
  • Avoid AnyView; use @ViewBuilder or some View returns.
  • Extract subviews so Observation tracks reads per view; a giant body re-renders everything when any read property changes.
  • Debug re-renders with let _ = Self._printChanges() at the top of body (debug only; remove before committing).
  • Keep body free of work: no formatting of large collections, no sorting, no I/O. Precompute in the model.

Async work

.task(id: query) {                  // cancels and restarts when query changes
    try? await Task.sleep(for: .milliseconds(300))
    guard !Task.isCancelled else { return }
    await model.search(query)
}
  • Prefer .task over onAppear { Task { } }: it is cancelled automatically when the view disappears.
  • onChange(of:initial:_:) takes the two-parameter closure { old, new in } on iOS 17+.
  • Views are @MainActor. Keep models that drive UI on the main actor and push heavy work into nonisolated functions or separate actors.

Accessibility

  • Use semantic fonts (.font(.body), .headline) so Dynamic Type works; for custom fonts use .font(.custom("Inter", size: 17, relativeTo: .body)).
  • Test at the largest accessibility size: stacks that overflow should switch axis via ViewThatFits or dynamicTypeSize checks.
  • Icon-only buttons need labels: Button("Delete", systemImage: "trash") { } with .labelStyle(.iconOnly) keeps a VoiceOver label.
  • Group related elements with .accessibilityElement(children: .combine).
  • Touch targets at least 44x44 pt (.contentShape(Rectangle()) plus padding).

Gotchas

  • @State var model: Model initialised from an init parameter is only read on first creation; later parent updates are ignored. Pass it as let/@Bindable instead.
  • @Environment(Model.self) without an injected value crashes at runtime, including in previews. Inject in every #Preview.
  • Do not mark @Observable properties with @Published; mixing the systems silently breaks tracking.
  • Computed properties on @Observable classes are tracked through the stored properties they read; caching them in @ObservationIgnored storage stops updates.
  • Text truncation in other languages: avoid fixed widths; check with a pseudolanguage (scheme option “Double-Length Pseudolanguage”).

Verification

xcodebuild -scheme App -destination 'platform=iOS Simulator,name=iPhone 17' \
  -derivedDataPath build/DerivedData build test | xcbeautify   # xcbeautify optional

Use -derivedDataPath inside the workspace so the build works under a sandbox that blocks writes to ~/Library. Run xcrun simctl list devices available if the simulator name does not exist. Then check each changed view in previews or the simulator: light and dark, smallest and largest Dynamic Type, iPhone SE width and iPad, VoiceOver labels via Accessibility Inspector. Snapshot tests (swift-snapshot-testing) lock layouts once agreed.

Report template

**Target:** iOS 17.0, Swift 6 language mode
**Changed:** ProfileModel migrated to @Observable; ProfileView split into header and form
**Checks:** build + 31 tests green; previews light/dark, XXXL text, iPhone SE, iPad
**Notes:** _printChanges showed list rows re-rendering on every keystroke; fixed by extracting RowView

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "SwiftUI Views"
  short_description: "One owner per state, views that adapt"
  default_prompt: "$swiftui-views Migrate these ObservableObject view models to @Observable"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc