navigation-menus

SKILL.md

SwiftUI Navigation and Menus

Comprehensive guide to modern SwiftUI navigation patterns, menus, toolbars, and routing for iOS 26 and macOS Tahoe development.

Prerequisites

  • iOS 16+ for NavigationStack (iOS 26 recommended)
  • Xcode 26+

NavigationStack (iOS 16+)

Basic NavigationStack

struct ContentView: View {
    var body: some View {
        NavigationStack {
            List(items) { item in
                NavigationLink(item.title) {
                    ItemDetailView(item: item)
                }
            }
            .navigationTitle("Items")
        }
    }
}

Programmatic Navigation with NavigationPath

struct ContentView: View {
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            List(items) { item in
                Button(item.title) {
                    path.append(item)  // Push programmatically
                }
            }
            .navigationDestination(for: Item.self) { item in
                ItemDetailView(item: item)
            }
            .navigationTitle("Items")
            .toolbar {
                Button("Go to Settings") {
                    path.append(Route.settings)
                }
            }
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .settings:
                    SettingsView()
                case .profile(let userId):
                    ProfileView(userId: userId)
                }
            }
        }
    }
}

enum Route: Hashable {
    case settings
    case profile(userId: String)
}

Navigation Path Operations

// Push a single value
path.append(item)

// Pop one view
path.removeLast()

// Pop multiple views
path.removeLast(2)

// Pop to root
path.removeLast(path.count)

// Clear and push new
path = NavigationPath()
path.append(newRoot)

// Check if empty
if path.isEmpty {
    // At root
}

Type-Safe Navigation with Codable

struct ContentView: View {
    // Codable path for state restoration
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            RootView()
                .navigationDestination(for: Note.self) { note in
                    NoteDetailView(note: note)
                }
                .navigationDestination(for: Folder.self) { folder in
                    FolderView(folder: folder)
                }
        }
        .onAppear {
            restoreNavigationPath()
        }
        .onChange(of: path) {
            saveNavigationPath()
        }
    }

    func saveNavigationPath() {
        guard let data = try? JSONEncoder().encode(path.codable) else { return }
        UserDefaults.standard.set(data, forKey: "navigationPath")
    }

    func restoreNavigationPath() {
        guard let data = UserDefaults.standard.data(forKey: "navigationPath"),
              let codable = try? JSONDecoder().decode(
                NavigationPath.CodableRepresentation.self,
                from: data
              ) else { return }
        path = NavigationPath(codable)
    }
}

NavigationSplitView

Two-Column Layout

struct TwoColumnView: View {
    @State private var selectedNote: Note?

    var body: some View {
        NavigationSplitView {
            // Sidebar
            List(notes, selection: $selectedNote) { note in
                NavigationLink(value: note) {
                    NoteRow(note: note)
                }
            }
            .navigationTitle("Notes")
        } detail: {
            // Detail
            if let note = selectedNote {
                NoteDetailView(note: note)
            } else {
                ContentUnavailableView(
                    "Select a Note",
                    systemImage: "doc.text",
                    description: Text("Choose a note from the sidebar")
                )
            }
        }
    }
}

Three-Column Layout

struct ThreeColumnView: View {
    @State private var selectedFolder: Folder?
    @State private var selectedNote: Note?

    var body: some View {
        NavigationSplitView {
            // Sidebar
            List(folders, selection: $selectedFolder) { folder in
                NavigationLink(value: folder) {
                    Label(folder.name, systemImage: "folder")
                }
            }
            .navigationTitle("Folders")
        } content: {
            // Content column
            if let folder = selectedFolder {
                List(folder.notes, selection: $selectedNote) { note in
                    NavigationLink(value: note) {
                        Text(note.title)
                    }
                }
                .navigationTitle(folder.name)
            } else {
                ContentUnavailableView("Select a Folder", systemImage: "folder")
            }
        } detail: {
            // Detail column
            if let note = selectedNote {
                NoteDetailView(note: note)
            } else {
                ContentUnavailableView("Select a Note", systemImage: "doc.text")
            }
        }
    }
}

Column Visibility Control

struct AdaptiveNavigationView: View {
    @State private var columnVisibility: NavigationSplitViewVisibility = .all
    @State private var selectedItem: Item?

    var body: some View {
        NavigationSplitView(columnVisibility: $columnVisibility) {
            SidebarView(selection: $selectedItem)
        } detail: {
            DetailView(item: selectedItem)
        }
        .navigationSplitViewStyle(.balanced)  // or .prominentDetail
    }
}

// Visibility options:
// .all - Show all columns
// .doubleColumn - Hide sidebar
// .detailOnly - Show only detail
// .automatic - System decides

NavigationSplitView in Portrait

On iPhone and narrow iPad, NavigationSplitView automatically collapses to NavigationStack behavior with a back button.

NavigationSplitView {
    SidebarView()
} detail: {
    DetailView()
}
.navigationSplitViewStyle(.balanced)

// Style options:
// .automatic - System decides
// .balanced - Equal column importance
// .prominentDetail - Detail takes priority

TabView

Basic TabView

struct MainTabView: View {
    @State private var selectedTab = 0

    var body: some View {
        TabView(selection: $selectedTab) {
            HomeView()
                .tabItem {
                    Label("Home", systemImage: "house")
                }
                .tag(0)

            SearchView()
                .tabItem {
                    Label("Search", systemImage: "magnifyingglass")
                }
                .tag(1)

            ProfileView()
                .tabItem {
                    Label("Profile", systemImage: "person")
                }
                .tag(2)
        }
    }
}

Tab with Badge

TabView {
    NotificationsView()
        .tabItem {
            Label("Notifications", systemImage: "bell")
        }
        .badge(unreadCount)  // Shows badge number
        .badge("New")        // Shows text badge
}

iOS 26 Search Tab Role

TabView {
    HomeView()
        .tabItem {
            Label("Home", systemImage: "house")
        }

    // Search tab morphs into search field when tapped
    SearchResultsView()
        .tabItem {
            Label("Search", systemImage: "magnifyingglass")
        }
        .tabItemRole(.search)  // iOS 26 - Morphs to search field
}

TabView with NavigationStack

Each tab should have its own NavigationStack:

struct MainTabView: View {
    @State private var selectedTab = 0
    @State private var homePath = NavigationPath()
    @State private var searchPath = NavigationPath()

    var body: some View {
        TabView(selection: $selectedTab) {
            NavigationStack(path: $homePath) {
                HomeView()
                    .navigationDestination(for: Item.self) { item in
                        ItemDetailView(item: item)
                    }
            }
            .tabItem {
                Label("Home", systemImage: "house")
            }
            .tag(0)

            NavigationStack(path: $searchPath) {
                SearchView()
                    .navigationDestination(for: SearchResult.self) { result in
                        SearchResultDetailView(result: result)
                    }
            }
            .tabItem {
                Label("Search", systemImage: "magnifyingglass")
            }
            .tag(1)
        }
    }
}

Programmatic Tab Switching

struct TabCoordinator: View {
    @State private var selectedTab = Tab.home

    enum Tab: Hashable {
        case home, search, profile
    }

    var body: some View {
        TabView(selection: $selectedTab) {
            HomeView(switchTab: { selectedTab = $0 })
                .tabItem { Label("Home", systemImage: "house") }
                .tag(Tab.home)

            SearchView()
                .tabItem { Label("Search", systemImage: "magnifyingglass") }
                .tag(Tab.search)

            ProfileView()
                .tabItem { Label("Profile", systemImage: "person") }
                .tag(Tab.profile)
        }
    }
}

Toolbars

Basic Toolbar

struct ContentView: View {
    var body: some View {
        NavigationStack {
            ContentList()
                .navigationTitle("Items")
                .toolbar {
                    Button("Add", systemImage: "plus") {
                        addItem()
                    }
                }
        }
    }
}

Toolbar Placements

.toolbar {
    // Leading position (left on LTR)
    ToolbarItem(placement: .topBarLeading) {
        Button("Edit") { }
    }

    // Trailing position (right on LTR)
    ToolbarItem(placement: .topBarTrailing) {
        Button("Done") { }
    }

    // Primary action (system decides best position)
    ToolbarItem(placement: .primaryAction) {
        Button("Save") { }
    }

    // Secondary action
    ToolbarItem(placement: .secondaryAction) {
        Button("Settings") { }
    }

    // Cancel/dismiss action
    ToolbarItem(placement: .cancellationAction) {
        Button("Cancel") { }
    }

    // Confirmation action
    ToolbarItem(placement: .confirmationAction) {
        Button("Confirm") { }
    }

    // Destructive action
    ToolbarItem(placement: .destructiveAction) {
        Button("Delete", role: .destructive) { }
    }

    // Bottom bar
    ToolbarItem(placement: .bottomBar) {
        Button("Action") { }
    }
}

ToolbarItemGroup

.toolbar {
    ToolbarItemGroup(placement: .topBarTrailing) {
        Button("Share", systemImage: "square.and.arrow.up") { }
        Button("More", systemImage: "ellipsis.circle") { }
    }
}

iOS 26 ToolbarSpacer

.toolbar {
    ToolbarItem(placement: .topBarTrailing) {
        Button("Edit") { }
    }

    // Fixed spacer - groups related items
    ToolbarSpacer(.fixed)

    ToolbarItem(placement: .topBarTrailing) {
        Button("Add", systemImage: "plus") { }
    }

    // Flexible spacer - expands
    ToolbarSpacer(.flexible)

    ToolbarItem(placement: .topBarTrailing) {
        Button("Settings", systemImage: "gear") { }
    }
}

iOS 26 Close Button Role

.toolbar {
    ToolbarItem(placement: .cancellationAction) {
        Button("Dismiss", role: .close) {
            dismiss()
        }
        // Renders as glass X button in iOS 26
    }
}

Toolbar Background with Liquid Glass

.toolbarBackgroundVisibility(.visible, for: .navigationBar)
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)

// iOS 26 - Glass toolbar
.toolbarBackground(.glass, for: .navigationBar)

Custom Toolbar Content

.toolbar {
    ToolbarItem(placement: .principal) {
        VStack {
            Text("Title")
                .font(.headline)
            Text("Subtitle")
                .font(.caption)
                .foregroundStyle(.secondary)
        }
    }
}

Context Menus

Basic Context Menu

struct ItemRow: View {
    let item: Item

    var body: some View {
        Text(item.title)
            .contextMenu {
                Button("Copy") {
                    copyItem()
                }
                Button("Share") {
                    shareItem()
                }
                Divider()
                Button("Delete", role: .destructive) {
                    deleteItem()
                }
            }
    }
}

Context Menu with Preview

Text(item.title)
    .contextMenu {
        Button("Open") { }
        Button("Share") { }
    } preview: {
        ItemPreviewView(item: item)
            .frame(width: 300, height: 200)
    }

Conditional Menu Items

.contextMenu {
    if item.isFavorite {
        Button("Remove from Favorites") {
            toggleFavorite()
        }
    } else {
        Button("Add to Favorites") {
            toggleFavorite()
        }
    }

    if canEdit {
        Button("Edit") { }
    }
}

Context Menu on Lists

List(items) { item in
    ItemRow(item: item)
        .contextMenu {
            Button("Edit") { editItem(item) }
            Button("Delete", role: .destructive) { deleteItem(item) }
        }
}

Menus

Menu Button

Menu {
    Button("Option 1") { }
    Button("Option 2") { }
    Button("Option 3") { }
} label: {
    Label("Menu", systemImage: "ellipsis.circle")
}

Nested Menus

Menu {
    Button("Quick Action") { }

    Menu("Sort By") {
        Button("Name") { }
        Button("Date") { }
        Button("Size") { }
    }

    Menu("Filter") {
        Button("All") { }
        Button("Recent") { }
        Button("Favorites") { }
    }

    Divider()

    Button("Settings") { }
} label: {
    Image(systemName: "ellipsis.circle")
}

Menu with Selection

@State private var sortOrder = SortOrder.name

Menu {
    Picker("Sort", selection: $sortOrder) {
        ForEach(SortOrder.allCases) { order in
            Text(order.displayName).tag(order)
        }
    }
} label: {
    Label("Sort", systemImage: "arrow.up.arrow.down")
}

Primary Action Menu

Menu {
    Button("Edit") { }
    Button("Duplicate") { }
    Button("Delete", role: .destructive) { }
} label: {
    Label("Actions", systemImage: "ellipsis.circle")
} primaryAction: {
    // Primary tap action
    openItem()
}

iPad Menu Bar (iOS 26)

Adding Commands

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .commands {
            // File menu
            CommandGroup(replacing: .newItem) {
                Button("New Document") {
                    createNewDocument()
                }
                .keyboardShortcut("n", modifiers: .command)
            }

            // Edit menu additions
            CommandGroup(after: .pasteboard) {
                Button("Duplicate") {
                    duplicateSelected()
                }
                .keyboardShortcut("d", modifiers: .command)
            }

            // Custom menu
            CommandMenu("View") {
                Button("Show Sidebar") {
                    showSidebar()
                }
                .keyboardShortcut("s", modifiers: [.command, .control])

                Toggle("Dark Mode", isOn: $isDarkMode)
            }
        }
    }
}

Command Groups

.commands {
    // Replace existing group
    CommandGroup(replacing: .help) {
        Button("My App Help") { }
    }

    // Add before group
    CommandGroup(before: .sidebar) {
        Button("Toggle Inspector") { }
    }

    // Add after group
    CommandGroup(after: .toolbar) {
        Divider()
        Button("Reset Layout") { }
    }
}

Searchable

Basic Search

struct SearchableView: View {
    @State private var searchText = ""

    var filteredItems: [Item] {
        if searchText.isEmpty {
            return items
        }
        return items.filter { $0.title.localizedCaseInsensitiveContains(searchText) }
    }

    var body: some View {
        NavigationStack {
            List(filteredItems) { item in
                ItemRow(item: item)
            }
            .searchable(text: $searchText, prompt: "Search items")
            .navigationTitle("Items")
        }
    }
}

Search with Suggestions

.searchable(text: $searchText) {
    ForEach(suggestions) { suggestion in
        Text(suggestion.title)
            .searchCompletion(suggestion.title)
    }
}

Search Scopes

@State private var searchScope = SearchScope.all

.searchable(text: $searchText)
.searchScopes($searchScope) {
    Text("All").tag(SearchScope.all)
    Text("Recent").tag(SearchScope.recent)
    Text("Favorites").tag(SearchScope.favorites)
}

iOS 26 Search Placement

// NavigationStack - search in navigation bar
NavigationStack {
    ContentView()
        .searchable(text: $searchText)
}

// NavigationSplitView - search in sidebar
NavigationSplitView {
    SidebarView()
        .searchable(text: $searchText)
} detail: {
    DetailView()
}

Search Tokens

@State private var tokens: [SearchToken] = []

.searchable(text: $searchText, tokens: $tokens) { token in
    Label(token.name, systemImage: token.icon)
}
.searchSuggestions {
    ForEach(suggestedTokens) { token in
        Label(token.name, systemImage: token.icon)
            .searchCompletion(token)
    }
}

Router/Coordinator Pattern

Central App Router

@Observable
class AppRouter {
    var homePath = NavigationPath()
    var searchPath = NavigationPath()
    var profilePath = NavigationPath()

    var selectedTab: Tab = .home

    enum Tab: Hashable {
        case home, search, profile
    }

    // MARK: - Navigation Actions

    func navigateToItem(_ item: Item) {
        switch selectedTab {
        case .home:
            homePath.append(item)
        case .search:
            searchPath.append(item)
        case .profile:
            profilePath.append(item)
        }
    }

    func navigateToProfile(userId: String) {
        selectedTab = .profile
        profilePath.append(ProfileDestination.user(userId))
    }

    func popToRoot() {
        switch selectedTab {
        case .home:
            homePath.removeLast(homePath.count)
        case .search:
            searchPath.removeLast(searchPath.count)
        case .profile:
            profilePath.removeLast(profilePath.count)
        }
    }

    func pop() {
        switch selectedTab {
        case .home where !homePath.isEmpty:
            homePath.removeLast()
        case .search where !searchPath.isEmpty:
            searchPath.removeLast()
        case .profile where !profilePath.isEmpty:
            profilePath.removeLast()
        default:
            break
        }
    }
}

Router Usage

@main
struct MyApp: App {
    @State private var router = AppRouter()

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(router)
        }
    }
}

struct RootView: View {
    @Environment(AppRouter.self) var router

    var body: some View {
        @Bindable var router = router

        TabView(selection: $router.selectedTab) {
            NavigationStack(path: $router.homePath) {
                HomeView()
                    .navigationDestination(for: Item.self) { item in
                        ItemDetailView(item: item)
                    }
            }
            .tabItem { Label("Home", systemImage: "house") }
            .tag(AppRouter.Tab.home)

            NavigationStack(path: $router.searchPath) {
                SearchView()
                    .navigationDestination(for: Item.self) { item in
                        ItemDetailView(item: item)
                    }
            }
            .tabItem { Label("Search", systemImage: "magnifyingglass") }
            .tag(AppRouter.Tab.search)

            NavigationStack(path: $router.profilePath) {
                ProfileView()
                    .navigationDestination(for: ProfileDestination.self) { dest in
                        ProfileDestinationView(destination: dest)
                    }
            }
            .tabItem { Label("Profile", systemImage: "person") }
            .tag(AppRouter.Tab.profile)
        }
    }
}

Using Router in Child Views

struct ItemRow: View {
    @Environment(AppRouter.self) var router
    let item: Item

    var body: some View {
        Button(item.title) {
            router.navigateToItem(item)
        }
    }
}

Deep Linking

URL Handling

@main
struct MyApp: App {
    @State private var router = AppRouter()

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(router)
                .onOpenURL { url in
                    handleDeepLink(url)
                }
        }
    }

    func handleDeepLink(_ url: URL) {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
            return
        }

        // myapp://item/123
        // myapp://profile/user456

        let pathComponents = components.path.split(separator: "/")

        switch pathComponents.first {
        case "item":
            if let idString = pathComponents.dropFirst().first,
               let id = UUID(uuidString: String(idString)) {
                router.selectedTab = .home
                router.homePath.append(ItemDestination(id: id))
            }
        case "profile":
            if let userId = pathComponents.dropFirst().first {
                router.navigateToProfile(userId: String(userId))
            }
        default:
            break
        }
    }
}

Universal Links

// In Info.plist, add Associated Domains capability
// applinks:yourdomain.com

// Handle in onOpenURL
.onOpenURL { url in
    // https://yourdomain.com/item/123
    if url.host == "yourdomain.com" {
        handleUniversalLink(url)
    }
}

Sheets and Presentations

Basic Sheet

struct ContentView: View {
    @State private var showingSheet = false

    var body: some View {
        Button("Show Sheet") {
            showingSheet = true
        }
        .sheet(isPresented: $showingSheet) {
            SheetContent()
        }
    }
}

Sheet with Item

@State private var selectedItem: Item?

List(items) { item in
    Button(item.title) {
        selectedItem = item
    }
}
.sheet(item: $selectedItem) { item in
    ItemDetailSheet(item: item)
}

Presentation Detents

.sheet(isPresented: $showingSheet) {
    SheetContent()
        .presentationDetents([.medium, .large])
        .presentationDragIndicator(.visible)
}

// Custom detent
.presentationDetents([
    .height(200),
    .fraction(0.4),
    .medium,
    .large
])

Sheet Customization

.sheet(isPresented: $showingSheet) {
    SheetContent()
        .presentationDetents([.medium, .large])
        .presentationDragIndicator(.visible)
        .presentationCornerRadius(20)
        .presentationBackground(.thinMaterial)
        .presentationContentInteraction(.scrolls)
        .interactiveDismissDisabled(hasUnsavedChanges)
}

Full Screen Cover

.fullScreenCover(isPresented: $showingFullScreen) {
    FullScreenView()
}

Alerts and Confirmations

Basic Alert

@State private var showingAlert = false

Button("Delete") {
    showingAlert = true
}
.alert("Delete Item?", isPresented: $showingAlert) {
    Button("Cancel", role: .cancel) { }
    Button("Delete", role: .destructive) {
        deleteItem()
    }
} message: {
    Text("This action cannot be undone.")
}

Alert with Data

@State private var itemToDelete: Item?

.alert("Delete Item?", isPresented: .constant(itemToDelete != nil), presenting: itemToDelete) { item in
    Button("Cancel", role: .cancel) {
        itemToDelete = nil
    }
    Button("Delete", role: .destructive) {
        delete(item)
        itemToDelete = nil
    }
} message: { item in
    Text("Are you sure you want to delete \"\(item.title)\"?")
}

Confirmation Dialog

@State private var showingConfirmation = false

.confirmationDialog("Choose Action", isPresented: $showingConfirmation) {
    Button("Share") { share() }
    Button("Duplicate") { duplicate() }
    Button("Delete", role: .destructive) { delete() }
    Button("Cancel", role: .cancel) { }
} message: {
    Text("What would you like to do with this item?")
}

Best Practices

1. Place NavigationStack at Root

// GOOD: NavigationStack wraps content
NavigationStack {
    TabView {
        // Content
    }
}

// BETTER: Each tab has its own NavigationStack
TabView {
    NavigationStack {
        HomeView()
    }
    .tabItem { ... }
}

2. Use Type-Safe Navigation

// GOOD: Type-safe destinations
.navigationDestination(for: Item.self) { item in
    ItemDetailView(item: item)
}

// AVOID: String-based or untyped navigation

3. Keep Navigation State Observable

// Centralize navigation state
@Observable
class Router {
    var path = NavigationPath()
}

// Inject via environment
.environment(router)

4. Handle Empty States

NavigationSplitView {
    SidebarView()
} detail: {
    if let selected = selectedItem {
        DetailView(item: selected)
    } else {
        ContentUnavailableView(
            "No Selection",
            systemImage: "doc",
            description: Text("Select an item to view details")
        )
    }
}

5. Support Deep Linking

// Always handle URL schemes
.onOpenURL { url in
    handleDeepLink(url)
}

Official Resources

Weekly Installs
2
GitHub Stars
1
First Seen
Jan 26, 2026
Installed on
opencode2
codex1
claude-code1
gemini-cli1