├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Package.resolved ├── Package.swift ├── README.md └── Sources └── Modals ├── Extensions ├── Animation+Ext.swift ├── CGFloat+Ext.swift ├── Color+Ext.swift ├── UIColor+Ext.swift └── UIScreen+Ext.swift ├── Modal ├── Modal.swift ├── ModalOption.swift ├── ModalSize.swift └── ModalView.swift ├── ModalStack ├── ModalStackContainerView.swift ├── ModalStackRootView.swift └── ModalStackView.swift ├── ModalSystem ├── ModalSystem.swift ├── ModalSystemDismissAction.swift ├── ModalSystemModifier.swift └── ModalSystemView.swift └── Support ├── CornerRadiusModifier.swift ├── HighlightlessButtonStyle.swift └── KeyboardObserver.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-collections", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/apple/swift-collections", 7 | "state" : { 8 | "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", 9 | "version" : "1.0.4" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-identified-collections", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/pointfreeco/swift-identified-collections", 16 | "state" : { 17 | "revision" : "d1e45f3e1eee2c9193f5369fa9d70a6ddad635e8", 18 | "version" : "1.0.0" 19 | } 20 | } 21 | ], 22 | "version" : 2 23 | } 24 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.8 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Modals", 8 | platforms: [ 9 | .iOS(.v15) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, and make them visible to other packages. 13 | .library( 14 | name: "Modals", 15 | targets: ["Modals"]), 16 | ], 17 | dependencies: [ 18 | // Dependencies declare other packages that this package depends on. 19 | // .package(url: /* package url */, from: "1.0.0"), 20 | .package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "1.0.0") 21 | ], 22 | targets: [ 23 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 24 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 25 | .target( 26 | name: "Modals", 27 | dependencies: [ 28 | .product(name: "IdentifiedCollections", package: "swift-identified-collections") 29 | ] 30 | ) 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Modals by New Material 2 | A lightweight alternative to system sheets on iOS. 3 | 4 | https://github.com/newmaterialco/Modals/assets/58298401/d4931000-55e1-4dac-ba0d-240b3fb6573a 5 | 6 | # Installation 7 | 8 | You can add Modals to your project using Swift Package Manager. 9 | 10 | - From the File menu, select Add Packages... 11 | - Enter the repo URL 12 | 13 | ``` 14 | http://github.com/newmaterialco/Modals.git 15 | ``` 16 | 17 | # Set Up 18 | 19 | To set up Modals in your project, wrap the root view of your project in the `ModalStackView`. This will allow modals to be overlayed from the root level of the view hierarchy. 20 | ```swift 21 | @main 22 | struct ModalsExample: App { 23 | var body: some Scene { 24 | WindowGroup { 25 | // Wrap your root view in the ModalStackView 26 | ModalStackView { 27 | ContentView() 28 | } 29 | } 30 | } 31 | } 32 | ``` 33 | 34 | # Usage 35 | 36 | To register a modal, call the `.modal()` modifier on any `View` (just like with system sheets). 37 | 38 | ```swift 39 | struct ContentView: View { 40 | 41 | @State var isPresented: Bool = false 42 | 43 | var body: some View { 44 | Button { 45 | isPresented = true 46 | } label: { 47 | Text("Open") 48 | } 49 | .modal(isPresented: $isPresented) { 50 | Text("Modal!") 51 | } 52 | } 53 | } 54 | ``` 55 | 56 | ### Dismiss Action 57 | 58 | To dismiss a modal programmatically from within a modal, use the `dismissAction` environment value. 59 | 60 | ```swift 61 | struct ModalContentView: View { 62 | 63 | @Environment(\.dismissModal) var dismissModal 64 | 65 | var body: some View { 66 | Button("Dismiss this modal!") { 67 | dismissModal() 68 | } 69 | } 70 | } 71 | ``` 72 | 73 | # Advanced Usage 74 | 75 | ## Modal Customization 76 | 77 | ### Size 78 | To change the size of the modal, pass in a `ModalSize` into the `.modal()` modifier. 79 | ```swift 80 | .modal( 81 | ..., 82 | size: .medium 83 | ) { 84 | ... 85 | } 86 | ``` 87 | 88 | There are `.small`, `.medium`, and `.large` default sizes, as well as custom sizes defined by a constant `.height` or a `.fraction` of the screen height (inspired by `presenationDetents` on system sheets). 89 | 90 | ### Corner Radius 91 | 92 | To change the corner radius of the modal, use the `cornerRadius` argument in the `.modal()` modifier. 93 | 94 | ```swift 95 | .modal( 96 | ..., 97 | cornerRadius: 18 98 | ) { 99 | ... 100 | } 101 | ``` 102 | 103 | ### Options 104 | 105 | You can also pass in an array of available customization options to the `.modal()` modifier. 106 | - `.prefersDragHandle`: Replaces the default close button with a center-aligned drag handle. 107 | - `.disableContentDragging`: Disables the ability to drag on content to dismiss the modal (sometimes useful when a ScrollView is embedded in the modal). 108 | 109 | ```swift 110 | .modal( 111 | ..., 112 | options: [.prefersDragHandle] 113 | ) { 114 | ... 115 | } 116 | ``` 117 | 118 | ## ModalStack Customization 119 | 120 | The ModalStack can be customized by using a variety of supported modifers. 121 | 122 | _In the below context, "Content" is just whatever view is being overlayed by the modal._ 123 | 124 | ### Content Scaling 125 | Content scaling can be disabled by using the `.contentScaling()` modifier. 126 | 127 | ```swift 128 | ModalStackView { 129 | ... 130 | } 131 | .contentScaling(false) 132 | ``` 133 | 134 | ### Content Saturation 135 | Content saturation can be disabled by using the `.contentSaturation()` modifier. 136 | 137 | ```swift 138 | ModalStackView { 139 | ... 140 | } 141 | .contentSaturation(false) 142 | ``` 143 | 144 | ### Content Background Color 145 | 146 | Content background color can be adjusted using the `.contentBackgroundColor()` modifier. This is the only way to have a background color ignore safe area completely while content scaling is enabled. 147 | 148 | ```swift 149 | ModalStackView { 150 | ... 151 | } 152 | .contentBackgroundColor(Color.blue) 153 | ``` 154 | 155 | ### Container Background Color 156 | 157 | To change the container background color (in the video, the area that is white behind the root content & presented modals), use the `.containerBackgroundColor()` modifier. 158 | 159 | ```swift 160 | ModalStackView { 161 | ... 162 | } 163 | .containerBackgroundColor(Color.blue) 164 | ``` 165 | -------------------------------------------------------------------------------- /Sources/Modals/Extensions/Animation+Ext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Animation+Ext.swift 3 | // FieldDay 4 | // 5 | // Created by Vedant Gurav on 08/12/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public extension Animation { 11 | static let stiffSpring = Animation.spring(response: 0.2, dampingFraction: 0.9) 12 | static let presentationSpring = Animation.interpolatingSpring(stiffness: 222, damping: 28) 13 | } 14 | -------------------------------------------------------------------------------- /Sources/Modals/Extensions/CGFloat+Ext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CGFloat+Ext.swift 3 | // FieldDay 4 | // 5 | // Created by Samuel McGarry on 1/19/23. 6 | // 7 | 8 | import Foundation 9 | 10 | extension CGFloat { 11 | func quarticEaseOut() -> CGFloat { 12 | let f = self - 1 13 | return f * f * f * (1 - self) + 1 14 | } 15 | 16 | func quarticEaseIn() -> CGFloat { 17 | return self * self * self * self 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/Modals/Extensions/Color+Ext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Color+Ext.swift 3 | // Modals 4 | // 5 | // Created by Samuel McGarry on 8/8/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | public extension Color { 12 | static let systemBackground = Color(UIColor.systemBackground) 13 | 14 | static let modalBackground = Color( 15 | uiColor: UIColor( 16 | light: UIColor(hex: "#f7f7f7"), 17 | dark: UIColor(hex: "#141414") 18 | ) 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /Sources/Modals/Extensions/UIColor+Ext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIColor+Ext.swift 3 | // Modals 4 | // 5 | // Created by Samuel McGarry on 8/8/23. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | extension UIColor { 12 | 13 | convenience init(hex: String) { 14 | let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) 15 | var int: UInt64 = 0 16 | Scanner(string: hex).scanHexInt64(&int) 17 | let a, r, g, b: UInt64 18 | switch hex.count { 19 | case 3: // RGB (12-bit) 20 | (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) 21 | case 6: // RGB (24-bit) 22 | (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) 23 | case 8: // ARGB (32-bit) 24 | (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) 25 | default: 26 | (a, r, g, b) = (1, 1, 1, 0) 27 | } 28 | 29 | self.init( 30 | red: Double(r) / 255, 31 | green: Double(g) / 255, 32 | blue: Double(b) / 255, 33 | alpha: Double(a) / 255 34 | ) 35 | } 36 | 37 | public convenience init( 38 | light lightModeColor: @escaping @autoclosure () -> UIColor, 39 | dark darkModeColor: @escaping @autoclosure () -> UIColor 40 | ) { 41 | self.init { traitCollection in 42 | switch traitCollection.userInterfaceStyle { 43 | case .light: 44 | return lightModeColor() 45 | case .dark: 46 | return darkModeColor() 47 | case .unspecified: 48 | return lightModeColor() 49 | @unknown default: 50 | return lightModeColor() 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/Modals/Extensions/UIScreen+Ext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIScreen+Ext.swift 3 | // FieldDay 4 | // 5 | // Created by Samuel McGarry on 1/19/23. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | extension UIScreen { 12 | private static let cornerRadiusKey: String = { 13 | let components = ["Radius", "Corner", "display", "_"] 14 | return components.reversed().joined() 15 | }() 16 | 17 | /// The corner radius of the display. Uses a private property of `UIScreen`, 18 | /// and may report 0 if the API changes. 19 | public var displayCornerRadius: CGFloat { 20 | guard let cornerRadius = self.value(forKey: Self.cornerRadiusKey) as? CGFloat else { 21 | return 0 22 | } 23 | 24 | return cornerRadius 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Modals/Modal/Modal.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Modal.swift 3 | // FieldDay 4 | // 5 | // Created by Samuel McGarry on 5/15/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | public struct Modal: Identifiable, Hashable { 12 | public static func == (lhs: Modal, rhs: Modal) -> Bool { 13 | lhs.id == rhs.id 14 | } 15 | 16 | public func hash(into hasher: inout Hasher) { 17 | hasher.combine(id) 18 | } 19 | 20 | public let id = UUID().uuidString 21 | @Binding var isPresented: Bool 22 | var size: ModalSize 23 | var cornerRadius: CGFloat 24 | var backgroundColor: Color 25 | var options: [ModalOption] 26 | var view: AnyView 27 | 28 | init( 29 | isPresented: Binding, 30 | size: ModalSize, 31 | cornerRadius: CGFloat, 32 | backgroundColor: Color, 33 | options: [ModalOption], 34 | view: AnyView 35 | ) { 36 | self._isPresented = isPresented 37 | self.size = size 38 | self.cornerRadius = cornerRadius 39 | self.backgroundColor = backgroundColor 40 | self.options = options 41 | self.view = view 42 | } 43 | } 44 | 45 | extension Modal { 46 | 47 | var isContentDraggable: Bool { 48 | for option in options { 49 | if option == .disableContentDragging { 50 | return false 51 | } 52 | } 53 | return true 54 | } 55 | 56 | var isHandleVisible: Bool { 57 | for option in options { 58 | if option == .prefersDragHandle { 59 | return true 60 | } 61 | } 62 | return false 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/Modals/Modal/ModalOption.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ModalOption.swift 3 | // FieldDay 4 | // 5 | // Created by Samuel McGarry on 5/22/23. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A series of options that can be passed into the .modal() modifier to adjust preferences for the modal stack. 11 | public enum ModalOption { 12 | 13 | /// Replaces the default close button with a center-aligned drag handle. 14 | case prefersDragHandle 15 | 16 | /// Disables the ability to drag on content to dismiss the modal (sometimes useful when a ScrollView is embedded in the modal). 17 | case disableContentDragging 18 | } 19 | -------------------------------------------------------------------------------- /Sources/Modals/Modal/ModalSize.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ModalSize.swift 3 | // FieldDay 4 | // 5 | // Created by Samuel McGarry on 5/15/23. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | /// A series of sizes that can be passed into the .modal() modifier to adjust the size of the presented modal (inspired by system sheet presentation detents). 12 | public enum ModalSize { 13 | case small 14 | case medium 15 | case large 16 | case height(CGFloat) 17 | case fraction(CGFloat) 18 | 19 | var value: CGFloat { 20 | let screenHeight = UIApplication.shared.screenSize.height 21 | 22 | switch self { 23 | case .small: 24 | return screenHeight * 0.65 25 | case .medium: 26 | return screenHeight * 0.75 27 | case .large: 28 | return screenHeight * 0.9 29 | case .height(let height): 30 | return height 31 | case .fraction(let fraction): 32 | return screenHeight * fraction 33 | } 34 | } 35 | } 36 | 37 | extension UIApplication { 38 | public var screenSize: CGRect { 39 | let scenes = UIApplication.shared.connectedScenes 40 | let windowScene = scenes.first as? UIWindowScene 41 | let window = windowScene?.windows.first 42 | return window?.screen.bounds ?? CGRect(x: 0, y: 0, width: 0, height: 0) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/Modals/Modal/ModalView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ModalView.swift 3 | // FieldDay 4 | // 5 | // Created by Samuel McGarry on 5/15/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import IdentifiedCollections 11 | 12 | struct ModalView: View { 13 | @StateObject var keyboardObserver = KeyboardObserver() 14 | 15 | var index: Int 16 | var isTopModal: Bool 17 | var modal: Modal 18 | 19 | @GestureState private var isDragging: Bool 20 | @State private var containerOffset: CGFloat 21 | @State private var containerHeight: CGFloat 22 | @State private var contentOffset: CGFloat 23 | @State private var contentHeight: CGFloat 24 | @State private var isModalOverlayed: Bool = false 25 | @State private var isAnimatingClose: Bool = false 26 | 27 | @State var modalScale: CGFloat = 1.0 28 | @State var modalOffset: CGFloat = 0 29 | 30 | @State var dismissButtonScale: CGFloat = 1.0 31 | @State var dismissButtonOpacity: CGFloat = 1.0 32 | 33 | private let selectionGenerator = UISelectionFeedbackGenerator() 34 | private let indicatorHeight: CGFloat = 26 35 | private let screenWidth: CGFloat 36 | private let screenHeight: CGFloat 37 | private let defaultHeight: CGFloat 38 | private let defaultOffset: CGFloat 39 | private let defaultModalOffset: CGFloat 40 | 41 | init(modal: Modal, index: Int = -1, isTopModal: Bool) { 42 | self.index = index 43 | self.isTopModal = isTopModal 44 | self.modal = modal 45 | 46 | self.screenWidth = UIApplication.shared.screenSize.width 47 | self.screenHeight = UIApplication.shared.screenSize.height 48 | self.defaultHeight = modal.size.value 49 | self.defaultOffset = screenHeight - modal.size.value 50 | 51 | let maxHeightDifference = ModalSize.large.value - ModalSize.small.value 52 | let heightDifference = (ModalSize.large.value - ModalSize.small.value) - (ModalSize.large.value - max(modal.size.value, ModalSize.small.value)) 53 | let modalOffsetMultiplier = heightDifference == 0 ? 0 : heightDifference / maxHeightDifference 54 | let maxModalOffset: CGFloat = 14 55 | self.defaultModalOffset = maxModalOffset * modalOffsetMultiplier 56 | 57 | self._isDragging = GestureState(initialValue: false) 58 | self._containerOffset = State(initialValue: modal.size.value) 59 | self._containerHeight = State(initialValue: modal.size.value) 60 | self._contentOffset = State(initialValue: screenHeight) 61 | self._contentHeight = State(initialValue: modal.size.value - indicatorHeight) 62 | } 63 | 64 | var dragToCloseGesture: some Gesture { 65 | DragGesture(coordinateSpace: .named("ModalCoordinateSpace")) 66 | .updating($isDragging) { (value, gestureState, transaction) in 67 | gestureState = true 68 | } 69 | .onChanged { gesture in 70 | dragToCloseGestureDidChange(gesture.translation.height) 71 | } 72 | } 73 | 74 | var body: some View { 75 | GeometryReader { _ in 76 | ZStack { 77 | Color.black.opacity(0.00001) 78 | .zIndex(0) 79 | .onTapGesture { 80 | close() 81 | } 82 | ZStack(alignment: .bottom) { 83 | modal.backgroundColor 84 | .cornerRadius(modal.cornerRadius, corners: [.topLeft, .topRight]) 85 | .shadow(color: .black.opacity(0.12), radius: 24) 86 | .frame(height: containerHeight) 87 | .offset(y: containerOffset) 88 | .zIndex(0) 89 | VStack(spacing: 0) { 90 | HStack { 91 | Spacer() 92 | visibilityIndicator 93 | Spacer() 94 | } 95 | .background(Color.black.opacity(0.00001)) 96 | .simultaneousGesture(!keyboardObserver.isShowing ? dragToCloseGesture : nil) 97 | .zIndex(20) 98 | VStack { 99 | modal.view 100 | .frame(width: screenWidth, height: contentHeight) 101 | .offset(y: -44) 102 | Spacer() 103 | } 104 | .frame(maxWidth: .infinity, maxHeight: .infinity) 105 | .background(Color.black.opacity(0.00001)) 106 | .simultaneousGesture(modal.isContentDraggable && !keyboardObserver.isShowing ? dragToCloseGesture : nil) 107 | } 108 | .cornerRadius(modal.cornerRadius) 109 | .frame(maxWidth: .infinity, maxHeight: .infinity) 110 | .zIndex(10) 111 | .offset(y: contentOffset) 112 | } 113 | .coordinateSpace(name: "ModalCoordinateSpace") 114 | .zIndex(1) 115 | .scaleEffect(x: modalScale, y: modalScale, anchor: .top) 116 | .offset(y: modalOffset) 117 | .onChange(of: containerOffset) { newValue in 118 | let percentage = newValue / defaultHeight 119 | ModalSystem.shared.dragProgress = percentage 120 | } 121 | .environment(\.dismissModal, ModalSystemDismissAction { 122 | if isTopModal { 123 | close() 124 | } 125 | }) 126 | .onChange(of: keyboardObserver.height) { newValue in 127 | guard keyboardObserver.isShowing else { return } 128 | guard index == ModalSystem.shared.modals.count - 1 else { return } 129 | 130 | let newHeight = newValue + 264 131 | if newHeight > defaultHeight { 132 | var transaction = Transaction() 133 | transaction.isContinuous = true 134 | transaction.animation = .interactiveSpring(response: 0.3, dampingFraction: 0.8, blendDuration: 0) 135 | 136 | withTransaction(transaction) { 137 | containerHeight = newHeight 138 | contentHeight = 264 139 | contentOffset = screenHeight - newHeight 140 | } 141 | } 142 | } 143 | .onChange(of: keyboardObserver.isShowing) { newValue in 144 | if !newValue { 145 | var transaction = Transaction() 146 | transaction.isContinuous = true 147 | transaction.animation = .interactiveSpring(response: 0.3, dampingFraction: 0.8, blendDuration: 0) 148 | 149 | withTransaction(transaction) { 150 | containerHeight = defaultHeight 151 | contentHeight = defaultHeight - indicatorHeight 152 | contentOffset = screenHeight - defaultHeight 153 | } 154 | } 155 | } 156 | .onChange(of: isDragging) { newValue in 157 | if !newValue { 158 | dragToCloseGestureDidEnd(containerOffset) 159 | } 160 | } 161 | .onAppear { 162 | open() 163 | self.keyboardObserver.addObserver() 164 | } 165 | .onDisappear { 166 | self.keyboardObserver.removeObserver() 167 | } 168 | .onReceive(ModalSystem.shared.$modals, perform: { output in 169 | self.isModalOverlayed = (output.count - 2 == index) 170 | }) 171 | .onReceive(ModalSystem.shared.$dragProgress, perform: { output in 172 | dragProgressDidChange(output) 173 | }) 174 | } 175 | .disabled(isAnimatingClose) 176 | } 177 | } 178 | 179 | var visibilityIndicator: some View { 180 | Button(action: { 181 | close() 182 | self.selectionGenerator.selectionChanged() 183 | }) { 184 | if modal.isHandleVisible { 185 | HStack(spacing: -2) { 186 | RoundedRectangle(cornerRadius: 16) 187 | .fill(Color.gray) 188 | .frame(width: 24, height: 3) 189 | .rotationEffect(Angle(degrees: isDragging ? 0 : 16), anchor: UnitPoint(x: 1, y: 0.5)) 190 | RoundedRectangle(cornerRadius: 16) 191 | .fill(Color.gray) 192 | .frame(width: 24, height: 3) 193 | .rotationEffect(Angle(degrees: isDragging ? 0 : -16), anchor: UnitPoint(x: 0, y: 0.5)) 194 | } 195 | .frame(width: 80, height: 44) 196 | .background(Color.black.opacity(0.00001)) 197 | .offset(x: 0, y: isDragging ? 0 : 4) 198 | .animation(.stiffSpring, value: isDragging) 199 | .opacity(dismissButtonOpacity) 200 | .scaleEffect(dismissButtonScale, anchor: .center) 201 | } else { 202 | HStack { 203 | Spacer() 204 | Image(systemName: "xmark") 205 | .font(.system(size: 14)) 206 | .foregroundColor(.primary.opacity(0.5)) 207 | .frame(width: 40, height: 40) 208 | .background(.ultraThinMaterial) 209 | .clipShape(Circle()) 210 | .opacity(dismissButtonOpacity) 211 | .scaleEffect(dismissButtonScale, anchor: .center) 212 | } 213 | .padding(.top, 30) 214 | .padding(.trailing, 8) 215 | .frame(height: 44) 216 | } 217 | } 218 | .buttonStyle(HighlightlessButtonStyle()) 219 | .disabled(isDragging) 220 | } 221 | 222 | func dragProgressDidChange(_ newValue: CGFloat) { 223 | if isModalOverlayed { 224 | var transaction = Transaction() 225 | transaction.isContinuous = true 226 | transaction.animation = .interpolatingSpring(stiffness: 222, damping: 28) 227 | 228 | withTransaction(transaction) { 229 | modalOffset = (-defaultModalOffset) + abs(-defaultModalOffset) * newValue 230 | modalScale = 0.92 + 0.08 * newValue 231 | dismissButtonScale = 0.92 + (0.08 * newValue) 232 | dismissButtonOpacity = newValue 233 | } 234 | } 235 | } 236 | 237 | func dragToCloseGestureDidChange(_ newValue: CGFloat) { 238 | 239 | var transaction = Transaction() 240 | transaction.isContinuous = true 241 | transaction.animation = .interpolatingSpring(stiffness: 400, damping: 20) 242 | 243 | withTransaction(transaction) { 244 | if newValue < 0 { 245 | let percentageCompletion = abs(newValue) / defaultHeight / (screenHeight / 72) 246 | let multiplier = 1 + percentageCompletion.quarticEaseOut() 247 | let newHeight: CGFloat = defaultHeight * multiplier 248 | containerHeight = newHeight 249 | contentHeight = newHeight - indicatorHeight 250 | contentOffset = -(containerHeight - defaultHeight) + defaultOffset 251 | } else { 252 | contentOffset = newValue + defaultOffset 253 | containerOffset = newValue 254 | } 255 | } 256 | } 257 | 258 | func dragToCloseGestureDidEnd(_ newValue: CGFloat) { 259 | if newValue > (defaultHeight / 2) { 260 | close() 261 | } else { 262 | var transaction = Transaction() 263 | transaction.isContinuous = true 264 | transaction.animation = .interpolatingSpring(stiffness: 222, damping: 28) 265 | 266 | withTransaction(transaction) { 267 | contentOffset = defaultOffset 268 | contentHeight = defaultHeight - indicatorHeight 269 | containerOffset = 0 270 | containerHeight = defaultHeight 271 | } 272 | } 273 | } 274 | 275 | func open() { 276 | var transaction = Transaction() 277 | transaction.isContinuous = true 278 | transaction.animation = Animation.presentationSpring 279 | 280 | withTransaction(transaction) { 281 | contentOffset = defaultOffset 282 | containerOffset = 0 283 | containerHeight = defaultHeight 284 | } 285 | } 286 | 287 | func close() { 288 | self.modal.isPresented = false 289 | 290 | var transaction = Transaction() 291 | transaction.isContinuous = true 292 | transaction.animation = Animation.presentationSpring 293 | 294 | withTransaction(transaction) { 295 | contentOffset = screenHeight 296 | contentHeight = defaultHeight - indicatorHeight 297 | containerOffset = defaultHeight 298 | containerHeight = defaultHeight 299 | } 300 | 301 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 302 | ModalSystem.shared.modals.remove(id: self.modal.id) 303 | } 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /Sources/Modals/ModalStack/ModalStackContainerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ModalStackContainerView.swift 3 | // Modals 4 | // 5 | // Created by Samuel McGarry on 8/8/23. 6 | // 7 | 8 | import SwiftUI 9 | import IdentifiedCollections 10 | 11 | struct ModalStackContainerView: View, Equatable { 12 | static func == (lhs: ModalStackContainerView, rhs: ModalStackContainerView) -> Bool { 13 | true 14 | } 15 | 16 | var content: () -> Content 17 | 18 | @State var modalCount = 0 19 | @State var contentSaturation: CGFloat = 1 20 | @State var contentScaleEffect: CGFloat = 1 21 | @State var contentCornerRadius: CGFloat = 36 22 | @State var contentOffset: CGFloat = 0 23 | 24 | var body: some View { 25 | ZStack(alignment: .top) { 26 | ModalSystem.shared.containerBackgroundColor.ignoresSafeArea() 27 | ZStack(alignment: .top) { 28 | ZStack { 29 | ModalSystem.shared.contentBackgroundColor 30 | .saturation(contentSaturation) 31 | .scaleEffect(contentScaleEffect, anchor: .center) 32 | .offset(y: contentOffset) 33 | Color.clear 34 | .edgesIgnoringSafeArea(.all) 35 | } 36 | .edgesIgnoringSafeArea(.all) 37 | 38 | ZStack { 39 | EquatableView(content: ModalStackRootView(content: content)) 40 | } 41 | .saturation(contentSaturation) 42 | .scaleEffect(contentScaleEffect, anchor: .center) 43 | .offset(y: contentOffset) 44 | } 45 | .mask( 46 | ZStack { 47 | RoundedRectangle(cornerRadius: contentCornerRadius, style: .continuous) 48 | Color.clear 49 | .edgesIgnoringSafeArea(.all) 50 | } 51 | .scaleEffect(contentScaleEffect, anchor: .center) 52 | .offset(y: contentOffset) 53 | .edgesIgnoringSafeArea(.all) 54 | ) 55 | } 56 | .onReceive(ModalSystem.shared.$modals, perform: { output in 57 | modalsDidChange(output) 58 | }) 59 | .onReceive(ModalSystem.shared.$dragProgress, perform: { output in 60 | dragProgressDidChange(output) 61 | }) 62 | } 63 | 64 | func dragProgressDidChange(_ newValue: CGFloat) { 65 | guard modalCount == 1 else { return } 66 | 67 | var transaction = Transaction() 68 | transaction.isContinuous = true 69 | transaction.animation = Animation.presentationSpring 70 | 71 | withTransaction(transaction) { 72 | if ModalSystem.shared.isContentSaturationEnabled { 73 | contentSaturation = newValue 74 | } 75 | 76 | if ModalSystem.shared.isContentScalingEnabled { 77 | contentScaleEffect = 0.92 + (0.08 * newValue) 78 | contentCornerRadius = 36 + (UIScreen.main.displayCornerRadius - 36) * newValue 79 | contentOffset = 30 - (30 * newValue) 80 | } 81 | } 82 | } 83 | 84 | func modalsDidChange(_ newValue: IdentifiedArrayOf) { 85 | 86 | if modalCount == 0 && newValue.count == 1 && ModalSystem.shared.isContentScalingEnabled { 87 | contentCornerRadius = UIScreen.main.displayCornerRadius 88 | } 89 | 90 | modalCount = newValue.count 91 | 92 | guard newValue.count < 2 else { return } 93 | 94 | var transaction = Transaction() 95 | transaction.isContinuous = true 96 | transaction.animation = Animation.presentationSpring 97 | 98 | withTransaction(transaction) { 99 | if ModalSystem.shared.isContentSaturationEnabled { 100 | contentSaturation = modalCount == 0 ? 1 : 0 101 | } 102 | 103 | if ModalSystem.shared.isContentScalingEnabled { 104 | contentScaleEffect = modalCount == 0 ? 1 : 0.92 105 | contentCornerRadius = modalCount == 0 ? UIScreen.main.displayCornerRadius : 36 106 | contentOffset = modalCount == 0 ? 0 : 30 107 | } 108 | } 109 | 110 | if modalCount == 0 { 111 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { 112 | guard modalCount == 0 else { return } 113 | contentCornerRadius = 0 114 | } 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Sources/Modals/ModalStack/ModalStackRootView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ModalStackRootView.swift 3 | // Modals 4 | // 5 | // Created by Samuel McGarry on 8/8/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ModalStackRootView: View, Equatable { 11 | static func == (lhs: ModalStackRootView, rhs: ModalStackRootView) -> Bool { 12 | true 13 | } 14 | 15 | var content: () -> Content 16 | 17 | var body: some View { 18 | ZStack { 19 | Color.clear.ignoresSafeArea() 20 | content() 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/Modals/ModalStack/ModalStackView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ModalStackView.swift 3 | // Modals 4 | // 5 | // Created by Samuel McGarry on 8/8/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// The underlying view wrapper that handles presenting modals as global overlays 11 | public struct ModalStackView: View { 12 | 13 | var content: () -> Content 14 | 15 | public init(content: @escaping () -> Content) { 16 | self.content = content 17 | } 18 | 19 | public var body: some View { 20 | ZStack { 21 | ModalStackContainerView(content: content) 22 | ModalSystemView() 23 | } 24 | } 25 | } 26 | 27 | public extension ModalStackView { 28 | 29 | /// Sets the background color for the modal stack container. 30 | /// - Parameter color: The color to set 31 | func containerBackgroundColor(_ color: Color) -> ModalStackView { 32 | ModalSystem.shared.containerBackgroundColor = color 33 | return self 34 | } 35 | 36 | /// Sets the background color for the modal stack root content. 37 | /// - Parameter color: The color to set 38 | func contentBackgroundColor(_ color: Color) -> ModalStackView { 39 | ModalSystem.shared.contentBackgroundColor = color 40 | return self 41 | } 42 | 43 | /// Sets whether content scaling is enabled for the modal stack root content. 44 | /// - Parameter enabled: The boolean reflecting if scaling is enabled. 45 | func contentScaling(_ enabled: Bool) -> ModalStackView { 46 | ModalSystem.shared.isContentScalingEnabled = enabled 47 | return self 48 | } 49 | 50 | /// Sets whether content saturation is enabled for the modal stack root content. 51 | /// - Parameter enabled: The boolean reflecting if saturation is enabled. 52 | func contentSaturation(_ enabled: Bool) -> ModalStackView { 53 | ModalSystem.shared.isContentSaturationEnabled = enabled 54 | return self 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/Modals/ModalSystem/ModalSystem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ModalSystem.swift 3 | // FieldDay 4 | // 5 | // Created by Samuel McGarry on 2/3/23. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | import IdentifiedCollections 10 | import Combine 11 | 12 | class ModalSystem { 13 | static let shared = ModalSystem() 14 | 15 | @Published var modals: IdentifiedArrayOf = [] 16 | @Published var dragProgress: CGFloat = 0 17 | 18 | var containerBackgroundColor: Color = Color.modalBackground 19 | var contentBackgroundColor: Color = Color.systemBackground 20 | 21 | var isContentScalingEnabled: Bool = true 22 | var isContentSaturationEnabled: Bool = true 23 | } 24 | -------------------------------------------------------------------------------- /Sources/Modals/ModalSystem/ModalSystemDismissAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DismissModal.swift 3 | // FieldDay 4 | // 5 | // Created by Samuel McGarry on 5/15/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | public struct ModalSystemDismissAction { 12 | private var action: () -> Void 13 | 14 | public func callAsFunction() { 15 | action() 16 | } 17 | 18 | init(action: @escaping () -> Void = { }) { 19 | self.action = action 20 | } 21 | } 22 | 23 | public struct ModalSystemDismissActionKey: EnvironmentKey { 24 | public static var defaultValue: ModalSystemDismissAction = ModalSystemDismissAction() 25 | } 26 | 27 | public extension EnvironmentValues { 28 | 29 | /// A closure that dismisses the top most presented modal when called. 30 | var dismissModal: ModalSystemDismissAction { 31 | get { self[ModalSystemDismissActionKey.self] } 32 | set { self[ModalSystemDismissActionKey.self] = newValue } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Modals/ModalSystem/ModalSystemModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ModalSystemModifier.swift 3 | // FieldDay 4 | // 5 | // Created by Samuel McGarry on 5/15/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct ModalSystemModifier: ViewModifier { 12 | 13 | @Binding var isPresented: Bool 14 | var size: ModalSize 15 | var cornerRadius: CGFloat 16 | var backgroundColor: Color 17 | var options: [ModalOption] 18 | var view: () -> V 19 | 20 | func body(content: Content) -> some View { 21 | content 22 | .onChange(of: isPresented) { newValue in 23 | if newValue { 24 | ModalSystem.shared.modals.append( 25 | Modal( 26 | isPresented: $isPresented, 27 | size: size, 28 | cornerRadius: cornerRadius, 29 | backgroundColor: backgroundColor, 30 | options: options, 31 | view: AnyView(view()) 32 | ) 33 | ) 34 | } 35 | } 36 | } 37 | } 38 | 39 | public extension View { 40 | 41 | /// Creates and optionally presents a modal on top of the view hierarchy 42 | /// - Parameters: 43 | /// - isPresented: A Binding Bool that sets the presentation of the modal. 44 | /// - size: The size of the modal. The default value is `ModalSize.large`. 45 | /// - cornerRadius: The corner radius of the modal. The default value is `36`. 46 | /// - backgroundColor: The background color of the moda. The default is `Color.modalBackground`. 47 | /// - options: An optional array of `ModalOption`'s that are applied to the modal. 48 | /// - view: The content to be embedded in the modal. 49 | func modal( 50 | isPresented: Binding, 51 | size: ModalSize = .large, 52 | cornerRadius: CGFloat = 36, 53 | backgroundColor: Color = Color.modalBackground, 54 | options: [ModalOption] = [], 55 | _ view: @escaping () -> V 56 | ) -> some View { 57 | modifier( 58 | ModalSystemModifier( 59 | isPresented: isPresented, 60 | size: size, 61 | cornerRadius: cornerRadius, 62 | backgroundColor: backgroundColor, 63 | options: options, 64 | view: view 65 | ) 66 | ) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/Modals/ModalSystem/ModalSystemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ModalSystemView.swift 3 | // FieldDay 4 | // 5 | // Created by Samuel McGarry on 5/15/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import IdentifiedCollections 11 | import Combine 12 | 13 | struct ModalSystemView: View { 14 | @State var modals: IdentifiedArrayOf = [] 15 | 16 | var body: some View { 17 | ZStack { 18 | ForEach(Array(modals.enumerated()), id: \.element.id) { index, modal in 19 | ModalView( 20 | modal: modal, 21 | index: index, 22 | isTopModal: index == modals.count - 1 23 | ) 24 | } 25 | } 26 | .edgesIgnoringSafeArea(.all) 27 | .onReceive(ModalSystem.shared.$modals, perform: { output in 28 | self.modals = output 29 | }) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/Modals/Support/CornerRadiusModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CornerRadiusModifier.swift 3 | // FieldDay 4 | // 5 | // Created by Samuel McGarry on 12/19/22. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct RoundedCorner: Shape { 12 | var radius: CGFloat = .infinity 13 | var corners: UIRectCorner = .allCorners 14 | 15 | func path(in rect: CGRect) -> Path { 16 | let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius)) 17 | return Path(path.cgPath) 18 | } 19 | } 20 | 21 | extension View { 22 | func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { 23 | clipShape( RoundedCorner(radius: radius, corners: corners) ) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Modals/Support/HighlightlessButtonStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HighlightlessButtonStyle.swift 3 | // FieldDay 4 | // 5 | // Created by Samuel McGarry on 12/20/22. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct HighlightlessButtonStyle: ButtonStyle { 12 | func makeBody(configuration: Self.Configuration) -> some View { 13 | configuration.label 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Modals/Support/KeyboardObserver.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardObserver.swift 3 | // FieldDay 4 | // 5 | // Created by Samuel McGarry on 1/9/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | class KeyboardObserver: ObservableObject { 12 | @Published var isShowing = false 13 | @Published var height: CGFloat = 0 14 | 15 | func addObserver() { 16 | NotificationCenter.default.addObserver(self, selector: #selector(self.keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil) 17 | NotificationCenter.default.addObserver(self, selector: #selector(self.keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil) 18 | } 19 | 20 | func removeObserver() { 21 | NotificationCenter.default.removeObserver(self,name: UIResponder.keyboardWillShowNotification,object: nil) 22 | NotificationCenter.default.removeObserver(self,name: UIResponder.keyboardWillHideNotification,object: nil) 23 | } 24 | 25 | @objc func keyboardWillShow(_ notification: Notification) { 26 | isShowing = true 27 | guard let userInfo = notification.userInfo as? [String: Any] else { 28 | return 29 | } 30 | guard let keyboardInfo = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else { 31 | return 32 | } 33 | let keyboardSize = keyboardInfo.cgRectValue.size 34 | height = keyboardSize.height 35 | } 36 | 37 | @objc func keyboardWillHide(_ notification: Notification) { 38 | DispatchQueue.main.async { 39 | self.isShowing = false 40 | self.height = 0 41 | } 42 | } 43 | } 44 | --------------------------------------------------------------------------------