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