This is the original 1.X version of this post. I’ve captured here in case anyone wanted to see how I did this before Async Sequences, with Combine. If you want to find the code, look at all releases prior to 2.0 in the repo.
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.
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.
When working on team projects, my teams tend to require a few things. Projects need to have design patterns that enable them to be:
SwiftUI’s tightly coupled navigation, dependency injection and data transmission running through the view layer, disrupts these things.
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.
I started sketching things out on my home office’s whiteboard:
If I want things to have single, or at least specific responsibilities, then this seemed like a good place to start!
Laying this out, gave me:
Router
* Creating Views
* Creating ViewModels
* Creating NavigationViews / TabViews
* Holding state to dictate screen flow
* Reacting to state to change screen flow
* Creating child Routers
View Model
* Carrying State for the view
* Sending data to dependencies
* Receiving data from dependencies (via bindings or delegation)
* Application Business logic
Screen - a type of view
* Sending data from user interactions to the View Model, to change state.
* Talking to the router to receive new views (I.e for NavigationLink destinations)
View
* A 'classic' SwiftUI view, receives bindings or maintains own state
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.
All dependencies in the project are defined by protocol, and held in an object conforming to a protocol called Services
that holds references to each one.
This is created when the app starts, and is transmitted from the first router, to all subsequent child routers and view models.
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.
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, can look as follows:
Here we create the Screen’s view model, give it our Services dependency class. Then we can create the view, and give it the view model.
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:
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 LoginManager. 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. 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.
Ultimately I’d love for Apple to provide a concept of a Router, or Flow controller that we can just use. I’m hopeful that if that happens, this fairly lightweight structure will mean I can refactor relatively painlessly!
If you have any thoughts about this then I’d love to hear from you - email or Mastodon !
You can find a github repository with the code for this post here .
Drop me a line.