Skip to content

Instantly share code, notes, and snippets.

@cwalo
Last active December 22, 2022 18:55
Show Gist options
  • Save cwalo/1398fc690b27988612013df92da658c5 to your computer and use it in GitHub Desktop.
Save cwalo/1398fc690b27988612013df92da658c5 to your computer and use it in GitHub Desktop.
//
// BottomSheetView.swift
// field
//
// Created by Corey Walo on 12/21/22.
//
import SwiftUI
fileprivate enum Constants {
static let radius: CGFloat = 16
static let cornerRadius: CGFloat = 24
static let indicatorHeight: CGFloat = 6
static let indicatorWidth: CGFloat = 44
static let snapThreshold: CGFloat = 50
static let padding: CGFloat = 8
}
enum SheetDetent: Equatable {
case ideal
case full
case fraction(CGFloat)
case height(CGFloat)
public static func == (lhs: SheetDetent, rhs: SheetDetent) -> Bool {
switch (lhs, rhs) {
case (.ideal, .ideal), (.full, .full):
return true
case (.fraction(let v1), .fraction(let v2)):
return v1 == v2
case (.height(let v1), .height(let v2)):
return v1 == v2
case (_, _):
return false
}
}
}
/// https://swiftwithmajid.com/2019/12/11/building-bottom-sheet-in-swiftui/
struct BottomSheet<Content: View, Header: View>: View {
@Binding
var detents: [SheetDetent]
@Binding
var selectedDetent: SheetDetent
/// Floating content above the sheet
private let headerContent: Header
/// The main content
private let content: Content
@GestureState
private var translation: CGFloat = 0
@State
var contentSize: CGSize = .zero
private var indicator: some View {
RoundedRectangle(cornerRadius: Constants.radius)
.fill(Color.secondary)
.frame(width: Constants.indicatorWidth, height: Constants.indicatorHeight)
}
private var dragHeader: some View {
HStack {
Spacer()
if detents.count > 1 {
self.indicator
.padding(.top, Constants.padding)
.padding(.bottom, Constants.padding)
}
Spacer()
}
.contentShape(Rectangle()) // full width drag box
}
init(detents: Binding<[SheetDetent]>, selectedDetent: Binding<SheetDetent>, @ViewBuilder headerContent: () -> Header, @ViewBuilder content: () -> Content) {
self._detents = detents
self._selectedDetent = selectedDetent
self.headerContent = headerContent()
self.content = content()
}
var body: some View {
GeometryReader { geometry in
VStack(spacing: 0) {
// MARK: Header and Gesture
self.dragHeader
.gesture(
DragGesture().updating(self.$translation) { value, state, _ in
if detents.count == 1 {
return
}
let detentHeights = detents.map { height(for: $0, in: geometry) }.sorted(by: <)
let minHeight = detentHeights.first!
let maxHeight = detentHeights.last!
let currentHeight = height(for: selectedDetent, in: geometry)
let proposedHeight = currentHeight + -(value.translation.height)
let withinThreshold = abs(currentHeight - proposedHeight) < Constants.snapThreshold
if (proposedHeight >= minHeight && proposedHeight <= maxHeight) || withinThreshold {
state = value.translation.height
}
}.onEnded { value in
let translationY = value.translation.height
guard let indexSelected = detents.firstIndex(where: { $0 == selectedDetent }) else {
return
}
let currentHeight = height(for: selectedDetent, in: geometry)
let proposedHeight = currentHeight + -(value.translation.height)
let withinThreshold = abs(currentHeight - proposedHeight) < Constants.snapThreshold
if withinThreshold { return }
// if trying to expand and can expand
if translationY < 0 && indexSelected < detents.count - 1 {
let next = detents[detents.index(after: indexSelected)]
let nextHeight = height(for: next, in: geometry)
if nextHeight > currentHeight {
selectedDetent = next
}
// if trying to shrink and can shrink
} else if translationY > 0 && indexSelected > 0 {
let prev = detents[detents.index(before: indexSelected)]
let prevHeight = height(for: prev, in: geometry)
if prevHeight < currentHeight {
selectedDetent = prev
}
}
}
)
// this spacer allows the sheet to grow beyond its contents
// while ensuring content remains bottom aligned
Spacer()
// MARK: Content
self.content
.layoutPriority(1)
.onSizeChange { size in
// this makes animations symmentric when the content changes
withAnimation(.easeOut(duration: 0.2)) {
self.contentSize = size
}
}
}
.background(.ultraThickMaterial)
.padding(.bottom, Constants.cornerRadius) // offset before rounding corners (we only want to see the rounded tops)
.cornerRadius(Constants.cornerRadius) // round corners
.padding(.bottom, -(Constants.cornerRadius + 10)) // 10 is the magic number for offsetting to exactly the bottom of the screen after rounding the corners
.shadow(radius: 4)
// TODO: consider using this behavior to toggle between record / play
.frame(height: max(height(for: selectedDetent, in: geometry) + -(translation), 0), alignment: .bottom) // sets the sheet's height. removing alignment: .bottom makes the sheet spring, but also looks weird
.safeAreaInset(edge: .top) { // putting this before the next modifier allows the header content to animate with the height of the content
// hide header if sheet takes up more than half the screen
if height(for: selectedDetent, in: geometry) < geometry.size.height / 2 {
let offset = translation > 0 ? -translation : 0
self.headerContent
.padding(.trailing, Constants.cornerRadius / 2)
.frame(maxHeight: .infinity, alignment: .bottom)
.offset(y: offset) // seems to bypass some automatic stuff to make the header track the sheet better
}
}
.frame(maxHeight: .infinity, alignment: .bottom) // wraps in a frame to bottom align content
.animation(.interactiveSpring(), value: translation)
}
}
private func maxHeight(in geo: GeometryProxy) -> CGFloat {
detents.map { height(for: $0, in: geo) }.sorted(by: >).first ?? geo.size.height
}
private func height(for detent: SheetDetent, in geo: GeometryProxy) -> CGFloat {
var height = detents.count > 1 ? Constants.indicatorHeight + Spacing.two : 0 // indicator space
switch detent {
case .ideal:
height += contentSize.height
case .fraction(let fraction):
height += geo.size.height * fraction
case .height(let explicitHeight):
height += explicitHeight
case .full:
height += geo.size.height * 0.8//geo.size.height - height
}
return height
}
}
struct BottomSheet_Previews: PreviewProvider {
@State
static var selected: SheetDetent = .ideal
static var previews: some View {
BottomSheet(detents: .constant([.ideal]), selectedDetent: $selected) {
Color.blue
.frame(minHeight: 50)
} content: {
Color.orange
.frame(minHeight: 200)
}.edgesIgnoringSafeArea(.all)
}
}
struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
extension View {
func onSizeChange(_ perform: @escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geo in
Color.clear
.preference(key: SizePreferenceKey.self, value: geo.size)
}
)
.onPreferenceChange(SizePreferenceKey.self, perform: perform)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment