SwiftUI Navigation in iOS 16: Stacks and Split Views
With iOS 16, Apple replaced the legacy NavigationView
with two purpose-built, data-driven components:
NavigationStack and NavigationSplitView. This
update fundamentally changes how SwiftUI handles routing by decoupling
view hierarchy from navigation logic. By binding view transitions
directly to data models, developers gain full programmatic control over
the view stack, native multi-column support across Apple platforms, and
streamlined deep linking.
The Modern Navigation Model
Prior to iOS 16, SwiftUI relied on
NavigationLink(destination:isActive:), which embedded the
destination view directly into the UI declaration. This approach caused
performance bottlenecks, redundant view instantiations, and unreliable
programmatic routing.
iOS 16 solves this by introducing type-driven routing using the
.navigationDestination(for:destination:) modifier. Views
present destinations dynamically based on the type of data passed to
them, keeping the source view decoupled from the destination view.
Using NavigationStack for Linear Hierarchies
NavigationStack is designed for single-column navigation
flows, standard on iPhone devices. It maintains a stack of views where
the root view is always present, and additional views can be pushed onto
or popped off the stack.
Programmatic Routing with State
A NavigationStack can be bound directly to a collection
of data representing the current path:
struct ContentView: View {
@State private var path: [Product] = []
var body: some View {
NavigationStack(path: $path) {
List(products) { product in
NavigationLink(value: product) {
Text(product.name)
}
}
.navigationDestination(for: Product.self) { product in
ProductDetailView(product: product)
}
}
}
}Key features of this pattern include:
- Heterogeneous Stacks: By using
NavigationPathinstead of a strongly typed array, developers can push different data types onto the same stack while maintaining type safety. - Deep Linking and State Restoration: Manipulating
the path collection automatically pushes or pops screens. Resetting
navigation to the root view requires only clearing the array
(
path.removeAll()). - Deferred Loading: Destination views are only initialized when pushed, eliminating the premature rendering issues present in older SwiftUI versions.
Using NavigationSplitView for Multi-Column Layouts
NavigationSplitView provides native support for
multi-pane layouts on iPadOS, macOS, and larger display environments. It
replaces the ambiguous split-screen behavior of
NavigationView with explicit column structures.
Two-Column and Three-Column Interfaces
NavigationSplitView accepts either two or three columns
depending on layout requirements:
struct SplitExampleView: View {
@State private var selectedCategory: Category?
@State private var selectedItem: Item?
var body: some View {
NavigationSplitView {
// Sidebar
List(categories, selection: $selectedCategory) { category in
Text(category.title).tag(category)
}
} content: {
// Content List
if let selectedCategory {
List(selectedCategory.items, selection: $selectedItem) { item in
Text(item.name).tag(item)
}
} else {
Text("Select a category")
}
} detail: {
// Detail View
if let selectedItem {
ItemDetailView(item: selectedItem)
} else {
Text("Select an item")
}
}
}
}Adaptive Behavior Across Screen Sizes
NavigationSplitView adapts automatically to the
available display environment:
- On regular horizontal size classes (such as iPad landscape or macOS), all columns appear side by side or as an interactive slide-out pane.
- On compact size classes (such as standard iPhone portrait), the split view collapses into a linear stack hierarchy. The system uses selection state bindings to coordinate between the collapsed single view and expanded multi-pane modes seamlessly.
Key Differences Summary
NavigationStackis intended for single-column, drill-down interactions where users navigate deeper into a single branch of content.NavigationSplitViewis intended for hierarchical, multi-level navigation across multiple columns that adapt responsively to screen size.- Both APIs eliminate fragile view-level state flags in favor of persistent, model-driven data paths.