SwiftUI Navigation skill for Codex
Navigation as data you can set from a URL. Codex models navigation as plain data: a Hashable route enum per stack, an optional sheet item and a selected tab, owned by a small router object. Deep links and notification taps are parsed once into that data, so any screen is reachable by assignment. It replaces NavigationView and eager NavigationLinks, and tests URL parsing and flows on iPhone and iPad.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- swiftui-navigation Installs to ~/.agents/skills/swiftui-navigation/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install swiftui-navigation 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-navigation Installs to .agents/skills/swiftui-navigation/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/swiftui-navigation.zipunzip -o swiftui-navigation.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-navigation 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-navigation, 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
Designs SwiftUI navigation with NavigationStack and typed path arrays, navigationDestination, NavigationSplitView, TabView with the Tab API, sheets, full-screen covers and presentation detents, a router or coordinator object, deep links via onOpenURL, and state restoration with Codable routes. Use when adding push navigation or modals in SwiftUI, replacing NavigationView or NavigationLink(destination:), handling a deep link or notification tap that must open a nested screen, popping to root, or fixing navigation that pushes twice or ignores destinations.
Use it when
- Building push, tab or modal navigation in SwiftUI
- Migrating off NavigationView or NavigationLink(destination:)
- A deep link or notification must open a nested screen
- Navigation state should survive relaunch
Use something else when
- You need to configure universal links or the apple-app-site-association file: use mobile-deep-links
- Navigation stays in UIKit while screens move to SwiftUI: use uikit-to-swiftui
How to check the result
Unit tests map sample URLs to the expected tab and path, and each flow was exercised on iPhone and iPad simulators including cold-launch deep links.
The mistake it prevents
Attaching navigationDestination inside a List or lazy stack, where SwiftUI ignores it and taps push nothing.
Done well means: navigation state is data (an array of Hashable routes per stack plus
optional presented items), any screen can be reached by setting that data, deep links and
notification taps reuse the same path, and back, pop-to-root and dismiss all behave on
iPhone and iPad.
Inspect first
cat AGENTS.md 2>/dev/null
grep -rnE 'NavigationView|NavigationStack|NavigationSplitView|navigationDestination|NavigationLink\(destination' \
--include='*.swift' . | head -40
grep -rnE 'onOpenURL|userActivity|UNUserNotificationCenter' --include='*.swift' . | head
NavigationView is deprecated. NavigationLink(destination:) builds destinations
eagerly and cannot be driven by state; replace both when touching a flow.
Choose the container
| App shape | Container |
|---|---|
| Single drill-down flow | NavigationStack(path:) |
| Top-level sections | TabView with one NavigationStack per tab |
| List-detail on iPad / Mac | NavigationSplitView (collapses to a stack on iPhone) |
| Task that interrupts the flow (compose, edit, sign in) | sheet / fullScreenCover, with its own NavigationStack inside if it has steps |
Routes as data
enum Route: Hashable, Codable {
case order(id: String)
case orderItem(orderID: String, itemID: String)
case settings
}
enum Sheet: Identifiable, Hashable {
case compose(draftID: UUID?)
case paywall
var id: Self { self }
}
@MainActor @Observable
final class Router {
var path: [Route] = []
var sheet: Sheet?
func push(_ route: Route) { path.append(route) }
func popToRoot() { path.removeAll() }
func present(_ sheet: Sheet) { self.sheet = sheet }
}
Carry IDs in routes, not model objects: routes stay Codable for restoration, and the
destination screen loads fresh data.
struct OrdersRoot: View {
@State private var router = Router()
var body: some View {
NavigationStack(path: $router.path) {
OrderListView()
.navigationDestination(for: Route.self) { route in
switch route {
case .order(let id): OrderDetailView(orderID: id)
case .orderItem(let orderID, let itemID): ItemView(orderID: orderID, itemID: itemID)
case .settings: SettingsView()
}
}
}
.sheet(item: $router.sheet) { sheet in
switch sheet {
case .compose(let draftID): ComposeView(draftID: draftID)
case .paywall: PaywallView().presentationDetents([.medium, .large])
}
}
.environment(router)
}
}
In child views: NavigationLink(value: Route.order(id: order.id)) { OrderRow(order) }
or router.push(...) from an action. Dismiss a presented view with
@Environment(\.dismiss) private var dismiss.
Use NavigationPath instead of [Route] only when one stack must hold unrelated route
types from separate modules; a typed array is easier to inspect, test and restore.
Tabs
enum AppTab: Hashable { case orders, inbox, account }
struct RootView: View {
@State private var tab: AppTab = .orders
@State private var ordersRouter = Router()
@State private var inboxRouter = Router()
var body: some View {
TabView(selection: $tab) {
Tab("Orders", systemImage: "bag", value: .orders) {
OrdersStack().environment(ordersRouter)
}
Tab("Inbox", systemImage: "tray", value: .inbox) {
InboxStack().environment(inboxRouter)
}
}
}
}
The Tab type needs iOS 18; on iOS 17 use .tabItem and .tag. One router per tab
keeps each tab’s back stack when switching. Re-tapping the selected tab to pop to root is
not automatic with custom routers: detect the repeated selection and call popToRoot().
Deep links and notification taps
Translate the URL into routes in one place, then set state:
extension Route {
static func path(for url: URL) -> (AppTab, [Route])? {
let parts = url.pathComponents.filter { $0 != "/" }
switch (url.host(), parts.first) {
case ("orders", let id?):
return (.orders, [.order(id: id)])
case ("orders", nil):
return (.orders, [])
default:
return nil
}
}
}
// in RootView
.onOpenURL { url in
guard let (tab, path) = Route.path(for: url) else { return }
self.tab = tab
ordersRouter.sheet = nil // dismiss modals first
ordersRouter.path = path
}
- Universal links arrive through the same
onOpenURLin SwiftUI apps. Domain setup (apple-app-site-association, entitlements) belongs to$mobile-deep-links. - Notification taps: in the
UNUserNotificationCenterDelegate, convert the payload to a URL or route and hand it to the same function. - Replace the whole path for a deep link rather than appending; otherwise links stack on top of whatever the user was viewing.
- Validate IDs from URLs; unknown IDs should land on a not-found state, not crash.
Test:
xcrun simctl openurl booted "acme://orders/1234"
State restoration
@SceneStorage("orders.path") private var savedPath: Data?
.task {
if let data = savedPath, let path = try? JSONDecoder().decode([Route].self, from: data) {
router.path = path
}
}
.onChange(of: router.path) { _, newPath in
savedPath = try? JSONEncoder().encode(newPath)
}
Decoding must tolerate removed cases (try?); never crash on stale restoration data.
Gotchas
navigationDestinationplaced inside a lazy container (List,LazyVStack) or on a view that is conditionally shown is ignored or warns. Attach it to the stack’s root content.- Registering two
navigationDestination(for: Route.self)in the same stack: only one wins. One per type per stack. - Nesting a
NavigationStackinside another stack’s destination produces double navigation bars and broken back behaviour. Only sheets get a new stack. - Setting
pathand presenting a sheet in the same transaction can drop one animation; dismiss first, then update path (or apply changes in onewithTransactionif needed). sheet(isPresented:)with data captured from state shows stale content; prefersheet(item:).NavigationSplitViewselection should be optional IDs (selection: $selectedID), not model objects, or selection breaks after a refresh.
Verification
- Unit test the router and URL parsing without UI: given a URL, assert the tab and path.
- UI test or manual check per flow: push, back, swipe back, pop to root, open and dismiss each sheet, deep link from cold launch and while the app is in the background with a sheet open.
- Run on an iPhone and an iPad simulator (split view collapse behaves differently).
xcodebuild test -scheme App -destination 'platform=iOS Simulator,name=iPhone 17' \
-derivedDataPath build/DerivedData -only-testing:AppTests/RouterTests
Output: navigation map
| Entry | Tab | Path | Presented |
|---|---|---|---|
| acme://orders/1234 | orders | [.order(1234)] | none |
| notification "new_message" | inbox | [.thread(id)] | none |
| Compose button | current | unchanged | .compose(nil) |
Tested: iPhone 17 + iPad simulators, cold and warm deep links, restoration after relaunch 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 Navigation"
short_description: "Navigation as data you can set from a URL"
default_prompt: "$swiftui-navigation Replace NavigationView in this app with NavigationStack and a router" 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.