RSS Amplifier

Shivam Sh · Sep 22, 2023

Building Custom Bottom Sheets in SwiftUI

0
Sign in to vote or save

This page cannot be shown here. You can still read it on the original site — the toolbar below keeps your place in the directory.

The release of iOS 16.4 came with some useful additions to SwiftUI. Specifically, building custom bottom sheets for your app became much easier.

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.

Read on /posts/bottom-sheets-ios-16-4/

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.