SwiftUI Navigation's Missing popToRoot

SwiftUI makes it really easy to create multi-level deep navigation structures but there doesn't seem to be an approved way to pop to root using the UIKit-style popToRootViewController().

The popToRoot problem

Using SwiftUI NavigationView and NavigationLink it is trivial to build an app that allows a guest to traverse through many screens. The Back button will allow the guest to go back a level, and playing with the presentationMode will allow the current page to be dismissed. But what if we want to popToRootViewController? Curiously this seems to be omitted in SwiftUI.

One temporary and not very SwiftUI-y hack of a solution

The app we're working on is for iOS only so we can hope that—for now—there's a UINavigationController lurking somewhere. If we were able to get hold of that then we could tell it to popToRootViewController()...

In our SceneDelegate we initialize our scene as usual but add in an observer for our own popToRoot notification.

        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: ContentView())            
            self.window = window
            window.makeKeyAndVisible()

            NotificationCenter.default.addObserver(self, selector: #selector(popToRoot(notification:)), name: Notification.Name("popToRoot"), object: nil)
        }

We then need to handle the popToRoot selector in which we tell the UINavigationController to popToRootViewController()

     @objc func popToRoot(notification: NSNotification) {
        if let navigationController = self.findNavigationController(withinController: self.window!.rootViewController!) {
            navigationController.popToRootViewController(animated: true)
        }
    }

And obviously we need a way to find the first (and probably only) UINavigationController by recursing through view controllers starting from the root:

     private func findNavigationController(withinController controller: UIViewController) -> UINavigationController? {
        for child in controller.children {
            if child is UINavigationController {
                return child as? UINavigationController
            } else {
                return findNavigationController(withinController: child)
            }
        }
        return nil
    }

The path to the navigationController in our app worked out as: self.window?.rootViewController?.children[0].children[0].children[0].navigationController.

Then all that's needed is to post a notification from whichever button we want to trigger the popToRoot behavior:

     var body: some View {
        Button(action: {
            NotificationCenter.default.post(name: Notification.Name("popToRoot"), object: nil)
        }) {
            Text("pop to root")
        }
    }

The caveats

This approach is definitely not very SwiftUI-y, it only works on iOS, it'll break if/when Apple swaps out from using UINavigationController behind the scenes in SwiftUI, and probably a couple of other things I haven't thought about just yet. But it does—at least for now—seem to work.

Discuss?

Haven't added a commenting system to this app, and probably won't. So if you have any feedback—positive or negative—either say it via email to david@cocoacheerleaders.com or on Twitter at @CocoaCheer.