Programmatic routing in SwiftUI

Background

As I’m sure any iOS developer now knows, the future of iOS app development is SwiftUI. Apple’s new UI development language is now on its 2nd major release. While my own personal feeling is that the framework is not quite ready for prime time (much like when Swift first arrived. It’s missing some fairly key features) and we are perhaps a version or 2 away from it realistically being an option for being used to build a complete app. There is no denying that it is the future and when it works well, it makes building UI a lot easier.

If you are using an advanced iOS architecture VIPER, MVVM etc it is probably the case that you have abstracted your routing or creating of your view controllers away into a separate part of your architecture. When you need to navigate or deal with a deeplink for example, you will hopefully have something like a dependency injection framework or factory to create your view controller. This is than pushed onto the navigation stack or presented by the view controller depending on what you are trying to do.

This is something that is fairly straight forward in UIKit and makes a lot of sense. In this article we are going to discuss the workings of SwiftUI and how that approach is no longer possible.

Example

// 1
struct ContentView: View {
    var body: some View {
        NavigationView(content: {
            NavigationLink(destination: DetailView()) {
                Text("Navigate")
            }
        })
        
    } 
}

// 2
struct DetailView: View {
    var body: some View {
        Text("Detail View")
    }
}

This is a fairly simple SwiftUI example but let’s talk through it.

  1. First we have a ContentView, this contains a NavigationView which is kind of similar to a UINavigationController in UIKit, however it is a lot more limited. We have a navigation link that allows the user to tap the text view and will ‘push’ the detail view on to the stack.
  2. Second we have our detail view that simply displays some text.

If we run the code and tap on the links we should get something like this:

SwiftUI Navigation

Seems to work as intended right? What problems are there with this approach?

  1. There is a tight coupling between the ContentView and the DetailView, what if we want to change the navigation destination?
  2. What if we want to use the ContentView in a different app that doesn’t contain a DetailView but something else?
  3. What if the DetailView has dependencies we need to inject when it’s created? How does the ContentView know what to do in order to create the DetailView?
  4. What if we wish to perform an event such as fire an analytics event before moving to the next view?
  5. What if we wish to present the view in a modal rather than pushing it to the navigation stack?

Many of the more advanced architectures and frameworks have already solved these problems using a router / co-ordinator pattern. These are responsible for handling any navigation logic and often talk to a dependency injection module in order to create the destination view and pushing it onto the navigation stack or presenting it.

Decoupling the Views

The first thing we can try to do is abstract away the creation of the detail view. This will at least give us the opportunity to change the destination without the knowledge of the ContentView.

// 1
final class ContentPresenter: ObservableObject {
    func getDetailView() -> AnyView {
        return AnyView(DetailView())
    }
}

// 2
struct ContentView: View {
    @ObservedObject private var presenter: ContentPresenter
    
    init(presenter: ContentPresenter) {
        self.presenter = presenter
    }
    
    var body: some View {
        NavigationView(content: {
            NavigationLink(destination: presenter.getDetailView()) {
                Text("Navigate")
            }
        })
        
    }
}

struct DetailView: View {
    var body: some View {
        Text("Detail View")
    }
}

So let’s have a look at what we are doing here:

  1. First of all we have tried to separate out the creation of the destination view into another object. Ideally we could put this into a protocol but for the purpose of simplicity we have just used an object.
  2. We are injecting the presenter into the ContentView now, you will also notice in the NavigationLink we are now calling a method on the presenter to get the destination.

What does this give us that the previous example doesn’t?

  1. There is no longer tight coupling between the ContentView and the DetailView. The destination is no longer hardcoded. If we make the presenter using a protocol for example. We can inject different presenters and have different destinations.
  2. If the detailview has its own dependencies that need injecting then the presenter can take care of that as well without having to code them in here.

However it’s not all tea and biscuits! There are still a number of issues highlighted earlier that this solution doesn’t solve:

  1. We are still not able to trigger any analytics events or any other app behaviours off the back of the navigation trigger. Block the user from navigating until they have logged in for example.
  2. We can’t change or configure how the navigation happens, for example presenting a login vs actually performing navigation.
  3. We are also exposing navigation to the view, a presenter typically would not need to expose navigation functionality to the view. It would handle a tap event and then hand off that navigation logic to the router. Here we have to expose that functionality to the view itself.

Keep with UIKit for navigation, for now

My personal feeling is that navigation in SwiftUI could do with some more work. Views themselves should not know or care about where they are navigating to and how. They should be a visual representation of state. Of course, the navigation could be a presentation of state too, however a quick peak at the NavigationView docs shows no access to any form of state at all. The navigation view polices its own state, nothing outside of the object has a way to modify that state.

Further to that, many of the methods we have come to expect from UINavigationController are simply not available here. Whether it’s lack of maturity or a slightly confused approach I don’t know. My recommendation for now would be to make use of UINavigationControllers and the UIHostingController to perform navigation for the time being, at least until a better way to manage and manipulate the navigation state is added to SwiftUI.

Let’s have a quick look at how that changes things. First we need to create a hosting controller and inject our SwiftUI view:

let presenter = ContentPresenter()
let vc = UIHostingController(rootView: ContentView(presenter: presenter))
let navigationController = UINavigationController(rootViewController: vc)
presenter.navigationController = navigationController

So here we are creating our presenter and our view as before but adding them into a UIHostingViewController and a navigation controller. The UIHostingViewController allows us to put SwiftUI views into what is essentially a UIViewController and use it within a UIKit app.

We have also passed a reference to the navigation controller to the presenter. Let’s have a look at our updated SwiftUI code now that we have refactored it into a UIHostingController.

// 1
final class ContentPresenter: ObservableObject {
    weak var navigationController: UINavigationController?
    
    func buttonTapped() {
        // Do whatever we like
        // ...
        // Navigate
        let vc = UIHostingController(rootView: DetailView())
        navigationController?.pushViewController(vc, animated: true)
    }
}

// 2
struct ContentView: View {
    @ObservedObject private var presenter: ContentPresenter
    
    init(presenter: ContentPresenter) {
        self.presenter = presenter
    }
    
    var body: some View {
        Button(action: { presenter.buttonTapped() }) {
            Text("Navigate")
        }
    }
}

What’s changed here:

  1. First of all our presenter has replaced our getDetailView with a more generic button tapped function. This function can do any number of things we need it to do, before finally navigating. Here you can see we are using our reference to the navigation controller to push the new view controller.
  2. In our SwiftUI view you will see we no longer have a NavigationView or a NavigationLink. Our view has become far more generic and doesn’t contain and navigation specific logic. You will also see that we have a button which has a tap action assigned by the presenter. This allows us to make the button do anything, not just trigger navigation.

Hopefully you found this helpful when exploring navigation options in SwiftUI. You can find the SwiftUI Sample and the UIHostingController samples on github.