lukecsmith.co.uk

No, SwiftUI navigation is not broken! With example.

Luke, 24 Mar 2022

Theres a persistent myth around SwiftUI, and the thing thats most often referred to when people say SwiftUI is not 'production ready' - and thats navigation. The myth is that navigation in SwiftUI is somehow buggy, or broken in some way. Here I want to explain not only why that isnt true, but how SwiftUI navigation is actually more powerful and flexible in many ways than its UIKit older sibling.

Lets just get some perspective for a second. How you navigate around an app is one of the most fundamentally important things about your app. SwiftUI has been out in the wild since June 2019, so its approaching three years in the public domain, and there were at least five years of development before then. With that in mind, is it really possible that Apple would leave an absolutely fundamental part of the language, broken in some way, for all that time?

You can break the navigation in SwiftUI, if you do it wrong. In UIKit it was made easy for you: you have a UINavigationController, and you could access it from the UIViewController. It was well documented and standardised, and everyone knew how to use it. SwiftUI in fact used that same UINavigationController under the hood. But you dont access it in the same (architecturally bad) way, and you dont have access to a simple command like popToRoot, which is the most common complaint with SwiftUI.

The two basic elements of navigation in SwiftUI are the NavigationView, and the NavigationLink. The former is the item which allows views to be pushed and popped onto a stack, while the latter represents a single link in that stack. Whether or not a view is presented or pushed by the link, is governed by a @State boolean (or @Published etc). If you have a series of views stacked on top of each other, each one is there in the stack because an @State boolean is true, to say it should be there. Often those bools are just placed within the view that governs when to show the new view.

The system Ive created to control navigation in SwiftUI, and to allow things like popToRoot, is to collect all these booleans into a single class which can be accessed throughout the app. This means you can govern the entire navigation of the app, from a single class, and from anywhere. You can go anywhere you like, with a deep link, for eg - just by setting a particular set of booleans to true. And a popToRoot is simply achieved by setting all link bools in a series of screens to false.

To demo this, Ive created a project on github. Heres a quick explanation of whats going on. First up, I control all NavigationLink booleans from this class:

class NavSwitches: ObservableObject {
    
    //onboarding
    @Published var onboardingNavBarHidden = true
    @Published var onboardingFirstToSecond = false
    
    ...
    

This class is placed into the @Environment, so it can be accessed safely wherever it is required, using SwiftUIs built in dependency injection. So the root of the app looks like this:

@main
struct TestNavigationApp: App {
    
    @StateObject private var navSwitches = NavSwitches()
     
    var body: some Scene {
        WindowGroup {
            OnboardingFirst()
                .environmentObject(navSwitches)
        }
    }
}

Now throughout the app, wherever I have a NavigationLink to push some new view, I place the boolean for that into the above NavSwitches class. Eg:

     @EnvironmentObject var navSwitches: NavSwitches

    ...
    
    NavigationLink(destination: OnboardingSecond(),
                   isActive: $navSwitches.onboardingFirstToSecond) {
        EmptyView()
    }.isDetailLink(false)
    
    ...

That reminds me: you need the above modifier isDetailLink(false), or isStackView(false), on the end of the NavigationLink. Thats a common omission by many, which can cause some of the visual bugs that lead some to conclude that SwiftUI is broken.

Now imagine that you have a series of NavigationLinks like the above, all with an associated bool that lives in the NavSwitches class. To achieve a popToRoot for that series of screens, you can add a func to the NavSwitches class, which turns them all false:

     func onboardingToRoot() {
        onboardingNavBarHidden = true
        onboardingFirstToSecond = false
        onboardingSecondToThird = false
        onboardingThirdToTabView = false
        onboardingToTabView = false
    }

Thats the gist of the solution. I do also find it incredibly useful that for a deep link, all I have to do is go to this class and set a bunch booleans to true, to navigate to a far flung corner of the app. Its an incredibly powerful system that can do so much more than the previous system using UINavigationController. Please do check the project on github for a working version.

Tagged with: