swiftship/internal/skills/data/always/navigation/SKILL.md
Navigation architecture: NavigationStack, NavigationSplitView, TabView, sheets, fullScreenCover, type-safe routing, programmatic navigation. Use when setting up app navigation, adding screens, presenting modals, or building tab-based flows. Triggers: NavigationStack, NavigationLink, TabView, sheet, fullScreenCover, NavigationPath, navigationDestination.
npx skillsauth add abdullah4ai/apple-developer-toolkit navigationInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
| Pattern | When to Use |
|---------|-------------|
| NavigationStack | Hierarchical drill-down (list → detail → edit) |
| TabView with Tab API | 3+ distinct top-level peer sections |
| .sheet(item:) | Creation forms, secondary actions, settings |
| .fullScreenCover | Immersive experiences (media player, onboarding) |
| NavigationStack + .sheet | Most MVPs with 2-4 features |
Use for hierarchical navigation with a back button:
NavigationStack {
List(items) { item in
NavigationLink(value: item) {
ItemRow(item: item)
}
}
.navigationDestination(for: Item.self) { item in
ItemDetailView(item: item)
}
.navigationTitle("Items")
}
Use when the app has 3+ distinct, peer-level sections:
TabView {
Tab("Home", systemImage: "house") {
HomeView()
}
Tab("Search", systemImage: "magnifyingglass") {
SearchView()
}
Tab("Profile", systemImage: "person") {
ProfileView()
}
}
Choose the right sheet variant based on context:
.sheet(item:) — for editing or viewing an existing item (the item drives the sheet).sheet(isPresented:) — acceptable for creation forms and simple actions (no item yet)// Editing/viewing an existing item — use item-driven
@State private var editingItem: Item?
.sheet(item: $editingItem) { item in
EditItemView(item: item)
}
// Creating a new item — isPresented is fine
@State private var showAddItem = false
.sheet(isPresented: $showAddItem) {
AddItemView()
}
Use for immersive content that should cover the entire screen:
@State private var showOnboarding = false
.fullScreenCover(isPresented: $showOnboarding) {
OnboardingView()
}
Always use navigationDestination(for:) for type-safe routing:
// Define route types
.navigationDestination(for: Note.self) { note in
NoteDetailView(note: note)
}
.navigationDestination(for: Category.self) { category in
CategoryView(category: category)
}
struct ContentView: View {
var body: some View {
NavigationStack {
List {
NavigationLink("Profile", value: Route.profile)
NavigationLink("Settings", value: Route.settings)
}
.navigationDestination(for: Route.self) { route in
switch route {
case .profile:
ProfileView()
case .settings:
SettingsView()
}
}
}
}
}
enum Route: Hashable {
case profile
case settings
}
struct ContentView: View {
@State private var navigationPath = NavigationPath()
var body: some View {
NavigationStack(path: $navigationPath) {
List {
Button("Go to Detail") {
navigationPath.append(DetailRoute.item(id: 1))
}
}
.navigationDestination(for: DetailRoute.self) { route in
switch route {
case .item(let id):
ItemDetailView(id: id)
}
}
}
}
}
enum DetailRoute: Hashable {
case item(id: Int)
}
// Good - item-driven
@State private var selectedItem: Item?
var body: some View {
List(items) { item in
Button(item.name) {
selectedItem = item
}
}
.sheet(item: $selectedItem) { item in
ItemDetailSheet(item: item)
}
}
// Avoid - boolean flag requires separate state
@State private var showSheet = false
@State private var selectedItem: Item?
Why: .sheet(item:) automatically handles presentation state and avoids optional unwrapping.
Sheets should handle their own dismiss and actions internally.
struct EditItemSheet: View {
@Environment(\.dismiss) private var dismiss
@Environment(DataStore.self) private var store
let item: Item
@State private var name: String
@State private var isSaving = false
init(item: Item) {
self.item = item
_name = State(initialValue: item.name)
}
var body: some View {
NavigationStack {
Form {
TextField("Name", text: $name)
}
.navigationTitle("Edit Item")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button(isSaving ? "Saving..." : "Save") {
Task { await save() }
}
.disabled(isSaving || name.isEmpty)
}
}
}
}
private func save() async {
isSaving = true
await store.updateItem(item, name: name)
dismiss()
}
}
@State private var showPopover = false
Button("Show Popover") {
showPopover = true
}
.popover(isPresented: $showPopover) {
PopoverContentView()
.presentationCompactAdaptation(.popover)
}
.alert("Delete Item?", isPresented: $showAlert) {
Button("Delete", role: .destructive) { deleteItem() }
Button("Cancel", role: .cancel) { }
} message: {
Text("This action cannot be undone.")
}
.confirmationDialog("Choose an option", isPresented: $showDialog) {
Button("Option 1") { handleOption1() }
Button("Option 2") { handleOption2() }
Button("Cancel", role: .cancel) { }
}
For ANY list-detail flow, use NavigationSplitView. It shows sidebar+detail on iPad and auto-collapses to NavigationStack on iPhone.
@State private var selectedItem: Item?
NavigationSplitView {
List(items, selection: $selectedItem) { item in
NavigationLink(value: item) {
ItemRow(item: item)
}
}
.navigationTitle("Items")
} detail: {
if let selectedItem {
ItemDetailView(item: selectedItem)
} else {
ContentUnavailableView("Select an Item", systemImage: "doc")
}
}
@State private var selectedCategory: Category?
@State private var selectedItem: Item?
NavigationSplitView {
List(categories, selection: $selectedCategory) { category in
Label(category.name, systemImage: category.icon)
}
.navigationTitle("Categories")
} content: {
if let selectedCategory {
List(selectedCategory.items, selection: $selectedItem) { item in
NavigationLink(value: item) { ItemRow(item: item) }
}
.navigationTitle(selectedCategory.name)
}
} detail: {
if let selectedItem {
ItemDetailView(item: selectedItem)
} else {
ContentUnavailableView("Select an Item", systemImage: "doc")
}
}
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
NavigationSplitView(columnVisibility: $columnVisibility) {
SidebarView()
} detail: {
DetailView()
}
For apps with 3+ top-level sections on iPad, use TabView wrapping NavigationSplitView inside each tab:
TabView {
Tab("Library", systemImage: "books.vertical") {
NavigationSplitView {
LibraryListView()
} detail: {
ContentUnavailableView("Select a Book", systemImage: "book")
}
}
Tab("Search", systemImage: "magnifyingglass") {
SearchView()
}
}
.popover(isPresented: $showOptions) {
OptionsView()
.frame(minWidth: 250, minHeight: 300)
}
.sheet(isPresented: $showSheet) {
SheetContent()
.presentationDetents([.medium, .large])
}
Always use .confirmationDialog — becomes popover on iPad, action sheet on iPhone:
.confirmationDialog("Options", isPresented: $showDialog) {
Button("Edit") { }
Button("Delete", role: .destructive) { }
Button("Cancel", role: .cancel) { }
}
NavigationSplitView for list-detail flows — never bare NavigationStack on iPadContentUnavailableView) for iPad empty state.popover() for contextual actions — SwiftUI adapts automaticallyUIDevice.current.userInterfaceIdiom for navigation decisionstesting
Use for 3D games: racing, 3D sports, board games, marble maze, tower defense, bowling. SceneKit + SceneView architecture, 3D scene hierarchy, physics, game loop, primitives, materials, cameras, particles, audio.
documentation
Game UI patterns: SwiftUI HUD overlays on SpriteKit, menus (main/pause/game-over), virtual joystick/d-pad, score displays, health bars, tutorial onboarding.
tools
Download free game sprites/textures/3D models and generate procedural assets. Covers nw_download_asset tool, texture factories, sprite atlas organization, 3D model loading, and programmatic asset creation.
testing
Use for 2D games: arcade, puzzle, sports, ping pong, platformer, shooter, 2D racing. SpriteKit + SpriteView architecture, scene hierarchy, physics, game loop, audio, particles, game feel.