How do we create an adaptive, resizable bottom sheet in SwiftUI without having to manually specify the height? Well, let’s figure it out together.
SwiftUI’s built-in bottom sheet component works great out of the box and is straightforward to use. The only downside is that for custom heights, we need to specify the exact height we want to support in the presentation detents. But sometimes that’s not something we know ahead of time—maybe because we want the sheet to resize dynamically based on the content size. Trying to guesstimate that static height beforehand is not fun. But that’s exactly why you’re reading this article.
All we really need to accomplish our auto-sizing goal is a mechanism that can compute the height of our sheet based on the sheet’s actual content size. Simple enough—it’s just a combo of measuring and binding the height to the presentation detent of our bottom sheet.
Here’s what I mean in code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
import SwiftUI
private struct AutoSizingBottomSheetWrapper<Content: View>: View {
private let title: String
private let backgroundColor: Color
@ViewBuilder private let content: () -> Content
init(
title: String,
backgroundColor: Color = Color(.systemBackground),
@ViewBuilder content: @escaping () -> Content
) {
self.title = title
self.backgroundColor = backgroundColor
self.content = content
}
var body: some View {
VStack(spacing: 0) {
navigationBar
Divider()
content()
}
.frame(maxWidth: .infinity)
.background(backgroundColor)
}
private var navigationBar: some View {
Text(title)
.font(.title2)
.bold()
.frame(height: 55)
.frame(maxWidth: .infinity)
}
}
// MARK: - View Modifier
struct AutoSizingBottomSheetModifier<SheetContent: View>: ViewModifier {
@State private var adaptiveHeight: CGFloat = 0
@Binding var isPresented: Bool
let title: String
let backgroundColor: Color
let sheetContent: SheetContent
init(
isPresented: Binding<Bool>,
title: String,
backgroundColor: Color = Color(.systemBackground),
@ViewBuilder _ sheetContent: () -> SheetContent
) {
self._isPresented = isPresented
self.title = title
self.backgroundColor = backgroundColor
self.sheetContent = sheetContent()
}
func body(content: Content) -> some View {
content
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background {
// Hidden view to measure content height
contentWrapperView
.background {
GeometryReader { proxy in
Color.clear
.task(id: proxy.size.height) {
adaptiveHeight = proxy.size.height
}
}
}
.hidden()
}
.sheet(isPresented: $isPresented) {
contentWrapperView
.presentationBackground(backgroundColor)
.presentationDetents([.height(adaptiveHeight)])
.presentationDragIndicator(.visible)
}
.id(adaptiveHeight) // this will Force refresh when height changes
}
private var contentWrapperView: some View {
AutoSizingBottomSheetWrapper(
title: title,
backgroundColor: backgroundColor,
content: { sheetContent }
)
}
}
// MARK: - View Extension
extension View {
/// Presents content in an adaptive bottom sheet that automatically sizes to content
func autoSizingBottomSheet<Content: View>(
isPresented: Binding<Bool>,
title: String,
@ViewBuilder content: @escaping () -> Content
) -> some View {
modifier(AutoSizingBottomSheetModifier(
isPresented: isPresented,
title: title,
content
))
}
/// Presents content in an adaptive bottom sheet with full customization options
func autoSizingBottomSheet<Content: View>(
isPresented: Binding<Bool>,
title: String,
backgroundColor: Color = Color(.systemBackground),
@ViewBuilder content: @escaping () -> Content
) -> some View {
modifier(AutoSizingBottomSheetModifier(
isPresented: isPresented,
title: title,
backgroundColor: backgroundColor,
content
))
}
}
How It Works
Let’s break down what’s happening here:
The Wrapper Component
The AutoSizingBottomSheetWrapper
is just our base UI container. It gives us a title bar with a divider, then shows whatever content we pass in with the help of a viewbuilder. Pretty straightforward—just a clean layout we can reuse.
The Modifier
This is where the actual work happens. The AutoSizingBottomSheetModifier
does a few key things:
-
Hidden Measurement: We create a hidden copy of our sheet content wrapped in a
GeometryReader
background. This invisible view gets rendered in the background so we can measure exactly how tall our content will be. -
Height Tracking: Using the
task(id:)
modifier, we watch for changes in the content height. When the height changes, we update ouradaptiveHeight
state. -
Sheet Presentation: The actual sheet uses
presentationDetents([.height(adaptiveHeight)])
to set its height to exactly what we measured. -
View Refresh: The
.id(adaptiveHeight)
modifier forces the view to refresh when the height changes, keeping everything in sync.
The nice thing about this approach is that it just works automatically. Your content grows or shrinks, and the sheet adjusts without you having to do anything.
The View Extension
We wrap everything up in a convenient View extension that makes using our auto-sizing bottom sheet as simple as calling any other SwiftUI modifier. Just pass in your content, and the sheet handles the rest.
Using The Auto-Sizing Bottom Sheet
Here’s how simple it is to use:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct ContentView: View {
@State private var showSheet = false
var body: some View {
Button("Show Sheet") {
showSheet = true
}
.autoSizingBottomSheet(
isPresented: $showSheet,
title: "Dynamic Content"
) {
VStack(spacing: 16) {
Text("This content can be any size!")
.font(.headline)
ForEach(0..<5) { index in
Text("Item \(index + 1)")
.padding()
.background(Color.blue.opacity(0.1))
.cornerRadius(8)
}
}
.padding()
}
}
}
Wrapping Up
What we’ve built here is a useful component that solves a real problem. SwiftUI’s bottom sheets works well, but sometimes you need to add your own logic to make it work exactly how you want.
The technique we used—measuring content with a hidden view and binding that measurement to presentation detents—is something you can use for other layout challenges in SwiftUI too. Hope that helps.
Happy Coding :)