
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.

Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.