Last active
June 29, 2024 23:27
-
-
Save rl-pavel/df7ba84e3d86e3ef53b978cbb8040640 to your computer and use it in GitHub Desktop.
This file contains 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
public struct TTDrawerModifier<DrawerContent: View, OverlayContent: View, Detent: TTDrawerDetent> { | |
struct AnimationValue: Equatable { | |
let isDragging: Bool | |
let drawerState: TTDrawerState<Detent> | |
init(view: TTDrawerModifier<DrawerContent, OverlayContent, Detent>) { | |
self.isDragging = view.dragHeight != nil | |
self.drawerState = view.state | |
} | |
} | |
@Binding var state: TTDrawerState<Detent> | |
var drawerContent: DrawerContent | |
var overlay: (content: OverlayContent, alignment: Alignment) | |
@GestureState private var dragHeight: CGFloat? | |
@State private var finalDrag: DragGesture.Value? | |
@State private var animation: Animation? | |
private var animationValue: AnimationValue { | |
AnimationValue(view: self) | |
} | |
public init( | |
state: Binding<TTDrawerState<Detent>>, | |
@ViewBuilder drawerContent: @escaping () -> DrawerContent, | |
overlayAlignment: Alignment = .top, | |
@ViewBuilder overlayContent: @escaping () -> OverlayContent = { EmptyView() } | |
) { | |
_state = state | |
self.drawerContent = drawerContent() | |
self.overlay = (overlayContent(), overlayAlignment) | |
} | |
} | |
extension TTDrawerModifier: ViewModifier { | |
public func body(content: Content) -> some View { | |
content | |
.overlay { | |
Color.black | |
.opacity(state.isDimmingBackground ? 0.3 : 0) | |
.ignoresSafeArea() | |
} | |
.overlay(alignment: .bottom) { | |
GeometryReader { geometry in | |
let parentHeight = geometry.size.height | |
let dragAdjustedHeight = state.calculateHeight( | |
dragTranslation: dragHeight ?? 0, | |
parentHeight: parentHeight | |
) | |
return VStack(spacing: 0) { | |
let isDraggable = state.detentOptions.count > 1 | |
handleView(parentSize: geometry.size) | |
.opacity(isDraggable ? 1 : 0) | |
.frame(size: isDraggable ? nil : .zero) | |
drawerContent | |
.padding(.top, 12) | |
} | |
.frame(height: dragAdjustedHeight, alignment: .top) | |
.background( | |
RoundedCorners(radius: 16, corners: [.topLeft, .topRight]) | |
.fill(Color.white) | |
.shadow(radius: 4) | |
.ignoresSafeArea(edges: .bottom) | |
) | |
.overlay(alignment: overlay.alignment) { overlay.content } | |
.frame(size: geometry.size, alignment: .bottom) | |
// Don't animate before appearance. `task` runs a frame later than `onAppear`. | |
.task { animation = .bouncy(duration: 0.4) } | |
.tt_onAppearOrChange(of: state.detent) { _ in | |
state.update(predictedEndTranslation: nil, parentHeight: parentHeight) | |
} | |
.onChange(of: finalDrag) { finalDrag in | |
guard let finalDrag else { return } | |
state.update( | |
predictedEndTranslation: -finalDrag.predictedEndTranslation.height, | |
parentHeight: parentHeight | |
) | |
} | |
.overlay(alignment: .bottom) { | |
_debugOverlay(width: geometry.size.width, parentHeight: parentHeight) | |
} | |
} | |
} | |
.animation(animation, value: animationValue) | |
} | |
} | |
private extension TTDrawerModifier { | |
func handleView(parentSize: CGSize) -> some View { | |
Capsule() | |
.fill(Color.gray) | |
.frame(width: 40, height: 5) | |
.padding(.top, 12) | |
.padding(.bottom, 4) | |
.frame(width: parentSize.width) | |
.background(alignment: .top) { | |
Color.white.opacity(0.01) | |
.frame(height: 64) // Extend the actual draggable area a little. | |
.gesture( | |
DragGesture(coordinateSpace: .global) | |
.updating($dragHeight) { drag, state, transaction in | |
state = -drag.translation.height | |
} | |
.onEnded { finalDrag in | |
self.finalDrag = finalDrag | |
} | |
) | |
} | |
} | |
func _debugOverlay(width: CGFloat, parentHeight: CGFloat) -> some View { | |
ZStack { | |
let midX = width / 2 | |
ForEach(state.boundaries.indices, id: \.self) { boundaryIndex in | |
let boundary = state.boundaries[boundaryIndex] | |
let detent = boundary.detent | |
let minY = max(boundary.min, 0) | |
let midY = boundary.mid | |
let maxY = min(boundary.max, parentHeight) | |
_debugLine(text: nil, color: .blue) | |
.position(x: midX, y: minY) | |
_debugLine(text: "\(detent) - [\(String(format: "%.1f", midY))]") | |
.position(x: midX, y: midY) | |
_debugLine(text: nil, color: .blue) | |
.position(x: midX, y: maxY) | |
} | |
} | |
} | |
func _debugLine(text: String?, color: Color = .red) -> some View { | |
color.frame(height: 4) | |
.opacity(0.5) | |
.overlay { | |
if let text { | |
Text(text) | |
.bold() | |
.shadow(color: .white, radius: 1) | |
.padding(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) | |
.background(Rectangle().fill(color).brightness(0.3)) | |
} | |
} | |
} | |
} | |
public extension View { | |
func tt_drawerOverlay<Drawer: View, Detents: TTDrawerDetent>( | |
state: Binding<TTDrawerState<Detents>>, | |
@ViewBuilder _ drawerContent: @escaping () -> Drawer | |
) -> some View { | |
modifier(TTDrawerModifier(state: state, drawerContent: drawerContent)) | |
} | |
func tt_drawerOverlay<Drawer: View, Overlay: View, Detents: TTDrawerDetent>( | |
state: Binding<TTDrawerState<Detents>>, | |
@ViewBuilder drawerContent: @escaping () -> Drawer, | |
overlayAlignment: Alignment = .top, | |
@ViewBuilder overlayContent: @escaping () -> Overlay | |
) -> some View { | |
modifier( | |
TTDrawerModifier( | |
state: state, | |
drawerContent: drawerContent, | |
overlayAlignment: overlayAlignment, | |
overlayContent: overlayContent | |
) | |
) | |
} | |
} |
This file contains 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
/// A type used for drawer sheet detents, defined by the fraction of the parent view's height it should occupy. | |
public protocol TTDrawerDetent: Equatable, Comparable { | |
var fraction: CGFloat { get } | |
} | |
public extension TTDrawerDetent where Self: RawRepresentable, RawValue == CGFloat { | |
var fraction: CGFloat { rawValue } | |
} | |
public extension TTDrawerDetent { | |
static func < (lhs: Self, rhs: Self) -> Bool { | |
lhs.fraction < rhs.fraction | |
} | |
} | |
public struct TTDrawerState<Detent: TTDrawerDetent>: Equatable { | |
struct DetentBoundary: Equatable { | |
let detent: Detent | |
let min, mid, max: CGFloat | |
init( | |
previousBoundary: DetentBoundary?, | |
detent: Detent, | |
nextDetent: Detent?, | |
parentHeight: CGFloat | |
) { | |
self.detent = detent | |
min = previousBoundary?.max ?? -.infinity | |
let currentMid = parentHeight - (parentHeight * detent.fraction) | |
max = nextDetent.map { | |
let nextDetentMid = parentHeight - (parentHeight * $0.fraction) | |
return (currentMid + nextDetentMid) / 2 | |
} ?? .infinity | |
self.mid = currentMid | |
} | |
} | |
/// Currently selected detent. | |
public internal(set) var detent: Detent { | |
didSet { currentHeight = calculateHeight(dragTranslation: 0, parentHeight: lastParentHeight) } | |
} | |
/// Current height of the drawer. | |
public internal(set) var currentHeight: CGFloat = 0 | |
public var isDimmingBackground: Bool { | |
guard let backgroundDimmingDetent else { return false } | |
return detent.fraction >= backgroundDimmingDetent.fraction | |
} | |
public internal(set) var detentOptions: [Detent] | |
public internal(set) var backgroundDimmingDetent: Detent? | |
var lastParentHeight: CGFloat = 0 | |
var boundaries: [DetentBoundary] = [] | |
// MARK: - Init | |
public init(detentOptions: [Detent], detent: Detent, backgroundDimmingDetent: Detent? = nil) { | |
Self.validateState( | |
detentOptions: detentOptions, | |
detent: detent, | |
backgroundDimmingDetent: backgroundDimmingDetent | |
) | |
self.detentOptions = detentOptions.sorted(by: { $0 > $1 }) | |
self.detent = detent | |
self.backgroundDimmingDetent = backgroundDimmingDetent | |
} | |
/// Selects the provided detent, which will animate the sheet to match it. | |
public mutating func select(detent: Detent) { | |
Self.validateState( | |
detentOptions: detentOptions, | |
detent: detent, | |
backgroundDimmingDetent: backgroundDimmingDetent | |
) | |
self.detent = detent | |
} | |
/// Update the available detent options, current detent, and the detent to dim the drawer background. | |
public mutating func update( | |
detentOptions: [Detent], | |
detent: Detent, | |
backgroundDimmingDetent: Detent? = nil | |
) { | |
Self.validateState( | |
detentOptions: detentOptions, | |
detent: detent, | |
backgroundDimmingDetent: backgroundDimmingDetent | |
) | |
self.detentOptions = detentOptions.sorted { $0 > $1} | |
self.detent = detent | |
self.backgroundDimmingDetent = backgroundDimmingDetent | |
self.boundaries.removeAll() // force re-calculate boundaries | |
} | |
} | |
// MARK: - Helpers | |
extension TTDrawerState { | |
func calculateHeight(dragTranslation: CGFloat, parentHeight: CGFloat) -> CGFloat { | |
let minHeight = parentHeight * (detentOptions.min()?.fraction ?? 0) | |
let maxHeight = parentHeight * (detentOptions.max()?.fraction ?? 1) | |
var result = parentHeight * detent.fraction + dragTranslation | |
// Swipes outside of the min/max thresholds will slow down to 15%. | |
if result > maxHeight { | |
let difference = result - maxHeight | |
result -= difference * 0.85 | |
} else if result < minHeight { | |
let difference = minHeight - result | |
result += difference * 0.85 | |
} | |
return result | |
} | |
mutating func update(predictedEndTranslation: CGFloat?, velocity: CGFloat = 0, parentHeight: CGFloat) { | |
let drawerHeight = calculateHeight(dragTranslation: predictedEndTranslation ?? 0, parentHeight: parentHeight) | |
// If there is only 1 detent, the drawer is fixed, just update the height. | |
guard detentOptions.count > 1 else { | |
currentHeight = drawerHeight | |
return | |
} | |
recalculateBoundariesIfNeeded(parentHeight: parentHeight) | |
let endTarget = parentHeight - drawerHeight | |
guard let boundary = boundaries.first(where: { endTarget >= $0.min && endTarget <= $0.max }) else { | |
return | |
} | |
detent = boundary.detent | |
} | |
/// Calculates detent boundaries if they haven't been yet, or if the parent view has changed. | |
mutating func recalculateBoundariesIfNeeded(parentHeight: CGFloat) { | |
guard boundaries.isEmpty || lastParentHeight != parentHeight else { return } | |
boundaries.removeAll() | |
lastParentHeight = parentHeight | |
var previousBoundary: DetentBoundary? | |
for index in detentOptions.indices { | |
let boundary = DetentBoundary( | |
previousBoundary: previousBoundary, | |
detent: detentOptions[index], | |
nextDetent: detentOptions[safeIndex: index + 1], | |
parentHeight: parentHeight | |
) | |
previousBoundary = boundary | |
boundaries.append(boundary) | |
} | |
} | |
static func validateState(detentOptions: [Detent], detent: Detent, backgroundDimmingDetent: Detent?) { | |
assert(!detentOptions.isEmpty, "There must be at least one detent.") | |
assert( | |
detentOptions.contains(detent), | |
"Detent options should have the selected `detent`." | |
) | |
if let backgroundDimmingDetent { | |
assert( | |
detentOptions.contains(backgroundDimmingDetent), | |
"Detent options should have the `backgroundDimmingDetent`." | |
) | |
} | |
} | |
} |
This file contains 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
fileprivate struct RoundedCorners: Shape { | |
var radius: CGFloat = .infinity | |
var corners: UIRectCorner = .allCorners | |
func path(in rect: CGRect) -> Path { | |
let path = UIBezierPath( | |
roundedRect: rect, | |
byRoundingCorners: corners, | |
cornerRadii: CGSize(width: radius, height: radius) | |
) | |
return Path(path.cgPath) | |
} | |
} | |
fileprivate extension Collection { | |
/// Returns the element at the specified index iff it is within bounds, otherwise nil. | |
subscript(safeIndex index: Index) -> Iterator.Element? { | |
indices.contains(index) ? self[index] : nil | |
} | |
} | |
fileprivate extension View { | |
/// Positions this view within an invisible frame with the specified size. | |
func frame(size: CGSize?, alignment: Alignment = .center) -> some View { | |
frame(width: size?.width, height: size?.height, alignment: alignment) | |
} | |
/// Positions this view within an invisible frame having the specified size constraints. | |
func frame( | |
minSize: CGSize? = nil, | |
idealSize: CGSize? = nil, | |
maxSize: CGSize? = nil, | |
alignment: Alignment = .center | |
) -> some View { | |
frame( | |
minWidth: minSize?.width, | |
idealWidth: idealSize?.width, | |
maxWidth: maxSize?.width, | |
minHeight: minSize?.height, | |
idealHeight: idealSize?.height, | |
maxHeight: maxSize?.height, | |
alignment: alignment | |
) | |
} | |
func tt_onAppearOrChange<Value: Equatable>( | |
of value: Value, | |
handleChange: @escaping (Value) -> Void | |
) -> some View { | |
self | |
.onAppear { handleChange(value) } | |
.onChange(of: value, perform: handleChange) | |
} | |
} |
This file contains 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
struct DrawerPreview: View { | |
enum Detents: CGFloat, TTDrawerDetent, CaseIterable { | |
case large = 1 | |
case medium = 0.5 | |
case small = 0.3 | |
} | |
let colors = [Color.red, .blue, .yellow, .purple, .green] | |
@State var drawerState = TTDrawerState<Detents>( | |
detentOptions: Detents.allCases, | |
detent: .medium, | |
backgroundDimmingDetent: .large | |
) | |
var body: some View { | |
Color.blue.opacity(0.2) | |
.ignoresSafeArea() | |
.tt_drawerOverlay( | |
state: $drawerState, | |
drawerContent: { | |
Text("Hello, world!") | |
.padding() | |
.background { | |
Capsule().fill(colors.randomElement()!) | |
} | |
}, | |
overlayAlignment: .topTrailing, | |
overlayContent: { | |
Text("hi") | |
.padding() | |
.background { | |
Circle().fill(colors.randomElement()!) | |
} | |
.alignmentGuide(.top) { $0[.bottom] + 12 } | |
.padding(.trailing, 12) | |
.opacity(drawerState.detent == .large ? 0 : 1) | |
} | |
) | |
} | |
} | |
#Preview { | |
DrawerPreview() | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment