Exploring Interactive Bottom Sheets in SwiftUI

Exploring Interactive Bottom Sheets in SwiftUI

Explore how to effectively use presentation detents to create interactive customized sheets like those in the Apple Maps, Find My and Stocks apps.

In apps like Maps, Find My and Stocks there’s a variation of sheets that remain visible at all times, giving users immediate access to specific features without overshadowing the background view, maintaining interaction.

Bottom Sheet in the Maps, Find My and Stocks apps

In this article, we’ll explore how to effectively use presentationDetents and related modifiers to create interactive customized sheets like those in the mentioned apps.

Sheets and Presentation Detents

Sheets in SwiftUI are a way to present modal content over the current view. Traditionally, sheets would cover the entire screen when presented.
In the Human Interface Guidelines in the Platform considerations for iOS and iPadOS there is also the mention of resizable sheets.

A resizable sheet expands when people scroll its contents or drag the grabber, which is a small horizontal indicator that can appear at the top edge of a sheet. Sheets resize according to their detents, which are particular heights at which a sheet naturally rests. Designed for iPhone, detents specify particular heights at which a sheet naturally rests.

Apple Human Interface Guidelines

Detents were introduced in WWDC 2021 in UIKit as a way to create a layered and customized sheet experience allowing developers to specify preset heights for sheets. Presentation detents are predefined stopping points for sheet presentations in SwiftUI. They allow you to specify multiple height options for a sheet, giving users the ability to resize the sheet to different positions. With iOS 16, SwiftUI introduced detents and the presentationDetents(_:) modifiers allowing the creation of customized sheets, without the need to create a representation of the UISheetPresentationController.

struct ContentView: View {
    @State private var isSheetPresented = false
    
    var body: some View {
        Button("Show Sheet") {
            isSheetPresented.toggle()
        }
        .sheet(isPresented: $isSheetPresented) {
            Text("Create with Swift")
                .presentationDetents([.medium, .fraction(0.7), .large])
        }
    }
}
Sheet with presentation detents

Built-in Sizes

SwiftUI provides two standard detent sizes:

  1. medium: Occupies approximately half the screen height
  2. large: Full-screen height (default behavior)
struct ContentView: View {
    @State private var isSheetPresented = false
    
    var body: some View {
        Button("Show Sheet") {
            isSheetPresented.toggle()
        }
        .sheet(isPresented: $isSheetPresented) {
            Text("Create with Swift")
                .presentationDetents([.medium, .large])
        }
    }
}
Sheet with Medium and Large detents

The medium detent is automatically disabled in landscape orientation to preserve usable space. All sheets will use the large detents in the landscape orientation independently of what is specified in the modifier.

Custom Sizes

For more precise control, SwiftUI offers several options for custom detents:

  1. fraction(_ fraction: CGFloat)specifying
    Specify a percentage of screen height (e.g.: .fraction(0.30) for 30% height)
  2. height(_ height: CGFloat)
    Set an exact point height
  3. custom(_:)
    A custom detent you defined with a calculated height.
struct ContentView: View {
    @State private var isSheetPresented = false
    
    var body: some View {
        Button("Show Sheet") {
            isSheetPresented.toggle()
        }
        .sheet(isPresented: $isSheetPresented) {
            Text("Create with Swift")
                .presentationDetents([.fraction(0.7), .height(100)])

        }
    }
}
Sheet with Fraction and Height detents

You can also create a custom detent by conforming to the CustomPresentationDetent protocol and specifying the height.

private struct SmallDetent: CustomPresentationDetent {
    static func height(in context: Context) -> CGFloat? {
        return 100
    }
}

private struct CompactDetent: CustomPresentationDetent {
    static func height(in context: Context) -> CGFloat? {
        max(50, min(context.maxDetentValue * 0.3, 200))
    }
}

The first detent is the one we used before, which returns just a detention of a specific height. The second calculates a height between 50 and 200 points, but no more than 30% of the available height.

struct ContentView: View {
    @State private var isSheetPresented = false
    
    var body: some View {
        Button("Show Sheet") {
            isSheetPresented.toggle()
        }
        .sheet(isPresented: $isSheetPresented) {
            Text("Create with Swift")
                .presentationDetents([.custom(SmallDetent.self), .custom(CompactDetent.self)])
        }
    }
}

private struct SmallDetent: CustomPresentationDetent {
    static func height(in context: Context) -> CGFloat? {
        return 100
    }
}

private struct CompactDetent: CustomPresentationDetent {
    static func height(in context: Context) -> CGFloat? {
        max(50, min(context.maxDetentValue * 0.3, 200))
    }
}

To simplify usage across your app, extend PresentationDetent:

struct ContentView: View {
    @State private var isSheetPresented = false
    
    var body: some View {
        Button("Show Sheet") {
            isSheetPresented.toggle()
        }
        .sheet(isPresented: $isSheetPresented) {
            Text("Create with Swift")
                .presentationDetents([.small, .compact])
        }
    }
}

private struct SmallDetent: CustomPresentationDetent {
    static func height(in context: Context) -> CGFloat? {
        return 100
    }
}

private struct CompactDetent: CustomPresentationDetent {
    static func height(in context: Context) -> CGFloat? {
        max(50, min(context.maxDetentValue * 0.3, 200))
    }
}

extension PresentationDetent {
    static let small = PresentationDetent.custom(SmallDetent.self)
    
    static let compact = PresentationDetent.custom(CompactDetent.self)
}
Detents are automatically ordered from bottom to top. In the example above you can notice that independently from the order the first detention is the 100 height.

Drag Indicator Behavior

If only one detent is available the sheet won't display the drag indicator.

The Drag Indicator

In fact, the drag indicator's visibility is automatically managed:

  • Single detent:
    Drag indicator hidden
  • Multiple detents:
    Drag indicator visible

You can override this behavior with the presentationDragIndicator(_:) modifier. Set the visibility to visible to show the indicator even when only one detent is defined.

struct ContentView: View {
    @State private var isSheetPresented = false
    
    var body: some View {
        Button("Show Sheet") {
            isSheetPresented.toggle()
        }
        .sheet(isPresented: $isSheetPresented) {
            Text("Create with Swift")
                .presentationDetents([.medium])
                .presentationDragIndicator(.visible)
        }
    }
}

Or you can set the visibility to hidden to hide the indicator even when multiple detents are available:

struct ContentView: View {
    @State private var isSheetPresented = false
    
    var body: some View {
        Button("Show Sheet") {
            isSheetPresented.toggle()
        }
        .sheet(isPresented: $isSheetPresented) {
            Text("Create with Swift")
                .presentationDetents([.medium, .large])
                .presentationDragIndicator(.hidden)
        }
    }
}

Dynamically check or control the height

You may want to change the content of your sheet according to the space available. For this, you can dynamically check or control the sheet's height using a binding:

struct ContentView: View {
    @State private var selectedDetent: PresentationDetent = .medium
    @State private var isSheetPresented = false

    var body: some View {
        Button("Show Sheet") {
            isSheetPresented.toggle()
        }
        .sheet(isPresented: $isSheetPresented) {
            sheetContent
                .presentationDetents([.height(100), .medium, .fraction(0.7), .large], selection: $selectedDetent)
        }
    }

    @ViewBuilder
    private var sheetContent: some View {
        switch selectedDetent {
        case .medium:
            Text("Medium Detent")
        case .fraction(0.7):
            Text("Fraction Detent")
        case .large:
            Text("Large Detent")
        case .height(100):
            Text("Height Detent")
        default:
            Text("Unknown Detent")
        }
    }
}
Sheet with detents that changes the content according to the selected one

Preventing Sheet Dismissal

In the examples above the user is able to dismiss the sheet. To replicate interfaces like Maps you can prevent the interactive dismissal of the sheet from the user with the interactiveDismissDisabled(_:) modifier and have it always presented by setting up a constant value for the isPresented parameter, using the Binding.constant(_:) method.

struct ContentView: View {
    @State private var selectedDetent: PresentationDetent = .height(100)

    var body: some View {
        Text("Create with Swift")
            .sheet(isPresented: .constant(true)) {
                VStack {
                    Image(systemName: "swift")
                        .resizable()
                        .scaledToFit()
                        .padding()
                        .foregroundStyle(Color.accentColor)
                    if (selectedDetent != .height(100)) {
                        Text("We can’t wait to see what you will Create with Swift!")
                    }
                }
                .presentationDetents([.height(100), .medium, .large], selection: $selectedDetent)
                .interactiveDismissDisabled()
        }
    }
}
The sheet can still be dismissed programmatically or you can specify in which condition the sheet can be dismissed using the isDisabled parameter.
Sheet with interactive dismiss disabled

Background Interaction

All the previous implementations of a sheet leave the background view non-interactive. In default sheet configurations, the background view is usually non-interactive while the sheet is active, preventing accidental touches. If you want to allow the user to interact with the background view while a sheet is presented use the presentationBackgroundInteraction(_:) modifier.

struct ContentView: View {
    @State private var selectedDetent: PresentationDetent = .height(100)
    
    @State private var selectedColor: Color = Color.accentColor

    var body: some View {
        VStack {
            Button("Change Color", action: {
                selectedColor = selectedColor == .accentColor ? .white : .accentColor
            })
            .padding(.top)
            Spacer()
        }
            .sheet(isPresented: .constant(true)) {
                VStack {
                    Image(systemName: "swift")
                        .resizable()
                        .scaledToFit()
                        .padding()
                        .foregroundStyle(selectedColor)
                    if (selectedDetent != .height(100)) {
                        Text("We can’t wait to see what you will Create with Swift!")
                    }
                }
                .presentationDetents([.height(100), .medium, .large], selection: $selectedDetent)
                .interactiveDismissDisabled()
                .presentationBackgroundInteraction(.enabled)
        }
    }
}
Sheet with the background interaction enabled

Conditional Background Interaction

You can even specify which detent level background interaction is permitted with the upThrough parameter. For example, .enabled(upThrough: .medium) enables interaction with the background view when the sheet is at or below the medium detent, while the background remains non-interactive at the large detent.

struct ContentView: View {
    @State private var selectedDetent: PresentationDetent = .height(100)
    
    @State private var selectedColor: Color = Color.accentColor

    var body: some View {
        VStack {
            Button("Change Color", action: {
                selectedColor = selectedColor == .accentColor ? .white : .accentColor
            })
            .padding(.top)
            Spacer()
        }
            .sheet(isPresented: .constant(true)) {
                VStack {
                    Image(systemName: "swift")
                        .resizable()
                        .scaledToFit()
                        .padding()
                        .foregroundStyle(selectedColor)
                    if (selectedDetent != .height(100)) {
                        Text("We can’t wait to see what you will Create with Swift!")
                    }
                }
                .presentationDetents([.height(100), .medium, .large], selection: $selectedDetent)
                .interactiveDismissDisabled()
                .presentationBackgroundInteraction(.enabled(upThrough: .height(100)))
        }
    }
}
Sheet with the background interaction enabled only with the height detent

We explored how SwiftUI's presentation detents and background interaction modifiers make sophisticated interfaces easy to implement. Through a combination of customizable heights, drag indicators, and interactive backgrounds, developers can now create sheets that enhance rather than interrupt the user experience. These features enable the creation of persistent, interactive interfaces that maintain context while providing additional functionality.