UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

The Complete Guide to NavigationView in SwiftUI

Programmatic navigation, customization, and more

Paul Hudson       @twostraws

NavigationView is one of the most important components of a SwiftUI app, allowing us to push and pop screens with ease, presenting information in a clear, hierarchical way for users. In this article I want to demonstrate the full range of ways you can use NavigationView in your apps, including simple things like setting a title and adding buttons, but also programmatic navigation, creating split views, and more.

 

 

Hacking with Swift is sponsored by RevenueCat

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

Getting a basic NavigationView with a title

To get started with NavigationView you should wrap one around whatever you’re trying to display, like this:

struct ContentView: View {
    var body: some View {
        NavigationView {
            Text("Hello, World!")
        }
    }
}

For simpler layouts navigation views should be the top-level thing in your view, but if you’re using them inside a TabView then the navigation view should be inside the tab view.

When learning SwiftUI, one thing that folks find confusing is how we attach titles to a navigation view:

NavigationView {
    Text("Hello, World!")
        .navigationTitle("Navigation")
}

Notice how the navigationTitle() modifier belongs to the text view, not to the navigation view? That’s intentional, and is the correct way to add a title here.

You see, navigation views let us display new screens of content by sliding them in from the right edge. Each screen can have its own title, and it’s the job of SwiftUI to make sure that title is shown in the navigation view at all times – you’ll see the old title animate away, while the new title animates in.

Now think about this: if we had attached the title directly to the navigation view, what we’re saying is “this is the fixed title for all time.” By attaching the title to whatever is inside the navigation view, SwiftUI can change the title as its content changes.

Tip: You can use navigationTitle() on any view inside the navigation view; it doesn’t need to be the outermost one.

You can customize the way the title is shown by adding a navigationBarTitleDisplayMode() modifier, which provides us with three options:

  1. The .large option shows large titles, which are useful for top-level views in your navigation stack.
  2. The .inline option shows small titles, which are useful for secondary, tertiary, or subsequent views in your navigation stack.
  3. The .automatic option is the default, and uses whatever the previous view used.

For most applications, you should rely on the .automatic option for your initial view, which you can get just by ignoring the modifier entirely:

.navigationTitle("Navigation")

For all views that get pushed on to the navigation stack, you will normally use the .inline option, like this:

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

Presenting new views

Navigation views present new screens using NavigationLink, which can be triggered by the user tapping their contents or by programmatically enabling them.

One of my favorite features of NavigationLink is that you can push to any view – it could be a custom view of your choosing, but it also could be one of SwiftUI’s primitive views if you’re just prototyping.

For example, this pushes directly to a text view:

NavigationView {
    NavigationLink(destination: Text("Second View")) {
        Text("Hello, World!")
    }
    .navigationTitle("Navigation")
}

Because I used a text view inside my navigation link, SwiftUI will automatically make the text blue to signal to users that it’s interactive. This is a really helpful feature, but it can come with an unhelpful side effect: if you use an image in your navigation link, you might find the image turns blue!

To try this out, try adding two images to your project’s asset catalog – one that’s a photo, and one that’s a shape with some transparency. I added my avatar and the Hacking with Swift logo, and used them like this:

NavigationLink(destination: Text("Second View")) {
    Image("hws")
}
.navigationTitle("Navigation")

The image I added was red, but when I run the app it will be colored blue by SwiftUI – it’s trying to be helpful, showing users that the image is interactive. However, the image has opacity, and SwiftUI leaves the transparent parts as they are so you can still clearly see the logo.

If I had used my photo instead, the result would be worse:

NavigationLink(destination: Text("Second View")) {
    Image("Paul")
}
.navigationTitle("Navigation")

As that’s a photograph it doesn’t have any transparency, so SwiftUI colors the whole thing blue – it now just looks like a blue square.

If you want SwiftUI to use your image’s original color, you should attach a renderingMode() modifier to it, like this:

NavigationLink(destination: Text("Second View")) {
    Image("hws")
        .renderingMode(.original)
}
.navigationTitle("Navigation")

Keep in mind that will disable the blue tint, which means the image won’t look interactive any more.

Passing data between views

When you use NavigationLink to push a new view onto your navigation stack, you can pass any parameters that new view needs to work.

For example, if we were flipping a coin and wanted users to choose either heads or tails, we might have a results view like this one:

struct ResultView: View {
    var choice: String

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

Then in our content view, we could show two different navigation links: one that creates ResultView with “Heads” as its choice, and the other with “Tails”. These values must be passed in as we create the result view, like this:

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")
        }
    }
}

SwiftUI will always make sure you provide the correct values to initialize your detail views.

Programmatic navigation

SwiftUI’s NavigationLink has a second initializer that has an isActive parameter, allowing us to read or write whether the navigation link is currently active. In practical terms, this means we can programmatically trigger the activation of a navigation link by setting whatever state it’s watching to true.

For example, this creates an empty navigation link and ties it to 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")
        }
    }
}

Notice how the button below the navigation link sets isShowingDetailView to true when triggered – that’s what makes the navigation action happen, rather than the user interacting with anything inside the navigation link itself.

Obviously having multiple Booleans to track different possible navigation destinations would be difficult, so SwiftUI gives us an alternative: we can add a tag to each navigation link, then control which one is triggered using a single property. As an example, this will display one of two detail views depending on which buttons was pressed:

struct ContentView: View {
    @State private var selection: String? = nil

    var body: some View {
        NavigationView {
            VStack {
                NavigationLink(destination: Text("Second View"), tag: "Second", selection: $selection) { EmptyView() }
                NavigationLink(destination: Text("Third View"), tag: "Third", selection: $selection) { EmptyView() }
                Button("Tap to show second") {
                    self.selection = "Second"
                }
                Button("Tap to show third") {
                    self.selection = "Third"
                }
            }
            .navigationTitle("Navigation")
        }
    }
}

It’s worth adding that you can use your state property to dismiss views as well as present them. As an example, we could write code to create a tappable navigation link that shows a detail screen, but also set isShowingDetailView back to false after two seconds. In practice, this means you can launch the app, tap the link by hand to show the second view, then after a brief pause you’ll automatically be taken back to the previous screen.

For example:

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
            }
        }
    }
}

Passing values using the environment

NavigationView automatically shares its environment with any child view that it presents, which makes it easy to share data even in very deep navigation stacks. The key is to make sure you use the environmentObject() modifier attached to the navigation view itself, as opposed to something inside it.

To demonstrate this, we could first define a simple observed object that will host our data:

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

We could then create a detail view to show that data using an environment object, while also providing a way to increase 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
            }
        }
    }
}

Finally, we could make our ContentView create a new User instance that gets injected into the navigation view environment so that it’s shared everywhere:

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)
    }
}

Remember, that environment object will be shared by all views presented by the navigation view, which means if ChangeView shows a detail screen of its own that will also inherit the environment.

Tip: In production applications you should be careful about creating reference types locally to a view, and should have a separate model layer for them.

Adding bar button items

We can add both leading and trailing buttons to a navigation view, using either one or several on either or both sides. These can be standard button views if you want, but you can also use navigation links.

For example, this creates one trailing navigation bar button that modifies a score value when tapped:

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
                        }
                )
        }
    }
}

If you wanted a button on the left and right, just pass leading and trailing parameters, like this:

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

If you want to place both buttons on the same side of the navigation bar, you should place them inside a HStack, like this:

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

Tip: Buttons added to the navigation bar have a very small tappable area, so it’s a good idea to add some padding around them to make them easier to tap.

Customizing the navigation bar

There are lots of ways we can customize the navigation bar, such as controlling its font, color, or visibility. However, support for this inside SwiftUI is a little lacking right now, and in fact there are only two modifiers you can use without dropping down to UIKit:

  • The navigationBarHidden() modifier lets us control whether the whole bar is visible or hidden.
  • The navigationBarBackButtonHidden() modifier lets us control whether the back button is hidden or visible, which is helpful for times you want to the user to actively make a choice before moving backwards.

Like navigationTitle(), both of these modifiers are attached to a view inside your navigation view as opposed to the navigation view itself. Somewhat confusingly, this is different from the statusBar(hidden:) modifier, which needs to be placed on the navigation view.

To demonstrate this, here’s some code that shows and hides both the navigation bar and status bar when a button is tapped:

struct ContentView: View {
    @State private var fullScreen = false

    var body: some View {
        NavigationView {
            Button("Toggle Full Screen") {
                self.fullScreen.toggle()
            }
            .navigationTitle("Full Screen")
            .navigationBarHidden(fullScreen)
        }
        .statusBar(hidden: fullScreen)
    }
}

When it comes to customize the bar itself – its colors, font, and so on – we need to drop down to UIKit. This isn’t hard, particularly if you’ve used UIKit before, but it is a bit of a shock to the system after SwiftUI.

Customizing the bar itself means adding some code to the didFinishLaunchingWithOptions method in AppDelegate.swift. For example, this will create a new instance of UINavigationBarAppearance, configure it with a custom background color, foreground color, and font, then assign that 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

I’m not going to claim that’s nice in a SwiftUI world, but it is what it is.

Creating split views using NavigationViewStyle

One of the most interesting behaviors of NavigationView is the way it also handles acting as a split view on larger devices – that’s usually plus-sized iPhones and iPads.

By default this behavior is a little confusing, because it can result in seemingly blank screens. For example, this shows a single-word label in a navigation view:

struct ContentView: View {
    var body: some View {
        NavigationView {
            Text("Primary")
        }
    }
}

That looks great in portrait, but if you rotate to landscape using an iPhone 11 Pro Max you’ll see the text view disappear.

What’s happening is that SwiftUI automatically considers landscape navigation views to form a primary-detail split view, where two screens can be shown side by side. Again, this only happens on large iPhones and iPads when there is enough space, but it’s still often enough to be confusing.

First, you can solve the problem the way SwiftUI expects by providing two views inside your NavigationView, like this:

struct ContentView: View {
    var body: some View {
        NavigationView {
            Text("Primary")
            Text("Secondary")
        }
    }
}

When that’s run on a large iPhone in landscape, you’ll see “Secondary” occupying all the screen, with a navigation bar button to reveal the primary view as a slide over. On iPad, you’ll see both views side by side most of the time, but if space is restricted you’ll get the same push/pop behavior you see on portrait iPhones.

When using two views like this, any NavigationLink in the primary view will automatically show its destination in place of the secondary view.

An alternative solution is to ask SwiftUI to only show one view at a time, regardless of what device or orientation is being used. This is done by passing a new StackNavigationViewStyle() instance to the navigationViewStyle() modifier, like this:

NavigationView {
    Text("Primary")
    Text("Secondary")
}
.navigationViewStyle(StackNavigationViewStyle())

That solution works well enough on iPhone, but it will trigger full screen navigation pushes on iPad and that’s not pleasant on your eyes.

Where now?

In this article we looked at the many ways you can use navigation views in SwiftUI, but there’s so much more out there to try!

If you’d like to learn all of SwiftUI, you should check out my 100 Days of SwiftUI course, which is completely free.

If you’re already building with SwiftUI and just want to see solutions for common problems, you should check out SwiftUI By Example instead – it’s packed with hands-on tips and code to help you get building faster.

If you have questions or feedback about this article, you should follow me on Twitter @twostraws.

Hacking with Swift is sponsored by RevenueCat

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

BUY OUR BOOKS
Buy Pro Swift Buy Pro SwiftUI Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Beyond Code

Was this page useful? Let us know!

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.