How to create Bottom Sheet with UIKit

Photo by Luca Bravo on Unsplash

How to create Bottom Sheet with UIKit

BottomSheet has been present in iOS applications for a while, with Apple first implementing it in the Maps app in iOS 10. However, it was not available for developers to use as a standard component, so if they wanted to include it in their apps, they had to create their implementation of it.

Apple provided an easy-to-use solution since iOS 15 with UISheetPresentationController.

let vc = UIViewController()
// can also use .formSheet and result will be the same on the iPhone
vc.modalPresentationStyle = .pageSheet
if
    #available(iOS 15.0, *),
    let sheet = vc.sheetPresentationController
{
    sheet.detents = [.medium(), .large()]
}
present(vc, animated: true, completion: nil)

But on iOS 15 UISheetPresentationController was able to show the bottom sheet only with 2 sizes (detents):

  • large (almost full-screen height);

  • medium (approximately half of the screen height).

Check this post for more information.

Only since the iOS 16 it became possible to create custom detents with specific preferred heights.

let smallDetentId = UISheetPresentationController.Detent.Identifier("small")
let smallDetent = UISheetPresentationController.Detent.custom(identifier: smallDetentId) { context in
    return 100
}
sheetPresentationController?.detents = [smallDetent, .medium(), .large()]

Using custom detents has given developers more flexibility in implementing bottom sheets in their apps, but it is still not perfect. The pageSheet and formSheet styles on iPads look different than they do on iPhones (as modals), which can be an issue if you want your component to have the same appearance on all devices. Additionally, the size of the bottom sheet is not automatically determined by its content size, which would be more convenient. Today, let's explore how to implement a desired bottom sheet using UIKit.

(It would be interesting to implement it with SwiftUI as well, but most of the apps still use UIKit, so we will do it with SwiftUI in one of the next articles).

Quick Intro

If you want to implement Bottom Sheet using custom transition, you need:

  1. Set its modalPresentationStyle property to custom;

  2. Assign an object that conforms to UIViewControllerTransitioningDelegate protocol to transitioningDelegate property of UIViewController you want to present.

let vc = ExampleViewController()
vc.modalPresentationStyle = .custom
vc.transitioningDelegate = delegate // UIViewControllerTransitioningDelegate
present(vc, animated: true)

UIViewControllerTransitioningDelegate is a protocol in UIKit that defines methods for supporting custom transitions between view controllers.

TIP: The property transitioningDelegate of the controller is weak, so it is necessary to additionally retain the object with a strong reference.

To implement your transitioning delegate object, you will need to provide to UIKit with different objects related to the animation process, interaction and presentation, such as:

  1. Transition animator object that conforms to the UIViewControllerAnimatedTransitioning protocol and is responsible for performing the actual animation for the transition.

    (TIP: You can even provide separate animator objects for presenting and dismissing the view controller).

  2. Interactive animator object that conforms to the UIViewControllerInteractiveTransitioning protocol optionally if you want to use touch input or other user interactions to control the timing of the animation.

  3. The UIPresentationController object is used to manage the presentation and dismissal process, including the position, size, and certain aspects of the presentation, such as displaying a shadow view during a custom transition. The presentation controller acts as a container for the presentation and its lifespan extends beyond the animator objects involved in the presentation, making it very useful in the transition process.

Let’s go to the code

Let’s see how we can implement all objects described above.

class BottomSheetTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate {

    let transition: BottomSheetTransition    

    func animationController(
        forPresented presented: UIViewController,
        presenting: UIViewController,
        source: UIViewController
    ) -> UIViewControllerAnimatedTransitioning? {
        transition.isPresenting = true
        transition.wantsInteractiveStart = false
        return transition
    }

    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        transition.isPresenting = false
        return transition
    }

    func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        transition.isPresenting = false
        return transition
    }

    func presentationController(
        forPresented presented: UIViewController,
        presenting: UIViewController?,
        source: UIViewController
    ) -> UIPresentationController? {
        BottomSheetPresentationController(presentedViewController: presented,
                                          presenting: presenting,
                                          configuration: configuration)
    }
}

As you can see from the code we provide BottomSheetTransition object named transition as a:

  1. Transition animator object for presentation transition (animationController(forPresented:) and for dismissal transition (animationController(forDismissed:)

  2. Interactive animator object for interactive dismissal (with user’s swipe gesture). We implement only method interactionControllerForDismissal for providing interactive animator object for dismiss transition because we don’t need interactive presentation transition.

This is how we implement BottomSheetTransition:

class BottomSheetTransition: UIPercentDrivenInteractiveTransition {
    /// `true` if the transition is for the presentation.
    /// `false` if the transition is for the dismissal.
    var isPresenting = true

    /// Set this to `false` in order to start an interruptible transition non
    /// interactively (for example when tap on overlay view)
    var wantsInteractiveStart: Bool

    /// The interactive animator used for the dismiss transition.
    private var dismissAnimator: UIViewPropertyAnimator?

    /// The animator used for the presentation animation.
    private var presentationAnimator: UIViewPropertyAnimator?
}

UIPercentDrivenInteractiveTransition is a class from UIKit that conforms to UIViewControllerInteractiveTransitioning and provides all the necessary functionality for an interactive transition. It is percent-driven, meaning that the progress of the transition can be accessed and tracked through the percentComplete property, which is expressed as a percentage.

class UIPercentDrivenInteractiveTransition: UIViewControllerInteractiveTransitioning {
     var percentComplete: CGFloat { get }
}

On top of that BottomSheetTransition also implements UIViewControllerAnimatedTransitioning protocol:

extension BottomSheetTransition: UIViewControllerAnimatedTransitioning {

    func transitionDuration(using _: UIViewControllerContextTransitioning?) -> TimeInterval {
        animationDuration
    }

    /// Will get called when the transition is not interactive 
    /// to animate presenting or dismissing of the controller.
    func animateTransition(
        using transitionContext: UIViewControllerContextTransitioning
    ) {
        interruptibleAnimator(using: transitionContext).startAnimation()
    }

    /// Will get called when the transition is interactive.
    func interruptibleAnimator(
        using transitionContext: UIViewControllerContextTransitioning
    ) -> UIViewImplicitlyAnimating {
        if isPresenting {
            return presentationAnimator(using: transitionContext)
        } else {
            return dismissAnimator(using: transitionContext)
        }
    }
}

As you can see UIViewControllerAnimatedTransitioning methods provide animationDuration and implement actual transition animations in animateTransition(using transitionContext:)and interruptibleAnimator(using transitionContext:).

UIPresentationController

Another important object we need to provide in BottomSheetTransitioningDelegate is UIPresentationController. Our presentation controller will handle several tasks:

  1. Provide the size of the presented view depending on its content size;

  2. Decorate the appearance of the presented view controller (by adding the overlay, pull bar, etc.);

  3. Add a swipe gesture recognizer and use it for interactive dismissal.

Next, let’s find out how to implement them.

Provide the size of the presented view

To provide the size of the bottom sheet, you need to override the frameOfPresentedViewInContainerView property.

class BottomSheetPresentationController: UIPresentationController {

    override var frameOfPresentedViewInContainerView: CGRect {
        // calculate presented view size here
    }

    // ...
}

The systemLayoutSizeFitting method of UIView can be used to determine the optimal size that best fits the constraints and closely aligns with the target size.

extension UIView {
    /// The size fitting most closely to targetSize in which the receiver's subtree
    /// can be laid out while optimally satisfying the constraints. 
    @available(iOS 8.0, *)
    open func systemLayoutSizeFitting(
                targetSize: CGSize, 
                horizontalFittingPriority: UILayoutPriority, 
                verticalFittingPriority: UILayoutPriority
        ) -> CGSize
}

This code we will use inside frameOfPresentedViewInContainerView property computation:

let fittingSize = CGSize(width: containerView.bounds.width,
                         height: UIView.layoutFittingCompressedSize.height)

let presentedViewHeight = presentedView.systemLayoutSizeFitting(
     fittingSize,
     withHorizontalFittingPriority: .required,
     verticalFittingPriority: .fittingSizeLevel
 ).height

We create fittingSize with layoutFittingCompressedSize.height and set verticalFittingPriority parameter to fittingSizeLevel to ensure that we will get the smallest possible size and aligns as closely as possible to satisfy the constraints.

Decorate presented view controller

To decorate our bottom sheet we will override the presentation controller’s methods:

presentationTransitionWillBegin - notifies the presentation controller that the presentation animations are about to start;

dismissalTransitionWillBegin - notifies the presentation controller that the dismissal animations are about to start;

containerViewDidLayoutSubviews - notifies the presentation controller that layout ended on the views of the container view. Here we will layout added subviews.

    override func presentationTransitionWillBegin() {
        super.presentationTransitionWillBegin()

        containerView?.addSubview(overlayView)
        presentedView?.addSubview(pullBarView)

        overlayView.alpha = 0

        presentedViewController.transitionCoordinator?.animate(alongsideTransition: { [weak self] _ in
            guard let self = self else { return }
            self.presentedView?.layer.cornerRadius = self.cornerRadius
            self.overlayView.alpha = 1
        })
    }

    override func dismissalTransitionWillBegin() {
        super.dismissalTransitionWillBegin()

        presentedViewController.transitionCoordinator?.animate(alongsideTransition: { [weak self] _ in
            guard let self = self else { return }
            self.presentedView?.layer.cornerRadius = .zero
            self.overlayView.alpha = 0
        })
    }

As previously mentioned, the UIPresentationController acts as a container for the transition and is the ideal candidate to decorate the presented controller with an overlay and pull bar. The containerView and presentedView properties of UIPresentationController were used to access the views involved in the transition that needed to be decorated.

Interactive dismiss with a pan gesture

The final task in the presentation controller is to handle user swipes to enable interactive dismissal. Let's examine the code for this:

    // The pan gesture used to drag and interactively dismiss the sheet.
    private lazy var panGesture = UIPanGestureRecognizer(target: self,
                                                         action: #selector(pannedPresentedView))

    @objc
    private func pannedPresentedView(_ recognizer: UIPanGestureRecognizer) {
        guard let presentedView = presentedView else {
            return
        }
        switch recognizer.state {
        case .began:
            // Start to dismiss interactively when user begins to swipe
            dismiss(interactively: true)

        case .changed:
            // Update the dismiss tansition progress
            let translation = recognizer.translation(in: presentedView)
            updateTransitionProgress(for: translation)

        case .ended, .cancelled, .failed:
            // Finish or cancel the dimissal depending on current progress of dismiss transition
            handleEndedInteraction()

        case .possible:
            break

        @unknown default:
            break
        }
    }

    private func handleEndedInteraction() {
        guard let transitioningDelegate = transitioningDelegate else {
            return
        }
        if transitioningDelegate.transition.dismissFractionComplete > dismissThreshold {
            transitioningDelegate.transition.finish()
        } else {
            transitioningDelegate.transition.cancel()
        }
    }

Conclusion

In conclusion, I would like to highlight that this is not a perfect solution as it does not support scrolling of content if the presented view controller is larger than the screen height. Additionally, navigation controllers inside the bottom sheet are not supported. However, it is still a simple and clean option if you need to add a bottom sheet to your project.

You can find all code here.

Did you find this article valuable?

Support Vitaly Batrakov by becoming a sponsor. Any amount is appreciated!