Bottom sheet on iOS15 using UISheetPresentationController and Xcode 13

matrejek

Mateusz Matrejek

Posted on June 8, 2021

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)
        ])
    }
}
Enter fullscreen mode Exit fullscreen mode

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

class FormController: UIViewController {

}
Enter fullscreen mode Exit fullscreen mode

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

protocol FormControllerDelegate: AnyObject {
    func formControllerDidFinish(_ controller: FormController)
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}

Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Now we need to assign the proper modalPresentationStyle on UINavigationController.

formNC.modalPresentationStyle = UIModalPresentationStyle.pageSheet
Enter fullscreen mode Exit fullscreen mode

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

if let sheetPresentationController = formNC.presentationController as? UISheetPresentationController {
...
}
Enter fullscreen mode Exit fullscreen mode

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()
]
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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

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!

💖 💪 🙅 🚩
matrejek
Mateusz Matrejek

Posted on June 8, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related