Custom View Controller Presentation Tips & Tricks

How to get UIPresentationController to use Auto Layout (and other bugs fixes)

February 9, 2019 - 8 minute read -
swift ios uikit

✨ 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! ✨