Skip to content

Instantly share code, notes, and snippets.

@rl-pavel
Last active June 29, 2024 23:27
Show Gist options
  • Save rl-pavel/df7ba84e3d86e3ef53b978cbb8040640 to your computer and use it in GitHub Desktop.
Save rl-pavel/df7ba84e3d86e3ef53b978cbb8040640 to your computer and use it in GitHub Desktop.
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
)
)
}
}
/// 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`."
)
}
}
}
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)
}
}
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