Custom container view controllers in Swift
Basics article available: Child View ControllersView 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:
- Implement a
KeyboardAwareViewController
that puts all of its child views in aUIScrollView
. It then observes the keyboard (using theNotificationCenter
API) and when the keyboard gets shown or hidden it adjusts the scroll position of its scroll view accordingly. That way we could just add any view controllers we'd want adjusted for keyboard events without having to implement observations in every single one. - If our app has different login states, we could implement a
LoginStateViewController
at the top level and have it automatically manage the user's login status. That way we can have a single place to deal with login code, and have our container show and hide various view controllers depending on whether the user is logged in or not. - If we have a paginated view in our app, we could implement a
PaginatedViewController
that gives us an easy way to let the user scroll between multiple paginated child view controllers.
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! 🚀