WWDC 21
Customize and resize sheets in UIKit
iOS 13 introduced a refined appearance for sheets, which don't cover the full screen anymore on the iPhone, but instead show a curved top edge that can be used to dismiss the sheet by pulling it down
iOS 15 expands the sheets API adding a lot of new customizations:
- you can make vertically resizable sheets that only cover half of the screen
- you can remove the dimming view, creating non-modal UIs where the user can interact with the content behind the sheet
- you can display non-full-screen sheets on iPhones in landscape position
Creating sheets
Sheet appearance is managed through a new presentation controller class, named UISheetPresentationController
You don't create a sheet controller instance yourself, but instead get it from the view controller managing the modal screen that you want to present:
if let sheet = viewController.sheetPresentationController { // customize the sheet... } present(viewController, animated: true)
The sheetPresentationController
property will always return a non-nil value as long as the view controller's modalPresentationStyle
is formSheet
or pageSheet
(which it is by default unless you override it)
Detents
A sheet can be configured with a list of so-called "detents"
Detents are positions at which the end of the sheet can rest after it's opened or resized
There are currently two system-defined detents available:
large
, which is the normal full size of a sheet, as in previous versions of iOS (the sheet going up almost to the top of the screen on the iPhone)medium
, which is about half the standard height (the sheet filling bottom half of the screen on the iPhone)
You can set the detents
property of a sheet to either or both of these:
// standard full-height sheet - the default value sheet.detents = [.large()] // a sheet that can be switched between half-height and full-height // the order matters - the first detent is the initial position sheet.detents = [.medium(), .large()] // a sheet that is medium height and cannot be made larger sheet.detents = [.medium()]
This can even be used on system modal views that normally expect to be presented in a full-height sheet, like the PHPhotoPicker
:
func showImagePicker() let picker = PHPickerViewController() picker.delegate = self if let sheet = picker.sheetPresentationController { sheet.detents = [.medium(), .large()] } present(picker, animated: true) } func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { // assign result to imageView.image // do not dismiss the picker }
In this example:
- calling the first function shows a system photo picker in a half-height sheet, in the bottom half of the screen
- the top half of the screen shows the parent screen behind it, which displays the previously selected photo
- when a photo is selected, we display it in the parent view, but we do not dismiss the picker sheet, allowing the user to quickly test different photos
- the sheet can be closed by pressing the Cancel button inside the sheet or by dragging the sheet down
- the sheet can also be resized by the user by dragging it up or down, switching between half height and full height
The sheet can also be resized by scrolling the scrollable contents inside the sheet, e.g. the grid of photos in the picker in this case
This is normally useful, but it might not be what you want
To disable this behavior, turn off this option in the sheet:
sheet.prefersScrollingExpandsWhenScrolledToEdge = false
In the example above, if we do not dismiss the dialog when the user makes a selection, it will work fine in "medium" half-height mode, but in the "large" full-height mode it might seem like the app is broken, because the user will not see the view behind the dialog
In such cases, it makes sense to programmatically adjust the detent position
You can do that by setting selectedDetentIdentifier
:
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { // assign result to imageView.image if let sheet = picker.sheetPresentationController { sheet.animateChanges { sheet.selectedDetentIdentifier = .medium } } }
The sheet.animateChanges
does exactly what you'd expect – if you want to change the detent position instantly, without animation, then just set the property without using an animateChanges
block
Sheets also automatically adjust when the software keyboard is shown or hidden – if the sheet is in half-height mode and the keyboard slides up, it will automatically move to the top of the screen, and back to the medium position when the keyboard goes away
Removing dimming overlay:
You can choose to hide the overlay covering the view behind the sheet
This makes the back view more visible, and also makes it possible to interact with it while the sheet is open, making the sheet non-modal
To hide the overlay, you set the largest detent level at which the dimming overlay should be hidden; at detent levels higher than this, the overlay will be visible
sheet.largestUndimmedDetentIdentifier = .medium
So:
- setting it to
.medium
means that.medium
is the largest detent with no overlay, and.large
will still show an overlay - setting it to
.large
means that it will show no overlay in either.medium
or.large
- leaving it at the default value (
nil
) means the overlay will be shown in all positions
ℹ️ In the talk, the property was called smallestUndimmedDetentIdentifier
, which didn't really make sense – the name was changed in one of the betas
Sheets in horizontal mode
When the iPhone is in horizontal orientation, presented view controllers are normally displayed in full screen mode, without the cut-off rounded edge at the top and covering full width of the device
You can now request to show a modal view as a sheet also in horizontal position by setting:
sheet.prefersEdgeAttachedInCompactHeight = true
The sheet will by default use the whole available width within in the safe area
If you want the sheet to have a custom width, you can additionally set:
sheet.widthFollowsPreferredContentSizeWhenEdgeAttached
You can customize the preferred sheet width using preferredContentSize
, just like on the iPad
Additional customizations
You can show a "grabber" bar at the top edge of the sheet, like on the bottom sheet in the Maps app, in order to make it more obvious that a sheet can be dragged by moving the top edge:
sheet.prefersGrabberVisible = true
You can also customize the corner radius of the top corners of the sheet:
sheet.preferredCornerRadius = 20.0
Note that when the screen behind the sheet is shown in a stacked sheet, it will also use the same corner radius for consistency
Popovers adapting into sheets
It's possible to present the same view as a popover on the iPad and as a sheet on the iPhone and in partial-width modes on the iPad (automatically adjusting between the two as you resize the window)
To do that, customize the popover presentation controller and use adaptiveSheetPresentationController
to configure its sheet presentation:
func showImagePicker(_ sender: UIBarButtonItem) { let picker = PHPickerViewController() picker.delegate = self // show the view as a popover by default picker.modalPresentationStyle = .popover // picker.sheetPresentationController is nil if let popover = picker.popoverPresentationController { popover.barButtonItem = sender // access the sheet controller of the popover let sheet = popover.adaptiveSheetPresentationController sheet.detents = [.medium(), .large()] sheet.smallestUndimmedDetentIdentifier = .medium } present(picker, animated: true) }
Note that if modalPresentationStyle
is set to .popover
, the sheetPresentationController
of the presented view will always be nil, so you need to access it through popoverPresentationController.adaptiveSheetPresentationController
Updating your app
How you can update your app for iOS 15:
- review your app for areas that would benefit from medium-height or non-modal sheets
- if you have any custom built half-height sheet views, replace them with built-in sheets