Building Custom Bottom Sheets in SwiftUI

With the release of iOS 16.4 there were some useful additions to SwiftUI, specifically to make building more complex sheets and modals easier.

I came across this when updating my own app to iOS 17. Customizing the behaviour of a sheet became a lot easier.

New Modifiers

There are five new modifiers available for customizing sheets and modals:

  • .presentationBackground: Change the background of a sheet to a custom colour, material, or even a view.
  • .presentationBackgroundInteraction: Prevents the view behind a sheet from being dimmed at the chosen sizes, allowing for interactive experiences with both views at the same time.
  • .presentationContentInteraction: Control which interaction takes priority, scrolling a scroll view within a sheet, or moving the sheet itself between detents.
  • .presentationCornerRadius: Allows you to specify a custom corner radius for a sheet.
  • .presentationCompactAdaptation: Control how a presented modal will adapt to compact size classes. Popovers usually show as a sheet on iPhones but now you can control this behaviour.

New Method

We can now make a custom bottom sheet like this:

import SwiftUI
import MapKit

struct BottomDrawerView: View {
    var body: some View {
        Map()
            .sheet(isPresented: .constant(true)) {
                Text("Bottom Sheet")
                    .font(.title2)
                    .bold()
                    .padding(.top)

                List {
                    ForEach(0..<30) { number in
                        Text("\(number)")
                    }
                }
                .scrollContentBackground(.hidden)
                .interactiveDismissDisabled()
                .presentationDetents([.height(120), .fraction(0.4), .large])
                // New properties
                .presentationBackground(.thinMaterial)
                .presentationBackgroundInteraction(
                    .enabled(upThrough: .fraction(0.4)))
                .presentationContentInteraction(.scrolls)
                .presentationCornerRadius(20.0)
                .presentationCompactAdaptation(.none)
            }
    }
}

Old Method

Before these modifiers existed making a custom implementation of a bottom drawer was a lot more complicated. Here's an example of an implementation I worked on before iOS 16.4:

import SwiftUI

// MARK: - BottomSheetViewConfiguration

struct BottomSheetViewConfiguration {
    var detents: Set<PresentationDetentAdapter>
    var largestUndimmedDetent: PresentationDetentAdapter
    var cornerRadius: CGFloat

    init(detents: Set<PresentationDetentAdapter> = [
            .height(80),
            .fraction(0.4),
            .large
         ],
         largestUndimmedDetent: PresentationDetentAdapter = .fraction(0.4),
         cornerRadius: CGFloat = 20.0) {
        self.detents = detents
        self.largestUndimmedDetent = largestUndimmedDetent
        self.cornerRadius = cornerRadius
    }
}

// MARK: - BottomSheetViewModifier

/**
 A view used to present an interactive sheet above a traditional view.

 - This view is made to look good on a scale where the horizontalSizeClass is .compact,
    in other cases the sheet will expand and probably won't look as good
 - Also it's probably best practice to not use this sheet with a verticalSizeClass of .compact,
    it may not look as nice with less vertical real-estate
 - You can try and customize the behaviour by providing a `BottomSheetViewConfiguration`
 */
struct BottomSheetViewModifier<SheetContent: View>: ViewModifier {
    @Environment(\.isPresented) var viewIsPresented
    @State var sheetIsPresented: Bool = true

    var configuration: BottomSheetViewConfiguration = BottomSheetViewConfiguration()
    var sheetContent: SheetContent

    init(configuration: BottomSheetViewConfiguration = BottomSheetViewConfiguration(),
         sheetContent: () -> SheetContent) {
        self.configuration = configuration
        self.sheetContent = sheetContent()
    }

    func body(content: Content) -> some View {
        content
            .sheet(isPresented: $sheetIsPresented) {
                sheetContent
                    .interactiveDismissDisabled()
                    .presentationDetents(
                        configuration.detents,
                        largestUndimmed: configuration.largestUndimmedDetent)
                    .presentationCornerRadius(configuration.cornerRadius)
                    .onChange(of: viewIsPresented) { _, newValue in
                        sheetIsPresented = newValue
                    }
            }
    }
}

// MARK: - View Extension

extension View {
    func bottomSheet<Content: View>(
        configuration: BottomSheetViewConfiguration = BottomSheetViewConfiguration(),
        _ content: () -> Content) -> some View {
        modifier(BottomSheetViewModifier(sheetContent: content))
    }
}
import SwiftUI

// MARK: - PresentationDetentAdapter

enum PresentationDetentAdapter: Hashable {
    case medium
    case large
    case fraction(_ fraction: CGFloat)
    case height(_ height: CGFloat)
    
    func swiftUIDetent() -> PresentationDetent {
        switch self {
        case .medium:
            return .medium
        case .large:
            return .large
        case .fraction(let fraction):
            return .fraction(fraction)
        case .height(let height):
            return .height(height)
        }
    }
    
    func uiKitDetent() -> UISheetPresentationController.Detent {
        switch self {
        case .medium:
            return .medium()
        case .large:
            return .large()
        case .fraction(let fraction):
            return .custom(
                identifier: .init("Fraction:\(String(format: "%.1f", fraction))")) { context in
                    context.maximumDetentValue * fraction
                }
        case .height(let height):
            return .custom(identifier: .init("Height:\(height)")) { _ in
                return height
            }
        }
    }
}

// MARK: - UndimmedDetentViewController

private class DetentUndimmingController: UIViewController {
    var largestUndimmedDetent: PresentationDetentAdapter
    
    init(largestUndimmedDetent: PresentationDetentAdapter) {
        self.largestUndimmedDetent = largestUndimmedDetent
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        sheetPresentationController?.largestUndimmedDetentIdentifier = largestUndimmedDetent.uiKitDetent().identifier
        sheetPresentationController?.preferredCornerRadius = 20
        sheetPresentationController?.prefersScrollingExpandsWhenScrolledToEdge = false
        presentingViewController?.view.tintAdjustmentMode = .normal
    }
}


// MARK: - UndimmedDetentViewController

private struct UndimmedDetentViewController: UIViewControllerRepresentable {
    var largestUndimmedDetent: PresentationDetentAdapter

    func makeUIViewController(context: Context) -> UIViewController {
        let result = DetentUndimmingController(largestUndimmedDetent: largestUndimmedDetent)
        return result
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}



// MARK: - UndimmedDetentViewModifier

private struct UndimmedDetentViewModifier: ViewModifier {
    var largestUndimmedDetent: PresentationDetentAdapter

    func body(content: Content) -> some View {
        content
            .background(UndimmedDetentViewController(largestUndimmedDetent: largestUndimmedDetent))
    }
}

// MARK: - View Extension

extension View {
    func presentationDetents(_ detents: Set<PresentationDetentAdapter>,
                             largestUndimmed: PresentationDetentAdapter) -> some View {
        modifier(
            UndimmedDetentViewModifier(largestUndimmedDetent: largestUndimmed)
        )
        .presentationDetents(
            Set(detents.map({ detent in
                detent.swiftUIDetent()
            })))
    }

    func presentationDetents(_ detents: Set<PresentationDetentAdapter>,
                             largestUndimmed: PresentationDetentAdapter,
                             selection: Binding<PresentationDetent>) -> some View {
        modifier(
            UndimmedDetentViewModifier(largestUndimmedDetent: largestUndimmed)
        )
        .presentationDetents(
            Set(detents.map({ detent in
                detent.swiftUIDetent()
            })),
            selection: selection)
    }
}

It's nice seeing these additional use cases being added to Swift UI in point releases of iOS. I'm looking forward to seeing more additions that make specific workarounds much less necessary.