MacKuba

🍎 Kuba Suder's blog on Mac & iOS development

WWDC 21

Customize and resize sheets in UIKit

Categories: UIKit 0 comments Watch the video

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


Leave a comment

*

*
This will only be used to display your Gravatar image.

*

What's the name of the base class of all AppKit and UIKit classes?

*