iOS with SwiftUI · Navigation, Networking, Persistence and Concurrency

Navigation with NavigationStack and Sheets

Push screens with NavigationStack and value-based NavigationLink, drive navigation from a path you own, present sheets, and handle deep links.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

A shop with a product list, a product detail screen, a cart sheet, and a deep link (shop://product/mug-01) that opens the right product with the list behind it.

The idea

  • NavigationStack manages a stack of screens. NavigationLink(value:) pushes a value, and .navigationDestination(for: Type.self) says which view shows that type.
  • Binding the stack to a path you own ([Route] or NavigationPath) makes navigation state: push by appending, pop by removing, go to the root by emptying it, and restore or deep link by setting the whole path.
  • Sheets (.sheet(item:) or .sheet(isPresented:)) present modal tasks such as a cart or a form, separate from the push stack.
  • On iPad, NavigationSplitView shows a sidebar and detail side by side.

The code

enum Route: Hashable {
    case product(id: String)
    case reviews(productID: String)
}

@Observable
final class Router {
    var path: [Route] = []
    func open(_ route: Route) { path.append(route) }
    func popToRoot() { path.removeAll() }

    func handle(url: URL) {                                 // shop://product/mug-01
        guard url.scheme == "shop", url.host() == "product" else { return }
        path = [.product(id: url.lastPathComponent)]
    }
}

struct ShopRoot: View {
    @State private var router = Router()
    @State private var showCart = false

    var body: some View {
        NavigationStack(path: $router.path) {
            ProductListView(onOpen: { router.open(.product(id: $0)) })
                .navigationTitle("Shop")
                .toolbar { Button("Cart", systemImage: "cart") { showCart = true } }
                .navigationDestination(for: Route.self) { route in
                    switch route {
                    case .product(let id): ProductDetailView(productID: id)
                    case .reviews(let id): ReviewsView(productID: id)
                    }
                }
        }
        .sheet(isPresented: $showCart) { CartView(onCheckoutDone: { showCart = false; router.popToRoot() }) }
        .onOpenURL { router.handle(url: $0) }
        .environment(router)
    }
}

Try the logic

The router is plain Swift, so its rules (no duplicate pushes, deep links, pop to root) run and test without a device:

A navigation path you own
import Foundation

enum Route: Hashable, CustomStringConvertible {
    case product(id: String)
    case reviews(productID: String)
    var description: String {
        switch self {
        case .product(let id): "product(\(id))"
        case .reviews(let id): "reviews(\(id))"
        }
    }
}

final class Router {
    var path: [Route] = []
    func open(_ route: Route) { if path.last != route { path.append(route) } }
    func back() { _ = path.popLast() }
    func popToRoot() { path.removeAll() }
    func handle(url: URL) {
        guard url.scheme == "shop", url.host == "product" else { return print("ignored \(url)") }
        path = [.product(id: url.lastPathComponent)]
    }
}

let router = Router()
router.open(.product(id: "mug-01"))
router.open(.product(id: "mug-01"))            // double tap
router.open(.reviews(productID: "mug-01"))
print("root > \(router.path)")
router.back()
print("after back: root > \(router.path)")
router.popToRoot()
print("after checkout: \(router.path.isEmpty ? "root" : "\(router.path)")")
router.handle(url: URL(string: "shop://product/teapot-07")!)
print("deep link: root > \(router.path)")
router.handle(url: URL(string: "https://example.com/x")!)

Output

root > [product(mug-01), reviews(mug-01)]
after back: root > [product(mug-01)]
after checkout: root
deep link: root > [product(teapot-07)]
ignored https://example.com/x

Run this on your computer — see “Get Started” for setup.

Common mistakes

  • NavigationLink(destination:) everywhere. Destination-based links can't be driven from state or deep links; use value-based links with navigationDestination.
  • Putting navigationDestination inside a lazy container. Attach it to a view that's always present, such as the list itself.
  • Nesting NavigationStacks. Each screen inside a stack shouldn't create its own stack.
  • Passing whole models as routes. Use small Hashable values (IDs), especially if you save the path to restore it.

Key points

  • NavigationStack with value-based links and navigationDestination is the modern navigation API.
  • Bind the stack to a path you own to push, pop, restore and deep link with plain data.
  • Use sheets for modal tasks and NavigationSplitView for sidebar layouts on iPad.
  • Keep routes small and Hashable.

Further reading: NavigationStack, Migrating to new navigation types.

Practice questions

What's the advantage of binding NavigationStack to a path?

Answer

Navigation becomes plain state: you can push or pop by changing an array, return to the root by clearing it, restore the user's place by saving and reloading it, and handle deep links by building the path, all testable without UI.

When should you use a sheet instead of pushing a screen?

Answer

For a self-contained task that interrupts the current flow and is dismissed when done (composing a message, a cart, a settings form, a sign-in). Pushing is for drilling down into more detail within the same hierarchy.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?