Home Let's Build an Auto-Sizing Bottom Sheet Component in SwiftUI
Post
Cancel

Let's Build an Auto-Sizing Bottom Sheet Component in SwiftUI

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:

  1. 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.

  2. Height Tracking: Using the task(id:) modifier, we watch for changes in the content height. When the height changes, we update our adaptiveHeight state.

  3. Sheet Presentation: The actual sheet uses presentationDetents([.height(adaptiveHeight)]) to set its height to exactly what we measured.

  4. 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 :)

This post is licensed under CC BY 4.0 by the author.