SECRET OF CSS

The Coordinator pattern in SwiftUI


Dive into this in-depth guide on handling SwiftUI’s navigation

1*9jsdWN0oVqDOITm0IX8wEA
Control Flow

This article is about a solution for handling complex flows in SwiftUI apps from iOS 13.0+. Navigation in SwiftUI apps can become a pain point.

When it comes to a useful pattern for handling the flow of an application, The Coordinator pattern must be among them. This article will show you how to integrate the Coordinator pattern with a declarative framework like SwiftUI. It will go through its navigation system by explaining each part and finding a solution to apply the Coordinator pattern to fix the navigation problem. If you understand the concept, you can map the solution to Jetpack Compose or other frameworks.

“Introduction to Coordinator pattern in SwiftUI” is part of a series of articles through which you learn to handle multiple Coordinators, how child coordinators can communicate with its parent, how to handle UITabBarControllers, different flows and Smart Coordinators that can choose the right flow for your app.

Reading the following articles is required to create a real-world application:

  • Comprehensive guide to Coordinator pattern (Coming Soon)
  • Smart Coordinators (Coming Soon)
  • Deeplinking with Coordinators (Coming Soon)

Many markup languages, such as HTML or other user-interface, are often declarative. HTML only describes what should appear on a webpage. It doesn’t specify the control flow for rendering a page.

But, within an imperative programming language, a control flow statement is a statement that results in a choice being made as to which of two or more paths to follow. That’s why instructions in the Coordinator pattern work well in imperative languages.

Quoting Matt Carroll in this article defining State very well:

The behavior of the app at a given moment in time

App(t) = State

So, we can consider Navigation as State? Navigation State can be a single value at a time that changes by a side effect from another function.

Coming up next, problems will be identified if you choose Navigation as State!

Download the starter project to follow the steps easier:

We know that SwiftUI is a declarative framework, so it’ll have States, and there are some limitations in the framework when it comes to transition from screen 1 to 2, 2 to 3, 3 to 4, …, n-1 to n. Some major issues will arise!

In a SwiftUI NavigationView, we miss that popping to root, popping one step back, dismissing the whole navigation stack in the middle of the stack, or replacing a view in between or other features and navigation codes adds a lot of complexity to each screen.

Screen 2 should hold a Navigation State from screen 1, and Screen 2 should make a side-effect to pop one step back to screen 1.

Even if it is hard to explain, let’s see some code!

coordinator-starter
2 Master and Detail screen inside a NavigationView to show push and pop
figure-1

Figure 1 looks like this: a master page (MapView) with a button to navigate to the detail page (CityView) by a push transition and a pop button inside the detail page to navigate back to the master page.

Chaos is coming!

@State var showCity: Bool is the Navigation State. The master page passes that down to the detail view to be able to trigger a side-effect in the detail page that changes navigation again in the master page. So in both cases, pushing from master to detail and popping from detail to master, the Navigation State is the actor and stored in the master page!

There is another problem. What if it was ten screens chained together with these states, and we had to push back to first/root screen? it will be complete chaos handling this flow!

Let’s summarize the facts:

  1. The Navigation State of an app is a State and has a single value at a time. Each time, our Navigation State can represent screen A, and at another time, it can be screen B.
  2. Navigation and routing is a side effect that must change the route of the other environment/screen, so the state is a route because it changes from the side effect of navigation.

With all the facts, what are the tools and instructions to solve the problem?

Current navigation systems are NavigationStack, NavigationView, and UINavigationController.

NavigationView is deprecated in iOS 16.0, but for new iOS versions, Apple introduced a new NavigationStack in WWDC22. This is available beginning in iOS 16.0+, and it works well for every Apple platform, but it is not a Coordinator pattern and is not backward compatible.

With the new NavigationStack, you can pop to root or pop one step back without passing the push reference of the previous view, but still, you can’t dismiss the whole navigation stack from a view middle of the stack. The dismiss behavior only works from the root view!

In comparison to UINavigationController:

  • UINavigationController works from iOS 2.0+, which is good for mixed UIKit/SwiftUI apps, but NavigationStack works from iOS 16.0
  • UINavigationController have more control over navigation than NavigationStack
  • UINavigationController is more flexible in customization
  • NavigationStack is more compatible with different apple platforms

What about NavigationView vs UINavigationController?

From a design point of view, UINavigationController in UIKit is different than the NavigationView in SwiftUI. It has different implementation and usage.

But from a technical point of view, NavigationView in SwiftUI is UINavigationController! That’s right! UIKit and SwiftUI are both closed source frameworks, and their documentation doesn’t say much about how their components are created. So there is a lot of uncertainty. But with a deeper look at the hierarchy of NavigationView, we are able to come to the certainty that they are the same!

view host will be added to the hierarcy if you add a NavigationView to your view
figure-2

So if it is all UINavigationController, then there is a great navigation manager, but it can’t be used as it is. SwiftUI Views can’t be stacked into a UINavigationController!

Good news! If you check that Debug View Hierarchy again, you will see that the MapView is embedded inside a UIHostingController that inherits from UIViewController.

SwiftUI View embedded inside UIHostingController

So it seems to be the same approach that we had in UIKit for Coordinators. UINavigationControllers holds stacks of UIViewControllers!

Soroush Khanlou introduced a new navigation flow manager, the Coordinator pattern. Following that article, a talk goes much deeper into how to solve the Massive View Controllers issue. The Coordinator pattern is the most straightforward and flexible pattern for handling flow once you understand the idea behind it!

Sounds interesting! We can apply the Coordinator pattern to it!

As stated, the Coordinators (Navigation System) should be a State that changes by a side effect of a function. The state manager can be a function to trigger a given one-shot value with a side effect that changes the flow.

A one-shot is a value/signal that happens exactly once in a time and produces a side effect! For example, the = operator in the calculator is a one-shot value/signal. It will calculate the calculations and produces a new value on the display. You can imagine the present/push function of the UIKit that takes a single object and produces a side effect for an app’s lifecycle.

In the coordinator pattern, the one-shot values are called ‘Route,’ and we store them in enumerations. Coordinator functions will use the Routes, and each Route should have routing requirements. In a simple application, a Route should at least contain its ‘transition style’ and ‘destination view’ so we can recognize a value of a Route and how and where it will route to! Routes are not just paths, but they can do what path does!

Let’s jump into the implementation!

For the transition style, we consider a simple app that can only push, presentModally, or presentFullScreen a single route.

NavigationTransitionStyle.swift

And for the destination, it must choose which view/path should be created according to its Route.

The function below is just pseudocode to better visualize what we’re going to achieve.

destination-view

Finally, all the requirements are available in pseudocode, and we can reach to our North Star ‘Route’ by putting all of them inside a new concept of NavigationRouter!

Once again, a Route should at least contain its transition style and destination view, so by abstraction of a Route, we can apply those requirements to any other concrete type.

NavigationRouter.swift

Since routes must be one-shot, the enums make more sense! These routes will be the Trigger Point of the Coordinator that points to a single flow

In the next article, ‘Comprehensive guide to Coordinator pattern,’ you can learn more about handling different flows, multiple UINavigationController, UITabBarControllers, and child coordinators.

Remember to check that after you did a lot of practice in this article.

MapRouter.swift

With all the Routes and UINavigationController, the only thing that remains is for a chef to cook a meal and call it Coordinator!

Let’s cook!

Developers are not wizards, but they can imagine a lot! They are making stuff by mixing instructions and continuing efforts, successes and failures of the past by power of imagination and adding out-of-the-box thinking to them.

First of all, let’s create an abstraction of a Coordinator to understand its core functionalities!

It must have:

  • A reference to a UINavigationController
  • A start function to start the flow
  • A function to show the destination of the route
  • All of UINavigationController functionalities, such as popping one step back, popping all of the steps back to root page, and dismissing the whole navigationController
  • It should be injectable to each Route’s destination/view/page. All of the pages in the navigation stack should have a single Coordinator object!

If we write down our needs, it should be something like:

coordinator-abstraction

Seems to be enough. Let’s implement this abstraction!

Coordinator.swift

Huh?? Questions arise!

What is the public let startingRoute: Router?

To start the coordinator, we could create a new class that inherits from the Coordinator class, and then inside the start function, we implement the logic for starting it! But instead, I decided to create a single property for the starting route to use the same Coordinator class on every screen.

You can use both scenarios and do some practice on your own. I’d be super happy to hear about your journey with subclasses in the comments section.

What is the public func show(_ route: Router, animated: Bool = true)?

This is the main functionality of the coordinator. It takes a route, creates the destination, injects a coordinator to it, and puts the view into UIHostingController using an opaque type of View. This View is based on the transition of the route, and it navigates the system to the destination!

What is the public func pop(animated: Bool = true)?

Since it is the functionality of UINavigationController, it should be accessible from the coordinator object. It has nothing to deal with the route, so it is just about changing something in the flow.

What is the public func popToRoot(animated: Bool = true) ?

Same as number 3, another functionality of the navigationController

What is the open func dismiss(animated: Bool = true)?

It is one of the main functionalities of UIViewController, but with some differences. In this architecture, the UIHostingControllers will not be deallocated from memory by just dismissing the navigationController. At this moment, a memory leak will happen.

The hostingControllers will be retained because they still have strong references to the navigationController, but by manually removing the hostingControllers from the navigation stack, the issue will be resolved, and their reference to the navigationController will be destroyed, so the hostingControllers will be deallocated successfully

Why are some functions public, open or private?

It depends on your needs, but I found the best encapsulation with this access control over the Coordinator properties.

Why it is an Observable Object?

As stated, it should be injectable to SwiftUI Views, so the Observable Object type is the only possible solution for Dependency Injection in SwiftUI Views. It will be served as an @EnvironmentObject.

Couldn’t find an answer? Ask in the comments section

While the Coordinator and the Router seem to be the right tools, how do we put them inside the app’s lifecycle?

The easiest way to put UINavigationController into the app’s lifecycle is the AppDelegate and SceneDelegate that we used in the UIKit-based applications. In my honest opinion, the separation they had and their functionalities was a pain killer for most complex features, even though we were able to add multiple windows to the screen!

So if we go back to the old days of using AppDelegate and SceneDelegate, we will be able to put the UINavigationController into the UIWindow and launch the app!

First of all, let’s get rid of the YOUR_PROJECT_NAMEApp.swift file that is the @main entry point of the application. Let’s throw that right in the trash!

Secondly, create AppDelegate.swift with configurationForConnecting delegate. It is the trick that connects the SceneDelegate class to your application.

AppDelegate.swift

IMPORTANT NOTE: Just remember that, if you build and run your application with old YourApp.swift file (the @main entry point of the app) on a device before, you should remove the app completely from that device and rerun the project.

Thirdly, create SceneDelegate.swift to modify the window and insert your first navigationController to it.

SceneDelegate.swift

After the app is launched, the new window will be created, and the coordinator will be moved to the startingRoute by the side effect of the start function.

The last thing that remains is the usage in the views. Here’s the code:

coordinator-intro-usage

The most interesting thing is that all the ViewModifiers that could apply to NavigationView can still apply to this pattern. Things like navigationBarHidden, navigationBarItems , etc.

After all, you can check this code block with the old State-based navigation management we had without the Coordinator pattern. There is no more concern about handling 100 pages, popping from page 73 to page 18, or dismissing the whole navigation stack on page 99.

That’s just an Introduction to the big Coordinator pattern. In the next articles, I’ll explain many things like:

  • how to handle multiple Coordinators
  • how child coordinators can communicate with their parent
  • how to handle UITabBarControllers
  • different flows and Smart Coordinators available for the right flow for your app.

Download the final project to follow each step and change them to practice:



News Credit

%d bloggers like this: