Last active
December 22, 2022 18:55
-
-
Save cwalo/1398fc690b27988612013df92da658c5 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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