SwiftUI: Deep Inside NavigationView

Michael Long
Jun 23 · 9 min read

Navigation is fundamental to applications, and SwiftUI has implemented some pretty good abstractions that cover most of the bases.

That said, there is some misinformation floating around, not to mention a bit of weirdness that becomes apparent when you start examining the code.

Namely, what the heck is going on with that navigationBarTitle modifier???

A Master/Detail Navigation List

To demonstrate the issues with SwiftUI navigation we’re going to implement a classic master/detail list pair. The code for the the master list is here…

struct BasicNavigationView : View {
let users: [User] = User.users
var body: some View {
NavigationView {
List(users) { user in
NavigationLink(destination: BasicNavigationDetailView(user: user)) {
Text(user.name)
}
}
.navigationBarTitle(Text("Users"))
}
}
}

I’ll show you the detail view code in a bit, but both of them generate the following master/detail views...

Let’s break it down, shall we?

The NavigationView

The Master list view gets a list of users and presents them. Tapping on a user takes you to a new screen that shows you some details regarding that user.

So first things first, if we want to do a basic push navigation from screen A to screen B, the contents of screen A must be contained within a SwiftUI NavigationView.

struct BasicNavigationView : View {
let users: [User] = User.users
var body: some View {
NavigationView {
...
}
}
}

A NavigationView is analogous to a UIKit UINavigationController, as it’s the primary container in which which all of our navigation pushes and pops will take place.

So our view’s body variable returns a NavigationView. And again in UIKit terms, this makes BasicNavigationView our root view controller.

The NavigationLink

Our master view contains a list where each element is a NavigationLink.

NavigationLink(destination: BasicNaviDetailView(user: user)) {
Text(user.name)
}

A NavigationLink wraps whatever content we desire with a gesture recognizer. When the user taps on that content, the user is taken to the view specified as the destination.

In this case our destination is BasicNavigationDetailView, to which we’re also passing our current user. That destination is created and pushed onto the current NavigationView, much the same as when a new UIViewController is instantiated and pushed onto the current navigationController.

Note that NavigationLink’s don’t have to be inside of lists. They can appear anywhere and wrap anything. They do, however, have to exist inside of a NavigationView. If a NavigationView isn’t present they’re disabled.

One last thing here is that when you use a NavigationLink inside of a list, the disclosure caret (>) is added for you automatically on iOS.

Let’s Examine That NavigationBarTitle Modifier

If you’re a little familiar with SwiftUI, you know that a good portion of the code you write consists of Views and view Modifiers.

If you’re not familiar with this, you might want to read my earlier article, SwiftUI: Understanding Declarative Programming. Don’t worry, we’ll wait.

So you have Views… and you have Modifiers, which return modified views… and I want to change the NavigationView’s title… so why in the heck is the navigationBarTitle Modifier not modifying the NavigationView???

In our master list, the .navigationBarTitle(Text(“Users”)) modifier appears to be modifying the List, not the NavigationView.

var body: some View {
NavigationView {
List(users) { user in
...
}
.navigationBarTitle(Text("Users"))
}
}

Don’t worry. You’re not going crazy and it all makes sense. I promise.

The View Modifier Secret

You may want to sit down for this. You see, some view modifiers don’t modify the view at all.

And this is one of those cases, where navigationBarTitle isn’t actually modifying the List view to which it’s attached.

What navigationBarTitle is actually doing is telling SwiftUI to look around and find the current NavigationView in which it’s residing. When it does, it needs to change its title to what we want, in this case “Users”. It does nothing to the list on which it happens to be attached.

Still confused? Don’t be.

The SwiftUI DSL

What you need to remember is that you’re writing SwiftUI you’re not creating “views” at all. Or at least not in the way you’d normally think of it like when you’re in UIKit and you’re creating and nesting UIStackViews and UILabels and the like.

In SwiftUI, every “View” and “Modifier” you create is just an instruction to SwiftUI’s build system.

Now, some of those instructions are in fact used to create the actual interface elements we see on the screen.

Others, however, are used to create things like gesture recognizers, and some of them specify target actions like .presentation(), and others control animations, and others are used simply to manipulation the system environment.

Sometimes you’ll see a reference to passthrough views or modifiers and this is what they’re talking about.

The fact that modifiers return a type of View is simply a syntactic convenience. Doing so lets us continue using SwiftUI’s builder pattern to chain additional modifiers and views together in our code.

This is one of the keys to understanding SwiftUI.

It’s a functional, Domain Specific Language (DSL) for interface declaration.

The Detail View

To further illustrate this point, I deliberately attached the navigationBarTitle modifier to the BasicNavigationAddressView subview inside of the BasicNavigationDetailView below.

struct BasicNavigationDetailView : View {
let user: User
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(user.name).font(.largeTitle)
BasicNavigationAddressView(user: user)
.navigationBarTitle(Text("Details"), displayMode: .inline)
Text(user.email)
Spacer()
.frame(minWidth: 0, maxWidth: .infinity)
}
.padding()
}
}

This also shows that the navigationBarTitle modifier can be anywhere in the view/modifier tree. It could even have been contained within the BasicNavigationAddressView subview should that have better suited the code.

Note as well how BasicNavigationDetailView does not contain a NavigationView. It doesn’t need one, as it already exists inside of the NavigationView context created by the parent master view.

Finally, the added parameter displayMode: .inline on navigationBarTitle switches the NavigationView from large title mode to the standard, smaller inline mode.

One NavigationView Per Navigation Stack

As mentioned, BasicNavigationDetailView doesn’t have it’s own NavigationView, as it already exists inside of the NavigationView context created by the parent master view.

Thus not every view that wants to navigate needs to have a distinct NavigationView specified, any more than you’d wrap a UIViewController inside of a UINavigationViewController and push it onto an existing navigationController. This is the misinformation I’d referred to before.

To demonstrate, let’s add a “See Photo” button to our detail view after the email address. (Yes, it’s a wonderful UI/UX design.)

...
Text(user.email)
if user.hasImage {
NavigationLink(destination: BasicNavigationPhotoView(user: user)) {
Text("See Photo")
}
}
Spacer()
...

Again, we wrap our text with a NavigationLink, with BasicNavigationPhotoView as our destination. When the button is tapped, that destination is pushed onto the stack using the current NavigationView created by our root view.

If you’re counting, our navigation stack now contains our root view, the detail view for a given user, and now that user’s image view.

As shown, that view display’s the user’s avatar, along with changing the title again. Note that in this case navigationBarTitle just happens to be tacked onto the Image view, but it still was able to reach up and tell the NavigationView to change its title.

struct BasicNavigationPhotoView : View {
let user: User
var body: some View {
Image(uiImage: user.image)
.resizable()
.aspectRatio(contentMode: .fill)
.navigationBarTitle(Text(user.name))
}
}

View Initialization Issues

I covered this in SwiftUI and How NOT to Initialize Bindable Objects, but since we’re digging around in NavigationView and NavigationLink, it bears repeating here.

Note again the button’s definition in the detail view’s code.

NavigationLink(destination: BasicNavigationPhotoView(user: user)) {
Text("See Photo")
}

The problem with the current implementation of SwiftUI (as of beta 2) is that a single instance of BasicNavigationPhotoView appears to be created and initialized when the detail view is instantiated.

To see the problem this creates, update the code in the photo navigation view.

struct BasicNavigationPhotoView : View {
...
let random = Int.random(in: 1..<100)
var body: some View {
Image(uiImage: user.image)
...
.onAppear { print(self.random) }
}
}

Basically we added a constant random number that we’re printing to the log whenever our view is shown. If you bounce back and forth between a given detail view and the photo view, you’ll see the same random number printed again and again.

Bounce back to the main screen, select a different user, and repeat the process and you’ll see a new number repeated between the detail and photo views.

This has some serious repercussions in regard to view state and when using more advanced architectures that use models and ObjectBinding.

Again, read SwiftUI and How NOT to Initialize Bindable Objects for more details.

No Navigation Stack Management

The current version of SwiftUI (again, beta 2) also appears to have no navigation stack management features.

To use UIKit terminology, there’s no way to programatically pop a view controller, pop back to a given view controller, or even to pop back to the root view controller. Nor can you see or inspect the navigation stack.

This means that you can’t implement a Save button that, when tapped, returns the user back to the previous view.

Flutter, another declarative UI system from Google, exposes a Navigator object via a widget’s context and that navigator can be used to manipulate the navigation stack.

Apple could do something similar via an environment variable, or they may have something else in mind. (A NavigationPopButton?)

We can only speculate at this time, but this is a serious omission that needs to be addressed. Soon.

One curiosity is that SwiftUI’s NavigationViews can be styled to a certain degree using UIKit. Add the following to our AppDelegate…

UINavigationBar.appearance().backgroundColor = .red

And the NavigationView’s background color becomes red. This would indicate that SwiftUI is working within and using the iOS UIKit framework to a certain degree, and that would make sense as SwiftUI Views can in fact be used within UIKit applications.

I’d very much consider this to be a private implementation detail at this point in time, and as such you probably shouldn’t rely on it.

Presentation and Tab Views

SwiftUI also let’s you present views modally, as we currently do with UIKit using navigationController.present.

Using it is a bit outside the scope of this discussion, other than to mention that if you present a new View, then that view may need its own NavigationView.

Again, this is the same as when using UIKit and note that the same follows for using tab views.

Tab views depend on the desired interface characteristics, however, as you may want a set of tabs, each with its own navigation stack, or you may have a navigation view that contains a view that contains a set of tabs.


Completion Block

So that’s today’s dive into NavigationView. To sum up:

  1. If you need to navigate you need a NavigationView.
  2. You only need one NaviagtionView per navigation stack.
  3. Use NavigationLink’s to push new views onto the navigation stack.
  4. NavigationLink’s can appear anywhere and wrap anything.

As is apparent from some of the bugs and the current lack of functionality, navigation in SwiftUI is still very much a work in progress.

Rest assured that when Apple drops a new beta I’ll repeat the tests, check out the new features, and update the article accordingly. So make sure you check back from time to time.

As always, leave any questions in the comments section below, and throw a clap or two my way if you like the article and if you want to see more like it.

Thanks for reading.


Edit for Xcode 11 Beta 3

As of Xcode 11 Beta 3, NavigationButton is deprecated and has been replaced with NavigationLink. NavigationLink appears to work the same as NavigationButton, with the same basic behaviors (including the View Initialization Issues).

The article and sample code have been updated accordingly.


Michael Long

Written by

Michael Long (RxSwifty) is a Senior Lead iOS engineer at CRi Solutions, a leader in cutting edge iOS, Android, and mobile corporate and financial applications.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade