Bottom sheet on iOS15 using UISheetPresentationController and Xcode 13

At least a couple of times I have faced the requirement of implementing a component that became incredibly popular across iOS apps design starting with the Maps app. With no out of the box iOS component it was always tricky to get this thing working in the way that covered all possible use cases in clear way. Thankfully, we just get the UIPresentationController subclass that gets these things done for us.

Here is how Apple docs introduces that new class:

UISheetPresentationController lets you present your view controller as a sheet. Before you present your view controller, configure its sheet presentation controller with the behavior and appearance you want for your sheet.

Note: You will need Xcode 13 (currently beta) and iOS15 device or simulator to run the code from this article.

Let's create our initial controller like that. It will have a text label and a bar button item triggering some simple controller presentation that we will implement a little bit later.

class ViewController: UIViewController {

    private var label: UILabel = UILabel()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
        navigationItem.title = "Sheet Demo"
        navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "pencil.circle.fill"),
                                                            style: .plain,
                                                            target: self,
                                                            action: #selector(showSheet))
        setupLabel()
    }

    @objc
    private func showSheet() {
        // TBD
    }

    private func setupLabel() {
        label.text = "Some Awesome Text"
        label.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(label)
        NSLayoutConstraint.activate([
            label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
        ])
    }
}

Let's also define some other controller that we will use to edit the value.

class FormController: UIViewController {

}

We are going to connect them using the simple delegation defined by the FormControllerDelegate protocol.

protocol FormControllerDelegate: AnyObject {
    func formControllerDidFinish(_ controller: FormController)
}

Apart from the delegate property, we will expose the text property allowing us to get and set editable text as well as the button allowing us to save content while we are done.

class FormController: UIViewController {

    var text: String?

    weak var delegate: FormControllerDelegate?

    private var textField = UITextField()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
        navigationItem.title = "Some Modal Form"
        navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .save,
                                                            target: self,
                                                            action: #selector(save))
    }

    @objc private func save() {
        // TBD
    }
}

The FormController is going to have the UITextField allowing as to change the provided text. The complete class looks as below

class FormController: UIViewController {

    var text: String? {
        didSet {
            textField.text = text
        }
    }

    weak var delegate: FormControllerDelegate?

    private var textField = UITextField()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
        navigationItem.title = "Some Modal Form"
        navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .save,
                                                            target: self,
                                                            action: #selector(save))
        setupTextField()
    }

    private func setupTextField() {
        textField.translatesAutoresizingMaskIntoConstraints = false
        textField.addTarget(self, action: #selector(handleNewText), for: .editingChanged)
        view.addSubview(textField)
        NSLayoutConstraint.activate([
            textField.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            textField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8),
            textField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8)
        ])
    }

    @objc func handleNewText() {
        text = textField.text
    }

    @objc private func save() {
        delegate?.formControllerDidFinish(self)
    }
}

Now, it's time to implement the previously not defined showSheet() method. First we are going to instantiate all the controllers we are about to present

let formController = FormController()
formController.delegate = self
formController.text = label.text

let formNC = UINavigationController(rootViewController: formController)

Now we need to assign the proper modalPresentationStyle on UINavigationController.

formNC.modalPresentationStyle = UIModalPresentationStyle.pageSheet

That way we will get the proper presentation controller set, so we can get it


if let sheetPresentationController = formNC.presentationController as? UISheetPresentationController {
...
}

We are now able to customize the way how it looks and behaves. For example, we may set the visibility of the grabber and allow our sheet to be sticky on particular heights.

// Let's have the grabber always visible
sheetPresentationController.prefersGrabberVisible = true
// Define which heights are allowed for our sheet
sheetPresentationController.detents = [
    UISheetPresentationController.Detent.medium(),
    UISheetPresentationController.Detent.large()
]

The complete method looks like that. Now the code is complete and we can try running it.

private func showSheet() {
    let formController = FormController()
    formController.delegate = self
    formController.text = label.text

    let formNC = UINavigationController(rootViewController: formController)
    formNC.modalPresentationStyle = UIModalPresentationStyle.pageSheet

    if let sheetPresentationController = formNC.presentationController as? UISheetPresentationController {
        // Let's have the grabber always visible
        sheetPresentationController.prefersGrabberVisible = true
        // Define which heights are allowed for our sheet
        sheetPresentationController.detents = [
            UISheetPresentationController.Detent.medium(),
            UISheetPresentationController.Detent.large()
        ]
    }
    present(formNC, animated: true)
}

The effect we achieved with just a couple lines of Swift is really nice:

UISheetPresentationController example
UISheetPresentationController example

This seem to be a really great and long awaited addition to the UIKit. Be sure to dive deeper in it's API here as it offers some additional behaviour customizations that are surely worth checking!