4

There are a lot of examples of bottom sheet out there for SwiftUI, however they all specify some type of maximum height the sheet can grow to using a GeometryReader. What I would like is to create a bottom sheet that becomes only as tall as the content within it. I've come up with the solution below using preference keys, but there must be a better solution. Perhaps using some type of dynamic scrollView is the solution?

struct ContentView: View{
    @State private var offset: CGFloat = 0
    @State private var size: CGSize = .zero

    var body: some View{
        ZStack(alignment:.bottom){
            VStack{
                Button(offset == 0 ? "Hide" : "Show"){
                    withAnimation(.linear(duration: 0.2)){
                        if offset == 0{
                            offset = size.height
                        } else {
                            offset = 0
                        }
                    }
                }
                .animation(nil)
                .padding()
                .font(.largeTitle)
                Spacer()
            }
            BottomView(offset: $offset, size: $size)
        }.edgesIgnoringSafeArea(.all)
    }
}

struct BottomView: View{
    @Binding var offset: CGFloat
    @Binding var size: CGSize

    var body: some View{
        VStack(spacing: 0){
            ForEach(0..<5){ value in
                Rectangle()
                    .fill(value.isMultiple(of: 2) ? Color.blue : Color.red)
                    .frame(height: 100)
            }
        }
        .offset(x: 0, y: offset)
        .getSize{
            size = $0
            offset = $0.height
        }
    }
}

struct SizePreferenceKey: PreferenceKey {
    struct SizePreferenceData {
        let bounds: Anchor<CGRect>
    }

    static var defaultValue: [SizePreferenceData] = []

    static func reduce(value: inout [SizePreferenceData], nextValue: () -> [SizePreferenceData]) {
        value.append(contentsOf: nextValue())
    }
}

struct SizePreferenceModifier: ViewModifier {
    let onAppear: (CGSize)->Void

    func body(content: Content) -> some View {
        content
            .anchorPreference(key: SizePreferenceKey.self, value: .bounds, transform: { [SizePreferenceKey.SizePreferenceData( bounds: $0)] })
            .backgroundPreferenceValue(SizePreferenceKey.self) { preferences in
                GeometryReader { geo in
                    Color.clear
                        .onAppear{
                            let size = CGSize(width: geo.size.width, height: geo.size.height)
                            onAppear(size)
                        }
                }
            }
    }
}

extension View{
    func getSize(_ onAppear: @escaping (CGSize)->Void) -> some View {
        return self.modifier(SizePreferenceModifier(onAppear: onAppear))
    }
}
Richard Witherspoon
  • 4,082
  • 3
  • 17
  • 33
  • I'm now using [this](https://fivestars.blog/swiftui/swiftui-share-layout-information.html) approach, which is a little cleaner but it still uses preference keys. – Richard Witherspoon Aug 18 '20 at 18:26

2 Answers2

8

Talk about over engineering the problem. All you have to do is specify a height of 0 if you want the sheet to be hidden, and not specify a height when it's shown. Additionally set the frame alignment to be top.

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

    var body: some View{
        ZStack(alignment: .bottom){
            Color.blue
                .overlay(
                    Text("Is hidden : \(hide.description)").foregroundColor(.white)
                        .padding(.bottom, 200)
                )
                .onTapGesture{
                    hide.toggle()
                }
            
            VStack(spacing: 0){
                ForEach(0..<5){ index in
                    Rectangle()
                        .foregroundColor(index.isMultiple(of: 2) ? Color.gray : .orange)
                        .frame(height: 50)
                        .layoutPriority(2)
                }
            }
            .layoutPriority(1)
            .frame(height: hide ? 0 : nil, alignment: .top)
            .animation(.linear(duration: 0.2))
        }.edgesIgnoringSafeArea(.all)
    }
}
Richard Witherspoon
  • 4,082
  • 3
  • 17
  • 33
2

My approach is SwiftUI Sheet based solution feel free to check the gist

you just need to add the modifier to the view and let iOS do the rest for you, no need to re-do the math ;) Plus you will have the sheet native behavior (swipe to dismiss) and i added "tap elsewhere" to dismiss.

struct ContentView: View {
@State var activeSheet: Bool = false
@State var activeBottomSheet: Bool = false

var body: some View {
    VStack(spacing: 16){
                Button {
                    activeSheet.toggle()
                } label: {
                    HStack {
                        Text("Activate Normal sheet")
                            .padding()
                    }.background(
                        RoundedRectangle(cornerRadius: 5)
                            .stroke(lineWidth: 2)
                            .foregroundColor(.yellow)
                    )
                }

                Button {
                    activeBottomSheet.toggle()
                } label: {
                    HStack {
                        Text("Activate Bottom sheet")
                            .padding()
                    }.background(
                        RoundedRectangle(cornerRadius: 5)
                            .stroke(lineWidth: 2)
                            .foregroundColor(.yellow)
                    )
                }
            }
    .sheet(isPresented: $activeSheet) {
        // Regular sheet
        sheetView
    }
    .sheet(isPresented: $activeBottomSheet) {
        // Responsive sheet
        sheetView
            .asResponsiveSheet()
    }
}

var sheetView: some View {
    VStack(spacing: 0){
        ForEach(0..<5){ index in
            Rectangle()
                .foregroundColor(index.isMultiple(of: 2) ? Color.gray : .orange)
                .frame(height: 50)
        }
    }
}

iPhone: enter image description here iPad : enter image description here