SwiftUI Navigation — NavigationStack, NavigationLink, and Paths
In this tutorial, you will learn about SwiftUI Navigation. We cover key concepts, practical examples, and best practices to help you master this topic.
SwiftUI navigation uses the NavigationStack (iOS 16+) to manage a stack of views, providing declarative push/pop navigation, programmatic path control, and deep linking support without needing UIKit's UINavigationController.
What You'll Learn
- NavigationStack and NavigationLink basics
- Programmatic navigation with NavigationPath
- Modal presentation with .sheet and .fullScreenCover
- Passing data between navigation destinations
- Deep linking and custom navigation paths
Why It Matters
Navigation is fundamental to multi-screen apps. UIKit's navigation was imperative — you pushed view controllers and managed the stack manually. SwiftUI's NavigationStack is declarative: you describe what the navigation should look like, and SwiftUI handles the transitions. NavigationPath gives you programmatic control for deep linking and complex flows.
Real-World Use
A shopping app uses NavigationStack with a list of products. Tapping a product pushes a detail view. From the detail, users can tap "Add Review" to push a review form. NavigationPath tracks the full path, and tapping the "Back to Products" button programmatically pops to root. A deep link from a notification directly opens the product detail.
Learning Path
flowchart LR A[SwiftUI Data Flow
Lesson 26] --> B[SwiftUI Navigation
You are here] B --> C[SwiftUI Lists
Lesson 28] B --> D[SwiftUI Animations
Lesson 29] style B fill:#f90,color:#fff
NavigationStack and NavigationLink
NavigationStack wraps a root view and manages the navigation stack. NavigationLink presents a destination view.
import SwiftUI
struct Product: Identifiable, Hashable {
let id: Int
let name: String
let price: Double
let description: String
}
struct ProductListView: View {
let products = [
Product(id: 1, name: "MacBook Pro", price: 2499.00, description: "Powerful laptop for professionals."),
Product(id: 2, name: "iPad Air", price: 599.00, description: "Versatile tablet for creativity."),
Product(id: 3, name: "AirPods Pro", price: 249.00, description: "Premium wireless earbuds."),
Product(id: 4, name: "Apple Watch", price: 399.00, description: "Health and fitness tracker.")
]
var body: some View {
NavigationStack {
List(products) { product in
NavigationLink(value: product) {
ProductRow(product: product)
}
}
.navigationTitle("Products")
.navigationDestination(for: Product.self) { product in
ProductDetailView(product: product)
}
}
}
}
struct ProductRow: View {
let product: Product
var body: some View {
HStack {
VStack(alignment: .leading) {
Text(product.name)
.font(.headline)
Text("$\(product.price, specifier: "%.2f")")
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.gray)
}
.padding(.vertical, 4)
}
}
struct ProductDetailView: View {
let product: Product
var body: some View {
VStack(spacing: 20) {
Image(systemName: "shippingbox")
.font(.system(size: 80))
.foregroundColor(.blue)
Text(product.name)
.font(.largeTitle)
.fontWeight(.bold)
Text("$\(product.price, specifier: "%.2f")")
.font(.title2)
.foregroundColor(.green)
Text(product.description)
.font(.body)
.multilineTextAlignment(.center)
.padding(.horizontal)
NavigationLink(value: product.id) {
Label("Write a Review", systemImage: "square.and.pencil")
}
.buttonStyle(.bordered)
}
.padding()
.navigationTitle("Product Detail")
.navigationDestination(for: Int.self) { productID in
ReviewFormView(productID: productID)
}
}
}
struct ReviewFormView: View {
let productID: Int
var body: some View {
Form {
Text("Review for Product #\(productID)")
TextField("Your review", text: .constant(""))
Button("Submit") { print("Review submitted") }
}
.navigationTitle("Write Review")
}
}
NavigationLink with value-based destinations is preferred over string-based navigation. Each value type gets a navigationDestination(for:) modifier that maps to the appropriate view.
Programmatic Navigation with NavigationPath
NavigationPath gives you control over the navigation stack programmatically.
import SwiftUI
struct ProgrammaticNavigationView: View {
@State private var navigationPath = NavigationPath()
var body: some View {
NavigationStack(path: $navigationPath) {
VStack(spacing: 20) {
Text("Navigation Control Center")
.font(.title)
Button("Go to Step 1") {
navigationPath.append("Step1")
}
Button("Go to Step 2") {
navigationPath.append("Step2")
}
Button("Go to Detail #5") {
navigationPath.append(5)
}
Button("Push Multiple Screens") {
navigationPath.append("Step1")
navigationPath.append("Step2")
navigationPath.append(99)
}
Button("Go Back One") {
navigationPath.removeLast()
}
Button("Go to Root") {
navigationPath.removeLast(navigationPath.count)
}
Text("Current path count: \(navigationPath.count)")
.foregroundColor(.secondary)
}
.navigationTitle("Navigation Demo")
.navigationDestination(for: String.self) { step in
StepView(step: step, path: $navigationPath)
}
.navigationDestination(for: Int.self) { number in
NumberDetailView(number: number, path: $navigationPath)
}
}
}
}
struct StepView: View {
let step: String
@Binding var path: NavigationPath
var body: some View {
VStack(spacing: 16) {
Text("You are at \(step)")
.font(.largeTitle)
Button("Go Forward") {
path.append("\(step)_extended")
}
Button("Go Back") {
path.removeLast()
}
}
.navigationTitle("Step \(step)")
}
}
struct NumberDetailView: View {
let number: Int
@Binding var path: NavigationPath
var body: some View {
VStack(spacing: 16) {
Text("Detail #\(number)")
.font(.largeTitle)
Button("Push Next (#\(number + 1))") {
path.append(number + 1)
}
Button("Back to Root") {
path.removeLast(path.count)
}
}
.navigationTitle("Number \(number)")
}
}
NavigationPath stores a type-erased array of hashable values. You can mix different types (String, Int, custom structs) in the same path.
Modal Presentations
Use .sheet for modal views and .fullScreenCover for full-screen modal presentations.
import SwiftUI
struct ModalPresentationView: View {
@State private var showSheet = false
@State private var showFullScreen = false
@State private var selectedItem: String?
var body: some View {
VStack(spacing: 20) {
Button("Show Sheet") {
showSheet = true
}
.buttonStyle(.borderedProminent)
Button("Show Full Screen Cover") {
showFullScreen = true
}
.buttonStyle(.borderedProminent)
Button("Show Sheet with Item") {
selectedItem = "Selected Item #42"
}
.buttonStyle(.bordered)
}
.sheet(isPresented: $showSheet) {
SheetContentView(isPresented: $showSheet)
}
.fullScreenCover(isPresented: $showFullScreen) {
FullScreenContentView(isPresented: $showFullScreen)
}
.sheet(item: $selectedItem) { item in
VStack(spacing: 20) {
Text(item)
.font(.title)
Button("Dismiss") {
selectedItem = nil
}
}
.padding()
}
}
}
struct SheetContentView: View {
@Binding var isPresented: Bool
var body: some View {
NavigationStack {
VStack(spacing: 16) {
Text("This is a sheet")
.font(.title)
Text("Drag down to dismiss or tap the button.")
.foregroundColor(.secondary)
}
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
isPresented = false
}
}
}
}
}
}
struct FullScreenContentView: View {
@Binding var isPresented: Bool
var body: some View {
VStack(spacing: 20) {
Text("Full Screen Cover")
.font(.largeTitle)
.fontWeight(.bold)
Image(systemName: "star.fill")
.font(.system(size: 60))
.foregroundColor(.yellow)
Button("Dismiss") {
isPresented = false
}
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.blue.opacity(0.1))
}
}
Use isPresented for boolean-controlled presentation and item for optional-based presentation that provides data to the presented view.
Navigation with TabView
NavigationStack can be nested inside TabView for multi-section apps.
import SwiftUI
struct TabNavigationView: View {
@State private var selectedTab = 0
var body: some View {
TabView(selection: $selectedTab) {
NavigationStack {
HomeTabView()
}
.tabItem {
Label("Home", systemImage: "house")
}
.tag(0)
NavigationStack {
SearchTabView()
}
.tabItem {
Label("Search", systemImage: "magnifyingglass")
}
.tag(1)
NavigationStack {
ProfileTabView()
}
.tabItem {
Label("Profile", systemImage: "person.circle")
}
.tag(2)
}
}
}
struct HomeTabView: View {
let categories = ["Technology", "Sports", "Music", "Food"]
var body: some View {
List(categories, id: \.self) { category in
NavigationLink(category, value: category)
}
.navigationTitle("Home")
.navigationDestination(for: String.self) { category in
Text("Category: \(category)")
.font(.title)
}
}
}
struct SearchTabView: View {
var body: some View {
Text("Search View")
.navigationTitle("Search")
}
}
struct ProfileTabView: View {
var body: some View {
Text("Profile View")
.navigationTitle("Profile")
}
}
Each tab has its own NavigationStack, so navigation state is preserved independently.
Deep Linking
Handle deep links by observing NavigationPath changes.
import SwiftUI
struct DeepLinkHandlingView: View {
@State private var navigationPath = NavigationPath()
var body: some View {
NavigationStack(path: $navigationPath) {
List {
Text("Deep Link Demo")
.font(.headline)
}
.navigationTitle("Home")
.navigationDestination(for: String.self) { id in
Text("Detail for \(id)")
}
}
.onOpenURL { url in
handleDeepLink(url)
}
}
private func handleDeepLink(_ url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let host = components.host else { return }
switch host {
case "product":
if let id = components.queryItems?.first(where: { $0.name == "id" })?.value {
navigationPath.append("product_\(id)")
}
case "settings":
navigationPath.append("settings")
default:
break
}
}
}
Common Mistakes
Using NavigationLink without NavigationStack: NavigationLink requires a NavigationStack ancestor. Without it, the link does nothing.
Multiple navigationDestination for the same type: You can only have one
navigationDestination(for:)per type per navigation stack.Modifying navigationPath from background threads: NavigationPath is not thread-safe. Always modify it from the main actor.
Not providing unique tag values in TabView: Tags must be unique across all tabs for proper selection binding.
Deep nesting of NavigationStack: Avoid nesting NavigationStack inside NavigationStack. This creates nested navigation bars and confusing behavior.
Practice Questions
- What is the difference between NavigationLink and
navigationDestination? - How does NavigationPath enable programmatic navigation?
- When would you use
.sheetinstead of pushing with NavigationLink? - How do you pass data to a destination view using NavigationLink(value:)?
- Challenge: Build a recipe app with categories, recipe lists, and recipe details using NavigationStack. Add programmatic navigation that navigates to a specific recipe from a deep link (recipe-app://recipe/123). Include a "Back to Categories" button.
Mini Project
Create a StoreNavigationApp with:
- A TabView with "Shop", "Cart", and "Profile" tabs, each with its own NavigationStack
- Shop tab: list of product categories, each pushing to a product list, each product pushing to detail
- Cart tab: list of cart items, "Checkout" button that pushes to a payment form
- Profile tab: settings list with navigation to various settings screens
- NavigationPath for the Shop tab to enable "Back to Home" from any depth
- A .sheet for the checkout confirmation
FAQ
What's Next
After mastering navigation, build data-driven lists with SwiftUI Lists, or add motion and transitions in SwiftUI Animations.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro