Complete Guide to Navigation View in SwiftUI

One of the most crucial parts of a SwiftUI project is the NavigationView, which allows us to push and pop panels with ease and organize content hierarchically for the benefit of users. In this post, I'll show you how to utilize NavigationView in a variety of ways, from the fundamentals like defining a title and adding buttons, to the more advanced topics like programmatic navigation and split views.

Learn the Fundamentals of Navigation Titled views

As a first step in working with NavigationView, just enclose your display content with one.

struct ContentView: View {
    var body: some View {
        NavigationView {
            Text(“Hey there!")
        }
    }
} 

If you're using a TabView, the navigation view should be within the TabView, but in simpler layouts, it should be at the top of the view hierarchy.

One of the most puzzling aspects of learning SwiftUI is the process of adding titles to a navigation view.

NavigationView {
    Text("Hey there! ")
        .navigationTitle("Navigation")
}

Take note that the navigationTitle() modification should be applied to the text view and not the navigation view itself. That's on purpose, and it's the proper method to insert a title.

As an example, using navigation views, we may slide in additional screens from the right side of the screen to provide more material. SwiftUI is responsible for updating the navigation view to reflect the currently active screen's title at all times; when a new title is selected, the previous one will fade out and the new one will fade in.

Think at it this way: if we had linked the title to the navigation view, we would be saying, "this is the permanent title from now on." SwiftUI is able to adapt the title to reflect the current state of the navigation view's contents by linking it to the view's internal elements.

It is not necessary to use navigationTitle() on the root view of the navigation stack.

Using the navigationBarTitleDisplayMode() modification, we have three choices for how the title is displayed:

  • The .large option displays oversized titles, perfect for usage with your navigation stack's root node.
  • In order to save space in your navigation stack, the .inline option displays short titles.
  • The .automatic setting takes into account whatever was in effect in the previous view.

Most uses benefit from starting with the .automatic option, which is obtained by simply omitting the modifier:

.navigationTitle("Navigation")

Typically, you'd use the.inline option like this for each view that is added to the navigation stack:

.navigationTitle("Navigation")
.navigationBarTitleDisplayMode(.inline)

Seeking fresh perspectives

Navigation views utilize NavigationLink to display subsequent screens, which may be activated by the user touching on the view's contents or by code.

The ability to push to any view, whether it a custom view or one of SwiftUI's basic views (useful for prototyping) is one of my favorite aspects of NavigationLink.

This, for instance, feeds into a textual representation:

NavigationView {
    NavigationLink(destination: Text(" 2nd  View")) {
        Text("Hey there!")
    }
    .navigationTitle("Navigation")
}

Since I embedded a text view within my navigation link, SwiftUI will color the text blue to let users know it can be interacted with. This is a fantastic feature, but it does have one annoying quirk: if you include an image in your navigation link, the image may suddenly turn blue.

You can test this out by including two images in your project's asset catalog: a photo and a shape with transparency. Specifically, I modified the following to include my profile picture and the Hacking with Swift logo:

NavigationLink(destination: Text("2nd View")) {
    Image("Image.png")
}
.navigationTitle("Navigation")

SwiftUI will color the image I uploaded (which was originally red) blue when I run the app because it is trying to be helpful by indicating to users that the image can be interacted with. Although the image has opacity, SwiftUI does not alter the transparent areas, so the logo can be seen without any difficulty.

The outcome would have been worse if I had used my own photo.

NavigationLink(destination: Text("2nd View")) {
    Image("Jak")
}
.navigationTitle("Navigation")

Since it is an image without transparency, SwiftUI has colored the whole thing blue, making it appear like a blue square.

Attaching a renderingMode() modification to your picture like this will cause SwiftUI to display it in its native color mode.

NavigationLink(destination: Text("2nd View")) {
    Image("Jak")
        .renderingMode(.original)
}
.navigationTitle("Navigation")

Don't forget that if you do that, the picture will no longer have the "interactive" blue hue.

Moving information between panes

When adding a new view to your navigation stack using NavigationLink, you may provide any necessary arguments.

This kind of results page may be used, for instance, if we flipped a coin and asked people to choose either "heads" or "tails."

struct ResultView: View {
    var choice: String
    var body: some View {
        Text("You chose \(choice)")
    }
}

This would allow us to provide two distinct navigation links in our content view, one of which would generate a ResultView with "Heads" as the default option and the other with "Tails." These parameters are required while generating the final output's view:

struct ContentView: View {
    var body: some View {
        NavigationView {
            VStack(spacing: 30) {
                Text("You're going to flip a coin – do you want to choose heads or tails?")
                NavigationLink(destination: ResultView(choice: "Heads")) {
                    Text("Choose Heads")
                }
                NavigationLink(destination: ResultView(choice: "Tails")) {
                    Text("Choose Tails")
                }
            }
            .navigationTitle("Navigation")
        }
    }
}

You may be certain that SwiftUI will always check that the values you provide to initialize your detail views are valid.

The second initializer for NavigationLink in SwiftUI takes an isActive argument, letting us read or write the active state of the link. A navigation link may be activated programmatically by making its monitored state true.

Using this method, we can do things like generate a navigation link with no content and associate it with the isShowingDetailView property:

struct ContentView: View {
    @State private var isShowingDetailView = false
    var body: some View {
        NavigationView {
            VStack {
                NavigationLink(destination: Text("Second View"), isActive: $isShowingDetailView) { EmptyView() }
                Button("Tap to show detail") {
                    self.isShowingDetailView = true
                }
            }
            .navigationTitle("Navigation")
        }
    }
}

The navigation action is initiated not by the user's interaction with the navigation link itself but by the button placed underneath it, which, when activated, sets isShowingDetailView to true.

SwiftUI provides a solution to the problem of keeping track of several Booleans for each potential navigation destination by allowing us to instead assign a tag to each navigation link and then determine which one is activated using a single property. In this case, depending on which buttons were touched, one of two granular views will appear:

struct ContentView: View {
    @State private var selection: String? = nil
    var body: some View {
        NavigationView {
            VStack {
                NavigationLink(destination: Text("2nd View"), tag: "Second", selection: $selection) { EmptyView() }
                NavigationLink(destination: Text("3rd View"), tag: "Third", selection: $selection) { EmptyView() }
                Button("Tap to show second") {
                    self.selection = "2nd"
                }
                Button("Tap to show third") {
                    self.selection = "3rd"
                }
            }
            .navigationTitle("Navigation")
        }
    }
}

It's also worth noting that state resources may be used to refute arguments as well as offer them. For instance, we could program a navigational link that, when tapped, displays a detail screen and, after two seconds, reverts to hiding that screen by setting isShowingDetailView to false. In fact, this means you may open the app, manually press the link to display the alternative view, and then, after a short delay, be returned to the first screen.

For instance:

struct ContentView: View {
    @State private var isShowingDetailView = false
    var body: some View {
        NavigationView {
            NavigationLink(destination: Text("Second View"), isActive: $isShowingDetailView) {
                Text("Show Detail")
            }
            .navigationTitle("Navigation")
        }
        .onAppear {
            DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
                self.isShowingDetailView = false
            }
        }
    }
}

Value transmission through the surrounding environment

To facilitate data sharing even in extremely deep navigation stacks, NavigationView automatically shares its environment with each child view that it shows. The trick is to modify the navigation view itself using the environmentObject() modification rather than an object contained inside it.

To show this, let's create a basic kind of observed object that will store our information:

class User: ObservableObject {
    @Published var score = 0
}

Then, we could use an environment object to display the data in a detailed view and provide a means of improving the score there.

struct ChangeView: View {
    @EnvironmentObject var user: User
    var body: some View {
        VStack {
            Text("Score: \(user.score)")
            Button("Increase") {
                self.user.score += 1
            }
        }
    }
}

We were able to inject a new User instance into the navigation view environment after having our ContentView generate one.

struct ContentView: View {
    @StateObject var user = User()
    var body: some View {
        NavigationView {
            VStack(spacing: 30) {
                Text("Score: \(user.score)")
                NavigationLink(destination: ChangeView()) {
                    Text("Show Detail View")
                }
            }
            .navigationTitle("Navigation")
        }
        .environmentObject(user)
    }
}

Just keep in mind that the navigation view's environment object will be inherited by any views that are shown by that view; for example, if ChangeView displays a separate detail screen, it will also have access to the environment.

Advice: Have a dedicated model layer for reference types instead of constructing them locally to a view in production applications.

The addition of new buttons to a bar

A navigation view may have one or more buttons in front of it, one or more buttons in back, or both. Such displays may take the form of buttons or hyperlinks.

This, for instance, generates one score-modifying button in the bottom bar of navigation:

struct ContentView: View {
    @State private var score = 0
    var body: some View {
        NavigationView {
            Text("Score: \(score)")
                .navigationTitle("Navigation")
                .navigationBarItems(
                    trailing:
                       Button("Add 1") {
                            self.score += 1
                        }
                )
        }
    }
}

To get a button on the left and right, for example, you would simply give the following initial and final parameters:

Text("Score: \(score)")
    .navigationTitle("Navigation")
    .navigationBarItems(
        leading:
            Button("Subtract 1") {
                self.score -= 1
            },
        trailing:
            Button("Add 1") {
                self.score += 1
            }
    )

To keep the two buttons together on the same side of the menu bar, you may use an HStack to do so.

Text("Score: \(score)")
    .navigationTitle("Navigation")
    .navigationBarItems(
        trailing:
            HStack {
                Button("Subtract 1") {
                    self.score -= 1
                }
                Button("Add 1") {
                    self.score += 1
                }
            }
    )

Advice: The tap target area of buttons added to the navigation bar is rather tiny, thus padding should be provided to make them simpler to tap.

Changes to the menu bar

The navigation bar's appearance may be altered in several ways, including text size, color, and transparency. However, there is currently limited support for this inside SwiftUI; in fact, just two modifiers may be used without resorting to UIKit:

Controlling whether or not the whole bar is shown is a breeze using the navigationBarHidden() modification.

To force the user to make a decision before reversing course, we can use the navigationBarBackButtonHidden() modification to toggle the visibility of the back button.

Both of these modifications, like navigationTitle(), are not applied to the navigation view itself but rather to a view inside it. This is distinct from the statusBar(hidden:) adjustment, which must be applied to the navigation view and might cause some consternation for users.

Here's some code that toggles the visibility of the toolbar and the status bar based on the user's actions:

struct ContentView: View {
    @State private var fullScreen = false
    var body: some View {
        NavigationView {
            Button("Full Screen") {
                self.fullScreen.toggle()
            }
            .navigationTitle("Full Screen")
            .navigationBarHidden(fullScreen)
        }
        .statusBar(hidden: fullScreen)
    }
}

To alter the appearance of the bar itself (by changing its color, typeface, etc.), UIKit must be used. This isn't difficult if you're familiar with UIKit, but it may come as a surprise to you after working with SwiftUI.

To modify the appearance of the bar itself, modify the code in AppDelegate.swift's didFinishLaunchingWithOptions function. This will construct a new instance of UINavigationBarAppearance with the specified background color, foreground color, and font, and then assign it to the navigation bar appearance proxy:

let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = .red
let attrs: [NSAttributedString.Key: Any] = [
    .foregroundColor: UIColor.white,
    .font: UIFont.monospacedSystemFont(ofSize: 36, weight: .black)
]
appearance.largeTitleTextAttributes = attrs
UINavigationBar.appearance().scrollEdgeAppearance = appearance

Not that I think it's really pleasant in the context of SwiftUI, but there you go.

You may use NavigationViewStyle to create many views at once.

NavigationView's ability to function as a split view on bigger devices, like as plus-sized iPhones and iPads, is one of its most intriguing characteristics.

This behavior is a little perplexing by default since it might lead to what seem to be blank displays. This illustrative example displays a one-word label in a navigational perspective:

struct ContentView: View {
    var body: some View {
        NavigationView {
            Text("1st view")
        }
    }
}

That looks fantastic in portrait mode, however on an iPhone XS Max 11, the lettering disappears when you go to landscape.

SwiftUI interprets landscape navigation views as constituting a primary-detail split view, where two displays may be shown side by side, automatically. Even though this only occurs on larger iPhones and iPads with plenty of free space, it occurs often enough to cause confusion.

What a first option, you may do as SwiftUI suggests and provide two views inside your NavigationView to get around the issue:

struct ContentView: View {
    var body: some View {
        NavigationView {
            Text("1st view")
            Text("2nd view")
        }
    }
}

If you run it in landscape mode on an iPhone 6 Plus, the whole screen will be taken up by the word "Secondary," and tapping the main view button in the menu bar will move it out of the way. Most of the time, both views will be shown side by side on an iPad; but, when space is limited, the iPad will behave like a portrait iPhone and push or pop between the two perspectives.

Any NavigationLink in the main view will replace the secondary view with its destination when utilizing two views in this manner.

Alternatively, you may instruct SwiftUI to always display the same view, irrespective of the user's device or orientation. To achieve this, we modify the navigationViewStyle() method by supplying a new instance of the StackNavigationViewStyle() class.

NavigationView {
    Text("1st view")
    Text("2nd view")
}
.navigationViewStyle(StackNavigationViewStyle())

That fix is OK for the iPhone, but it will cause annoying full-screen navigation pushes on the iPad.