Coordinator pattern with UITabBarController
Recommended way to seamlessly gel UITabBarController with Coordinator pattern, where each tab has its own Coordinator.
Leonardo Maia Pugliese recently published a blog post that picked my interest: Coordinators and Tab Bars: A Love Story. I always love to read how other people employ this useful pattern thus I carefully read through it.
The problem he is solving is this:
Imagine that you have a tab bar with two tabs and each tab is a navigation controller. The Home Tab has 2 levels deep and the Orders Tab has 3 levels deep.
The problem is to navigate from the second level deep view controller of home Tab to the 3 level deep Orders tab maintaining the hierarchical sequence of orders tab, this means that the return button should return to level 2 of level tab and so on.
In UIKit, event/data flow is achieved through UIResponder
+ a special set of overridable methods on UIViewController
, like show(vc)
, present(vc)
etc.
UIKit’s show()
method has super simple logic. When called inside any UIVC, this method will:
- check if current UIVC has
parent
- if yes, callparent.show(vc)
and do nothing else - If no parent, popup the target UIVC (a rather crude fallback)
Sadly, UIKit falls way short of making true separation of concerns. Per example project from Leonardo’s post, to switch from second screen in Home tab to a third screen in Orders tab and to keep the Orders drill-down navigation hierarchy, UIKit forces you to do this:
let controllers = [
OrdersViewController(),
Orders2ViewController(),
Orders3ViewController()
]
let target = tabBarController.viewControllers[1] as? UINavigationController
target?.viewControllers = controllers
Poor Home2ViewController
needs to
- create and configure 3 completely separate controllers to whom it has no actual relation
- needs to know exactly where itself is located in the UIKit hierarchy and call specific properties after performing fixed type-casting
That’s just…horrible. Navigation remains the worst aspect of iOS SDK, regardless if you are using UIKit or SwiftUI.
To solve these issues, Leonardo correctly declares the app hierarchy independent of actual UIKit objects:
enum AppFlow {
case home(HomeScreen)
case orders(OrdersScreen)
}
enum HomeScreen {
case initialScreen
case doubleButtonScreen
}
enum OrdersScreen {
case firstScreen
case secondScreen
case thirdScreen
}
This works for an example project. It can easily be extended into truly complex flows, screens that accept parameters (using associated values on cases) etc. Thus it’s good approach.
Further, he implements Coordinators to create and configure UIVC for each of these screens. There’s MainCoordinator
which manages UITabBarController
while each of the tabs are child Coordinator managing their own UINavigationController
instance. Again — this is good implementation where each Coordinator is responsible to create and display its subset of UIVCs. For example, second screen in Home tab:
func goToHome2ScreenWith(title: String) {
let home2ViewController = Home2ViewController(coordinator: self)
home2ViewController.title = title
navigationRootViewController?.pushViewController(home2ViewController, animated: true)
}
So far, this solves the first issue with UIKit navigation.
Where it falls short is the second problem: the implementation of Home2ViewController request flow transfer to Orders3ViewController is not good.
The solution used is the one that builds on Soroush’s original approach of using Delegate pattern to offload navigation (switching from one screen/view to another) from the view-controllers to coordinators. Simplified:
- UIViewController will ask its Coordinator to switch to another UIVC
- if the requested UIVC is in another tab, Coordinator will ask its parent Coordinator to do that
It’s a really simple approach but also very limiting one. Delegate pattern is great when you need to transfer something from point A to point B. It does not matter which points they actually are; the limiting factor is that there’s only two points. The pattern does not allow transfer to potential point C (or D or E). If you need that, you need to implement another Delegate pattern which handles transfer from B to C etc.
Navigating between screens may mean that you have to transfer data or flow between many points. In our practice, we regularly encountered transferring stuff from 5-6 levels deep to the top and then to completely different flow stack, to screen maybe 2-3 levels down. Delegating between all those levels leads to super rigid structure which becomes additional maintenance issue.
You can glimpse the problem in this code:
protocol FlowCoordinator: AnyObject {
var parentCoordinator: MainBaseCoordinator? { get set }
}
protocol Coordinator: FlowCoordinator {
}
protocol MainBaseCoordinator: Coordinator {
var homeCoordinator: HomeBaseCoordinator { get }
var ordersCoordinator: OrdersBaseCoordinator { get }
var deepLinkCoordinator: DeepLinkBaseCoordinator { get }
func moveTo(flow: AppFlow)
func handleDeepLink(text: String)
}
protocol HomeBaseCoordinator: Coordinator {
func goToHome2ScreenWith(title: String)
func goToFavoritesFlow()
func goToDeepViewInFavoriteTab()
}
protocol OrdersBaseCoordinator: Coordinator {
@discardableResult func goToOrder2Screen(animated: Bool ) -> Self
@discardableResult func goToOrder3Screen(animated: Bool) -> Self
}
protocol HomeBaseCoordinated {
var coordinator: HomeBaseCoordinator? { get }
}
protocol OrdersBaseCoordinated {
var coordinator: OrdersBaseCoordinator? { get }
}
Usage of protocols is superfluous as the structure is rather rigid: Main
is always a parent of Home
and Orders
and there’s fixed connection between each point. The flow-transferring methods do exactly one thing thus I’m not sure why all of these protocols even exist; there’s nothing I see they help to abstract. The data flows implemented further in the post would work the same without any of these protocols, as there’s fixed connections (using stored properties):
class MainCoordinator: MainBaseCoordinator {
var parentCoordinator: MainBaseCoordinator?
lazy var homeCoordinator: HomeBaseCoordinator = ...
lazy var ordersCoordinator: OrdersBaseCoordinator = ...
...
class HomeCoordinator: HomeBaseCoordinator {
var parentCoordinator: MainBaseCoordinator?
func goToDeepViewInFavoriteTab() {
parentCoordinator?.moveTo(flow: .Favorites)
...
...
Further down, view controllers also store fixed connection to specific parent Coordinator, again creating rigid connection:
class HomeViewController: UIViewController, HomeBaseCoordinated {
var coordinator: HomeBaseCoordinator?
...
I am confused what benefit all this scaffolding brings. If you opt to use fixed connections like this, you don’t need Coordinators nor protocols of any kind. Simply using embedded parent/child UIViewControllers and setting the equivalent properties will achieve the same thing.
The method call :
var parentCoordinator: MainBaseCoordinator?
...
parentCoordinator?.moveTo(flow: .orders(.secondScreen))
would work just the same with hypothetical Main
VC:
var parentController: MainController?
...
parentController?.moveTo(flow: .orders(.secondScreen))
The main issue is that, with this approach, content UIViewControllers have direct connection to specific parent objects. It’s the same thing as what we already have:
let target = tabBarController.viewControllers[1] as? UINavigationController
target?.moveTo(flow: .orders(.secondScreen))
Content view controllers should not know nor care who created them nor where they are located inside app’s flow/view hierarchy. They should be self-contained, have specific purpose and some exit points. Content UIVC should not know what other object will handle .moveTo(flow:)
(One of the) benefits of Coordinators — separation of app flow from the screens belonging to the flow — can only be achieved if you completely isolate screens from the coordinators and from other screens.
- UIViewController should not know nor care what its parent coordinator is.
- Child Coordinator should not know nor care what its parent Coordinator is.
- In both cases, child object should be able to communicate to their “parent” that flow needs to transfer to something else.
So, how to achieve that?
The key is to re-purpose the framework’s existing scaffolding. I already explained how show(vc)
works its magic. The beautiful part is that we can create our own magic by re-purposing responder chain and adding additional methods.
My Coordinator library does this with few tricks which take less than 20 lines of actual code:
import UIKit
// Inject parentCoordinator property into all UIViewControllers
extension UIViewController {
private class WeakCoordinatingTrampoline: NSObject {
weak var coordinating: Coordinating?
}
private struct AssociatedKeys {
static var ParentCoordinator = "ParentCoordinator"
}
public weak var parentCoordinator: Coordinating? {
get {
let trampoline = objc_getAssociatedObject(self, &AssociatedKeys.ParentCoordinator) as? WeakCoordinatingTrampoline
return trampoline?.coordinating
}
set {
let trampoline = WeakCoordinatingTrampoline()
trampoline.coordinating = newValue
objc_setAssociatedObject(self, &AssociatedKeys.ParentCoordinator, trampoline, .OBJC_ASSOCIATION_RETAIN)
}
}
}
extension UIResponder {
@objc open var coordinatingResponder: UIResponder? {
return next
}
}
extension UIViewController {
override open var coordinatingResponder: UIResponder? {
guard let parentCoordinator = self.parentCoordinator else {
guard let parentController = self.parent else {
guard let presentingController = self.presentingViewController else {
return view.superview
}
return presentingController as UIResponder
}
return parentController as UIResponder
}
return parentCoordinator as? UIResponder
}
}
This code creates new property called coordinatingResponder
on all UIKit’s components: anything that’s based on UIResponder
which means all views, controls and controllers. With that added, we can re-create our own show(vc)
that works with AppFlow
:
extension UIResponder {
@objc func openFlow(_ flow: AppFlowBox,
keepHierarchy: Bool = false,
userData: [String: Any]? = nil,
sender: Any?
) {
coordinatingResponder?.openFlow(flow, keepHierarchy: keepHierarchy, userData: userData, sender: sender)
}
}
I have re-factored Leonardo’s example project to use my Coordinator library and that special method to transfer flow.
Check the contents or any Coordinator or ViewController — there’s 0 references to parent anything. Whenever you need to switch flow to anywhere else, you just call openFlow()
and pass the target flow point. Each Coordinator will override the method and perform routing as needed.
For example, inside HomeCoordinator:
override func openFlow(_ flowboxed: AppFlowBox, keepHierarchy: Bool = false, userData: [String : Any]? = nil, sender: Any?) {
let flow = flowboxed.unbox
switch flow {
case .home(let screen):
displayScreen(screen, userData: userData, sender: sender)
case .orders:
coordinatingResponder?.openFlow(flowboxed, keepHierarchy: keepHierarchy, userData: userData, sender: sender)
}
}
If the flow transfer request is from some of its children, it routes to its private method that displays that screen. If the target flow is not its responsibility, it will pass the request to next coordinatingResponder
, upwards the responder chain. Note that HomeCoordinator does not know what that next (parent) object is. It simply passes the request through the magic conduit. Someone will handle it. 😇
Responder chain in UIKit is truly wonderful concept. This particular implementation of Coordinator pattern is just one of its many uses.