✨ Custom Presentations and Transitions
Custom view controller transitions can add incredible value to your app. From communicating context, to adding a fun bounce, to more utilitarian presentations like a notification, they all add polish and make your app feel more complete and personal.
A custom side menu presentation in my app Cypher.
On a technical level though, they can be pretty frustrating. Properly implementing UIViewControllerAnimatedTransitioning
and a custom UIPresentationController
can be
nuanced. UIPresentationController
doesn’t play well with Auto Layout, and has a few issues to work around. In this post, we’ll focus on some options for working around those issues with UIPresentationController
.
📐 Using Auto Layout with UIPresentationController
UIPresentationController
allows you to customize the presentation of a view controller by adding accessory views and specifying a custom frame for the presentation. But let’s say you want your view controller to size its view based on Auto Layout constraints - it seems like you’re out of luck, since the size is customized by setting a frame.
Enter UIView.systemLayoutSizeFitting
, which allows you to calculate the size of a view based off of its internal constraints! Using this method, you can calculate the height for a fixed width (useful for a toast, notification, or “panel” presentation), the width for a fixed height, or if your constraints are sufficient, the entire size. The method takes a target size, along with options to specify the priority for the width or height to match that target size. It’s important to specify required
for one dimension, or views with multi-line labels may not calculate their size correctly.
In this example, we’ll make a toast presentation that can resize itself vertically based on the text content. It’ll have a fixed width, which is the container width with a 16pt inset on each side.
To accomplish this layout, we’ll calculate the fixed width, and then use systemLayoutSizeFitting
to calculate the height. Then we can construct a frame to set, all based off of the Auto Layout constraints from the label to the view controllers view.
All of this math is implemented in frameOfPresentedViewInContainerView
, which you override in your subclass. Then, once your container view lays out, you set the frame of the presentedView
to that property:
class ToastPresentationController: UIPresentationController {
override var frameOfPresentedViewInContainerView: CGRect {
guard let containerView = containerView,
let presentedView = presentedView else { return .zero }
let inset: CGFloat = 16
// Make sure to account for the safe area insets
let safeAreaFrame = containerView.bounds
.inset(by: containerView.safeAreaInsets)
let targetWidth = safeAreaFrame.width - 2 * inset
let fittingSize = CGSize(
width: targetWidth,
height: UIView.layoutFittingCompressedSize.height
)
let targetHeight = presentedView.systemLayoutSizeFitting(
fittingSize, withHorizontalFittingPriority: .required,
verticalFittingPriority: .defaultLow).height
var frame = safeAreaFrame
frame.origin.x += inset
frame.origin.y += frame.size.height - targetHeight - inset
frame.size.width = targetWidth
frame.size.height = targetHeight
return frame
}
override func containerViewDidLayoutSubviews() {
super.containerViewDidLayoutSubviews()
presentedView?.frame = frameOfPresentedViewInContainerView
}
}
Now our toast can have as much text as it needs, and its presentation height will adapt 🙌
🙈 It wouldn’t be UIKit without a gotcha!
So far, our presentation controller code has been working pretty well. However, there’s a pretty big visual glitch lurking right under our noses, and all we have to do is present a view controller over a custom presentation:
Oh no! The custom presentation size seems to get reset when we present a view controller over it, and doesn’t get set again until the dismissal transition completes. This happens because when a full screen view controller is presented, UIKit removes the view behind it from the view hierarchy. When the view controller is dismissed, it adds the view back to the hierarchy. Unfortunately it doesn’t set the size back to its custom size until the dismissal transition completes. If we make our presented view transparent, we can see card view being removed:
One solution is to set the modalPresentationStyle
of the presented view controller to .overFullScreen
, which prevents the view behind it from being removed. I don’t love this solution because a) it’s less efficient to leave the view in the hierarchy, and b) you have to set that every time, for every modal over every custom presentation.
Fortunately, we can subclass UIPresentationController
to make a new base class, and fix this for all of our custom presentations. There are a few options, but this is the one I found works best: if the custom presentation is complete, then set the frame of the presentedView any time it’s accessed. This ensures that when the presentation controller adds the view back into the hierarchy, the frame is correct.
override var presentedView: UIView? {
super.presentedView?.frame = frameOfPresentedViewInContainerView
return super.presentedView
}
The final “gotcha” here is that you might use the presented view when calculating frameOfPresentedViewInContainerView (just like we do above to calculate the height). This causes an infinite loop, as you access frameOfPresentedViewInContainerView
in the getter for presentedView
, and access presentedView
in the getter for frameOfPresentedViewInContainerView
!
The fix is to calculate the frame when the container view lays out and to store that value; use the stored value to set the frame, and you avoid the infinite loop. Here’s the final PresentationController
subclass I start out with:
class PresentationController: UIPresentationController {
private var calculatedFrameOfPresentedViewInContainerView = CGRect.zero
private var shouldSetFrameWhenAccessingPresentedView = false
override var presentedView: UIView? {
if shouldSetFrameWhenAccessingPresentedView {
super.presentedView?.frame = calculatedFrameOfPresentedViewInContainerView
}
return super.presentedView
}
override func presentationTransitionDidEnd(_ completed: Bool) {
super.presentationTransitionDidEnd(completed)
shouldSetFrameWhenAccessingPresentedView = completed
}
override func dismissalTransitionWillBegin() {
super.dismissalTransitionWillBegin()
shouldSetFrameWhenAccessingPresentedView = false
}
override func containerViewDidLayoutSubviews() {
super.containerViewDidLayoutSubviews()
calculatedFrameOfPresentedViewInContainerView = frameOfPresentedViewInContainerView
}
}
Now subclass PresentationController
instead of UIPresentationController
, and our card view stays where it should 😍
That’s all I’ve got today! You can find all the code used in the videos in this repo. Happy animations! ✨