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:
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.
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:
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:
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:
Our red view controller doesn't have the right size because when UIViewController
s 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:
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:
UIViewController
wrapping our UINavigationController
.preferredContentSize
to mirror the child's preferredContentSize
using popoverPresentationController
.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!
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!
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!
Hopefully this was a helpful look into the world of preferredContentSize
, view controller wrapping, and UIKit popovers. Here's a tl;dr:
preferredContentSize
to the result of systemLayoutSizeFitting
UINavigationController
in a PopoverPushController
(see code below)preferredContentSize
, be sure to change the preferredContentSize
of your controller's popoverPresentationController
's presentedViewController
as wellThough this solution takes some time to explain, I think it ends up being pretty clean:
UINavigationController
subclass 🎉UINavigationControllerDelegate
- you can even modify PopoverPushController
to accept your own navigation controller, along with your own delegate 😮preferredContentSize
except when the size actually changes ✅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.
If you're interested in learning more about what I've built with UIKit (like the surprising complexity in adding padding to a UIButton), you can follow me on Twitter.
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)
}
}
I'm Noah, a software developer based in the San Francisco Bay Area. I focus mainly on full stack web and iOS development