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