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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- swiftui-views Installs to ~/.agents/skills/swiftui-views/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install swiftui-views The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project swiftui-views Installs to .agents/skills/swiftui-views/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/swiftui-views.zipunzip -o swiftui-views.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) swiftui-views PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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.
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
| Data | Declare as | Notes |
|---|---|---|
| Value owned by this view (toggle, text) | @State private var | Private; 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 only | let model: Model | Observation 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 / @ObservedObject | Migrate 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,Gridfor aligned rows and columns,LazyVStack/LazyVGridinsideScrollViewfor long content,Listwhen you want platform list behaviour (swipe actions, separators, edit mode). - Adapt instead of measuring:
ViewThatFitspicks the first child that fits;@Environment(\.horizontalSizeClass)switches compact vs regular layouts;containerRelativeFramesizes relative to the scroll container. - Avoid
GeometryReaderfor ordinary layout; it takes all offered space and breaks intrinsic sizing. Use it (oronGeometryChange) only to read a size you truly need. - Write a custom
Layoutwhen stacks cannot express it (flow/tag layouts). - Use
.frame(maxWidth: .infinity, alignment: .leading)rather thanSpacer()chains for alignment.
Identity and performance
ForEachneeds stable identity:Identifiablemodels with persistent IDs. Neverid: \.selfon mutable values or array indices for dynamic data.- Conditional
if/elsecreates two different views; toggling destroys state. To change appearance, use a modifier with a conditional value (.opacity(isOn ? 1 : 0)). - Avoid
AnyView; use@ViewBuilderorsome Viewreturns. - Extract subviews so Observation tracks reads per view; a giant
bodyre-renders everything when any read property changes. - Debug re-renders with
let _ = Self._printChanges()at the top ofbody(debug only; remove before committing). - Keep
bodyfree 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
.taskoveronAppear { 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 intononisolatedfunctions 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
ViewThatFitsordynamicTypeSizechecks. - 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: Modelinitialised from an init parameter is only read on first creation; later parent updates are ignored. Pass it aslet/@Bindableinstead.@Environment(Model.self)without an injected value crashes at runtime, including in previews. Inject in every#Preview.- Do not mark
@Observableproperties with@Published; mixing the systems silently breaks tracking. - Computed properties on
@Observableclasses are tracked through the stored properties they read; caching them in@ObservationIgnoredstorage 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.yaml
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.