From 00b9169344af8687f8c7b9e96c8ff9c4a1d4279e Mon Sep 17 00:00:00 2001 From: Gary Tokman Date: Sat, 8 May 2021 18:43:27 -0400 Subject: [PATCH] feat: add new bottom sheet with animatable opacity, corner radius, and offset --- .../SwiftUI/BottomSheet/BottomSheet.swift | 154 ++++++++++++++++++ .../SwiftUI/Keyboard/KeyboardInfo.swift | 2 +- Sources/ExtensionKit/SwiftUI/View.swift | 26 +++ 3 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 Sources/ExtensionKit/SwiftUI/BottomSheet/BottomSheet.swift diff --git a/Sources/ExtensionKit/SwiftUI/BottomSheet/BottomSheet.swift b/Sources/ExtensionKit/SwiftUI/BottomSheet/BottomSheet.swift new file mode 100644 index 0000000..a00234d --- /dev/null +++ b/Sources/ExtensionKit/SwiftUI/BottomSheet/BottomSheet.swift @@ -0,0 +1,154 @@ +import SwiftUI + +/// Bottom sheet modal height +public enum Height: Equatable { + + /// Third of Screen size + case low + /// Half of Screen size + case mid + /// Full Screen size + case full + /// Custom screen size + case custom(CGFloat) + + var value: CGFloat { + switch self { + case .low: return Screen.height * 0.33 + case .mid: return Screen.height * 0.55 + case .full: return Screen.height * 0.95 + case let .custom(custom): return custom + } + } +} + +/// Buttom sheet view with animatable offset, background opacity, modal corner radius +struct BottomSheet: View { + + private var cornerRadiusUpperbound: CGFloat = 10 + private let backgroundOpacityUpperbound: CGFloat = 0.5 + private let dragIndicatorVerticalPadding: CGFloat = 15 + + private var modalHeight = Height.mid + private let animation: Animation + private let indicatorHidden: Bool + + @State private var modalCornerRadius: CGFloat = 10 + @State private var backgroundOpacity: Double = 0.5 + @State private var offset = CGSize.zero + + @Binding var isPresented: Bool + + let content: () -> Content + + init( + isPresented: Binding, + height: Height = .mid, + animation: Animation = .easeInOut(duration: 0.3), + thumbHidden: Bool = false, + @ViewBuilder content: @escaping () -> Content + ) { + self._isPresented = isPresented + self.modalHeight = height + self.animation = animation + self.content = content + self.indicatorHidden = thumbHidden + } + + // MARK: - Computed vars + + private var yRange: ClosedRange { + return 0...modalHeight.value + } + + var newOpacity: Double { + let y = offset.height + let lower = yRange.lowerBound + let upper = yRange.upperBound + let newOpacity = (y - lower) / ((upper - lower) / backgroundOpacityUpperbound) + return Double(backgroundOpacityUpperbound - newOpacity) + } + + var newCornerRadius: CGFloat { + let y = offset.height + let lower = yRange.lowerBound + let upper = yRange.upperBound + let newCorner = (y - lower) / ((upper - lower) / cornerRadiusUpperbound) + return cornerRadiusUpperbound - newCorner + } + + // MARK - Views + + var body: some View { + ZStack(alignment: .bottom) { + if isPresented { + background + modal + } + } + .edgesIgnoringSafeArea(.all) + } + + private var background: some View { + Color.black + .fillParent() + .opacity(backgroundOpacity) + .animation(animation) + .onTapGesture { isPresented.toggle() } + } + + private var modal: some View { + VStack { + indicator + self.content() + } + .frame( + width: Screen.width, + height: modalHeight.value, + alignment: .top) + .background(Color.white) + .cornerRadius(modalCornerRadius) + .offset(y: offset.height) + .gesture( + DragGesture() + .onChanged(onChangedDragValueGesture) + .onEnded(onEndedDragValueGesture) + ) + .animation(animation) + .transition(.move(edge: .bottom)) + } + + private var indicator: some View { + Rectangle() + .fill(Color(.systemGray4)) + .frame(width: 50, height: 6) + .cornerRadius(3) + .padding(.vertical, dragIndicatorVerticalPadding) + .hide(if: indicatorHidden) + } + + // MARK: - Helpers + + private func onChangedDragValueGesture(_ value: DragGesture.Value) { + guard value.translation.height > 0 else { return } + self.offset = value.translation + backgroundOpacity = newOpacity + modalCornerRadius = newCornerRadius + } + + private func onEndedDragValueGesture(_ value: DragGesture.Value) { + guard value.translation.height >= self.modalHeight.value / 2 else { + self.offset = .zero + self.backgroundOpacity = Double(backgroundOpacityUpperbound) + self.modalCornerRadius = cornerRadiusUpperbound + return + } + + withAnimation(animation) { + self.isPresented.toggle() + self.offset = .zero + self.backgroundOpacity = Double(backgroundOpacityUpperbound) + self.modalCornerRadius = cornerRadiusUpperbound + } + } +} diff --git a/Sources/ExtensionKit/SwiftUI/Keyboard/KeyboardInfo.swift b/Sources/ExtensionKit/SwiftUI/Keyboard/KeyboardInfo.swift index beaf64b..7ddbbc2 100644 --- a/Sources/ExtensionKit/SwiftUI/Keyboard/KeyboardInfo.swift +++ b/Sources/ExtensionKit/SwiftUI/Keyboard/KeyboardInfo.swift @@ -1,7 +1,7 @@ import UIKit /// Struct modeling keyboard updates -public struct KeyboardInfo { +public struct KeyboardInfo: Equatable { /// Keyboard height public var height: CGFloat = 0 /// Keyboard animation curve diff --git a/Sources/ExtensionKit/SwiftUI/View.swift b/Sources/ExtensionKit/SwiftUI/View.swift index 9afef31..6b38d09 100644 --- a/Sources/ExtensionKit/SwiftUI/View.swift +++ b/Sources/ExtensionKit/SwiftUI/View.swift @@ -478,4 +478,30 @@ public extension View { } } + /// Adds a bottom sheet to View + /// - Parameters: + /// - isPresented: Binding for presenting the View + /// - height: Height, default .mid + /// - animation: Animation + /// - content: modal content + /// - Returns: View + func bottomSheet( + isPresented: Binding, + height: Height = .mid, + animation: Animation = .easeInOut(duration: 0.3), + thumbHidden: Bool = false, + @ViewBuilder content: @escaping () -> Content + ) -> some View { + return ZStack { + self + BottomSheet( + isPresented: isPresented, + height: height, + animation: animation, + thumbHidden: thumbHidden, + content: content + ) + } + } + }