Fit Sheet height to view content

In my app I have a sheet that has relatively small content inside. I’d like to fit the height of the sheet to the content of it. As I of course also want to support large type and don’t know how text will break to new lines, I can’t simply set a fixed pixel height.

Currently I’m using this trick I saw in some blog post to get it to work. This is the view I’m presenting inside my .sheet:

struct SheetContentView: View {
    @State private var contentHeight: CGFloat = .zero
    
    var body: some View {
        VStack {
            Text("Foo")
            Text("Bar")
            // …
        }
        .background(
            // Required for proper calculation of content height
            GeometryReader { geo in
                Color.clear
                    .onChange(of: geo.size.height, initial: true) { _, newValue in
                        contentHeight = newValue
                    }
            }
        )
        .presentationDetents([.height(contentHeight)])
    }
}

Is this a valid way to do it? What are the best practices to handle this properly or is there even a native way to do this?

Answered by Frameworks Engineer in 892371022

This is a perfectly good pattern to have your sheet set to the custom detent of the height of the view it contains. This can get tricky with ScrollViews in sheets in which case you need to measure the size of the view inside the ScrollView since the ScrollView itself doesn't have a preferred size when it's height proposal is nil.

For slightly better performance you can use on geometry change instead:

import SwiftUI

@main struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

struct ContentView: View {
    @State private var showingSheet = false

    @State private var swatchHeight: CGFloat = 100

    @State private var measuredSheetHeight: CGFloat = 100
    @State private var selectedDetent: PresentationDetent = .height(100)

    var body: some View {
        Button("Show Sheet") {
            showingSheet = true
        }
        .sheet(isPresented: $showingSheet) {
            AutoSizingSheet(
                swatchHeight: $swatchHeight,
                measuredSheetHeight: $measuredSheetHeight,
                selectedDetent: $selectedDetent
            )
        }
    }
}

private struct AutoSizingSheet: View {
    @Binding var swatchHeight: CGFloat

    @Binding var measuredSheetHeight: CGFloat
    @Binding var selectedDetent: PresentationDetent

    var body: some View {
        VStack(spacing: 20) {
            Color.blue
                .frame(height: swatchHeight)

            Stepper(
                "Color height: \(Int(swatchHeight))",
                value: $swatchHeight,
                in: 100...500,
                step: 100
            )

            Divider()

            VStack(alignment: .leading, spacing: 8) {
                Text("Debug")
                    .font(.headline)

                Text("Measured content height: \(Int(measuredSheetHeight))")

                Text("Selected detent: .height(\(Int(measuredSheetHeight)))")
            }
            .font(.caption)
            .frame(maxWidth: .infinity, alignment: .leading)
        }
        .padding()
        .onGeometryChange(for: CGFloat.self) { proxy in
            proxy.size.height
        } action: { newHeight in
            let roundedHeight = newHeight.rounded(.up)

            guard abs(roundedHeight - measuredSheetHeight) > 0.5 else {
                return
            }

            measuredSheetHeight = roundedHeight
            selectedDetent = .height(roundedHeight)
        }
        .presentationDetents(
            [ .height(measuredSheetHeight)],
            selection: $selectedDetent
        )
    }
}
#Preview {
    ContentView()
}
Accepted Answer

This is a perfectly good pattern to have your sheet set to the custom detent of the height of the view it contains. This can get tricky with ScrollViews in sheets in which case you need to measure the size of the view inside the ScrollView since the ScrollView itself doesn't have a preferred size when it's height proposal is nil.

For slightly better performance you can use on geometry change instead:

import SwiftUI

@main struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

struct ContentView: View {
    @State private var showingSheet = false

    @State private var swatchHeight: CGFloat = 100

    @State private var measuredSheetHeight: CGFloat = 100
    @State private var selectedDetent: PresentationDetent = .height(100)

    var body: some View {
        Button("Show Sheet") {
            showingSheet = true
        }
        .sheet(isPresented: $showingSheet) {
            AutoSizingSheet(
                swatchHeight: $swatchHeight,
                measuredSheetHeight: $measuredSheetHeight,
                selectedDetent: $selectedDetent
            )
        }
    }
}

private struct AutoSizingSheet: View {
    @Binding var swatchHeight: CGFloat

    @Binding var measuredSheetHeight: CGFloat
    @Binding var selectedDetent: PresentationDetent

    var body: some View {
        VStack(spacing: 20) {
            Color.blue
                .frame(height: swatchHeight)

            Stepper(
                "Color height: \(Int(swatchHeight))",
                value: $swatchHeight,
                in: 100...500,
                step: 100
            )

            Divider()

            VStack(alignment: .leading, spacing: 8) {
                Text("Debug")
                    .font(.headline)

                Text("Measured content height: \(Int(measuredSheetHeight))")

                Text("Selected detent: .height(\(Int(measuredSheetHeight)))")
            }
            .font(.caption)
            .frame(maxWidth: .infinity, alignment: .leading)
        }
        .padding()
        .onGeometryChange(for: CGFloat.self) { proxy in
            proxy.size.height
        } action: { newHeight in
            let roundedHeight = newHeight.rounded(.up)

            guard abs(roundedHeight - measuredSheetHeight) > 0.5 else {
                return
            }

            measuredSheetHeight = roundedHeight
            selectedDetent = .height(roundedHeight)
        }
        .presentationDetents(
            [ .height(measuredSheetHeight)],
            selection: $selectedDetent
        )
    }
}
#Preview {
    ContentView()
}

There's not currently a native way to do this in the framework. Your snippet is the best workaround I'm aware of.

Please file a feedback report requesting a more ergonomic way to achieve this – that actually would be appreciated!

Fit Sheet height to view content
 
 
Q