iosdev

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:

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

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:

  1. each Coordinator will re-use the ApplicationCoordinator’s rootViewController (which is our subclass of UINC)
  2. NavigationCoordinator naturally keeps the viewControllers array of content VCs it created
  3. 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:

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. :)