Container View Controllers

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.

User Interface

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.

Initial Storyboard

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:

Container view in object library

Interface Builder creates two placeholder container views connected to generic child view controllers with an embed segue.

Embedding container views

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:

Creating an 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:

Both child views controllers embedded in 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:

Container views embedded in a stack view

The stack view has a fill equally distribution and standard spacing. The view hierarchy:

View hierarchy and constraints

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:

landscape layout

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 passing nil 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: