navigation-menus
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
More from bluewaves-creations/bluewaves-skills
photographer-testino
Generate images in Mario Testino's glamorous vibrant style. Use when users ask for Testino style, high fashion glamour, bold saturated colors, warm luxurious photography, dynamic sensual energy.
35photographer-lindbergh
Generate images in Peter Lindbergh's iconic black and white style. Use when users ask for Lindbergh style, raw authentic beauty, emotional B&W portraits, supermodel aesthetic, or unretouched natural photography.
30photographer-lachapelle
Generate images in David LaChapelle's surreal pop art style. Use when users ask for LaChapelle style, pop surrealism, hyper-saturated colors, theatrical staging, baroque maximalism, kitsch aesthetic.
24epub-creator
Create production-quality EPUB 3 ebooks from markdown and images with automated QA, formatting fixes, and validation. Use when creating ebooks, converting markdown to EPUB, or compiling chapters into a publishable book. Handles markdown quirks, generates TOC, adds covers, and validates output automatically.
22photographer-vonunwerth
Generate images in Ellen von Unwerth's playful vintage style. Use when users ask for von Unwerth style, playful sensuality, vintage film noir, whimsical feminine photography, retro glamour, narrative storytelling.
19photographer-ritts
Generate images in Herb Ritts' sculptural black and white style. Use when users ask for Ritts style, classical Greek aesthetic, sculptural body photography, California golden hour, minimalist athletic portraits.
18