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.