Build tabs-based UI using Coordinators
Replicating UITabBarController behavior using Coordinators flattens your UI hierarchy and thus considerably simplifies controller transitions
UITabBarController is one of the staples of UIKit. Available since the beginning in the Phone and iTunes apps, among other ones. Thus every iPhone customer knows how to use them and what to expect from them.
The essential characteristics:
- each tab is a world of its own
- each tab can have a navigation controller inside
- entirely separate from navigation controllers inside other tabs
- when switching back and forth, tab content keeps its place
This is one of those system components that was built because Apple devs needed them way back and never really got improved. Using them got significantly worse with the introduction of UIViewController transitions in iOS 7 and their API which uses fromViewController and toViewController. Trouble here is that you usually want transition from one content controller to another, but fromVC and toVC returned are actually the container controllers, like NC and TC. So you need to hunt down into the hierarchy to find what you are actually working with.
Tabs were the worst here, since you have content VC inside NC inside TC. I never liked this and over time abandoned use of tab-based apps. Since discovering Coordinators, I realized I can actually replicate the tabs with
- just one single instance of UINavigationController and
- keeping content VCs for each tab into separate Coordinators
- using coordinatingResponder messages to switch tabs
Here’s how to do it.
Fair warning: understanding this article requires good knowledge of how Coordinator library works and is used.
Tabs
First – you need to subclass UINavigationController which is fairly easy and painless to do, despite Apple’s warnings that it’s not designed for that.
The reason to subclass is so you can add the tabs. Simplest way (for me) is with UICollectionView
but implement them any way you want. But you need to do this in code, since IB does not even shows the .view
of the NC component.
final class NavigationController: UINavigationController {
static var tabsBaseHeight: CGFloat = 64
private weak var tabsCollectionView: UICollectionView!
}
You’ll need the data source for the tabs:
private lazy var tabs: [Tab] = setupDataSource()
In viewDidLoad
– or loadView
if you like living on the edge – create the tabs CV and register the corresponding cell:
override func viewDidLoad() {
super.viewDidLoad()
addTabs()
tabsCollectionView.register(TabCell.self)
tabsCollectionView.dataSource = self
tabsCollectionView.delegate = self
}
TabCell
is anything you want it to be. Just an image, icon + text, only text etc.
addTabs
is fairly large method, which is why I always recommend to extract it out from viewDidLoad:
func addTabs() {
let layout = TabLayout()
let cv = UICollectionView(frame: .zero, collectionViewLayout: layout)
cv.translatesAutoresizingMaskIntoConstraints = false
if #available(iOS 11.0, *) {
cv.contentInsetAdjustmentBehavior = .never
} else {
self.automaticallyAdjustsScrollViewInsets = false
}
view.addSubview(cv)
cv.heightAnchor.constraint(equalToConstant: NavigationController.tabsBaseHeight).isActive = true
cv.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
cv.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
cv.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
tabsCollectionView = cv
cv.backgroundColor = .clear
cv.backgroundView = {
let e = UIBlurEffect(style: UIBlurEffectStyle.extraLight)
let v = UIVisualEffectView(effect: e)
v.isUserInteractionEnabled = false
return v
}()
}
As an added bonus, notice how I replicated the default system blur behavior, behind the tabs. Pro-tip and a task: you’ll need to account for iPhone X’ safeAreaInsets
here, at the bottom.
TabLayout
is the simplest possible subclass of horizontal UICollectionViewFlowLayout
.
Tap on a cell in the CV will initiate the tab/content change:
extension NavigationController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let t = tabs[indexPath.item]
tabTapped(t.id, onQueue: .main, sender: self)
}
More on what’s tabTapped(...)
in a minute. We need to build the tab content first.
Coordinators
If you are familiar with Coordinator-based architecture, you’ll know it always starts from ApplicationCoordinator
, which spawns any other coordinator. The root VC here is usually UINavigationController
…or its subclass. ;)
Thus using my Coordinator library:
final class AppCoordinator: NavigationCoordinator {
init(rootViewController: NavigationController?) {
super.init(rootViewController: rootViewController)
}
private var navigationController: NavigationController {
return rootViewController as! NavigationController
}
}
Let’s imagine this app is a shopping app, like in my Coordinator example app. Tabs can be modeled (inside ApplicationCoordinator) like this:
enum Section {
case sales
case catalog
case cart
case account
case popup
}
/// Currently active (shown) Coordinator
var section: Section = .catalog
Each of these sections == one Coordinator of the same name and each will also be a subclass of NavigationCoordinator. This is the key since:
- each Coordinator will re-use the ApplicationCoordinator’s
rootViewController
(which is our subclass of UINC) - NavigationCoordinator naturally keeps the
viewControllers
array of content VCs it created - when it’s brought up it will replace whatever is currently shown in rootVC with that array
The last bit is what replicates the illusion of tabs keeping their content and context. UINC is just a vehicle to show the content VCs, they are actually kept around by NavigationCoordinator.
So how do you perform this switch of viewControllers
from one Coordinator to another? Using coordinatingResponder
, of course.
coordinatingResponder messaging
Declare and implement the default message behavior:
extension UIResponder {
@objc dynamic func tabTapped(_ tabID: String, onQueue queue: OperationQueue, sender: Any?) {
coordinatingResponder?.tabTapped(tabID, onQueue: queue, sender: sender)
}
}
Then on ApplicationCoordinator implement the switching:
override func tabTapped(_ tabID: String, sender: Any?) {
switch tabID {
case Tab.IDSales:
setupActiveSection(.sales)
case Tab.IDCatalog:
setupActiveSection( .catalog )
case Tab.IDCart:
setupActiveSection( .cart )
case Tab.IDAccount:
setupActiveSection( .account )
default:
break
}
}
setupActiveSection()
is pretty app-specific but its general purpose is two-fold:
- load or re-activate appropriate coordinator
- update local
section
property
What it does not do in this case: it’s not stopping the already active Coordinator, which stays in memory kept by childCoordinators
property. Hence all its viewControllers also stay in memory.
The first task is the interesting one. Here’s possible implementation for the Catalog section:
func showCatalog(_ page: CatalogCoordinator.Page? = nil) {
let identifier = String(describing: CatalogCoordinator.self)
// if Coordinator is already created...
if let c = childCoordinators[identifier] as? CatalogCoordinator {
c.dependencies = dependencies
// restore all the viewControllers
c.activate()
// then just display new page
if let page = page {
c.display(page: page)
}
return
}
// otherwise, create the coordinator
let c = CatalogCoordinator(rootViewController: rootViewController)
// populate dependencies
c.dependencies = dependencies
// setup first Page (content VC) to show
if let page = page {
c.page = page
}
// start it
startChild(coordinator: c, completion: {})
}
Here’s what activate()
method on the NavigationCoordinator is doing:
open override func activate() {
super.activate() // rootViewController.parentCoordinator = self
rootViewController.delegate = self
rootViewController.viewControllers = viewControllers
}
As you can see, it takes over the ownership of the NC and installs its own set of content VCs. Thus by simply calling showCatalog()
, showCart()
, showAccount()
etc – you can easily switch between the tabs.
There are few more details here I skipped, like making sure that proper tab is visually selected. But they are minor implementation detail.
Pay attention that CatalogCoordinator
’s root VC is the same NavigationController
instance already created on ApplicationCoordinator. Thus entire app is using just one single UINavigationController instance. You can’t get simpler view hierarchy than this.
If you also make that instance the UIViewControllerTransitioningDelegate
…well, just imagine the possibilities. :)