Search code examples
iosswiftswiftuiswiftui-sheet

SwiftUI: Make sheet show content header or complete content


I'd like to have a SwiftUI sheet that either shows the header or complete content. Requiring iOS 16 is ok.

I already get the correct two measured heights a presentationDetents

import Foundation
import SwiftUI

struct ContentView: View {
    @State private var showSheet = false
    @State private var headerSize: CGSize = .zero
    @State private var overallSize: CGSize = .zero
    
    var body: some View {
        Button("View sheet") {
            showSheet = true
        }
        .sheet(isPresented: $showSheet) {
            Group {
                VStack(alignment: .leading) {
                    Text("Header")
                        .background(
                            GeometryReader { geometryProxy in
                                Color.clear
                                    .preference(key: HeaderSizePreferenceKey.self, value: geometryProxy.size)
                            }
                        )
                    Text("")
                    Text("Some very long text ...")
                    Text("Some very long text ...")
                    Text("Some very long text ...")
                    Text("Some very long text ...")
                    Text("Some very long text ...")
                    Text("Some very long text ...")
                    Text("Some very long text ...")
                    Text("Some very long text ...")
                } // VStack
                .padding()
                .background(
                    //measure without spacer
                    GeometryReader { geometryProxy in
                        Color.clear
                            .preference(key: SizePreferenceKey.self, value: geometryProxy.size)
                    }
                )
                Spacer()
            } // Group
            .onPreferenceChange(SizePreferenceKey.self) { newSize in
                overallSize.height = newSize.height
            }
            .onPreferenceChange(HeaderSizePreferenceKey.self) { newSize in
                headerSize.height = newSize.height
            }
            .presentationDetents([
                .height(headerSize.height),
                .height(overallSize.height)
            ]
            )
            
        } // sheet content
    }
}

struct HeaderSizePreferenceKey: PreferenceKey {
    static var defaultValue: CGSize = .zero
    static func reduce(value: inout CGSize, nextValue: () -> CGSize) { value = nextValue() }
}

struct SizePreferenceKey: PreferenceKey {
    static var defaultValue: CGSize = .zero
    static func reduce(value: inout CGSize, nextValue: () -> CGSize) { value = nextValue() }
}

struct MySheet_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

This code is based on ideas from Make sheet the exact size of the content inside

It almost works. This is the complete content size:

Complete content size

This is the header content size:

Header content size

What can I do that the last case shows the top of the content ("Header") instead of the center of the content?


Solution

  • So you want this:

    A button titled “View sheet”. I tap the button and a sheet slides up from the bottom, just tall enough to show the title “Header”. I drag the sheet up and it shows a body several lines of text below the title, and is exactly big enough to fit the title and the body. I drag the sheet back down and it stops when it just fits the title again. I tap the background around and the sheet is dismissed.

    The sheet content's intrinsic height includes both the header and the body (the “Some very long text” lines). The problem to solve: when the sheet is at the header detent, the content doesn't fit in the height, and draws itself center-aligned. One way to solve this is to put the content inside a container that draws its children top-aligned.

    I tried ZStack(alignment: .top), and I tried adding a .frame(alignment: .top) modifier, but neither worked. I discovered that GeometryReader does what we need.

    I also restructured the sheet content so the header's height is measured including vertical padding.

    struct ContentView: View {
        @State private var showSheet = false
    
        var body: some View {
            Button("View sheet") {
                showSheet = true
            }
            .sheet(isPresented: $showSheet) {
                SheetContent()
            }
        }
    }
    
    struct SheetContent: View {
        @State private var heights = HeightRecord()
    
        var body: some View {
            // Outermost GeometryReader exists only to draw its content top-aligned instead of center-aligned.
            GeometryReader { _ in
                // spacing: 0 here so only the standard padding separates the
                // header from the body.
                VStack(alignment: .leading, spacing: 0) {
                    Text("Header")
                        .padding([.top, .bottom])
                        .recordHeight(of: \.header)
    
                    // Standard spacing here for the body's subviews.
                    VStack(alignment: .leading) {
                        Text("Some very long text ...")
                        Text("Some very long text ...")
                        Text("Some very long text ...")
                        Text("Some very long text ...")
                        Text("Some very long text ...")
                        Text("Some very long text ...")
                        Text("Some very long text ...")
                        Text("Some very long text ...")
                    }
                }
                // No top padding here because the header has top padding.
                .padding([.leading, .trailing, .bottom])
                .recordHeight(of: \.all)
            }
            .onPreferenceChange(HeightRecord.self) {
                heights = $0
            }
            .presentationDetents([
                .height(heights.header ?? 10),
                .height(heights.all ?? 10)
            ])
        }
    }
    
    struct HeightRecord: Equatable {
        var header: CGFloat? = nil
        var all: CGFloat? = nil
    }
    
    extension HeightRecord: PreferenceKey {
        static var defaultValue = Self()
        static func reduce(value: inout Self, nextValue: () -> Self) {
            value.header = nextValue().header ?? value.header
            value.all = nextValue().all ?? value.all
        }
    }
    
    extension View {
        func recordHeight(of keyPath: WritableKeyPath<HeightRecord, CGFloat?>) -> some View {
            return self.background {
                GeometryReader { g in
                    var record = HeightRecord()
                    let _ = record[keyPath: keyPath] = g.size.height
                    Color.clear
                        .preference(
                            key: HeightRecord.self,
                            value: record)
                }
            }
        }
    }