Sized-to-fit SwiftUI bottom sheet
Using detents to control the height of a bottom sheet
With Xcode 14, Apple introduced a PresentationDetent
struct into SwiftUI, to control the height of a bottom sheet. Possible values are:
.large
to show a full-height sheet.medium
to show a half-height sheet.custom<D>(D.Type)
to provide a custom type which specifies the height.fraction(CGFloat)
to specify a fraction of the available height.height(CGFloat)
for a fixed height
These are useful, but most of the time we want a sheet which is sized for its content.
Sizing a bottom-view to fit its content
- first get the height of the sheet contents, and put that value into a
Preference
- listen for that preference in the presenting view
- store the height in a
@State
value in the presenting view - use that state value in a fixed-height detent
Let's start with these two views:
struct ContentView: View {
@State var presentSheet: Bool = false
var body: some View {
Button("Tap me") {
self.presentSheet.toggle()
}
.sheet(isPresented: self.$presentSheet) {
BottomView()
}
}
}
struct BottomView: View {
var body: some View {
VStack {
Text("Hello")
Text("World")
}
}
}
How to size the bottom-view correctly
I guess that most SwiftUI projects have a ViewModifier
which captures the height of a view. Here's mine:
struct HeightPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat?
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
guard let nextValue = nextValue() else { return }
value = nextValue
}
}
private struct ReadHeightModifier: ViewModifier {
private var sizeView: some View {
GeometryReader { geometry in
Color.clear.preference(key: HeightPreferenceKey.self, value: geometry.size.height)
}
}
func body(content: Content) -> some View {
content.background(sizeView)
}
}
extension View {
func readHeight() -> some View {
self
.modifier(ReadHeightModifier())
}
}
Remember that preference values propagate up the view hierarchy, from child to parent.
It's all wired-up like this:
struct ContentView: View {
@State var presentSheet: Bool = false
@State var detentHeight: CGFloat = 0
var body: some View {
Button("Tap me") {
self.presentSheet.toggle()
}
.sheet(isPresented: self.$presentSheet) {
BottomView()
.readHeight()
.onPreferenceChange(HeightPreferenceKey.self) { height in
if let height {
self.detentHeight = height
}
}
.presentationDetents([.height(self.detentHeight)])
}
}
}
And yes, it works great with dynamic type!

Note about presentationDragIndicator
If you intent to show a drag indicator, using the .presentationDragIndicator(.visible)
modifier, then you'll also need to add a little top padding to bottom-sheet content:
struct BottomView: View {
var body: some View {
VStack {
Text("Hello")
Text("World")
}
.padding(.top)
}
}
First published 27 November 2022