Popovers, UINavigationController, and preferredContentSize

August 25, 2019

In this post we'll look at a very specific but tricky interaction in UIKit, one which took me multiple days to work out how to implement.

It's two view controllers of different sizes, pushed on a UINavigationController, which is presented as a popover. The interaction has a few unique qualities:

  1. The view controllers have different sizes (defined by autolayout)
  2. Only one of them displays a nav bar
  3. The popover animates when their autolayout-defined sizes change
  4. Including when they get pushed onto the nav stack, where the resize animation happens at the same time as the push animation
Finished presentation of UIViewControllers with different content sizes inside a popover

Surprisingly this is very tricky to implement in UIKit, and it ties together a lot of concepts that I wasn't really familiar with until I ran into this case.

The finished code for this interaction is available at NGPopoverForceResizeTest. There's also a summary at the bottom of this post.

Motivation

At work, we do a fair amount of presenting view controllers as popovers to add context or more data to our existing iPad view. A common flow is the following:

  1. Present a small popover to add more context to something the user tapped.
  2. When the user taps on a button in that popover, push on a new view controller with more information.

UIKit provides the building blocks for this out of the box, but the kicker is when the two view controllers are different sizes.

Let's assume we have two view controllers to present: one 300x300 with a red background and no navigation bar content, and one 600x400 with a green background and a nav bar title. Here's what we want them to look like:

Red view controller
SmallViewControllerNoNavBar
Green view controller
LargeViewControllerWithNavBar

preferredContentSize

Let's start with the red controller.

Setting up the width and height is doable with autolayout:

override func viewDidLoad() {
    // ...
    self.view.widthAnchor.constraint(
        equalToConstant: 300
    ).isActive = true
    self.view.heightAnchor.constraint(
        equalToConstant: 300
    ).isActive = true
}

We'll set the navigation bar hidden in viewWillAppear:

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    self.navigationController?.setNavigationBarHidden(true, animated: false)
}

And, from the main controller, present our red view as a popover:

let containerController = UINavigationController(rootViewController: firstVC)
containerController.modalPresentationStyle = .popover
// ... set the sourceView, sourceRect, etc
self.present(containerController, animated: true)

Unfortunately, this doesn't exactly produce the effect we're going for:

Red view controller displaying in a popover with the wrong height and width
Interesting

preferredContentSize + autolayout

Our red view controller doesn't have the right size because when UIViewControllers are presented as popovers, the popover's size is determined by the controller's preferredContentSize. Since we haven't set self.preferredContentSize in our controller, the system uses the default: on iOS 12 with a 12.9 inch iPad, this is 375x636 points.

To fix this, we have to trigger a layout pass to determine our autolayout-defined size, and we can set that as our preferred size, as many posts online have detailed:

override func viewWillAppear(_ animated: Bool) {
    // ...
    self.preferredContentSize = self.view.systemLayoutSizeFitting(
        UIView.layoutFittingCompressedSize
    )
}

Assuming we've implemented the same thing in our green view controller (with different height and width), we now get our controllers displaying their correct sizes!

Unfortunately, the animation is a little weird - our green controller starts out 300x300, then transitions to 600x400:

Red view controller with the right size, pushing on a green view controller, whose size starts the same as the red controller but expands when finished pushing.
Also interesting

UINavigationController animations

Apparently, UINavigationController queues the animations for pushing on a new view controller and setting its own preferredContentSize based on the controller being pushed, so the push animation and the resize animation happen in series. Ideally, we'd like these animations to happen simultaneously.

After quite a bit of spelunking online, I realized that UINavigationController just doesn't support this use case. However, I stumbled across this example by Hok Shun Poon, which noted that you can get the resize to happen at the same time if you encapsulate the UINavigationController in a parent view controller!

The flow looks like this:

  1. We have a parent UIViewController wrapping our UINavigationController.
  2. When the new controller appears, we'll set the wrapper view controller's preferredContentSize to mirror the child's preferredContentSize using popoverPresentationController.

Wrapping the nav controller

Our wrapper controller looks like this:

final class PopoverPushController: UIViewController {
    private let wrappedNavigationController: PopoverPushNavigationController

    init(rootViewController: UIViewController) {
        self.wrappedNavigationController = PopoverPushNavigationController(
            rootViewController: rootViewController
        )
        super.init(nibName: nil, bundle: nil)
        self.wrappedNavigationController.delegate = self
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        wrappedNavigationController.willMove(toParent: self)
        self.addChild(wrappedNavigationController)
        self.view.addSubview(wrappedNavigationController.view)
    }

    // ...
}

When our container controller's view loads, we add the navigation controller's view as a subview, after using willMove(toParent:) and addChild() to add the navigation controller as a child view controller.

We also need the implementation of viewWillAppear to set the wrapper controller's content size. Luckily we can access the wrapper controller using popoverPresentationController's presentedViewController, even though it's two levels up:

override func viewWillAppear(_ animated: Bool) {
    // ...
    let contentSize = self.view.systemLayoutSizeFitting(
        UIView.layoutFittingCompressedSize
    )
    self.preferredContentSize = contentSize
    self.popoverPresentationController?
        .presentedViewController
        .preferredContentSize = contentSize
}

And finally, we'll change our presentation code to present the PopoverPushController wrapper instead of the regular navigation controller:

let containerController = PopoverPushController(rootViewController: firstVC)
containerController.modalPresentationStyle = .popover
// ... set the sourceView, sourceRect, etc
self.present(containerController, animated: true)

No we have the simultaneous animation we want!

Red view controller transitioning to green view controller with size animating at the same time as the push animation.
🎉

Updating the size

Everything's well and good now, so we can implement the last part of the interaction: resizing the green controller on button tap. Our code isn't particularly complicated, but we'll use the same autolayout technique from the red controller:

func setPreferredContentSizeFromAutolayout() {
    self.preferredContentSize = self.view.systemLayoutSizeFitting(
        UIView.layoutFittingCompressedSize
    )
}

var isExpanded: Bool = false {
    didSet {
        let constant = self.isExpanded ? 600 : 400
        self.heightConstraint.constant = constant
        self.setPreferredContentSizeFromAutolayout()
    }
}

@objc private func didTap() {
    self.isExpanded = !self.isExpanded
}

But now we've got a new problem: the popover size doesn't animate!

Green view controller updating height, but without animation.
We were expecting a smooth animation

Informing the presented view controller

The issue we have here is that our green view controller is changing its preferredContentSize, but the popover view controller doesn't know it changed. We'll use the same technique that we used for setting the popover view controller's content size when the green view initially appeared (using popoverPresentationController):

func setPreferredContentSizeFromAutolayout() {
    let contentSize = self.view.systemLayoutSizeFitting(
        UIView.layoutFittingCompressedSize
    )
    self.preferredContentSize = contentSize
    self.popoverPresentationController?
        .presentedViewController
        .preferredContentSize = contentSize
}

Now our interaction finally works as expected!

Finished presentation of UIViewControllers with different content sizes inside a popover
🎉🎉

Conclusion

Hopefully this was a helpful look into the world of preferredContentSize, view controller wrapping, and UIKit popovers. Here's a tl;dr:

  1. To define the size of your popover with autolayout, set preferredContentSize to the result of systemLayoutSizeFitting
  2. To animate popover size updates at the same time as navigation controller animations, wrap your UINavigationController in a PopoverPushController (see code below)
  3. When you change your controller's preferredContentSize, be sure to change the preferredContentSize of your controller's popoverPresentationController's presentedViewController as well

Though this solution takes some time to explain, I think it ends up being pretty clean:

  1. No UINavigationController subclass 🎉
  2. No need to implement UINavigationControllerDelegate - you can even modify PopoverPushController to accept your own navigation controller, along with your own delegate 😮
  3. No need to set preferredContentSize except when the size actually changes ✅
  4. If you make the width and height constraints have priority = .defaultHigh, no unsatisfiable constraint warnings clogging your logs 💪

With more plumbing, it's possible to implement the same interaction without having to call into self.popoverPresentationController - you can have a UINavigationController subclass implement preferredContentSizeWillChange, and have it set the navgation controller's content size, which will be intercepted by PopoverPushController's preferredContentSizeWillChange. You can also provide a UINavigationControllerDelegate to avoid setting the popover controller's content size in viewWillAppear.

Ultimately though, it's better to do whatever feels more workable for your use case. I prefer using the popoverPresentationController because it feels cleaner to me, but if you think differently, let me know.

Code

All the code is available at NGPopoverForceResizeTest.

Here's the full implementation of the nav controller wrapper:

final class PopoverPushController: UIViewController {
    private let wrappedNavigationController: UINavigationController

    init(rootViewController: UIViewController) {
        self.wrappedNavigationController = UINavigationController(rootViewController: rootViewController)
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        wrappedNavigationController.willMove(toParent: self)
        self.addChild(wrappedNavigationController)
        self.view.addSubview(wrappedNavigationController.view)
    }
}
SPONSORED CONTENT
Instabug logo
Screenshot of Instabug running in Trestle

Instabug implements in-app bug reports and message threads with users in a simple, easy to configure interface.

Drop Instabug's library into your app to support user feedback out of the box!

Try Instabug Free

Visiting Instabug helps support my writing and open source projects. Learn more