Self-sizing Child Views

If you are struggling with massive view controllers you are likely to come across the suggestion to use container view controllers. A parent view controller splits a user interface into several, simpler, child view controllers. You add the root views of each child view controller to the parent which sets the size and position of the child views (manually or with Auto Layout).

What I find less well documented is how to implement a self-sizing child view. In this post, I look at how the UIContentContainer protocol allows the child view controller to tell the parent how big it wants to be (and an alternate, possibly simpler, approach).

Container View Controllers

If you need a recap, see this earlier post on Container View Controllers. I cover setting up a parent view controller with two child view controllers both in a storyboard and programmatically.

In that example, I sized the two child view controllers to fill the vertical space equally. Using fixed proportions for the child views keeps it simple but is not always what you want. For example, suppose I have a table view listing some books and a preview view at the top of the screen that shows the first line from the selected book:

Table view with preview view

The height of the preview view depends on the content. Here it is with less text:

Smaller preview window

The height also varies depending on the dynamic type size. To prevent the preview from filling the screen, I have it scroll when it reaches 25% of the screen height:

Maximum height preview window

You could do this with a single view controller. I prefer to keep the list and the preview in separate view controllers embedded in a parent container view controller. The resulting view controllers are simpler and easier to reuse. Here’s how it looks in Interface Builder:

Container view in storyboard

Child View Controller Layout

A child view controller should not know or care about its parent view controller. The job of the child view controller is to fill the available space of its root view with content. That root view might fill the screen or be sharing the screen with other view controllers.

My preview view content view has a single multi-line label in this simple example, embedded in a scroll view that fills the child view:

preview layout

Parent View Controller Layout

It’s the job of the parent, container view controller to set the frame of the root view for each of its children. It can do that by manually calculating the frame or by using Auto Layout. In my case I am managing the two container views with a vertical stack view:

parent view layout

I highlighted the two interesting constraints. The first constraint sets the preferred height of the preview text view. The actual height doesn’t matter as we set it at runtime. What is important is that it is an optional constraint (priority < 1000). The auto layout engine satisfies this constraint as best as it can without violating any of the required constraints:

Preferred height constraint

The second constraint is a required constraint that limits the height of the preview view to 25% of the superview height:

Max height constraint

Preferred Content Size

The parent view controller sets the height of the child views. We don’t want the child view controller messing with the parent view layout directly, so we need a way for the child to pass its size to the parent. Luckily the UIContentContainer protocol gives us such a mechanism. There are two steps:

  • The child view controller sets its preferredContentSize.
  • The parent view controller implements preferredContentSizeDidChange and uses the new preferred size to adjust the layout.

Calculate The Preferred Content Size

In the child view controller we start by calculating the preferred size of our content view:

private func calculatePreferredSize() {
  let targetSize = CGSize(width: view.bounds.width,
      height: UIView.layoutFittingCompressedSize.height)
  preferredContentSize = contentView.systemLayoutSizeFitting(targetSize)
}

I’m using Auto Layout for the content view so we can call systemLayoutSizeFitting on the view with a target size that uses the constant layoutFittingCompressedSize for the height. This will gives us the smallest size for the view that satisfies the constraints. We use this calculated value to set the preferredContentSize of the child view controller.

To keep the preferred content size updated I call it from viewDidLayoutSubviews:

override func viewDidLayoutSubviews() {
  super.viewDidLayoutSubviews()
  calculatePreferredSize()
}

Setting the preferred content size in the child view controller does not directly change the size of the child view. It is up to the parent view controller to decide if and how to take the preferred size of the child into account. Let’s see how to do that.

Adjust The Layout

When you change the preferred content size of a child view controller, UIKit calls the preferredContentSizeDidChange method of the container view controller. This gives us a chance to adjust the height constraint for the child view controller to take into account the new preferred size.

I have an outlet in the parent view controller connected to the message height constraint created in the storyboard:

@IBOutlet private var messageHeightConstraint: NSLayoutConstraint?

We use the preferredContentSizeDidChange method to change the height constraint using the preferred content size of the child:

override func preferredContentSizeDidChange(forChildContentContainer container: UIContentContainer) {
  super.preferredContentSizeDidChange(forChildContentContainer: container)
  if (container as? MessageViewController) != nil {
    messageHeightConstraint?.constant = container.preferredContentSize.height
  }
}

Note the clear separation of responsibilities for the layout of the parent and child views. The parent sets the frame of the root view of its children but otherwise does not interfere with the child layout. The child only concerns itself with laying out its subviews.

Alternate Approach

There is a different approach in this stack overflow answer (via @salutis). It works by disabling the default auto-resizing mask of the root view of the child view controller when it is embedded in a parent:

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
  ...
  previewController?.view.translatesAutoresizingMaskIntoConstraints = false
}

We no longer need the fixed height constraint on the container in the parent view controller (though we keep it as a placeholder in the storyboard to stop Interface Builder complaining):

Placeholder height constraint

The size of the root view of the child view controller must now be set by its constraints. Since I have the content embedded in a scroll view which does not have an intrinsic content size I need an extra optional constraint to set the height of the scroll view to the height of the content view:

Content height constraint

This is a low priority constraint so that we can still limit the maximum height in our parent view controller. (To stop Interface Builder complaining the priority needs to be lower than the placeholder constraint). This can be a bit tricky to get right, but it does avoid the preferred content size dance. See the sample code for the full example and decide for yourself which is better.

Sample Code

The sample Xcode projects for this post are in my CodeExamples GitHub repository.

Further Reading

For a recap on using container view controllers:

To learn more about Auto Layout and building adaptive layouts: