Articles, podcasts and news about Swift development, by John Sundell.

Custom container view controllers in Swift

Published on 01 Jul 2018
Basics article available: Child View Controllers

View controller containment has been an essential part of UIKit ever since the early days of the iPhone. With classes like UINavigationController and UITabBarController, container view controllers in many ways define how we interact with most apps on iOS and how their various screens are structured.

Essentially, container view controllers are subclasses of UIViewController that are dedicated to managing other view controllers, and presenting them in some fashion - just like how UINavigationController presents its children in a navigation stack and manages a navigation bar.

This week, let's take a look at how we can build our own container view controllers, and how doing so can help us make parts of our UI code more modular and easier to manage.

Managing state

A lot of UI code revolves around managing state in one way or another. We might be waiting for the user to complete a form, or for content to be loaded while we show a loading spinner. Often changes in state means transitioning to a new UI - for example showing a table view and hiding a loading spinner as a view's content becomes available.

It's very common for a single view controller to be responsible both for transitioning between such states, and also for rendering them. For example, here's how we might make a ProductViewController handle its loading, error and rendering states:

class ProductViewController: UIViewController {
    ...

    func loadProduct() {
        showActivityIndicator()

        productLoader.loadProduct(withID: productID) { [weak self] result in
            switch result {
            case .success(let product):
                self?.render(product)
            case .failure(let error):
                self?.render(error)
            }
        }
    }

    private func showActivityIndicator() {
        // Activity indicator setup and rendering
        ...
    }

    private func render(_ product: Product) {
        // Product view setup and rendering
        ...
    }

    private func render(_ error: Error) {
        // Error view setup and rendering
        ...
    }
}

The above might not look so bad, but as we all know view controllers have many more responsibilities than just loading and rendering content. Once we add our layout code, responding to system events like the keyboard appearing, and all other tasks we usually perform in our view controllers - chances are we have another Massive View Controller on our hands.

Child view controllers

Like we took a look at in "Using child view controllers as plugins in Swift", one way to mitigate the problem of view controllers growing massive is to split them up into multiple ones that can easily be plugged in whenever needed. If we give each view controller a narrow area of responsibility, we can let them focus on what they do best - controlling views.

Child view controllers are also what makes container view controllers possible. But rather than just adding children to its view, containers take things a step further and also controls the transitioning between them. That means that our content view controllers can stay focused on layout and rendering, while leaving the UI state management to their container.

Let's refactor the above ProductViewController to use a container view controller to manage the transitions between its three states. We'll start by creating one view controller for each state:

class LoadingViewController: UIViewController {
    private lazy var activityIndicator = UIActivityIndicatorView(
        activityIndicatorStyle: .gray
    )
    
    ...
}

class ErrorViewController: UIViewController {
    private lazy var errorLabel = UILabel()
    ...
}

class ProductContentViewController: UIViewController {
    private lazy var productView = ProductView()
    ...
}

As you can see above, each view controller is dedicated to rendering a specific state. LoadingViewController shows a loading spinner, ErrorViewController displays any encountered error and ProductContentViewController renders the actual product content once it has been loaded.

Creating a container

Next, let's create our container view controller. Since this container will be all about transitioning between various content states - let's call it ContentStateViewController. Like in Modelling state in Swift, we'll use an enum to represent our three possible states, and we'll start by adding a method that makes our container view controller transition to a new state:

class ContentStateViewController: UIViewController {
    private var state: State?

    func transition(to newState: State) {
        ...
    }
}

extension ContentStateViewController {
    enum State {
        case loading
        case failed(Error)
        case render(UIViewController)
    }
}

Note how we actually pass the UIViewController that should be rendered when creating the .render state, rather than passing a Product. That way we can use our new container view controller for any type of content, and we don't need to make it aware of any specific models - it just knows how to render any content view controller.

Next, let's fill in that transition method and also add a separate method to resolve which child view controller to present for a given state:

class ContentStateViewController: UIViewController {
    private var state: State?
    private var shownViewController: UIViewController?

    func transition(to newState: State) {
        shownViewController?.remove()
        let vc = viewController(for: newState)
        add(vc)
        shownViewController = vc
        state = newState
    }
}

private extension ContentStateViewController {
    func viewController(for state: State) -> UIViewController {
        switch state {
        case .loading:
            return LoadingViewController()
        case .failed(let error):
            return ErrorViewController(error: error)
        case .render(let viewController):
            return viewController
        }
    }
}

I'm once again using my beloved extension from "Using child view controllers as plugins in Swift" above, that makes it much simpler to add and remove child view controllers using the add() and remove() methods.

Finally, let's make .loading the default state in case the container view controller hasn't transitioned into any particular state when its view was loaded:

override func viewDidLoad() {
    super.viewDidLoad()

    if state == nil {
        transition(to: .loading)
    }
}

And just like that, we've created our very own custom container view controller for transitioning between various content states! 🎉

It's implementation time!

Let's take our new ContentStateViewController for a spin. There are two ways to go here. We can either use inheritance and have ProductViewController become a subclass of ContentStateViewController to gain its abilities, or we can use composition and add our container view controller as a child. For now, let's go with the latter, since it'll give us more flexibility in the future.

Here's what ProductViewController looks like with all content state transitioning delegated to ContentStateViewController:

class ProductViewController: UIViewController {
    private lazy var stateViewController = ContentStateViewController()
    
    ...

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

    func loadProduct() {
        productLoader.loadProduct(withID: productID) { [weak self] result in
            switch result {
            case .success(let product):
                self?.render(product)
            case .failure(let error):
                self?.render(error)
            }
        }
    }

    private func render(_ product: Product) {
        let contentVC = ProductContentViewController(product: product)
        stateViewController.transition(to: .render(contentVC))
    }

    private func render(_ error: Error) {
        stateViewController.transition(to: .failed(error))
    }
}

The cool thing is that, with the above approach, ProductViewController doesn't have to contain much (if any) layout code - since all rendering has been delegated to child view controllers managed by our container view controller. As the top level view controller, it can instead focus on responding to events and telling its embedded container view controller what to do.

Other use cases

There's a lot of other tasks that we could potentially use container view controllers for as well. We could, for example:

In general, screens that require us to transition between various views or states is usually a great use case for a custom container view controller đź‘Ť.

Conclusion

iOS developers use container view controllers all the time - when implementing stacked navigation, tab bars, search controllers and other types of system-defined UIs - but the fact that we can easily create our own containers as well is something that's easily overlooked. By delegating the transitioning between child view controllers to a dedicated type, we can often end up with simpler UI code that's easier to manage.

Regardless of what app we're building, chances are high that there are some parts of our UI that could benefit from view controller containment. The trick is to try to move away from the mental model that each screen always consists of just a single view controller. Just like how we typically build up hierarchies of UIViews, by nesting view controllers we usually end up with thinner, simpler implementations that are a lot more modular.

What do you think? Do you currently use any custom container view controllers, or is it something you'll try out? Let me know - along with your questions, comments or feedback - on Twitter @johnsundell.

Thanks for reading! 🚀