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.
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
NavigationStackmanages 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]orNavigationPath) 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,
NavigationSplitViewshows 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:
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 withnavigationDestination.- Putting
navigationDestinationinside 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
Hashablevalues (IDs), especially if you save the path to restore it.
Key points
NavigationStackwith value-based links andnavigationDestinationis 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
NavigationSplitViewfor 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.