A Look At iOS Native Half Sheets

A Look At iOS Native Half Sheets

ยท

6 min read

One of the things that stood out to me from WWDC this year was the ability to have native half sheets. This has been a request from the designers at every job I have ever had and until now we have always had to roll our own. So I thought I would play around with the code from this video and see how they work in practice. This code is all written in Xcode 13, beta 1 on macOS 11.3 (Big Sur) and run on iOS 15 simulators.

And please excuse the quality of the gifs. Gradients don't play super well with a limited palette, but it was the easiest way I could find to have some simple examples of what the movement of these sheets looks like.

Finding The Sheet Presentation Controller

The first thing I noticed is that the sample code doesn't actually compile (at least not on the version of Xcode I'm running). UIViewController doesn't have a property called sheetPresentationController. It is accessible through the presentationController property, assuming the modalPresentationStyle is set to pageSheet or formSheet, but you have to cast it as a UISheetPresentationController to access the new properties. I added this shim for now to get the sample code to run, but I assume the new sheetPresentationController property will be made available eventually.

extension UIViewController {
 var sheetPresentationController: UISheetPresentationController? {
   presentationController as? UISheetPresentationController
 }
}

With that available, I had a route to start adding some sheets:

override func viewDidLoad() {
    super.viewDidLoad()

    view.backgroundColor = .systemTeal
    presentSheet()
}

override func viewDidAppear(_ animated: Bool) {
    presentSheet()
}

private func presentSheet() {
    let viewController = UIViewController()
    viewController.view.backgroundColor = .systemOrange

    let sheet = viewController.sheetPresentationController
    sheet?.detents = [.medium(), .large()]

    present(viewController, animated: true)
}

That gives us a bottom sheet like this:

first-half-sheet.gif

Making It Look Nice

It's pretty cool out of the box. It looks good, you can expand it up to full height and back and dismiss it like you would expect. But I wanted to see how customizable it is, see what it is good for and what it isn't, etc. And I want it to look good in the process, so let's get some gradients in there.

First, I'm using this simple GradientView which is a UIView subclass that has a CAGradientLayer as its layer class and provides direct access to it.

class GradientView: UIView {
    override class var layerClass: AnyClass {
        return CAGradientLayer.self
    }

    var gradientLayer: CAGradientLayer {
        return layer as! CAGradientLayer
    }
}

Then, I set the view of ViewController to be that GradientView

private lazy var contentView: GradientView = .init()

override func loadView() {
    view = contentView
}

And then make some semi-random combinations of colors:

let colorSets: [[UIColor]] = [
    [.systemTeal, .systemGreen, .systemOrange],
    [.systemRed, .systemPurple, .systemBlue],
    [.systemPink, .systemIndigo, .systemTeal],
    [.systemPink, .systemYellow, .systemGreen],
    [.systemGreen, .systemTeal, .systemPurple],
    [.systemYellow, .systemPink, .systemIndigo],
    [.systemRed, .systemYellow, .systemTeal]
]

// in viewDidLoad()
contentView.gradientLayer.colors = colorSets.randomElement()!
                                            .map(\.cgColor)

To add a little more variety, I also added a couple of different start points:

let startPoints: [CGPoint] = [
    CGPoint(x: 0.5, y: 0),
    CGPoint(x: 0, y: 0.3),
    CGPoint(x: 0.5, y: 1),
    CGPoint(x: 0, y: 0.8),
    CGPoint(x: 1, y: 0.3),
    CGPoint(x: 1, y: 0.8),
]

extension CGPoint {
    func opposite() -> CGPoint {
        let x = 1 - self.x
        let y = 1 - self.y
        return CGPoint(x: x, y: y)
    }
}

// in viewDidLoad()
let start = startPoints.randomElement()!
contentView.gradientLayer.startPoint = start
contentView.gradientLayer.endPoint = start.opposite()

FInally, to add new sheets, I added a button instead of doing it automatically in viewDidAppear. This allows it to be recursive, so you can just keep adding new iterations onto the stack. This is using the new UIButtonConfiguration, which I won't go into detail on here, but maybe in a future article. Needless to say, it is also pretty nice.

private let addButton = UIButton(configuration: .plain())

// in viewDidLoad()
let arrowConfig = UIImage.SymbolConfiguration(pointSize: 36,
                                              weight: .black)
let arrow = UIImage(systemName: "arrow.clockwise",
                    withConfiguration: arrowConfig)
addButton.configuration?.image = arrow
addButton.tintColor = .white
let addAction = UIAction { _ in self.presentSheet() }
addButton.addAction(addAction, for: .primaryActionTriggered)

view.addSubview(addButton)
addButton.centerAnchors == view.centerAnchors

// in presentSheet()
sheetPresentationController?.animateChanges {
    sheetPresentationController?.selectedDetentIdentifier = .large
}

let viewController = ViewController()

That leads to something that looks like this.

gradient-sheets.gif

Customizing Things

Now that's looking a lot more fun! And it gives us a pretty solid foundation to mess with some of the interface talked about in the WWDC session. The first thing I tried as messing with the preferredCornerRadius, which I found is not yet a property on UISheetPresentationController. But there is a private version called __preferredCornerRadius, which, again, I'm assuming will be fixed before the release of the GM. To mess around with this, I added a slider, that will let us set it in the app.

static var preferredCornerRadius: CGFloat = 24
private let cornerRadiusSlider = UISlider()

// in viewDidLoad()
cornerRadiusSlider.maximumValue = 60
cornerRadiusSlider.minimumValue = 0
cornerRadiusSlider.maximumTrackTintColor = .white.withAlphaComponent(0.3)
cornerRadiusSlider.minimumTrackTintColor = .white
cornerRadiusSlider.value = Float(Self.preferredCornerRadius)

let updateRadiusAction = UIAction { _ in self.updateRadius() }
cornerRadiusSlider.addAction(updateRadiusAction, for: .primaryActionTriggered)

view.addSubview(cornerRadiusSlider)
cornerRadiusSlider.horizontalAnchors == view.readableContentGuide.horizontalAnchors + 20
cornerRadiusSlider.topAnchor == addButton.bottomAnchor + 20

// in presentSheet()
sheet?.__preferredCornerRadius = Self.preferredCornerRadius

private func updateRadius() {
    let radius = CGFloat(cornerRadiusSlider.value)
    sheetPresentationController?.__preferredCornerRadius = radius
    Self.preferredCornerRadius = radius
}

This works pretty much as you would expect. As you move the slider, the corner radius updates.

changing-corner-radius.gif

Interestingly, setting the preferred corner radius on the sheet presenter doesn't affect the view behind if it is at half-height, but it does at full height.

changing-radius-at-half-height.gif

changing-radius-at-full-height.gif

Even more interesting, it only affects the view immediately behind it, even at full height.

only-changing-the-view-behind.gif

Most of these things shouldn't really affect much in practice, because how often are you presenting sheet on top of sheet on top of sheet, or allowing the user to change the corner radius of a view at will? But it is good to know where it might fall down.

prefersGrabberVisible, prefersEdgeAttachedInCompactHeight, and smallestUndimmedDetentIdentifier are all accessible and work expected. I added these methods to expose them in the app, and hooked them up to respective buttons.

private func updateGrabber() {
    showsGrabberButton.isSelected.toggle()
    sheetPresentationController?.animateChanges {
        sheetPresentationController?.prefersGrabberVisible = showsGrabberButton.isSelected
    }
}

private func updateEdgeAttached() {
    edgeAttachedButton.isSelected.toggle()
    sheetPresentationController?.animateChanges {
        sheetPresentationController?.prefersEdgeAttachedInCompactHeight = edgeAttachedButton.isSelected
    }
}

private func updateDimming(_ detent: Detent) {
    sheetPresentationController?.animateChanges {
        sheetPresentationController?.smallestUndimmedDetentIdentifier = detent.identifier
    }
}

sheet-with-grabber.png

sheet-in-landscape.png

And it all works well on iPad too, although you have to access the sheet controller through popoverPresentationController?.adaptiveSheetPresentationController if your view is presented in a popover.

testing-on-ipad.gif

One thing that is really cool is that, if you set the sheet to not dim the view behind it, you can still interact with the view controller behind it. That doesn't mean much in this toy app, but the WWDC video had a good example of showing an image picker on the bottom (in the front view controller) and displaying the image picked on the top (in the back view controller).

Wrap Up

All in all, I think it looks pretty slick and (mostly) works like you would expect it to out of the box. If they get the last few pieces of the interface cleaned up before the GM release, I'll definitely be using it my apps when the design calls for it. Download my toy app and play around with this stuff for yourself in this repo.

Did you find this article valuable?

Support Dillon McElhinney by becoming a sponsor. Any amount is appreciated!