Overview of resizable sheet APIs in SwiftUI

Starting from iOS 16 we can present resizable sheets natively in SwiftUI. In this article we'll look into what we can achieve with the new APIs and what limitations they have in comparison with UIKit.

# Half-sheet

To present a sheet that takes approximately half of the screen on iPhone we need to apply presentationDetents() modifier inside the sheet content and pass it a single medium detent.

struct ContentView: View {
    @State private var showSheet = false
    
    var body: some View {
        Button("Show Sheet") {
            showSheet = true
        }
        .sheet(isPresented: $showSheet) {
            Text("Hello from the SwiftUI sheet!")
                .presentationDetents([.medium])
        }
    }
}
Screenshot of an iPhone with a Show Sheet button Screenshot of an iPhone with half-sheet presented

Since we are only passing one detent in the set of detents, this kind of sheet cannot be resized by the user.

# Resizable half-sheet

To present a half-sheet that can be resized by the user, we can pass both medium and large system detents to presentationDetents() modifier.

struct ContentView: View {
    @State private var showSheet = false
    
    var body: some View {
        Button("Show Sheet") {
            showSheet = true
        }
        .sheet(isPresented: $showSheet) {
            Text("Hello from the SwiftUI sheet!")
                .presentationDetents([.medium, .large])
        }
    }
}
Screenshot of an iPhone with a half-sheet presented that also has a drag indicator Screenshot of an iPhone with a full sheet presented that also has a drag indicator

Notice how SwiftUI adds a drag indicator automatically when we provide more than one detent. It helps the user realize that the sheet can be dragged to resize it.

# Custom detents

Apart from the system detents such as medium and large, we can also provide custom detents where we define the size ourselves.

# Fraction and height

There are two convenience methods to create a custom detent in place. We can provide the height in fraction of the full height with fraction() method, or provide the height in points with height() method.

The following example creates a fractional detent that takes 20% of the full height of the sheet, a detent that is 600 points in height and the system large detent. Custom and system detents can be mixed together in the detents set. SwiftUI will figure out the order in which one detent should transition into another when the user drags the sheet.

struct ContentView: View {
    @State private var showSheet = false
    
    var body: some View {
        Button("Show Sheet") {
            showSheet = true
        }
        .sheet(isPresented: $showSheet) {
            Text("Hello from the SwiftUI sheet!")
                .presentationDetents([
                    .fraction(0.2),
                    .height(600),
                    .large
                ])
        }
    }
}

# Custom detent type

We can create custom detent types that can be re-used across multiple sheets. We need to conform our custom type to CustomPresentationDetent protocol and provide height(in:) method that returns the calculated detent height.

Inside height() method we are provided a special context that includes SwiftUI environment. We can use that environment to calculate the size of the detent.

For example, we can define TinyDetent type that has the height of 60 points. If the user sets larger accessibility font on their device, we should adjust the height of the detent to be larger, so that our content doesn't get truncated or cut off.

Here is how we can do something like this.

struct TinyDetent: CustomPresentationDetent {
    static func height(in context: Context) -> CGFloat? {
        if context.dynamicTypeSize.isAccessibilitySize {
            return 140
        } else {
            return 60
        }
    }
}

struct ContentView: View {
    @State private var showSheet = false
    
    var body: some View {
        Button("Show Sheet") {
            showSheet = true
        }
        .sheet(isPresented: $showSheet) {
            Text("Hello from the SwiftUI sheet!")
                .presentationDetents([
                    .custom(TinyDetent.self),
                    .medium
                ])
        }
    }
}
Screenshot of an iPhone with a sheet presented in a very small detent just wrapping the text inside Screenshot of an iPhone with a sheet presented in small detent just wrapping the text inside that has large accessibility font

# Programmatically set active detent

To programmatically set the detent the sheet is presented in, we need to pass a selection binding to presentationDetents() modifier. We can store the current detent in a State variable, give it an initial value and later programmatically set a new value in response to some user action.

struct ContentView: View {
    @State private var showSheet = false
    @State private var selectedDetent = PresentationDetent.medium
    
    var detentButton: Button<Text> {
        if selectedDetent == .medium {
            return Button("Show more") {
                selectedDetent = .large
            }
        } else {
            return Button("Show less") {
                selectedDetent = .medium
            }
        }
    }
    
    var body: some View {
        Button("Show Sheet") {
            showSheet = true
        }
        .sheet(isPresented: $showSheet) {
            detentButton
                .presentationDetents(
                    [.medium, .large],
                    selection: $selectedDetent
                )
        }
    }
}

Note that selected detent has to be included in the set of detents provided to presentationDetents() modifier. If we attempt to set selection to a detent that is not in the set, SwiftUI will issue a warning saying Cannot set selected sheet detent if it is not included in supported sheet detents.

To make it easier to refer to a custom detent type for programmmatic control of active detent, we can define a static property on PresentationDetent.

extension PresentationDetent {
    static let tiny = Self.custom(TinyDetent.self)
    static let small =  Self.fraction(0.3)
}

Then we can use it exactly like a system detent.

selectedDetent = .tiny

// or

selectedDetent = .small

# Show and hide drag indicator

SwiftUI automatically adds drag indicator to the sheet when we provide more than one detent to presentationDetents() modifier. But we can also manually control its visibility with presentationDragIndicator.

For example, if our design requires to always show the indicator even with a single available detent, we can set presentation drag indicator to visible.

struct ContentView: View {
    @State private var showSheet = false
    
    var body: some View {
        Button("Show Sheet") {
            showSheet = true
        }
        .sheet(isPresented: $showSheet) {
            Text("Hello from the SwiftUI sheet!")
                .presentationDetents([.medium])
                .presentationDragIndicator(.visible)
        }
    }
}

# Resizable sheet and device configurations

Presentation detents take effect when we present a sheet on iPhone in portrait mode, but if the phone is rotated to landscape mode, the sheet transitions to a full screen cover and the detents are ignored.

On iPad the detents are only respected when the sheet is edge-attached. It's edge-attached when the screen is devided between multiple scenes and the scene the sheet is presented in is narrow.

When the sheet is presented as a floating sheet in the middle of the iPad scene, the detents are ignored.

Presentation detents are always ignored on the Mac.

# Resizable adaptive sheet

Adaptive sheet is presented when popover transitions into a sheet on iPad in narrow scenes. It's also presented instead of a popover on the phone.

Adaptive sheet also respects detents, they can be set inside the content of a popover.

struct ContentView: View {
    @State private var showPopover = false
    
    var body: some View {
        Button("Show Popover") {
            showPopover = true
        }
        .popover(isPresented: $showPopover) {
            Text("Hello from the SwiftUI popover!")
                .presentationDetents([.medium, .large])
        }
    }
}

# API limitations

It's great that we have all those additions that allow us to present resizable sheets in SwiftUI. We can already build a very large set of presentation experiences with system detents, custom detents and programmatic control over the active one.

There are still some things that are not possible with the current set of APIs. I thought we could compare them to what is provided by UISheetPresentationController in UIKit, to have an idea on what is still missing.

I'm going to go over the most prominent limitations in the current SwiftUI APIs.

Largest undimmed detent

In UIKit we can set largestUndimmedDetentIdentifier to remove the dimming from the view behind the sheet up to a certain detent. This allows users to interact with the rest of the app content while the sheet is presented.

This is not currently possible in SwiftUI.

Prevent scroll gesture from expanding the sheet

UISheetPresentationController has prefersScrollingExpandsWhenScrolledToEdge property that controls whether the scroll gesture expands the sheet to a larger detent.

In SwiftUI we can't control this behavior. The scroll gesture always expands the sheet if there is a larger detent available, we can't prevent that at the moment.

Edge attached sheet in compact height

When iPhone is rotated to landscape mode, sheet shows as a full screen cover by default. In UIKit we can force it to be attached to the bottom edge instead by setting prefersEdgeAttachedInCompactHeight to true.

In SwiftUI the sheet is always presented as a full screen cover in compact height.

Preferred corner radius

UIKit allows us to set preferredCornerRadius on the sheet that takes effect when the sheet is at the front of its sheet stack.

At the moment we can't control the corner radius in SwiftUI.