Have you ever wondered how tab bar controllers and navigation controllers do their work? Even though it may seem as if UITabBarController and UINavigationController are magical classes, they are nothing more than UIViewController subclasses.

What have these classes in common? Both classes allow you to insert custom content in the form of one or more view controllers. A navigation controller, for example, manages a stack of view controllers. You can push and pop view controllers onto and from a navigation stack. The same is true for a tab bar controller. It manages an ordered list of view controllers accessible through a tab bar at the bottom.

Container View Controllers

Both UINavigationController and UITabBarController are container view controllers. What does that mean? A container view controller manages a view just like any other UIViewController subclass. In addition to managing a view, a container view controller also manages one or more child view controllers. It acts as the parent view controller of one or more child view controllers.

The parent view controller is responsible for setting the size and position of the view of each child view controller. The view of the child view controller becomes part of the parent view controller's view hierarchy.

That said, the child view controller continues to be responsible for its own view hierarchy and that is what gives container view controllers their power. A navigation controller is highly reusable thanks to the container view controller architecture. It knows how to manage a stack of view controllers and it doesn't know or care what each of its child view controllers displays.

The same is true for tab bar controllers. A tab bar controller is aware of its child view controllers, but it doesn't know or care what they display. It only knows how to manage them and navigate between them.

Benefits

Reusability

A key advantage of container view controllers is reusability. As I mentioned earlier, UIKit includes a number of UIViewController subclasses that are container view controllers, such as UITabBarController, UINavigationController, and UISplitViewController. Each of these classes implements a navigation paradigm that is commonly found in iOS, tvOS, and macOS applications.

A UISplitViewController instance, for example, presents a master view controller on the left and a detail view controller on the right. If the user taps an item in the master view controller, the item's details are shown in the detail view controller. The master and detail view controllers are both child view controllers of the UISplitViewController instance.

Lean View Controllers

View controller containment makes it much easier to keep view controllers lean. Complex user interfaces no longer need to be managed by a single view controller. By using a container view controller, a user interface can be split up into logical or functional components, each managed by a view controller.

This also makes it easy to deconstruct a user interface and reuse components in various parts of a project. It is easy or tempting to cram seemingly related functionality into a single view controller, but this often leads to UIViewController subclasses that span hundreds or thousands of lines. That is not what you want. Right?

An Example

In this episode, I would like to show you how view controller containment can be used by creating a simple application that demonstrates the benefits of the pattern. You learn about the relationship of a container view controller and its children, and you also become familiar with the containment view controller API.

In Samsara, users can view their sessions in the statistics view. This view is managed by one view controller, a container view controller. At the top, the user can switch between a summary and a detailed list of each session. Each section is managed by a separate view controller, a child view controller of the container view controller. In this episode, we implement a similar user interface.

Samsara's Statistics View

Setting Up the Project in Xcode

The example we are about to create is pretty simple. Open Xcode and create a new project by choosing the App template from the iOS > Application section.

Project Setup

Name the project ViewControllerContainment. Make sure to set Interface to Storyboard and Language to Swift. For this episode, there's no need to check any of the checkboxes at the bottom.

Project Setup

Setting Up the User Interface

Start by renaming ViewController.swift to MasterViewController.swift. Open MasterViewController.swift. Change the name of the class from ViewController to MasterViewController and mark it as final. This is what the contents of MasterViewController.swift should look like.

import UIKit

final class MasterViewController: UIViewController {

    // MARK: - View Life Cycle

    override func viewDidLoad() {
        super.viewDidLoad()
    }

}

Create two UIViewController subclasses, SummaryViewController and SessionsViewController, using the Cocoa Touch Class template. These view controller classes will serve as the child view controllers of the container view controller.

Create Child View Controllers

Open Main.storyboard and select the view controller of the scene that is already present. Open the Identity Inspector on the right and set Class to MasterViewController in the Custom Class section.

Update Class in Storyboard

With the view controller selected, choose Embed In > Navigation Controller from the Editor menu. This adds a navigation controller to the storyboard with the master view controller as its root view controller.

Embed in Navigation Controller

To switch between the child view controllers, we use a segmented control. Click the + button in the top right to bring up the Library and add a segmented control to the navigation bar of the master view controller.

Add Segmented Control

Open MasterViewController.swift and create an outlet for the segmented control. In Main.storyboard, connect the outlet with the segmented control.

import UIKit

final class MasterViewController: UIViewController {

    // MARK: - Properties

    @IBOutlet var segmentedControl: UISegmentedControl!

    // MARK: - View Life Cycle

    override func viewDidLoad() {
        super.viewDidLoad()
    }

}

We are almost done with the user interface. Add two view controllers from the Object Library to the storyboard. In the Identity Inspector, set the class of the view controllers to SummaryViewController and SessionsViewController respectively.

Select the summary view controller and set the background color of its view to orange. Open the Identity Inspector and set Storyboard ID to SummaryViewController. This will allow us to load the view controller from the storyboard in code.

Select the sessions view controller and set the background color of its view to blue. Open the Identity Inspector and set Storyboard ID to SessionsViewController. This is what the storyboard should look like when you are finished.

Add Child View Controllers to Storyboard

Build and run the application in the simulator or on a physical device to make sure everything is wired up correctly.

Configuring the Segmented Control

Open MasterViewController.swift and update the viewDidLoad() method as shown below. The setupView() method is nothing more than a helper method to keep the viewDidLoad() method short and uncluttered.

override func viewDidLoad() {
    super.viewDidLoad()

    setupView()
}

In setupView(), we invoke another helper method, setupSegmentedControl(). In setupSegmentedControl(), we add two segments to the segmented control and add the master view controller as a target to the segmented control. We also set the selectedSegmentIndex property to 0 to select the first segment.

private func setupView() {
    setupSegmentedControl()
}
private func setupSegmentedControl() {
    // Configure Segmented Control
    segmentedControl.removeAllSegments()
    segmentedControl.insertSegment(withTitle: "Summary", at: 0, animated: false)
    segmentedControl.insertSegment(withTitle: "Sessions", at: 1, animated: false)
    segmentedControl.addTarget(self, action: #selector(selectionDidChange(_:)), for: .valueChanged)

    // Select First Segment
    segmentedControl.selectedSegmentIndex = 0
}

The implementation of selectionDidChange(_:) is pretty simple. We invoke another helper method, updateView(), which we will implement later in this episode.

@objc private func selectionDidChange(_ sender: UISegmentedControl) {
    updateView()
}

Adding a Child View Controller

There are several ways we can instantiate the child view controllers. We can add lazy properties to the MasterViewController class or we can set the child view controllers up when the master view controller is initialized. I prefer to use lazy properties because it instantiates the child view controllers when they are needed. If the user never taps the Sessions segment of the segmented control, then there is no need to instantiate an instance of the SessionsViewController class. Lazy properties let us do this.

private lazy var summaryViewController: SummaryViewController = {
    // Load Storyboard
    let storyboard = UIStoryboard(name: "Main", bundle: Bundle.main)

    // Instantiate View Controller
    guard let viewController = storyboard.instantiateViewController(withIdentifier: "SummaryViewController") as? SummaryViewController else {
        fatalError("Unable to Instantiate Summary View Controller")
    }

    // Add View Controller as Child View Controller
    self.add(asChildViewController: viewController)

    return viewController
}()

private lazy var sessionsViewController: SessionsViewController = {
    // Load Storyboard
    let storyboard = UIStoryboard(name: "Main", bundle: Bundle.main)

    // Instantiate View Controller
    guard let viewController = storyboard.instantiateViewController(withIdentifier: "SessionsViewController") as? SessionsViewController else {
        fatalError("Unable to Instantiate Sessions View Controller")
    }

    // Add View Controller as Child View Controller
    self.add(asChildViewController: viewController)

    return viewController
}()

Note that we throw a fatal error if the cast in the guard statement fails. This is fine because this should never happen. If the cast does fail, then that means we made a mistake we need to fix. What is more interesting is the implementation of the add(asChildViewController:) method.

private func add(asChildViewController viewController: UIViewController) {
    // Add Child View Controller
    addChild(viewController)

    // Add Child View as Subview
    view.addSubview(viewController.view)

    // Define Constraints
    NSLayoutConstraint.activate([
        viewController.view.topAnchor.constraint(equalTo: view.topAnchor),
        viewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        viewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
        viewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor)
    ])

    // Notify Child View Controller
    viewController.didMove(toParent: self)
}

In this method, the magic happens. The implementation shows the steps that are required to add a child view controller to a container view controller. First, we add the child view controller to the container view controller by invoking addChild(_:) on the container view controller, passing in the child view controller as an argument. By invoking this method, the child view controller automatically receives a message of willMove(toParent:) with the container view controller as the only argument.

Second, we add the view of the child view controller to the view of the container view controller. Remember that the container view controller is responsible for the size and position of the child view controller's view. The view hierarchy of the child view controller continues to be the responsibility of the child view controller, though.

Last but not least, when the child view controller is added to the container view controller and the child view controller's view is ready to be displayed, the container view controller notifies the child view controller by sending it a message of didMove(toParent:), passing itself as the only argument.

Removing a Child View Controller

To remove a child view controller from a container view controller, we need to take a few steps. Take a look at the implementation of the remove(asChildViewController:) method below.

private func remove(asChildViewController viewController: UIViewController) {
    // Notify Child View Controller
    viewController.willMove(toParent: nil)

    // Remove Child View From Superview
    viewController.view.removeFromSuperview()

    // Notify Child View Controller
    viewController.removeFromParent()
}

First, we notify the child view controller that it is about to be removed from the container view controller by sending it a message of willMove(toParent:), passing in the container view controller as an argument. Second, the child view controller's view is removed from the view hierarchy of the container view controller. Third, the child view controller is notified that it is removed from the container view controller by sending it a message of removeFromParent().

Note that we pass nil as the argument of willMove(toParent:). This indicates that the child view controller is about to be removed from the container view controller.

Updating the View

The last piece of the puzzle involves updating the user interface when the user taps a segment of the segmented control. We update the view of the container view controller in the updateView() method. In this method, we add or remove the child view controllers, depending on the segment that is currently selected.

private func updateView() {
    if segmentedControl.selectedSegmentIndex == 0 {
        remove(asChildViewController: sessionsViewController)
        add(asChildViewController: summaryViewController)
    } else {
        remove(asChildViewController: summaryViewController)
        add(asChildViewController: sessionsViewController)
    }
}

Because the master view controller keeps a reference to the summary view controller and sessions view controller, these view controllers are not deallocated when they are removed from the container view controller. In other words, they are only instantiated once and, more important, they keep their state even if they are not visible.

This is similar to how the UITabBarController class behaves. The main difference with a tab bar controller is that the tab bar controller manages an array of view controllers, which makes the UITabBarController class very reusable. We could apply a similar strategy for the MasterViewController class by defining a viewControllers property of type [UIViewController] for storing a reference to each of the child view controllers.

We also need to invoke updateView() at the end of the setupView() method as shown below.

private func setupView() {
    setupSegmentedControl()
   updateView()
}

Build and run the application to see if everything is working as expected.

Running the Example Application

Why Is View Controller Containment Important?

You may be wondering what we gain by using a container view controller. Why can't we add the child view controller's view to any view controller? Why do we need to invoke addChild(_:), removeFromParent(), willMove(toParent:), and didMove(toParent:). There is a very good reason for doing so. Open SummaryViewController.swift and add the following implementations for viewWillAppear(_:) and viewWillDisappear(_:).

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)

    print("Summary View Controller Will Appear")
}

override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)

    print("Summary View Controller Will Disappear")
}

Open SessionsViewController.swift and add the following implementations for viewWillAppear(_:) and viewWillDisappear(_:).

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)

    print("Sessions View Controller Will Appear")
}

override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)

    print("Sessions View Controller Will Disappear")
}

Run the application in the simulator and inspect the console. Do you see the print statement we added to the SummaryViewController class?

Summary View Controller Will Appear

Tap the Sessions segment of the segmented control and inspect the output in the console.

Summary View Controller Will Disappear
Sessions View Controller Will Appear

Great. In MasterViewController.swift, update the implementation of add(asChild:) as shown below. Note that we removed every method call related to view controller containment. We only add the child view controller's view to the view of the master view controller.

private func add(asChildViewController viewController: UIViewController) {
    // Add Child View as Subview
    view.addSubview(viewController.view)

    // Define Constraints
    NSLayoutConstraint.activate([
        viewController.view.topAnchor.constraint(equalTo: view.topAnchor),
        viewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        viewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
        viewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor)
    ])
}

Run the application one more time and inspect the logs. The summary view controller is no longer notified when its view is about to appear even though the application looks and feels identical. This shows the importance of view controller containment.

By adding a view controller as a child view controller to a container view controller, the latter forwards messages related to appearance to its children. This is a key aspect of view controller containment. If the view of a child view controller is added to another view controller's view without using the view controller containment APIs, the child view controller isn't notified about events related to appearance. This means that messages such as viewDidAppear(_:) and viewDidDisappear(_:) are not forwarded to the child view controller, which could cripple the view controller.

Things to Keep in Mind

Ignorance Is Bliss

Even though UITabBarController, UINavigationController, and UISplitViewController are container view controllers, Apple cheated a little bit when it added support for these classes to UIKit. Ideally, a child view controller should not be aware of its container view controller. But have you noticed that the UIViewController class has getters for each of these container view controllers? That is what enables a child view controller in a navigation stack to push another view controller onto or to pop itself from the navigation stack.

To promote reusability, make sure the child view controllers know as little as possible about the container view controller that manages them. You can use protocols and delegation to facilitate the communication between a container view controller and its children.

Responsibilities

Remember that a container view controller is responsible for sizing and positioning the view of the child view controller it manages. The child view controller is the only view controller in charge of managing the views of its view hierarchy. In other words, the container view controller should only access the child view controller's view. It has no business interacting with other views in the child view controller's view hierarchy.

What's Next?

Most iOS, tvOS, and macOS applications make use of view controller containment through UITabBarController, UINavigationController, and UISplitViewController. This episode has shown you how you can leverage this pattern in your own applications. The result is improved reusability and leaner view controllers.