Coordinator pattern in iOS 13 world
First let’s see how it was done before iOS 13. Then we’ll take a look how scenes changed this setup. Spoiler: quite a bit.
Before iOS 13, structure of an iOS app was really simple and straightforward:
① UIApplication
· unowned(unsafe) open var delegate: UIApplicationDelegate?
⤷ ② AppDelegate
· var window: UIWindow?
· ③ open var rootViewController: UIViewController?
iOS runtime:
- creates an instance of
UIApplication
, then - looks into your project for a class marked as
@UIApplicationMain
that implementsUIApplicationDelegate
protocol - makes an instance of it and assigns as
application.delegate
and - calls willFinishLaunching and didFinishLaunching methods to give you further control.
Hence the bare minimum needed to display your first UIViewController
is this:
final class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
window = UIWindow(frame: UIScreen.main.bounds)
let vc = UIViewController()
window?.rootViewController = vc
window?.makeKeyAndVisible()
return true
}
The only “your” object guaranteed to last throughout the application lifecycle is AppDelegate
. You could change VCs as you wanted but application’s delegate
and its window
were always there.
Now, to refresh our memory: how Coordinator fits in here? Essential role of the Coordinator is to manage UIViewControllers:
- create and configure them
- display them when requested
- coordinate UI and data flow between them.
In simplest terms: it manages value of window.rootViewController
property.
Logical place to inject Coordinator is between points ② and ③ in order to take over the job of setting up window.rootViewController
.
final class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
lazy var applicationCoordinator = ApplicationCoordinator(applicationDelegate: self)
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
window = UIWindow(frame: UIScreen.main.bounds)
window?.rootViewController = applicationCoordinator.rootViewController
window?.makeKeyAndVisible()
applicationCoordinator.start()
return true
}
I create strong reference from AppDelegate
to ApplicationCoordinator
thus guaranteeing that applicationCoordinator
will live as long as the app.
final class ApplicationCoordinator: Coordinator<…> {
override func start(with completion: @escaping () -> Void = {}) {
buildAppDependencies()
super.start(with: completion)
setupInitialContent()
}
And from there, whatever is shown as rootViewController
of the ApplicationCoordinator will automatically be window.rootViewController
as well. Responder chain is intact and Coordinator
is ready to do its job of managing various UIViewControllers which are self-sufficient and unaware of each other.
AppDelegate
remains oblivious to entire UI logic and complexity; it’s free to deal with whatever Apple throws in there (there’s plenty of that) and can pass any UI tasks to ApplicationCoordinator
(like displaying proper UI for incoming push notification).
One useful tip for people already using my Coordinator library: injecting Coordinator like this will prevent coordinatingResponder
methods to reach AppDelegate, which is needed to handle stuff like openURL(…)
. It’s easy to patch that up:
final class ApplicationCoordinator: Coordinator<…> {
weak var appDelegate: AppDelegate?
override var coordinatingResponder: UIResponder? {
return appDelegate
}
Now you know why AppDelegate
reference is passed to the ApplicationCoordinator
.
From here-on, each project is unique. Some apps are simple and need only one Coordinator. Some are very complex and can have dozens of child Coordinators, each with one, two or dozen managed UIViewControllers. There’s really no limits what you can do, while keeping beautifully linear complexity curve.
BTW — have you noticed buildAppDependencies()
in the ApplicationCoordinator’s start
method?
Dependencies
In the KILS architecture, UI layer is dealing only with UI. Business logic implementation, data management, network services — all of those objects are independent of the UI and UI is unaware of them.
Those objects must exist somewhere in the app, somewhere that will guarantee they remain alive as long as the app is running. There are two possible places: AppDelegate
or ApplicationCoordinator
. Both are OK choices and before iOS 13 I usually chose the latter:
final class ApplicationCoordinator: … {
var webService: …
var accountManager: …
var dataImporter: …
}
Coordinators coordinate between UI and middleware/data. To do that, each Coordinator instance must always have access to all those objects. I create simple struct with references to all of them and make sure that every time that struct is rebuilt, all active Coordinators in the app receive the updated instance:
struct AppDependency {
var webService: …
var accountManager: …
var dataImporter: …
}
final class ApplicationCoordinator: … {
var appDependency: AppDependency? {
didSet {
updateChildCoordinatorDependencies()
}
}
}
This is deceptively simple and very useful. Adding new services, middleware etc is straightforward.
iOS 13 & scenes
In iOS 13, Apple introduced possibility to have multiple active scenes (AKA windows). App architecture is a bit more complex now:
① UIApplication
· unowned(unsafe) open var delegate: UIApplicationDelegate?
⤷ ② AppDelegate
· open var openSessions: Set<③ UISceneSession>
· open var connectedScenes: Set<④ UIScene>
UIApplication
still has its delegate which stays in memory as long as the app is alive. However, there is no UIWindow
instance there any more; it’s moved to SceneDelegate.swift file, which implements delegate
property of the UIScene
which belongs to UISceneSession
.
③ UISceneSession
· ④ UIScene(.session)
open var delegate: UISceneDelegate?
⤷ ⑤ SceneDelegate
· var window: UIWindow?
· ⑥ open var rootViewController: UIViewController?
“Belong” is a bit of a stretch; UIScene
and UISceneSession
are actually symbiotic, they always go together as 1-1 pairing, cross-referencing one another.
Here’s how app/scene lifecycle goes:
- iOS runtime creates an instance of
UIApplication
, then - looks into your project for a class marked as
@UIApplicationMain
that implementsUIApplicationDelegate
protocol, then - makes an instance of it and assigns as
application.delegate
, then - calls willFinishLaunching and didFinishLaunching where you can do non-UI stuff (setup logging, metrics, dependencies etc).
- Runtime creates an instance of
UISceneSession
, then - calls configurationForConnecting:UISceneSession on AppDelegate passing that
UISceneSession
instance, to give you a chance to supply customUISceneConfiguration
for it (otherwise it will use whatever is set inInfo.plist
). - Runtime uses given scene configuration to instantiate
UIScene
and set that object as value of UISceneSession.scene
property. - Part of the scene configuration is the name of your class that implements
UIWindowSceneDelegate
(which inheritsUISceneDelegate
).
By default that’sSceneDelegate
so iOS runtime will instantiate that class and then - call its scene(UIScene, willConnectTo: UISceneSession to give you further control.
- In that method you setup
UIWindow
instance for the given scene and assign someUIViewController
aswindow.rootViewController
As you can see, step 4 has changed to not setup any UI. UI setup is done in steps 5–10.
Notice that UIApplication
has separate properties called openSessions
and connectedScenes
. This hints that during app’s lifecycle these sets may not have same number of elements.
WWDC 2019’ session 258: Architecting Your App for Multiple Windows explains this in greater detail but to summarise important bits:
UISceneSession
sets up theUIScene
using givenUISceneConfiguration
- Each session that’s created is added to
application.openSessions
property - when
UIScene
is shown, it’s added toapplication.connectedScenes
property - Scenes, when not shown, may be disconnected and thus removed from
application.connectedScenes
property. Remember: scene = window with your UI thus may take a lot of memory. - But its corresponding
UISceneSession
is not removed fromopenSessions
automatically. It stays there and you have the option to recreate that scene and restore its entire UI state, if you want to. Or you can choose to discard the session as well. SceneDelegate
instance is deallocated along withUIScene
object it belonged to.
This last bit is important for Coordinators. Coordinator manages related UIVCs for one window, one scene. Thus natural place to inject them is between ⑤ and ⑥:
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
var coordinator: SceneCoordinator?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene)
self.window = window
let sceneCoordinator = …
self.coordinator = sceneCoordinator
window.rootViewController = sceneCoordinator.rootViewController
window.makeKeyAndVisible()
sceneCoordinator.start()
}
}
But what about AppDependency? It contains objects that should be common for an entire app, not have one instance of each, per scene. Service wrappers, data importers etc – these are usually singleton instances rest of the app uses as needed. Because SceneDelegate goes away with the UIScene, we can’t keep those singletons on it. They now must be kept on AppDelegate since it’s the only object we have that lives as long as the app does.
final class AppDelegate: UIResponder, UIApplicationDelegate {
var webService: …
var accountManager: …
var dataImporter: …
var appDependency: AppDependency? {
didSet {
updateSceneDependencies()
}
}
Each time dependencies change, all active SceneCoordinators must be updated through updateSceneDependencies()
. But how? What’s the connection between AppDelegate and SceneDelegate? Well, there is none. They do not reference one another, ever.
UISceneSession
instance is the only one we have access to, in AppDelegate, so we must inject AppDependency into it:
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
connectingSceneSession.appDependency = appDependency
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
}
How to inject this? In the same way we inject Coordinator into every UIViewController:
extension UISceneSession {
private struct AssociatedKeys {
static var appDependency = "AppDependency"
static var sceneCoordinator = "SceneCoordinator"
}
var appDependency: AppDependency? {
get {
return objc_getAssociatedObject(self, &AssociatedKeys.appDependency) as? AppDependency
}
set {
objc_setAssociatedObject(self, &AssociatedKeys.appDependency, newValue, .OBJC_ASSOCIATION_COPY)
sceneCoordinator?.appDependency = newValue
}
}
weak var sceneCoordinator: SceneCoordinator? {
get {
return objc_getAssociatedObject(self, &AssociatedKeys.sceneCoordinator) as? SceneCoordinator
}
set {
objc_setAssociatedObject(self, &AssociatedKeys.sceneCoordinator, newValue, .OBJC_ASSOCIATION_ASSIGN)
}
}
}
We go one step further and also inject a weak reference to SceneCoordinator
into UISceneSession
too. Thus when appDependency
is set, it will automatically set corresponding property on sceneCoordinator
.
Hence the scene coordinator setup in SceneDelegate
goes like this:
let sceneCoordinator = SceneCoordinator(scene: scene, sceneDelegate: self)
self.coordinator = sceneCoordinator
Here’s a typical implementation of SceneCoordinator:
final class SceneCoordinator: NavigationCoordinator, NeedsDependency {
private weak var scene: UIScene!
private weak var sceneDelegate: SceneDelegate!
init(scene: UIScene, sceneDelegate: SceneDelegate) {
self.scene = scene
self.sceneDelegate = sceneDelegate
let nc = UINavigationController()
super.init(rootViewController: nc)
appDependency = scene.session.appDependency
scene.session.sceneCoordinator = self
}
override var coordinatingResponder: UIResponder? {
return sceneDelegate
}
var appDependency: AppDependency? {
didSet {
updateChildCoordinatorDependencies()
}
}
override func start(with completion: @escaping () -> Void = {}) {
super.start(with: completion)
displayContent()
}
}
private extension SceneCoordinator {
func displayContent() {
let vc = FirstController()
show(vc)
}
}
In AppDelegate we need just this bit to close the updating chain for AppDependency:
private extension AppDelegate {
func updateSceneDependencies() {
let application = UIApplication.shared
application.openSessions.forEach {
$0.appDependency = appDependency
}
}
}
This finishes administrative parts of the Coordinator pattern. You are now free to do as before, using SceneCoordinator
in the same way you used ApplicationCoordinator
before iOS 13.
I created a skeleton repo which you can use as starting point for an iOS 13 app employing Coordinator pattern.
Consider it some sort of public beta. I have yet to use this implementation in multiple apps, especially those that truly use multiple windows. Practice will make this better.