Navigating without NavigationLink in SwiftUI

David Chavez
Double Symmetry
Published in
5 min readNov 11, 2020

Update: With new releases of this library, some of the things covered in this article might be slightly out of date (i.e. no more need for AnyView and improved extraction of information via Mirror) — for the latest code and examples please check out the Github repository.

At the time of writing, navigation in SwiftUI is achieved by embedding your root view in a NavigationView and navigating to other SwiftUI views via NavigationLink.

While this works for common cases, there’s an issue that I ran into while developing a not so large application.

NavigationLink is part of the render body, meaning that when state changes happen, the links get recomputed and possibly pushed again.

More concretely the issue I ran into was:

I had a root view that would present a settings view via a NavigationLink. The settings view allowed you to set some settings that might change data that the root view was observing which was causing it to re-render and push the settings view a few more times into the navigation stack.

Note: Everything talked about here has been released as a SwiftPackage.

Existing Solutions

After googling for a few hours, it seems that the usual solution for navigation issues in the SwiftUI community is to create their own UINavigationController at the start of the app, embed their root view into it via a UIHostingController and use that.

To navigate you then call present or pushViewController on your navigation controller with any new SwiftUI view wrapped in a UIHostingController as the view controller to show.

The problem with this is that you lose the nice transitions you expect between navigation bars and managing their state can become complex.

An Alternative

If a root view is embedded in a NavigationView then there must be a UINavigationController being used internally right? Could we then use that to push new SwiftUI views into the stack?

The answer is yes! (with a bit of setup)

Step 1. A Global Navigation Object 🌐
Since our goal is to move away from NavigationLink, we need a way to push new ViewControllers. We’ll do this via a central navigation object that we’ll pass to our SwiftUIView’s as an EnvironmentObject.

We will later add methods for presentation. For now, we’ll set it up in our SceneDelegate and instantiate it with a reference to our window.

Let’s update SceneDelegate to instantiate the Navigation object and pass it into the ContentView that we’ll be displaying.

Step 2. Presenting a ViewController 🔎
Let’s setup our root view with a navigation bar item that will present our settings view. We’ll also make sure to grab our navigation object for later use!

Now, let’s prepare our Navigation class to handle pushing a new view controller. We’ll add two new methods: pushView and pushViewController.

Ideally, the navigation class will handle me giving it a SwiftUI view so it can worry about embedding it in a UIHostingController and embedding the navigation object into it for us. Unfortunately, because we can’t use View as a parameter, we’ll have to use AnyView — a type erased View! When we use this method we’ll have to wrap our view in AnyView(…).

The pushViewController method gets the rootViewController of the window and then tries to find the navigation controller in the stack which it uses to actually push the new view.

I’ve left the pushViewController method public, because that can later be useful to present other view controllers like SFSafariViewController.

Now that the methods are in place, let’s call the method in our SwiftUI view.

Voila! Does it work? Not fully. While it does push successfully, there’s a visual jump. This is happening because it takes a while for the SwiftUI view to populate the navigation item details.. and while it loads, its in a weird state of transition between thinking it should have a large title and not. Note: The jump won’t happen if your root view uses an inline title but the title will be empty for a bit and then populate.

Step 3. Making Our Own UIHostingController 🚀

So what do we do now? When you use a NavigationLink it’s a very smooth transition, so Apple has clearly found the way. Having investigated deeper, it looks like Apple is using a custom UIHostingController that can somehow read the .navigationBarTitle modifier and manually set the navigationItem properties of the view controller.

I tried using reflection via Mirror to do something similar, but unfortunately Mirror is not very robust and a lot of SwiftUI is still hidden from us 😢

Until someone smarter points me in the direction of a better way to do this part, the only other thing I can think of to pass these values along is to use a protocol. This protocol would live at the View level and would define the properties that the navigation bar title should have. We can then make a custom UIHostingController that reads these and pre-populates the navigationItem for a buttery smooth transition.

Let’s define the protocol that our SwiftUI views that can be pushed will conform to. It will be simple: the title and the display mode — the same parameters that the .navigationBarTitle modifier takes.

Now let’s update our SettingsView to conform to this now. I’ve also made a custom modifier for .navigationBarTitle that takes in the configuration to make life a bit sweeter.

Now that our view’s can expose what their navigation item configuration should be we need a way to set these at the view controller level. This means we’ll have to create a custom UIHostingController! …yay.

The modifications to it will be that when the view controller is presented, we’ll grab the configuration from the view if available and set the navigationItem. Sounds easy, right?

What’s all this Mirror stuff? As I mentioned before, SwiftUI is very secret and has a lot of internal types.. making it really hard to find the things you need.

Long story short, we’re using Mirror to inspect the properties of the rootView given to the UIHostingController. We then dig down deep until we find a SwiftUIView, check if it conforms to our protocol and if so, set the navigationItem properties! 🎉

Now let’s run the app. Buttery smooth!
You can now push views easily and with smooth transitions ✨

Thanks for reading this article! I hope this package makes navigation easier for you and your team in your SwiftUI application.

Are you building mobile apps and overpaying for Github Actions in your private repos? Check out Blaze — Apple Silicon runners designed to reduce your build time at a much lower cost.

All of the above has been released as a SwiftPackage that you can use in your applications. It also has some nice additions not seen in this post.

--

--

David Chavez
Double Symmetry

Building apps | iOS, Android, Kotlin/Native. Cofounder at DoubleSymmetry.