Clean, Simple and Composable Routing for iOS Apps
This is the first part of a series of articles about a Clean, Simple and Composable Routing for iOS apps.
Most of mid to big projects will have dozens of screens and rather complex routing flows between them.
In order to explore some real-life scenarios of a more complex routing mechanism, including managing a Siri Shortcut flow, let's think of a simple sample app that has a TabBarController with a Shop and Wishlist tabs.
From the Shop tab, a user can open a Product page. From this Product page, the user can open another Product page, add an item to their Wishlist, which will present a Pop Up confirmation, or even create a Siri Shortcut for that product.
From the Wishlist tab, a user can also open a Product, with the same flow as described above, and Login pages. From the Login page, a user can access the Sign Up and Forgot Password pages. They can also access the Forgot Password page from Sign Up. Finally, from the Forgot Password page, users can reset their password which will present them with a Pop Up confirmation.
Phew 😅. I know that this "simple" example is hard to picture, so have a look a the flow drawn below:
Dem hand-writing skillz tho 😳
Independent of the app's architecture it's clear that there are many repetitive flows in this example, and it'd be best if we could somehow make them isolated, reusable, composable and testable (hi there, SOLID! 👋). Seems like a lot, doesn't it? But we'll break these concepts down throughout this post.
An ideal approach will allow us to inject a Route into a Controller or ViewModel so we can easily open and/or close certain screens. Also, we should only be able to access routes that are relevant to that page. Finally, and probably most importantly, the approach should be easy to support deeplinks/Universal Links.
This is the app flow in action 😰
Amongst the many different approaches explored by the community, I'd split them between in two main ideas: one that uses one massive AppCoordinator that handles the whole app's flow in one place, and another that has multiple coordinators, perhaps one per screen if necessary, which are chained together.
The first is more convenient since you just pass the same instance around and you have access through it to the whole app's flow. However, this approach can be messy quite easily for bigger flows, as well as leaking too much information to the view models by exposing routes they don't need.
The latter isolates logic better, but requires more management of references, deallocation, and organisation. In our sample app, would you instantiate a ProductCoordinator in the WishlistCoordinator, ShopCoordinator and in the ProductCoordinator itself since it may also present a product, or would you move it to the AppCoordinator itself since it's a very used route?
Apps change quickly, flows are updated constantly, A/B testing is here and it's real. What may begin with one Product being shown from the Wishlist might end up with it being presented in many places later one. You often don't know that beforehand and need to refactor it later down the track. This has come back to bite me a few times while using the pattern 🤐.
Don't get me wrong, Coordinators are very good and provide the tools to solve most of the problems with routing, and I'm sure they can be used in a way that avoids these issues mentioned above too, but sometimes these tricky situations may cause you to constantly refactor your flow, get a bit lost with managing coordinator references or re-instantiating/re-testing coordinators in multiple coordinator leaves.
Routers, a cleaner approach
There are a couple of good articles and samples about routing in iOS. You should read them too! While playing with the Coordinators and Routers ideas, I hadn't found a standard way that provided me enough flexibility and simplicity to achieve all the requirements mentioned before. So I began to work on something 🕵️♂️.
Assuming we want to route users to other screens, we'd need a couple of protocols for such:
A route should be able to be shown, closed and dismissed. The difference between close and dismiss is that close will apply the same Transition received during the routing, while dismiss will forcibly dismiss the screen.
A Transition is as simple as the protocol below. It's a way of opening and closing a screen by applying whatever animation the developer provides. For example, Modal, Push or Fade transitions (have a look at the source-code to see their implementation).
Combining Closable, Dismissable and Routable with a Router protocol, we're ready to implement our first default router. For simpler applications, the DefaultRouter will be everything you need to get your routing logic going.
Have a look at the implementation:
The DefaultRouter is initialised with a rootTransition which will be used later on to close the root controller.
The route method takes in a controller to be opened, as well as the transition that will be applied to it. The route's method transition argument has nothing to do with the rootTransition. In that method the router is just opening up new screens which will have their own routers and transitions.
Finally, the close and dismiss methods provide the ability to remove the root in different ways, as previously explained.
Now that our DefaultRouter is ready, we can start creating the app's routes. We'll need eight of them: ProductRoute, LoginRoute, PopUpRoute, SignUpRoute, ForgottenPasswordRoute, SiriRoute, WishlistTabRoute and ShopTabRoute.
Have a look at the LoginRoute as an example:
We create a protocol LoginRoute that specifies what this route does. We then extend this protocol and create a default implementation constrained to classes that implement the Router protocol. Finally, we extend DefaultRouter to conform to LoginRouter.
Since DefaultRouter also conforms to Router it gets the default openLogin implementation for free! 😱
Now, breaking down the implementation of openLogin, we see that a ModalTransition is create on line 7 and passed as the rootTransition for the newly created DefaultRouter. This router is then passed in to the LoginViewModel, which will hold a strong reference to it, ensuring it won't get deallocated.
The view model is then injected into a LoginViewController, that's assigned to the router as the root view controller on line 12. The root reference is weak, so no strong reference cycle is created here. Our LoginViewController instance is embedded into a UINavigationController that's routed as a modal on line 14.
The code on line 14 is, probably, the most important concept to be understood here: note that the route(to: as:) is being executed by the current router, not the newly created one, hence you must use the same Transition instance defined as the rootTransition on line 8 also in the route(to: as:) method on line 14.
The flexibility and reusability of this routing system comes from the fact that one router spawns new routers without making any direct connection between themselves besides the view controllers and view models that keep the routers alive 🤯.
Once a view controller is dismissed/pop/removed from any stack and deallocated, its view model will deallocate and the router attached to it too, freeing up all resources in memory. All of this is managed automatically by the view controllers stack. No extra work for us! 👏
Using the Routes
Alright, it's time to put these routes to work! Have a look at the view controller and view model implementations below:
A typealias named Routes is created to easily define which routes our screen can handle. For now, only the LoginRoute. Look how simple it is to use it.
In order to support opening the Product page from the Wishlist we need to create a route for it too. Here's how it looks:
It's just another protocol extension that adds the ability to open product pages with a PushTransition to the DefaultRouter.
Now we just need to update our Routes typealias to include ProductRoute and we're done. Easy, isn't it? 🎉
New routes can be easily added to the Routes typealias
We've seen how easy it is to extend the Router protocol to add new routes to it, and to its implementations, such as the DefaultRouter, via protocols. All of this without leaking unnecessary information to the view models, thanks to the Routes typealias, which restricts the access to routes which are strictly necessary to the view models.
DefaultRouter will take us very far, covering most of our necessities, but let's make things a bit more complex and implement a SiriRouter to handle Siri Intents in the Product page.
It looks like a lot, but it's just the Intent's delegate verbosity. SiriRoute doesn't need to do anything besides conforming to the intent delegate, since everything else will be managed by the IntentsUI framework. The route protocol must still be created to ensure the routing is abstracted from the view models, making everything testable.
SiriRouter inherits from DefaultRouter to ensure all the previous logic to open, close and dismiss controllers is kept intact (again, Open-closed principle 😊).
The router then implements all the delegates required by the shortcut button (I've actually hidden a few delegate methods in the image above for brevity, but in the source code you'll see the full implementation).
Alright, SiriRouter is done. Since we'll use it in the Product page, we'll have to update the ProductRoute to use it.
As you can see, the only change in the route was replacing the DefaultRouter on line 8 for the SiriRouter on line 9 to enable us to take advantage of the intent handling.
Let's update the product screen to include a Siri Intent:
On line 8 we return the router as the intent delegate since SiriRoute conforms to that protocol, and SiriRouter implements it.
Then siriButtonDelegate, A.K.A. SiriRoute, is assigned as the shortcut button's delegate. Done, everything else will be managed by SiriRouter now 😱.
Lean code, reusable routes and clean routers ❤️.
Bonus: Deeplink Router
Deeplinking is hard. Handling it can become a nightmare quite quickly, especially when using hardcoded view controller orchestration directly in your view controllers.
If you've read everything till here and payed close attention to it (thanks! 🙏), you know what we're up to next. We'll build a DeeplinkRouter! 💪
Our routing system is already pretty good, what else do we need in order to open arbitrary links? A way of mapping urls to screens and a way to route users to them. So, I present you… Deeplinkable! I know, I'm really bad at naming things 🙈.
So far our routes implement a single way of transitioning to the screens, but when deeplinking to a screen the strategy may change. A screen that we used to push may now be required by stakeholders to be presented as a modal, for example.
That's what route(to url: as transition:) aims to solve. Differently to the other routing methods, this one returns a boolean because some URLs may not be supported yet, and we might want to consider them separately.
Before beginning to work on DeeplinkRouter we need to update our routes to handle opening with different transitions. Thanks to protocol extensions power, we can easily do that by following this approach:
Exposing a method that's only accessible to instances that conform to Router is a nice and easy way to make the routes more flexible to different transitions.
Since we're following the Interface Segregation Principle and hiding the implementation behind the route's protocol, only openProduct will be visible to the view models 💃.
Once all routes are updated to this new pattern, we're ready to create the DeeplinkRouter.
There are many ways of mapping your screens to the links relevant to your app. In this example I'm using an enum and relying on the URL's path to match it to an unexistent-made-up website. Use your discretion to parse it in a way that makes sense to your business rules.
Once the paths have been parsed, we make use of the newly created route methods that take a Transition as an argument to open the screens. Note that since Wishlist and Shop are tabs embedded in the tabBarController we're just updating their selectedIndex via their respective routes. 👌
This is a simple approach that works fine for this sample app, but bear in mind that in real apps some of these transitions may conflict when previously presented screen were already loaded during the deeplink, etc. It's a good ideia to write some UI Tests for it!
Alright, now is time to update the SceneDelegate or AppDelegate to instantiate the TabBarController and its tabs. However, how can we create tabs using this routing pattern?
It's simple. We should think of our routes as ways to instantiate and present the controllers in our app. In this case, we can't present a tab, so we should create a route that just instantiates it to be used by the TabBarController.
Optionally, you can also create a TabBarRoute, but we won't do that in this sample.
We now apply the same concept above to the ShopTabRoute and create a MainTabBarController that accepts a list of controllers as arguments:
Putting everything together, our SceneDelegate instantiation will look like this:
Finally, we can add the deeplink methods to handle the links. In the example bellow I've done it for a custom url scheme.
Lines 18 and 24 will trigger the deeplink helper method on line 28. In this example, I've chosen to always show any deeplink as a modal, as you can see on line 30.
I've created a helper method named topMostViewController that ensures the deeplink will be presented on top of everything else. Note how our DeeplinkRouter having this topMostViewController as its root is able to open the deeplink without issues.
It just works™ 😂.
Finally, we need to keep an instance of the DeeplinkRouter in the scene to ensure it's kept alive while the transition happens. It's not being cleaned up here since sceneDelegate will never be deallocated. I personally don't think this will be much of an issue since it's just one small router object, but if you want to make sure everything is freed up, you can add a completion handler in the route method, for example.
It's been a long read! Thanks for sticking around till the end 🤗.
I find this routing system very dynamic, reusable, easily extensible and testable. There may be a couple of edge cases here and there but overall it's pretty solid.
One very important point I didn't touch in this article is that the routes themselves aren't very testable in the article's example, since we're instantiating them directly with hardcoded values. In a real app they'll have many dependencies and be even more complex to instantiate.
Don't panic! This routing system goes along very well with Dependency Container frameworks or even factories, making it more testable. I intend to write more about it in the future, stay tuned! 😊
The source-code of the project is available on my Github, with all the implementations done.
Continue reading this series by having a look at the part 2 here.
Thanks for reading and let me know what you think. Feel free to message me up on Twitter too!