SwiftUI - Router Pattern - 2.0

A design pattern for SwiftUI that includes dependency injection and a separation of navigation logic from each screen.

SwiftUI - Router Pattern - 2.0
Close up of my whiteboard, showing a wooden laser cut title labelled "Source Code"

This is an update to an original post from May '21 (since taken down), where I showed a Router pattern with Combine based bindings in the view models. The Router also had the responsibility of providing dependencies to each View Model, requiring screens and view models to be initialized together in the Router. I have since moved on, and am now using Async Sequences for bindings

I'm also using a property wrapper based method of dependency injection, inspired by this post from Ihor Vovk.

Introduction

SwiftUI is fantastic. I’ve fallen in love with how rapidly I can pull together a UI for iOS. The declarative and responsive nature of it, enables some really elegant code.
However, I quickly found that there’s a few ‘edges’ to the experience.

Dependency Injection

I like to divide business logic into view model objects, that my SwiftUI views read from. Those objects have dependencies (data managers, the app’s networking layer, etc).

I felt like SwiftUI was forcing a pattern of creating the VM’s in the view itself (@StateObject / @ObservedObject).This can then require the dev to inject dependencies from the view, using SwiftUI's Environment. Or, we can end up using patterns like Singleton builder objects to give things to the view models.

Another responsibility that SwiftUI gives to the view is navigation. Via NavigationLinks, TabViews, and Sheets, our views aren’t just responsible for their own content on the screen.

It starts to feel like the view is doing an awful lot.


Why fight it?

When working on team projects, my teams tend to require a few things. Projects need to have design patterns that enable them to be:

  • Easily - explained to juniors
  • Dividable - Enable working on separate screens within the codebase by different team members
  • Flexible - moving a flow or screen from one place to another in the app shouldn’t cause days worth of refactoring.
  • Testable - (with unit tests across core business logic)

SwiftUI’s tightly coupled navigation, dependency injection and data transmission running through the view layer, disrupts these things.

Previous experience

When working on UIKit based projects, I have these same requirements for design patterns. I’ve used the Coordinator pattern, along with either separate Xibs for UI or programmatic UI and Snapkit, and MVVM to separate out business logic.

There’s a well trodden ground here. I have found these approaches help to split work up across developers in teams. The result is a codebase that is relatively easy for someone to contribute to, pretty soon after they join the team.

With all this in mind, I set about trying to figure out what an equivalent would look like for SwiftUI.

I decided I’d call any object in charge of flow, a Router.

The Objects

I started sketching things out on my home office’s whiteboard:

Router, View Model, and View objects listed on a whiteboard
Router, View Model, and View objects listed on a whiteboard

If I want things to have single, or at least specific responsibilities, then this seemed like a good place to start!

An example project

You can find a github repository with the code for this post here.

To kick things off, I created xctemplate files, to help facilitate adding new Routers to a project, and new views.

You can add these templates by running the symlink-templates.sh shell script from the root of the repository.

Dependencies

All dependencies in the project are defined by protocol. Dependencies are held within the DependencyInjection object, in it's static var assembly.

Dependencies are brought into ViewModels or Routers by using the @Injected property wrapper along with the keypath for the dependency. e.g:
@Injected(\.loginManager) var loginManager will bring the loginManager loaded in DependencyInjection.assembly into a view model.

Shared state is brought into a view model in the same way. e.g:
@Injected(\.appState.loginState) var loginState will bring the shared loginState ObservableObject into a view model.

Adding our first router

Using the Router xctemplate, from a new file, a router can be added like so:

This gives us a basic Router, with it's own Routerview containing a NavigationView, and a call to the router to create it's content.

In the example, our AppRouter is started in the app's window group like so:


Adding a screen

A Screen is a SwiftUI view, that in this pattern, represents a full screen of content. This is essentially the same as the main view of a ViewController in UIKit.

This gives us a Screen file:

And an accompanying ViewModel:

The Screen has a protocol (seen here at line 3, protocol TestScreenRouter: AnyObject { ...). This defines an interface for the screen to talk back to it's Router.

This means that different Routers can create and present the same Screens, provided they implement that screen's protocol.

Creating a screen in a Router

Creating a Screen in a Router, can look as follows:

We can then reference this from the Router's view:

Conditional flow in Routers

An example of conditional flow can be found in AppRouter.swift

In the AppRouterView's body, it switches over it's screen variable to choose whether to show the login screen or the main app.

We could also contain conditional flow logic in our @ViewBuilder functions where we create screens, in the Router.

NavigationLinks have to be held in our Screens. We can't quite take this out of SwiftUI's control. Our Router stil has responsibility for creating the view we're navigating to though. To see an example of this look at the AccountScreen

Here, the AccountScreen talks to it's router, via it's protocol, to obtain the detail screen, when the NavigationLink is activated.

The AccountRouter, can create and return the detail screen like so:

Async Binding

In the example project, the AppRouter has a binding in the setBindings method. It looks like so:

Here, a Task is launched the iterates over each published value of the Login state's loggedIn boolean. It then sends the updated value to the AppRouter's updateScreen function. This in turn switches a Published var in the AppRouter, which changes our view from the login screen to the logged in home screen with tab.

Hang on, what is this TaskCancellable?

A closer look at the setBindings code shows we send the Task in the bindings into a local variable called cancellables. This is an instance of a class I've created called TaskCancellable.

Bindings created by Tasks that iterate over async sequences, can remain in memory after the object that created them has been deallocated. The TaskCancellable object provides a wrapper to hold a reference to each binding's task. Upon deinit the TaskCancellable object cancels each Task.

You can take a closer look here.

Without this, our [unowned self] would then cause the binding to create a fatal error, as it could execute after the Router or view model has been dellocated, if the user moves to a different flow within the app.

Conclusion:

When this is all put together, our global dependencies to track our app's state. In the example, the Login state is tracked in the LoginState Observable object. The LoginManager handles updates to that state.

Our AppRouter binds itself to that state, and chooses to respond to changes in that state accordingly. We don't need to transmit bindings and data up and down our view hierarchy manually.

Our Views, represented by Screens, are coupled only with their own ViewModels, with protocol linking them back to their parent Routers. They can be moved between Routers with relative ease.

Our Screens, are, essentially just SwiftUI views. I'm not using any real 'magic' or secret source here.

This provides an approach that offers a simple structure, to enable our separation of concerns.

If you have any thoughts about this then I'd love to hear from you - email or Mastodon !

Where to find the code:

You can find a github repository with the code for this post here.