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
Repository
bluewaves-creat…s-skillsGitHub Stars
1
First Seen
Jan 26, 2026
Security Audits
Installed on
opencode2
codex1
claude-code1
gemini-cli1