Issue #834

In iOS 15, we can use UISheetPresentationController to show bottom sheet like native Maps app. But before that there’s no such built in bottom sheet in UIKit or SwiftUI.

We can start defining API for it. There are 3 ways to show overlay content in SwiftUI

  • fullScreenCover: this presents like a modal view controller bottom up
  • offset: initially hide our bottom sheet
  • overlay: show overlay full screen so we can have custom control. This is the approach I usually use.
extension View {
    func bottomSheet<Content: View>(
        isPresented: Binding<Bool>,
        @ViewBuilder content: @escaping () -> Content
    ) -> some View {
        self
            .overlay(
                Group {
                    if isPresented.wrappedValue {
                        BottomSheet(
                            isPresented: isPresented,
                            content: content
                        )
                    }
                }
            )
    }
}

so we can use like

struct ContentView: View {
    @State private var showsBottomSheet: Bool = false

    var body: some View {
        VStack {
            Button(action: { showsBottomSheet = true }) {
                Text("Show")
            }
        }
        .bottomSheet(isPresented: $showsBottomSheet) {
            Text("Sheet content")
        }
    }
}

The bottom sheet contains a handle followed by its content. We use @ViewBuilder to let user provide custom content.

Here I use normal State instead of @GestureState in order to present our bottom sheet content up initially. I also use a bottom patch to make an elastic effect when our sheet is dragged up. Note also that we use .animation with value parameter to let animation kicks it when the value changes

.animation(.interactiveSpring(), value: isPresented)
import SwiftUI

private struct Distances {
    static let hidden: CGFloat = 500
    static let maxUp: CGFloat = -100
    static let dismiss: CGFloat = 200
}

struct BottomSheet<Content: View>: View {
    @Binding var isPresented: Bool
    @ViewBuilder let content: Content

    @State private var translation = Distances.hidden

    var body: some View {
        ZStack {
            Color.gray
                .opacity(0.5)

            VStack {
                Spacer()
                contentView
                    .offset(y: translation)
                    .animation(.interactiveSpring(), value: isPresented)
                    .animation(.interactiveSpring(), value: translation)
                    .gesture(
                        DragGesture()
                            .onChanged { value in
                                guard translation > Distances.maxUp else { return }
                                translation = value.translation.height
                            }
                            .onEnded { value in
                                if value.translation.height > Distances.dismiss {
                                    translation = Distances.hidden
                                    isPresented = false
                                } else {
                                    translation = 0
                                }
                            }
                    )
            }
            .background(
                VStack {
                    Spacer()
                    Color.bottomSheetBackground
                        .frame(height: abs(Distances.maxUp) * 2)
                }
            )
        }
        .ignoresSafeArea()
        .onAppear {
            withAnimation {
                translation = 0
            }
        }
    }

    private var contentView: some View {
        VStack(spacing: 0) {
            handle
                .padding(.top, 6)
            content
                .padding(20)
                .padding(.bottom, 30)
        }
        .frame(maxWidth: .infinity)
        .background(Color.bottomSheetBackground)
        .cornerRadius(24, corners: [.topLeft, .topRight])
        .shadow(color: Color.dropShadow, radius: 2, x: 0, y: -2)
    }

    private var handle: some View {
        RoundedRectangle(cornerRadius: 3)
            .fill(Color.gray)
            .frame(width: 48, height: 5)
    }
}

The result looks like this

https://user-images.githubusercontent.com/2284279/128351510-432491c7-11a1-408a-b374-1d147ba3d5b5.mp4

Read more