Massive View Controllers are a common pain with iOS development. A quick search will give you much good advice on how to slim down these monsters. In this post I want to give some attention to the often overlooked use of Container View Controllers.
Last Updated: Jun 12, 2020
Too Many Responsibilities
Take a look at this user interface. The top half is a table view listing countries with a latitude and longitude coordinate. The bottom half is a standard iOS map view. When a user chooses a location in the table view we show it in the map.
This is a simple application but there is a lot happening with interactions between table view data source, delegate and map view. This is a great time to use container view controllers and split our app into a parent and two child view controllers:
- A parent view controller manages the view hierarchy and communication between the child view controllers.
- A child table view controller handles the list of countries.
- A child map view controller handles displaying a location.
Using a Storyboard
This is our starting Storyboard with a parent view controller (P) embedded in a navigation controller. The table view controller (L) lists locations for a user to choose and has a delegate protocol to send the location back to the parent view controller. The map view controller (M) holds a MKMapView
to show the location passed to it by the parent view controller.
The aim is to have the root views of our child controllers appear in the view of the parent view controller. Using Interface Builder drag two container views from the Xcode Object Library into the empty root view of the parent view controller:
Interface Builder creates two placeholder container views connected to generic child view controllers with an embed segue.
We already have our two child view controllers so we can delete the two new view controllers. We then need to create embed segues from the two container views in the parent view controller to our two child view controllers.
Control-drag from the top container view in the parent view controller to the location table view controller and choose embed segue:
Repeat this process for the lower container view and the map view controller. The two child view controllers will resize to match the size of their container views:
Container View Constraints
The next step is to setup some Auto Layout constraints for our container views. I have added both views to a vertical stack view pinned to the margins of the root view:
The stack view has a fill equally distribution and standard spacing. The view hierarchy:
Note that when using Interface Builder to create container views you end up with an extra placeholder container view that the root view of the child controller is then added to.
Accessing the Child Controllers
The childViewControllers
property of UIViewController
is a read-only array of child view controllers. We can use this in the viewDidLoad
of the master view controller to get references to the children and set the delegate:
private var locationTableViewController: LocationTableViewController?
private var mapViewController: MapViewController?
override func viewDidLoad() {
super.viewDidLoad()
guard let locationController = childViewControllers.first as? LocationTableViewController else {
fatalError("Check storyboard for missing LocationTableViewController")
}
guard let mapController = childViewControllers.last as? MapViewController else {
fatalError("Check storyboard for missing MapViewController")
}
locationTableViewController = locationController
mapViewController = mapController
locationController.delegate = self
}
Alternatively we can use prepare(for segue: sender:)
which is called for both of the embed segues we created in the storyboard:
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
let destination = segue.destination
if let locationController = destination as? LocationTableViewController {
locationTableViewController = locationController
locationController.delegate = self
}
if let mapController = destination as? MapViewController {
mapViewController = mapController
}
}
Communication Between Parent and Child
Instead of implementing everything in one massive view controller we have three smaller, easier to understand, view controllers. The location table view controller only has to take care of showing a list of locations and telling the parent if the user selects one:
// LocationTableViewController.swift
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if let location = locationDataSource?.locationAtIndexPath(indexPath) {
delegate?.didSelectLocation(location)
}
}
The parent view controller handles the initial setup and then decides what to do when the user selects a location. It does that by implementing the child view controllers delegate protocol to update the map view controller:
// MasterViewController.swift
extension MasterViewController: LocationProviderDelegate {
func didSelectLocation(_ location: Location) {
mapViewController?.coordinate = location.coordinate
}
}
The map view controller doesn’t care about anything except updating its map view anytime someone sets its coordinate property:
// MapViewController.swift
var coordinate: CLLocationCoordinate2D? {
didSet {
if let coordinate = coordinate {
centerMap(coordinate)
annotate(coordinate)
}
}
}
Stack View Fun
A quick side-track to show the table and map side-by-side in landscape. That needs a few lines of code in the parent view controller to switch the axis of the stack view:
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
topStackView.axis = axisForSize(view.bounds.size)
}
We switch to a horizontal stack view when the width is greater than the height:
private func axisForSize(_ size: CGSize) -> UILayoutConstraintAxis {
return size.width > size.height ? .horizontal : .vertical
}
Much better:
Adding and remove child views in code
Storyboards make it easy to setup static container views but what if you want to dynamically add and remove child view controllers? To see an example I will remove the container views and embed segues from the Storyboard and recreate the same setup in code.
My parent view controller now takes care of the stack view and lazily creates the two child view controllers from the Storyboard when first accessed (details omitted for brevity):
private let topStackView = UIStackView()
private lazy var locationTableViewController: LocationTableViewController = ...
private lazy var mapViewController: MapViewController = ...
In the viewDidLoad
method of the parent view controller we set up the stack view and add the two child view controllers:
override func viewDidLoad() {
super.viewDidLoad()
setupStackView()
addContentController(locationTableViewController, to: topStackView)
addContentController(mapViewController, to: topStackView)
locationTableViewController.delegate = self
}
I will skip the setup of the stack view (setting the axis, alignment and pinning to the margins). What is interesting are the steps to add a child view controller which I have collected into a small method:
private func addContentController(_ child: UIViewController, to stackView: UIStackView) {
addChildViewController(child)
stackView.addArrangedSubview(child.view)
child.didMove(toParentViewController: self)
}
There are only three steps:
- Create the parent-child relationship by calling the
addChildViewController
method of the parent passing the child view controller as the argument. - Add the root view of the child to the parent container view hierarchy and setup any constraints.
- Call the
didMove(toParentViewController:)
method of the child passing the parent view controller as the argument.
Notes:
- Calling
addChildViewController
for a child view controller that is already a child of a different parent container removes it from that other container. - In this example we add our child views to a stack view in the parent so we use
addArrangedSubview
and the stack view takes care of adding contraints to our child view.
I don’t need it this time but the steps to remove the child from the parent are as follows:
private func removeContentController(_ child: UIViewController, from stackView: UIStackView) {
child.willMove(toParentViewController: nil)
child.view.removeFromSuperview()
child.removeFromParentViewController()
}
We reverse the steps compared to the add operation:
- Call the
willMove(toParentViewController:)
method of the child view controller passingnil
as the argument. - Remove the child view from the parent view hierarchy.
- Call the
removeFromParentViewController
method of the child view controller.
Sample Code
You can find both versions of the Container project in my CodeExamples GitHub repository.
Further Reading
The Apple View Controller Programming Guide for iOS goes into more details:
For an example of dynamically sizing a child view controller to fit its content see this later post: