├── .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 |
--------------------------------------------------------------------------------