Navigation
SwiftUI navigation patterns using NavigationStack, programmatic routing, and deep linking
You are an expert in navigation patterns for building iOS applications with Swift and SwiftUI. ## Key Points - **Use `NavigationStack` with a `NavigationPath` for programmatic control.** Avoid the legacy `NavigationView` and the `isActive`/`selection` binding patterns. - **Wrap each tab's content in its own `NavigationStack`.** Placing `NavigationStack` inside `TabView` (not outside) prevents tabs from sharing navigation state. - **Centralize routing logic** in a router or coordinator for apps with deep linking, notifications, or complex flows. This keeps views focused on presentation. - **Use `navigationDestination(for:)` to map types to views.** Register one destination per type at the appropriate level in the hierarchy — usually on the list that pushes the detail. - **Prefer value-based `NavigationLink(value:)`** over view-based `NavigationLink(destination:)`. The value-based API enables programmatic push and type-safe routing. - **Use `.navigationTitle` and `.toolbar` on the inner view**, not on the `NavigationStack`. Modifiers on child views propagate up to the container. - **Nesting `NavigationStack` inside `NavigationStack`.** This creates a double navigation bar. Each navigation hierarchy should have exactly one stack at the root. - **State loss on tab switch.** If navigation paths are stored in `@State` inside a tab's content view, they reset when switching tabs. Lift path state into a shared model or the router.
skilldb get ios-swift-skills/NavigationFull skill: 335 linesNavigation — iOS/Swift
You are an expert in navigation patterns for building iOS applications with Swift and SwiftUI.
Core Philosophy
Navigation in SwiftUI should be data-driven, not imperative. The old UIKit pattern of "push this view controller onto the stack" has been replaced by a model where navigation state is a collection of values, and the framework renders whatever screens those values describe. This shift means navigation becomes testable -- you can verify that your app navigates to the right screen by checking the path array, without rendering a single view. It also means deep linking becomes trivial: parse a URL into values, set the path, and the navigation stack reflects the URL.
The guiding principle is to keep navigation concerns out of leaf views. A detail screen should not know how it was presented or what it should push next. Instead, it reports user intent through callbacks or actions, and a coordinator or router at a higher level translates those intents into navigation mutations. This separation means you can reuse the same screen in a stack, a sheet, or a split view without modifying it.
State ownership matters for navigation just as it does for any other state. If the path is stored in @State inside a view that gets recreated (such as a tab's content view), the path resets unexpectedly. Navigation paths that need to survive tab switches, sheet dismissals, or app backgrounding must be lifted into a shared model at the appropriate level -- typically an @Observable router or a persistent store for state restoration.
Anti-Patterns
-
Nesting NavigationStack inside NavigationStack: This creates a double navigation bar and confusing push behavior. Each navigation hierarchy should have exactly one stack at its root. Sheets and split views create their own contexts, but within a single navigation flow, there must be one stack.
-
Passing the NavigationPath deep into the view hierarchy: When leaf views directly manipulate the path, navigation logic scatters across the entire codebase. Instead, pass navigation callbacks (closures) or use an environment-injected router so leaf views remain unaware of the navigation mechanism.
-
Using the deprecated NavigationView and isActive binding pattern: The
NavigationViewAPI withisActiveandselectionbindings is fragile, poorly documented, and replaced byNavigationStackin iOS 16. Using it in new code creates technical debt and limits your ability to programmatically control navigation. -
Hardcoding navigation structure into views: A view that contains
NavigationLink(destination: SpecificDetailView())is tightly coupled to both the navigation mechanism and the destination. Use value-basedNavigationLink(value:)withnavigationDestination(for:)so the mapping from values to views lives in one place. -
Processing deep links before the view hierarchy is ready: Appending values to a
NavigationPathbefore theNavigationStackhas finished its initial render can silently fail. Defer deep link processing to after the root view appears, or queue the link and apply it in an.onAppearor.taskmodifier.
Overview
SwiftUI's navigation system has evolved significantly. NavigationStack (iOS 16+) replaced NavigationView with a data-driven, programmatic model. Navigation state is represented by a collection of values (a path), enabling deep linking, state restoration, and complex routing logic. For tab-based and split-view apps, TabView and NavigationSplitView complement the stack model.
Core Concepts
NavigationStack and NavigationLink
NavigationStack manages a stack of views driven by a path of values:
struct ContentView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
List {
NavigationLink("Recipes", value: Route.recipes)
NavigationLink("Favorites", value: Route.favorites)
NavigationLink("Settings", value: Route.settings)
}
.navigationTitle("Home")
.navigationDestination(for: Route.self) { route in
switch route {
case .recipes: RecipeListView(path: $path)
case .favorites: FavoritesView()
case .settings: SettingsView()
}
}
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetailView(recipe: recipe)
}
}
}
}
enum Route: Hashable {
case recipes
case favorites
case settings
}
Programmatic Navigation
Manipulating the path directly enables push, pop, and deep-link scenarios:
struct RecipeListView: View {
@Binding var path: NavigationPath
let recipes: [Recipe]
var body: some View {
List(recipes) { recipe in
NavigationLink(value: recipe) {
RecipeRow(recipe: recipe)
}
}
.navigationTitle("Recipes")
.toolbar {
Button("Go to featured") {
if let featured = recipes.first(where: { $0.isFeatured }) {
path.append(featured)
}
}
}
}
func popToRoot() {
path = NavigationPath()
}
func popLast() {
if !path.isEmpty {
path.removeLast()
}
}
}
NavigationSplitView (iPad / Mac)
For multi-column layouts on larger screens:
struct MailView: View {
@State private var selectedFolder: Folder?
@State private var selectedMessage: Message?
var body: some View {
NavigationSplitView {
List(Folder.allCases, selection: $selectedFolder) { folder in
Label(folder.name, systemImage: folder.icon)
}
.navigationTitle("Mail")
} content: {
if let folder = selectedFolder {
MessageListView(folder: folder, selection: $selectedMessage)
} else {
ContentUnavailableView("Select a Folder", systemImage: "folder")
}
} detail: {
if let message = selectedMessage {
MessageDetailView(message: message)
} else {
ContentUnavailableView("Select a Message", systemImage: "envelope")
}
}
.navigationSplitViewStyle(.balanced)
}
}
TabView
struct MainTabView: View {
@State private var selectedTab = Tab.home
enum Tab: Hashable {
case home, search, profile
}
var body: some View {
TabView(selection: $selectedTab) {
NavigationStack {
HomeView()
}
.tabItem {
Label("Home", systemImage: "house")
}
.tag(Tab.home)
NavigationStack {
SearchView()
}
.tabItem {
Label("Search", systemImage: "magnifyingglass")
}
.tag(Tab.search)
NavigationStack {
ProfileView()
}
.tabItem {
Label("Profile", systemImage: "person")
}
.tag(Tab.profile)
}
}
}
Implementation Patterns
Router Pattern
Centralize navigation logic in a router for testability and deep linking:
@Observable
class Router {
var homePath = NavigationPath()
var searchPath = NavigationPath()
var selectedTab: Tab = .home
enum Tab: Hashable {
case home, search, profile
}
func navigate(to destination: any Hashable) {
switch selectedTab {
case .home: homePath.append(destination)
case .search: searchPath.append(destination)
case .profile: break
}
}
func popToRoot() {
switch selectedTab {
case .home: homePath = NavigationPath()
case .search: searchPath = NavigationPath()
case .profile: break
}
}
// Deep link handling
func handleDeepLink(_ url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let host = components.host else { return }
switch host {
case "recipe":
if let id = components.queryItems?.first(where: { $0.name == "id" })?.value {
selectedTab = .home
homePath = NavigationPath()
homePath.append(Route.recipeDetail(id: id))
}
case "search":
selectedTab = .search
searchPath = NavigationPath()
if let query = components.queryItems?.first(where: { $0.name == "q" })?.value {
searchPath.append(Route.searchResults(query: query))
}
default:
break
}
}
}
// Usage in App
@main
struct MyApp: App {
@State private var router = Router()
var body: some Scene {
WindowGroup {
MainTabView()
.environment(router)
.onOpenURL { url in
router.handleDeepLink(url)
}
}
}
}
Sheet and Full-Screen Cover Navigation
struct ItemListView: View {
@State private var presentedItem: Item?
@State private var showNewItemSheet = false
var body: some View {
List(items) { item in
Button(item.title) {
presentedItem = item
}
}
.toolbar {
Button("Add", systemImage: "plus") {
showNewItemSheet = true
}
}
.sheet(item: $presentedItem) { item in
NavigationStack {
ItemDetailView(item: item)
}
}
.sheet(isPresented: $showNewItemSheet) {
NavigationStack {
NewItemView()
}
.interactiveDismissDisabled()
}
}
}
State Restoration with Codable NavigationPath
@Observable
class NavigationStore {
var path = NavigationPath()
private let savePath = URL.documentsDirectory.appending(path: "navigation.json")
func save() {
guard let representation = path.codable else { return }
do {
let data = try JSONEncoder().encode(representation)
try data.write(to: savePath)
} catch {
print("Failed to save navigation state: \(error)")
}
}
func restore() {
guard let data = try? Data(contentsOf: savePath),
let decoded = try? JSONDecoder().decode(
NavigationPath.CodableRepresentation.self, from: data
) else { return }
path = NavigationPath(decoded)
}
}
Best Practices
- Use
NavigationStackwith aNavigationPathfor programmatic control. Avoid the legacyNavigationViewand theisActive/selectionbinding patterns. - Wrap each tab's content in its own
NavigationStack. PlacingNavigationStackinsideTabView(not outside) prevents tabs from sharing navigation state. - Centralize routing logic in a router or coordinator for apps with deep linking, notifications, or complex flows. This keeps views focused on presentation.
- Use
navigationDestination(for:)to map types to views. Register one destination per type at the appropriate level in the hierarchy — usually on the list that pushes the detail. - Prefer value-based
NavigationLink(value:)over view-basedNavigationLink(destination:). The value-based API enables programmatic push and type-safe routing. - Use
.navigationTitleand.toolbaron the inner view, not on theNavigationStack. Modifiers on child views propagate up to the container.
Common Pitfalls
- Nesting
NavigationStackinsideNavigationStack. This creates a double navigation bar. Each navigation hierarchy should have exactly one stack at the root. - Missing
navigationDestinationregistration. If aNavigationLink(value:)pushes a type that has no registered destination, the link appears disabled. Ensure destinations are registered in the sameNavigationStackscope. - State loss on tab switch. If navigation paths are stored in
@Stateinside a tab's content view, they reset when switching tabs. Lift path state into a shared model or the router. - Sheets losing navigation context. Sheets create a new presentation context. A
NavigationStackinside a sheet is independent from the parent stack. If you need navigation within a sheet, add aNavigationStackinside it explicitly. - Deep link race conditions. Processing a deep link before the view hierarchy is fully loaded can silently fail. Defer deep link handling to after the root view appears using
.onAppearor a small delay.
Install this skill directly: skilldb add ios-swift-skills
Related Skills
App Architecture
MVVM and Clean Architecture patterns for structuring scalable, testable iOS applications
Combine
Combine framework reactive programming patterns for handling asynchronous events in iOS
Core Data
Core Data persistence framework for modeling, storing, and querying structured data in iOS apps
Networking
URLSession networking patterns for building robust API clients and handling data transfer in iOS
Swift Concurrency
Swift structured concurrency with async/await, actors, and task groups for safe concurrent iOS code
Swiftui
SwiftUI declarative UI framework fundamentals for building modern iOS interfaces