Hands-on with NavigationStack in SwiftUI
Background
Navigation is best described as the glue that binds your whole app together. Your navigation logic has to be able to coordinate views and their transitions across multiple hierarchies of various depths and view types.
For over a decade, the standard for building navigation flows in Mac or iOS apps has been to create a series of View Controllers that were managed by some sort of Navigation Controller. The typical classes to manage navigation were UINavigationController
in UIKit, or a NSTabViewController
in AppKit. They each served a different goal, with navigation on the smaller iOS-devices typically focusing on hierarchical systems where you drilled down into detail views, and Mac apps presenting content only on one level using a sidebar or tab bar.
NavigationView
The introduction of SwiftUI back in 2019 brought about a brand new standard for building navigation on Apple platforms. The new system was focused around two view structs, NavigationLink
and NavigationView
. By simply declaring a NavigationView
, the operating system would create a UI component adapted for the specific platform, without any extra setup required.
To support navigation, the idea was to create a series of NavigationLink
views that contained a destination view, as well as a closure to define a custom Label
which would make up the appearance of the link (typically a button or list item).
NavigationView {
NavigationLink(destination: Text("Second View")) {
Text("Hello, World!")
}
.navigationTitle("Navigation")
}
Developers had very limited control over the behaviour here. We were able to use the NavigationViewStyle
modifier to imply that we wanted a stack
(hierarchical navigation) or column
(split view navigation), but for the most part the experience was left for the OS to handle internally. At a first glance, this solution looked too good to be true, and it turned out to be just that.
NavigationView
worked fine for very basic navigation. You could declare a link to a child view, and that child view could link to a deeper child view. But that is usually not enough for a real world use case, where our data models are dynamic and the app state will dictate what navigation flows are available. Even in UIKit, we could usually not depend on UINavigationController
alone. Apps would employ certain patterns such as Coordinator or Router-pattern, usually built to allow additional logic to control complex navigation flows.
Because NavigationView
was designed around views presenting views, it relied on each view having intrinsic knowledge about the next level of views it could present. It lacked logic for common use cases such as handling deep linking, where a user could navigate straight down the hierarchy without passing by the views in between.
On certain platforms like macOS, it also didn’t even support hierarchical push-pop navigation at all. This, together with a series of other similar limitations, had most developers finding it impossible to integrate into many real-world app architectures.
Navigation in SwiftUI 4
It seems that Apple has realised the shortcomings of NavigationView
, because they've just presented yet another a brand new method for navigation in SwiftUI. NavigationView
is now deprecated in favour of two new container types: NavigationStack
and NavigationSplitView
. The first thing to note is that these are now two distinct containers, as opposed to the single NavigationView
that used different view styles for stack or split view.
In this article, we'll be taking a closer look at the NavigationStack
API. NavigationStack
defines navigation as a stack of either views or data objects. In the former case, we can use it similarily to how we would use the now-deprecated NavigationView
, by declaring a NavigationLink
that takes the user to a new view. Let’s take a look.
NavigationStack
To play around with a simple NavigationStack
example in SwiftUI 4, first let's define a data type to display some of the friends from my camera roll.
enum Friend: String, CaseIterable, Identifiable {
case alex
case boo
case carl
case emma
case steve
var id: String {
return rawValue
}
var name: String {
return rawValue.capitalized
}
}
struct FriendView: View {
let friend: Friend
var body: some View {
VStack {
Image(friend.id)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 340, height: 300)
}
.navigationTitle(friend.name)
}
}
Now, let’s present it using a NavigationStack.
struct ContentView: View {
var body: some View {
NavigationStack {
VStack {
ForEach(Friend.allCases) { friend in
NavigationLink(friend.rawValue) {
FriendView(friend: friend)
}
}
}
}
.navigationTitle("Friends")
}
}
This first scenario is almost identical to how you would use the older NavigationView
, but there is a key difference here: the default navigation on macOS is no longer a split view, but a hierarchical push-pop navigation.
Working with Stacks
Now, let’s get into the new advantages of using NavigationStack
. Instead of declaring target views for our NavigationLinks
inside of the body, we can use a new navigationDestination(for:destination:)
modifier.
This new modifier is available on any view, and it allows us to define multiple destinations for a parent NavigationStack
depending on a certain data type. We can now switch to using this method by simply changing our NavigationLink
to instead pass on the data object that we know the next view wants as a parameter.
struct ContentView: View {
var body: some View {
NavigationStack {
VStack {
ForEach(Friend.allCases) { friend in
NavigationLink(friend.rawValue, value: friend)
}
}
.navigationDestination(for: Friend.self, destination: { friend in
FriendView(friend: friend)
})
}
.navigationTitle("Friends")
}
}
Let’s stop to think about what we’ve done here. By decoupling the NavigationLink
from the child view, we’ve opened up for a much more intuitive and flexible navigational structure. Instead of designing our navigation around links pointing to views, we have links that represents a data object, and we control what child view to use in a separate step for each data type.
Behind the scenes, NavigationStack
will use our data to construct a stack that defines our navigation path. When we tap our link, NavigationStack
appends the model to the path, and pushes the navigation destination representing that friend onto the stack.
Advanced scenarios
The most important part of NavigationStack
is that it exposes access to the model path itself, so we can navigate programmatically by simply mutating the path. To take advantage of this, we first have to define our own path state locally in our parent view, and pass that into to the NavigationStack
as a binding.
struct ContentView: View {
@State private var path: [Friend] = []
var body: some View {
NavigationStack(path: $path) {
(...)
}
}
}
With this setup, we can easily manipulate the navigation flow. We can initiate our view to show a certain friend immediately by just instantiating our path to path = [.carl]
, or create a function to display any series of views by just mutating path
directly.
Want to go back to the root? Simply remove all of the the contents of the path.
func popToRoot() {
path.removeAll()
}
Let’s try it out by changing our example to display all of our Friends, by instantiating our path
to all cases of Friend
.
@State private var path: [Friend] = Friend.allCases
The possibilities here are endless. One could imagine that in a real-world scenario, we would have an @ObservableObject
navigation model class managing the path and powering one or several instances of NavigationStack
, essentially acting as a router.
Summing it up
We've only scratched the surface of the new navigation APIs in SwiftUI 4, but we're super excited about the new improvements. Developers are finally in complete control of the navigation behaviours in SwiftUI, and the new API is super easy to use 🥳