├── .github
└── FUNDING.yml
├── .gitignore
├── LICENSE
├── MijickNavigationView.podspec
├── Package.swift
├── README.md
└── Sources
├── Internal
├── Extensions
│ ├── Animation++.swift
│ ├── Array++.swift
│ └── Equatable++.swift
├── Managers
│ ├── KeyboardManager.swift
│ ├── NavigationManager.swift
│ └── ScreenManager.swift
├── Protocols
│ ├── Configurable.swift
│ └── NavigatableView.swift
├── Type Erasers
│ └── AnyNavigatableView.swift
├── View Modifiers
│ └── AnimationCompletionModifier.swift
└── Views
│ └── NavigationView.swift
└── Public
├── Public+NavigatableView.swift
├── Public+NavigationBackGesture.swift
├── Public+NavigationConfig.swift
├── Public+NavigationGlobalConfig.swift
├── Public+NavigationManager.swift
├── Public+SafeAreaEdges.swift
├── Public+TransitionAnimation.swift
└── Public+View.swift
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: mijick
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12 | polar: # Replace with a single Polar username
13 | buy_me_a_coffee: mijick
14 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | <<<<<<< HEAD
2 | # Xcode
3 | #
4 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
5 |
6 | ## User settings
7 | xcuserdata/
8 |
9 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
10 | *.xcscmblueprint
11 | *.xccheckout
12 |
13 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
14 | build/
15 | DerivedData/
16 | *.moved-aside
17 | *.pbxuser
18 | !default.pbxuser
19 | *.mode1v3
20 | !default.mode1v3
21 | *.mode2v3
22 | !default.mode2v3
23 | *.perspectivev3
24 | !default.perspectivev3
25 |
26 | ## Obj-C/Swift specific
27 | *.hmap
28 |
29 | ## App packaging
30 | *.ipa
31 | *.dSYM.zip
32 | *.dSYM
33 |
34 | ## Playgrounds
35 | timeline.xctimeline
36 | playground.xcworkspace
37 |
38 | # Swift Package Manager
39 | #
40 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
41 | # Packages/
42 | # Package.pins
43 | # Package.resolved
44 | # *.xcodeproj
45 | #
46 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
47 | # hence it is not needed unless you have added a package configuration file to your project
48 | # .swiftpm
49 |
50 | .build/
51 |
52 | # CocoaPods
53 | #
54 | # We recommend against adding the Pods directory to your .gitignore. However
55 | # you should judge for yourself, the pros and cons are mentioned at:
56 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
57 | #
58 | # Pods/
59 | #
60 | # Add this line if you want to avoid checking in source code from the Xcode workspace
61 | # *.xcworkspace
62 |
63 | # Carthage
64 | #
65 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
66 | # Carthage/Checkouts
67 |
68 | Carthage/Build/
69 |
70 | # Accio dependency management
71 | Dependencies/
72 | .accio/
73 |
74 | # fastlane
75 | #
76 | # It is recommended to not store the screenshots in the git repo.
77 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
78 | # For more information about the recommended setup visit:
79 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
80 |
81 | fastlane/report.xml
82 | fastlane/Preview.html
83 | fastlane/screenshots/**/*.png
84 | fastlane/test_output
85 |
86 | # Code Injection
87 | #
88 | # After new code Injection tools there's a generated folder /iOSInjectionProject
89 | # https://github.com/johnno1962/injectionforxcode
90 |
91 | iOSInjectionProject/
92 | =======
93 | .DS_Store
94 | /.build
95 | /Packages
96 | /*.xcodeproj
97 | xcuserdata/
98 | DerivedData/
99 | .swiftpm/config/registries.json
100 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
101 | .netrc
102 | .swiftpm/xcode/package.xcworkspace/xcshareddata
103 | >>>>>>> bfd2701 (Initial Commit)
104 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Mijick
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/MijickNavigationView.podspec:
--------------------------------------------------------------------------------
1 | Pod::Spec.new do |s|
2 | s.name = 'MijickNavigationView'
3 | s.summary = 'Navigation made simple'
4 | s.description = <<-DESC
5 | NavigationView is a free and open-source library dedicated for SwiftUI that makes navigation easier and much cleaner.
6 | DESC
7 |
8 | s.version = '1.1.3'
9 | s.ios.deployment_target = '15.0'
10 | s.swift_version = '5.0'
11 |
12 | s.source_files = 'Sources/**/*'
13 | s.frameworks = 'SwiftUI', 'Foundation'
14 |
15 | s.homepage = 'https://github.com/Mijick/NavigationView.git'
16 | s.license = { :type => 'MIT', :file => 'LICENSE' }
17 | s.author = { 'Tomasz Kurylik' => 'tomasz.kurylik@mijick.com' }
18 | s.source = { :git => 'https://github.com/Mijick/NavigationView.git', :tag => s.version.to_s }
19 | end
20 |
--------------------------------------------------------------------------------
/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: "MijickNavigationView",
8 | platforms: [
9 | .iOS(.v15)
10 | ],
11 | products: [
12 | .library(name: "MijickNavigationView", targets: ["MijickNavigationView"]),
13 | ],
14 | targets: [
15 | .target(name: "MijickNavigationView", dependencies: [], path: "Sources")
16 | ]
17 | )
18 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | Navigation made simple
13 |
14 |
15 |
16 | Improve the navigation in your project in no time. Keep your code clean
17 |
18 |
19 |
20 | Try demo we prepared
21 | |
22 | Roadmap
23 | |
24 | Propose a new feature
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | NavigationView by Mijick is a powerful, open-source library dedicated for SwiftUI that makes navigation process super easy and much cleaner.
59 | * **Custom animations.** Our library provides full support for any animation.
60 | * **Gesture support.** You can easily enable navigation gestures for a selected screen.
61 | * **Remembers the current scroll view offset.** Library automatically saves the current scroll view offset when you leave the view.
62 | * **Improves code quality.** Navigate through your screens with just one line of code. Focus on what’s important to you and your project, not on Swift's intricacies.
63 | * **Stability at last!** At Mijick, we are aware of the problems that were (and still are) with the native NavigationView and how many problems it caused to developers. Therefore, during the development process we put the greatest emphasis on the reliability and performance of the library.
64 | * **Designed for SwiftUI.** While developing the library, we have used the power of SwiftUI to give you powerful tool to speed up your implementation process.
65 |
66 |
67 |
68 | # Getting Started
69 | ### ✋ Requirements
70 |
71 | | **Platforms** | **Minimum Swift Version** |
72 | |:----------|:----------|
73 | | iOS 15+ | 5.0 |
74 |
75 | ### ⏳ Installation
76 |
77 | #### [Swift package manager][spm]
78 | Swift package manager is a tool for automating the distribution of Swift code and is integrated into the Swift compiler.
79 |
80 | Once you have your Swift package set up, adding NavigationView as a dependency is as easy as adding it to the `dependencies` value of your `Package.swift`.
81 |
82 | ```Swift
83 | dependencies: [
84 | .package(url: "https://github.com/Mijick/NavigationView", branch(“main”))
85 | ]
86 | ```
87 |
88 |
89 | #### [Cocoapods][cocoapods]
90 | Cocoapods is a dependency manager for Swift and Objective-C Cocoa projects that helps to scale them elegantly.
91 |
92 | Installation steps:
93 | - Install CocoaPods 1.10.0 (or later)
94 | - [Generate CocoaPods][generate_cocoapods] for your project
95 | ```Swift
96 | pod init
97 | ```
98 | - Add CocoaPods dependency into your `Podfile`
99 | ```Swift
100 | pod 'MijickNavigationView'
101 | ```
102 | - Install dependency and generate `.xcworkspace` file
103 | ```Swift
104 | pod install
105 | ```
106 | - Use new XCode project file `.xcworkspace`
107 |
108 |
109 |
110 | # Usage
111 | ### 1. Setup library
112 | Inside your `@main` structure, call the `implementNavigationView` method with the view that is to be the root of the navigation stack. The view must be of type `NavigatableView`. The method takes an optional argument - `config`, which can be used to configure certain attributes of all the views that will be placed in the navigation stack.
113 | ```Swift
114 | @main struct NavigationView_Main: App {
115 | var body: some Scene {
116 | WindowGroup {
117 | ContentView()
118 | .implementNavigationView(config: nil)
119 | }
120 | }
121 | }
122 | ```
123 |
124 | ### 2. Declare a view to be pushed to the navigation stack
125 | NavigationView by Mijick provides the ability to push any view conforming to the `NavigatableView` protocol to the navigation stack.
126 | ```Swift
127 | struct ExampleView: NavigatableView {
128 | ...
129 | }
130 | ```
131 |
132 | ### 3. Implement `body`
133 | Fill your view with content
134 | ```Swift
135 | struct ExampleView: NavigatableView {
136 | var body: some View {
137 | VStack(spacing: 0) {
138 | Text("Witaj okrutny świecie")
139 | Spacer()
140 | Button(action: pop) { Text("Pop") }
141 | }
142 | }
143 | ...
144 | }
145 | ```
146 |
147 | ### 4. Implement `configure(view: NavigationConfig) -> NavigationConfig` method
148 | *This step is optional - if you wish, you can skip this step and leave the configuration as default.*
149 | Each view has its own set of methods that can be used to create a unique look for each view in the stack.
150 | ```Swift
151 | struct ExampleView: NavigatableView {
152 | func configure(view: NavigationConfig) -> NavigationConfig { view.backgroundColour(.red) }
153 | var body: some View {
154 | VStack(spacing: 0) {
155 | Text("Witaj okrutny świecie")
156 | Spacer()
157 | Button(action: pop) { Text("Pop") }
158 | }
159 | }
160 | ...
161 | }
162 | ```
163 |
164 | ### 5. Present your view - from any place in your code!
165 | Just call `ExampleView().push(with:)` from the selected place. As simple as that!
166 | ```Swift
167 | struct SettingsViewModel {
168 | ...
169 | func openSettings() {
170 | ...
171 | ExampleView().push(with: .verticalSlide)
172 | ...
173 | }
174 | ...
175 | }
176 | ```
177 |
178 | ### 6. Close your view - it's even simpler!
179 | There are two ways to do this:
180 | - By calling one of the methods `pop`, `pop(to type:)`, `popToRoot` inside any view
181 | ```Swift
182 | struct ExampleView: NavigatableView {
183 | ...
184 | func createButton() -> some View {
185 | Button(action: popToRoot) { Text("Tap to return to root") }
186 | }
187 | ...
188 | }
189 | ```
190 | - By calling one of the static `NavigationManager` methods:
191 | - `NavigationManager.pop()`
192 | - `NavigationManager.pop(to type:)` where type is the type of view you want to return to
193 | - `NavigationManager.popToRoot()`
194 |
195 | ### 7. Wait, there's even more!
196 | We're almost done, but we'd like to describe three additional methods that you might like:
197 | - With the `setAsNewRoot` method you can change the root of your navigation stack:
198 | ```Swift
199 | ExampleView()
200 | .push(with: .verticalSlide)
201 | .setAsNewRoot()
202 | ```
203 |
204 | - `EnvironmentObject` can be passed, but remember to do this **BEFORE** pushing the view to the stack:
205 | ```Swift
206 | ExampleView()
207 | .environmentObject(object)
208 | .push(with: .verticalSlide)
209 | ```
210 |
211 | - Use `onFocus`, not `onAppear`
212 | If you want to be notified every time a view is visible (is on top of the stack), use `onFocus` method:
213 | ```Swift
214 | struct ExampleView: NavigatableView {
215 | var body: some View {
216 | VStack(spacing: 0) {
217 | Text("Witaj okrutny świecie")
218 | Spacer()
219 | Button(action: pop) { Text("Pop") }
220 | }
221 | .onFocus(self) {
222 | // Do something
223 | }
224 | }
225 | ...
226 | }
227 | ```
228 |
229 |
230 |
231 | # Try our demo
232 | See for yourself how does it work by cloning [project][Demo] we created
233 |
234 | # License
235 | NavigationView is released under the MIT license. See [LICENSE][License] for details.
236 |
237 |
238 |
239 | # Our other open source SwiftUI libraries
240 | [PopupView] - The most powerful popup library that allows you to present any popup
241 |
242 | [CalendarView] - Create your own calendar object in no time
243 |
244 | [GridView] - Lay out your data with no effort
245 |
246 | [CameraView] - The most powerful CameraController. Designed for SwiftUI
247 |
248 | [Timer] - Modern API for Timer
249 |
250 |
251 |
252 | [MIT]: https://en.wikipedia.org/wiki/MIT_License
253 | [spm]: https://www.swift.org/package-manager
254 | [cocoapods]: https://cocoapods.org/
255 | [generate_cocoapods]: https://github.com/square/cocoapods-generate
256 |
257 | [Demo]: https://github.com/Mijick/NavigationView-Demo
258 | [License]: https://github.com/Mijick/NavigationView/blob/main/LICENSE
259 |
260 | [PopupView]: https://github.com/Mijick/PopupView
261 | [CalendarView]: https://github.com/Mijick/CalendarView
262 | [CameraView]: https://github.com/Mijick/CameraView
263 | [GridView]: https://github.com/Mijick/GridView
264 | [Timer]: https://github.com/Mijick/Timer
265 |
--------------------------------------------------------------------------------
/Sources/Internal/Extensions/Animation++.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Animation++.swift of NavigationView
3 | //
4 | // Created by Tomasz Kurylik
5 | // - Twitter: https://twitter.com/tkurylik
6 | // - Mail: tomasz.kurylik@mijick.com
7 | // - GitHub: https://github.com/FulcrumOne
8 | //
9 | // Copyright ©2024 Mijick. Licensed under MIT License.
10 |
11 |
12 | import SwiftUI
13 |
14 | extension Animation {
15 | static func keyboard(withDelay: Bool) -> Animation { .easeOut(duration: 0.25).delay(withDelay ? 0.1 : 0) }
16 | }
17 |
--------------------------------------------------------------------------------
/Sources/Internal/Extensions/Array++.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Array++.swift of NavigationView
3 | //
4 | // Created by Tomasz Kurylik
5 | // - Twitter: https://twitter.com/tkurylik
6 | // - Mail: tomasz.kurylik@mijick.com
7 | //
8 | // Copyright ©2023 Mijick. Licensed under MIT License.
9 |
10 |
11 | import Foundation
12 |
13 | extension Array {
14 | @inlinable mutating func append(_ newElement: Element, if prerequisite: Bool) { if prerequisite {
15 | append(newElement)
16 | }}
17 | @inlinable mutating func removeLastExceptFirst() { if count > 1 {
18 | removeLast()
19 | }}
20 | @inlinable mutating func removeAllExceptFirst() { if count > 1 {
21 | removeLast(count - 1)
22 | }}
23 | @inlinable mutating func removeLastTo(elementWhere predicate: (Element) throws -> Bool) rethrows { if let index = try lastIndex(where: predicate) {
24 | removeLast(count - index - 1)
25 | }}
26 | }
27 | extension Array {
28 | var nextToLast: Element? { count >= 2 ? self[count - 2] : nil }
29 | }
30 |
31 |
32 | // MARK: - Equatable Elements
33 | extension Array where Element: Equatable {
34 | func appendingAsFirstAndRemovingDuplicates(_ newElement: Element) -> [Element] {
35 | var elements = self
36 |
37 | elements.removeAll(where: { $0 == newElement })
38 | elements[0] = newElement
39 |
40 | return elements
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/Sources/Internal/Extensions/Equatable++.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Equatable++.swift of NavigationView
3 | //
4 | // Created by Tomasz Kurylik
5 | // - Twitter: https://twitter.com/tkurylik
6 | // - Mail: tomasz.kurylik@mijick.com
7 | //
8 | // Copyright ©2023 Mijick. Licensed under MIT License.
9 |
10 |
11 | import Foundation
12 |
13 | extension Equatable {
14 | func isOne(of other: Self?...) -> Bool { other.contains(self) }
15 | }
16 |
--------------------------------------------------------------------------------
/Sources/Internal/Managers/KeyboardManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // KeyboardManager.swift of NavigationView
3 | //
4 | // Created by Tomasz Kurylik
5 | // - Twitter: https://twitter.com/tkurylik
6 | // - Mail: tomasz.kurylik@mijick.com
7 | // - GitHub: https://github.com/FulcrumOne
8 | //
9 | // Copyright ©2024 Mijick. Licensed under MIT License.
10 |
11 |
12 | import SwiftUI
13 | import Combine
14 |
15 | class KeyboardManager: ObservableObject {
16 | @Published private(set) var height: CGFloat = .zero
17 | private var subscription: [AnyCancellable] = []
18 |
19 | static let shared: KeyboardManager = .init()
20 | private init() { subscribeToKeyboardEvents() }
21 | }
22 |
23 | // MARK: - Hiding Keyboard
24 | extension KeyboardManager {
25 | static func hideKeyboard() { UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) }
26 | }
27 |
28 | // MARK: - Show / Hide Events
29 | private extension KeyboardManager {
30 | func subscribeToKeyboardEvents() { Publishers.Merge(keyboardWillOpenPublisher, keyboardWillHidePublisher)
31 | .sink { self.height = $0 }
32 | .store(in: &subscription)
33 | }
34 | }
35 | private extension KeyboardManager {
36 | var keyboardWillOpenPublisher: Publishers.CompactMap { NotificationCenter.default
37 | .publisher(for: UIResponder.keyboardWillShowNotification)
38 | .compactMap { $0.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect }
39 | .map { $0.height }
40 | }
41 | var keyboardWillHidePublisher: Publishers.Map { NotificationCenter.default
42 | .publisher(for: UIResponder.keyboardWillHideNotification)
43 | .map { _ in .zero }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/Sources/Internal/Managers/NavigationManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NavigationManager.swift of NavigationView
3 | //
4 | // Created by Tomasz Kurylik
5 | // - Twitter: https://twitter.com/tkurylik
6 | // - Mail: tomasz.kurylik@mijick.com
7 | //
8 | // Copyright ©2023 Mijick. Licensed under MIT License.
9 |
10 |
11 | import SwiftUI
12 |
13 | public class NavigationManager: ObservableObject {
14 | @Published private(set) var views: [AnyNavigatableView] = [] { willSet { onViewsWillUpdate(newValue) } }
15 | private(set) var transitionsBlocked: Bool = false { didSet { onTransitionsBlockedUpdate() } }
16 | private(set) var transitionType: TransitionType = .push
17 | private(set) var transitionAnimation: TransitionAnimation = .no
18 | private(set) var navigationBackGesture: NavigationBackGesture.Kind = .no
19 |
20 | static let shared: NavigationManager = .init()
21 | private init() {}
22 | }
23 |
24 | // MARK: - Operations Handler
25 | extension NavigationManager {
26 | static func performOperation(_ operation: Operation) { if !NavigationManager.shared.transitionsBlocked {
27 | DispatchQueue.main.async { shared.views.perform(operation) }
28 | }}
29 | }
30 |
31 | // MARK: - Setters
32 | extension NavigationManager {
33 | static func setRoot(_ rootView: some NavigatableView) { DispatchQueue.main.async { shared.views = [.init(rootView, .no)] }}
34 | static func replaceRoot(_ newRootView: some NavigatableView) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { shared.transitionType = .replaceRoot(.init(newRootView, .no)) }}
35 | static func blockTransitions(_ value: Bool) { shared.transitionsBlocked = value }
36 | static func setTransitionType(_ value: TransitionType) { if shared.transitionType != value { shared.transitionType = value }}
37 | }
38 |
39 | // MARK: - Gesture Handlers
40 | extension NavigationManager {
41 | func gestureStarted() {
42 | transitionAnimation = views.last?.animation ?? .no
43 | navigationBackGesture = views.last?.configure(view: .init()).navigationBackGesture ?? .no
44 | }
45 | }
46 |
47 | // MARK: - On Attributes Will/Did Change
48 | private extension NavigationManager {
49 | func onViewsWillUpdate(_ newValue: [AnyNavigatableView]) { if newValue.count != views.count {
50 | transitionType = newValue.count > views.count || !transitionType.isOne(of: .push, .pop) ? .push : .pop
51 | transitionAnimation = (transitionType == .push ? newValue.last?.animation : views[newValue.count].animation) ?? .no
52 | navigationBackGesture = newValue.last?.configure(view: .init()).navigationBackGesture ?? .no
53 | }}
54 | func onTransitionsBlockedUpdate() { if !transitionsBlocked, case let .replaceRoot(newRootView) = transitionType {
55 | views = views.appendingAsFirstAndRemovingDuplicates(newRootView)
56 | }}
57 | }
58 |
59 | // MARK: - Transition Type
60 | extension NavigationManager { enum TransitionType: Equatable {
61 | case pop, push
62 | case replaceRoot(AnyNavigatableView)
63 | }}
64 |
65 | // MARK: - Array Operations
66 | extension NavigationManager { enum Operation {
67 | case insert(any NavigatableView, TransitionAnimation)
68 | case removeLast, removeAll(toID: String), removeAllExceptFirst
69 | }}
70 | fileprivate extension [AnyNavigatableView] {
71 | mutating func perform(_ operation: NavigationManager.Operation) { if !NavigationManager.shared.transitionsBlocked {
72 | hideKeyboard()
73 | performOperation(operation)
74 | }}
75 | }
76 | private extension [AnyNavigatableView] {
77 | func hideKeyboard() {
78 | KeyboardManager.hideKeyboard()
79 | }
80 | mutating func performOperation(_ operation: NavigationManager.Operation) {
81 | switch operation {
82 | case .insert(let view, let animation): append(.init(view, animation), if: canBeInserted(view))
83 | case .removeLast: removeLastExceptFirst()
84 | case .removeAll(let id): removeLastTo(elementWhere: { $0.id == id })
85 | case .removeAllExceptFirst: removeAllExceptFirst()
86 | }
87 | }
88 | }
89 | private extension [AnyNavigatableView] {
90 | func canBeInserted(_ view: any NavigatableView) -> Bool { !contains(where: { $0.id == view.id }) }
91 | }
92 |
--------------------------------------------------------------------------------
/Sources/Internal/Managers/ScreenManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ScreenManager.swift of NavigationView
3 | //
4 | // Created by Tomasz Kurylik
5 | // - Twitter: https://twitter.com/tkurylik
6 | // - Mail: tomasz.kurylik@mijick.com
7 | // - GitHub: https://github.com/FulcrumOne
8 | //
9 | // Copyright ©2024 Mijick. Licensed under MIT License.
10 |
11 |
12 | import SwiftUI
13 |
14 | class ScreenManager: ObservableObject {
15 | @Published var size: CGSize = .init()
16 | @Published var safeArea: UIEdgeInsets = .init()
17 |
18 | static let shared: ScreenManager = .init()
19 | private init() {}
20 | }
21 |
22 | // MARK: - Updating Dimensions
23 | extension ScreenManager {
24 | static func update(_ reader: GeometryProxy) {
25 | shared.size.height = reader.size.height + reader.safeAreaInsets.top + reader.safeAreaInsets.bottom
26 | shared.size.width = reader.size.width + reader.safeAreaInsets.leading + reader.safeAreaInsets.trailing
27 |
28 | shared.safeArea.top = reader.safeAreaInsets.top
29 | shared.safeArea.bottom = reader.safeAreaInsets.bottom
30 | shared.safeArea.left = reader.safeAreaInsets.leading
31 | shared.safeArea.right = reader.safeAreaInsets.trailing
32 | }
33 | }
34 |
35 | // MARK: - Getting Value For Safe Area
36 | extension ScreenManager {
37 | func getSafeAreaValue(for edge: Edge.Set) -> CGFloat { switch edge {
38 | case .top: safeArea.top
39 | case .bottom: KeyboardManager.shared.height > 0 ? KeyboardManager.shared.height : safeArea.bottom
40 | case .leading: safeArea.left
41 | case .trailing: safeArea.right
42 | default: 0
43 | }}
44 | }
45 |
--------------------------------------------------------------------------------
/Sources/Internal/Protocols/Configurable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Configurable.swift of NavigationView
3 | //
4 | // Created by Tomasz Kurylik
5 | // - Twitter: https://twitter.com/tkurylik
6 | // - Mail: tomasz.kurylik@mijick.com
7 | //
8 | // Copyright ©2023 Mijick. Licensed under MIT License.
9 |
10 |
11 | public protocol Configurable {}
12 | extension Configurable {
13 | func changing(path: WritableKeyPath, to value: T) -> Self {
14 | var clone = self
15 | clone[keyPath: path] = value
16 | return clone
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Sources/Internal/Protocols/NavigatableView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NavigatableView.swift of NavigationView
3 | //
4 | // Created by Tomasz Kurylik
5 | // - Twitter: https://twitter.com/tkurylik
6 | // - Mail: tomasz.kurylik@mijick.com
7 | //
8 | // Copyright ©2023 Mijick. Licensed under MIT License.
9 |
10 |
11 | import SwiftUI
12 |
13 | public protocol NavigatableView: View {
14 | var id: String { get }
15 |
16 | func configure(view: NavigationConfig) -> NavigationConfig
17 | }
18 |
19 | // MARK: - Internals
20 | public extension NavigatableView {
21 | var id: String { .init(describing: Self.self) }
22 | }
23 |
--------------------------------------------------------------------------------
/Sources/Internal/Type Erasers/AnyNavigatableView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AnyNavigatableView.swift of NavigationView
3 | //
4 | // Created by Tomasz Kurylik
5 | // - Twitter: https://twitter.com/tkurylik
6 | // - Mail: tomasz.kurylik@mijick.com
7 | //
8 | // Copyright ©2023 Mijick. Licensed under MIT License.
9 |
10 |
11 | import SwiftUI
12 |
13 | struct AnyNavigatableView: NavigatableView, Equatable {
14 | let id: String
15 | let animation: TransitionAnimation
16 | private let _body: AnyView
17 | private let _configure: (NavigationConfig) -> (NavigationConfig)
18 |
19 |
20 | init(_ view: some NavigatableView, _ animation: TransitionAnimation) {
21 | self.id = view.id
22 | self.animation = animation
23 | self._body = AnyView(view)
24 | self._configure = view.configure
25 | }
26 | init(_ view: some NavigatableView, _ environmentObject: some ObservableObject) {
27 | self.id = view.id
28 | self.animation = .no
29 | self._body = AnyView(view.environmentObject(environmentObject))
30 | self._configure = view.configure
31 | }
32 | }
33 | extension AnyNavigatableView {
34 | static func == (lhs: AnyNavigatableView, rhs: AnyNavigatableView) -> Bool { lhs.id == rhs.id }
35 | }
36 | extension AnyNavigatableView {
37 | var body: some View { _body }
38 | func configure(view: NavigationConfig) -> NavigationConfig { _configure(view) }
39 | }
40 |
--------------------------------------------------------------------------------
/Sources/Internal/View Modifiers/AnimationCompletionModifier.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AnimationCompletionModifier.swift of NavigationView
3 | //
4 | // Created by Tomasz Kurylik
5 | // - Twitter: https://twitter.com/tkurylik
6 | // - Mail: tomasz.kurylik@mijick.com
7 | //
8 | // Copyright ©2023 Mijick. Licensed under MIT License.
9 |
10 |
11 | import SwiftUI
12 |
13 | extension View {
14 | func onAnimationCompleted(for value: V, perform action: @escaping () -> ()) -> some View { modifier(Modifier(observedValue: value, completion: action)) }
15 | }
16 |
17 | // MARK: - Implementation
18 | fileprivate struct Modifier: AnimatableModifier {
19 | var animatableData: V { didSet { notifyCompletionIfFinished() }}
20 | private var targetValue: V
21 | private var completion: () -> ()
22 |
23 |
24 | init(observedValue: V, completion: @escaping () -> ()) {
25 | self.animatableData = observedValue
26 | self.targetValue = observedValue
27 | self.completion = completion
28 | }
29 | func body(content: Content) -> some View { content }
30 | }
31 |
32 | private extension Modifier {
33 | func notifyCompletionIfFinished() { if animatableData == targetValue {
34 | DispatchQueue.main.async { self.completion() }
35 | }}
36 | }
37 |
--------------------------------------------------------------------------------
/Sources/Internal/Views/NavigationView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NavigationView.swift of NavigationView
3 | //
4 | // Created by Tomasz Kurylik
5 | // - Twitter: https://twitter.com/tkurylik
6 | // - Mail: tomasz.kurylik@mijick.com
7 | //
8 | // Copyright ©2023 Mijick. Licensed under MIT License.
9 |
10 |
11 | import SwiftUI
12 |
13 | struct NavigationView: View {
14 | let config: NavigationGlobalConfig
15 | @ObservedObject private var stack: NavigationManager = .shared
16 | @ObservedObject private var screenManager: ScreenManager = .shared
17 | @ObservedObject private var keyboardManager: KeyboardManager = .shared
18 | @GestureState private var isGestureActive: Bool = false
19 | @State private var temporaryViews: [AnyNavigatableView] = []
20 | @State private var animatableData: AnimatableData = .init()
21 | @State private var gestureData: GestureData = .init()
22 |
23 |
24 | var body: some View {
25 | ZStack { ForEach(temporaryViews, id: \.id, content: createItem) }
26 | .ignoresSafeArea()
27 | .frame(maxWidth: .infinity, maxHeight: .infinity)
28 | .gesture(createDragGesture())
29 | .onChange(of: stack.views, perform: onViewsChanged)
30 | .onChange(of: isGestureActive, perform: onDragGestureEnded)
31 | .onAnimationCompleted(for: animatableData.opacity, perform: onAnimationCompleted)
32 | .animation(.keyboard(withDelay: isKeyboardVisible), value: isKeyboardVisible)
33 | }
34 | }
35 | private extension NavigationView {
36 | func createItem(_ item: AnyNavigatableView) -> some View {
37 | item.body
38 | .padding(.top, getPadding(.top, item))
39 | .padding(.bottom, getPadding(.bottom, item))
40 | .padding(.leading, getPadding(.leading, item))
41 | .padding(.trailing, getPadding(.trailing, item))
42 | .frame(maxWidth: .infinity, maxHeight: .infinity)
43 | .background(getBackground(item).compositingGroup())
44 | .opacity(getOpacity(item))
45 | .scaleEffect(getScale(item))
46 | .offset(getOffset(item))
47 | .offset(x: getRotationTranslation(item))
48 | .rotation3DEffect(getRotationAngle(item), axis: getRotationAxis(), anchor: getRotationAnchor(item), perspective: getRotationPerspective())
49 | .compositingGroup()
50 | .disabled(gestureData.isActive)
51 | }
52 | }
53 |
54 | // MARK: - Handling Drag Gesture
55 | private extension NavigationView {
56 | func createDragGesture() -> some Gesture { DragGesture()
57 | .updating($isGestureActive) { _, state, _ in state = true }
58 | .onChanged(onDragGestureChanged)
59 | }
60 | }
61 | private extension NavigationView {
62 | func onDragGestureChanged(_ value: DragGesture.Value) { guard canUseDragGesture(), canUseDragGesturePosition(value) else { return }
63 | updateAttributesOnDragGestureStarted()
64 | gestureData.translation = calculateNewDragGestureDataTranslation(value)
65 | }
66 | func onDragGestureEnded(_ value: Bool) { guard !value, canUseDragGesture() else { return }
67 | switch shouldDragGestureReturn() {
68 | case true: onDragGestureEndedWithReturn()
69 | case false: onDragGestureEndedWithoutReturn()
70 | }
71 | }
72 | }
73 | private extension NavigationView {
74 | func canUseDragGesture() -> Bool {
75 | guard stack.views.count > 1 else { return false }
76 | guard !stack.transitionsBlocked else { return false }
77 | guard stack.navigationBackGesture == .drag else { return false }
78 | return true
79 | }
80 | func canUseDragGesturePosition(_ value: DragGesture.Value) -> Bool { if config.backGesturePosition == .anywhere { return true }
81 | let startPosition = stack.transitionAnimation == .verticalSlide ? value.startLocation.y : value.startLocation.x
82 | return startPosition < 50
83 | }
84 | func updateAttributesOnDragGestureStarted() { guard !gestureData.isActive else { return }
85 | stack.gestureStarted()
86 | gestureData.isActive = true
87 | }
88 | func calculateNewDragGestureDataTranslation(_ value: DragGesture.Value) -> CGFloat { switch stack.transitionAnimation {
89 | case .horizontalSlide, .cubeRotation, .scale: max(value.translation.width, 0)
90 | case .verticalSlide: max(value.translation.height, 0)
91 | default: 0
92 | }}
93 | func shouldDragGestureReturn() -> Bool { gestureData.translation > screenManager.size.width * config.backGestureThreshold }
94 | func onDragGestureEndedWithReturn() { NavigationManager.pop() }
95 | func onDragGestureEndedWithoutReturn() { withAnimation(getAnimation()) {
96 | NavigationManager.setTransitionType(.push)
97 | gestureData.isActive = false
98 | gestureData.translation = 0
99 | }}
100 | }
101 |
102 | // MARK: - Local Configurables
103 | private extension NavigationView {
104 | func getPadding(_ edge: Edge.Set, _ item: AnyNavigatableView) -> CGFloat {
105 | guard let ignoredAreas = getConfig(item).ignoredSafeAreas,
106 | ignoredAreas.edges.isOne(of: .init(edge), .all)
107 | else { return screenManager.getSafeAreaValue(for: edge) }
108 |
109 | if ignoredAreas.regions.isOne(of: .keyboard, .all) && isKeyboardVisible { return 0 }
110 | if ignoredAreas.regions.isOne(of: .container, .all) && !isKeyboardVisible { return 0 }
111 | return screenManager.getSafeAreaValue(for: edge)
112 | }
113 | func getBackground(_ item: AnyNavigatableView) -> Color { getConfig(item).backgroundColour ?? config.backgroundColour }
114 | func getConfig(_ item: AnyNavigatableView) -> NavigationConfig { item.configure(view: .init()) }
115 | }
116 |
117 | // MARK: - Calculating Opacity
118 | private extension NavigationView {
119 | func getOpacity(_ view: AnyNavigatableView) -> CGFloat { guard canCalculateOpacity(view) else { return 0 }
120 | let isLastView = isLastView(view)
121 | let opacity = calculateOpacityValue(isLastView)
122 | return opacity
123 | }
124 | }
125 | private extension NavigationView {
126 | func canCalculateOpacity(_ view: AnyNavigatableView) -> Bool {
127 | guard view.isOne(of: temporaryViews.last, temporaryViews.nextToLast) else { return false }
128 | return true
129 | }
130 | func isLastView(_ view: AnyNavigatableView) -> Bool {
131 | let lastView = stack.transitionType == .push ? temporaryViews.last : stack.views.last
132 | return view == lastView
133 | }
134 | func calculateOpacityValue(_ isLastView: Bool) -> CGFloat { switch stack.transitionAnimation {
135 | case .no, .horizontalSlide, .verticalSlide, .cubeRotation: 1
136 | case .dissolve: isLastView ? animatableData.opacity : 1 - animatableData.opacity
137 | case .scale: calculateOpacityValueForScaleTransition(isLastView)
138 | }}
139 | }
140 | private extension NavigationView {
141 | func calculateOpacityValueForScaleTransition(_ isLastView: Bool) -> CGFloat { switch isLastView {
142 | case true: gestureData.isActive ? 1 - gestureProgress * 1.5 : 1
143 | case false: gestureData.isActive ? 1 : 1 - animatableData.opacity * 1.5
144 | }}
145 | }
146 |
147 | // MARK: - Calculating Offset
148 | private extension NavigationView {
149 | func getOffset(_ view: AnyNavigatableView) -> CGSize { guard canCalculateOffset(view) else { return .zero }
150 | let offsetSlideValue = calculateSlideOffsetValue(view)
151 | let offset = animatableData.offset + offsetSlideValue + gestureData.translation
152 | let offsetX = calculateXOffsetValue(offset), offsetY = calculateYOffsetValue(offset)
153 | let finalOffset = calculateFinalOffsetValue(view, offsetX, offsetY)
154 | return finalOffset
155 | }
156 | }
157 | private extension NavigationView {
158 | func canCalculateOffset(_ view: AnyNavigatableView) -> Bool {
159 | guard stack.transitionAnimation.isOne(of: .horizontalSlide, .verticalSlide) || stack.navigationBackGesture == .drag else { return false }
160 | guard view.isOne(of: temporaryViews.last, temporaryViews.nextToLast) else { return false }
161 | return true
162 | }
163 | func calculateSlideOffsetValue(_ view: AnyNavigatableView) -> CGFloat { switch view == temporaryViews.last {
164 | case true: stack.transitionType == .push || gestureData.isActive ? 0 : maxOffsetValue
165 | case false: stack.transitionType == .push || gestureData.isActive ? -maxOffsetValue : 0
166 | }}
167 | func calculateXOffsetValue(_ offset: CGFloat) -> CGFloat { stack.transitionAnimation == .horizontalSlide ? offset : 0 }
168 | func calculateYOffsetValue(_ offset: CGFloat) -> CGFloat { stack.transitionAnimation == .verticalSlide ? offset : 0 }
169 | func calculateFinalOffsetValue(_ view: AnyNavigatableView, _ offsetX: CGFloat, _ offsetY: CGFloat) -> CGSize { switch view == temporaryViews.last {
170 | case true: .init(width: offsetX, height: offsetY)
171 | case false: .init(width: offsetX * offsetXFactor, height: 0)
172 | }}
173 | }
174 |
175 | // MARK: - Calculating Scale
176 | private extension NavigationView {
177 | func getScale(_ view: AnyNavigatableView) -> CGFloat { guard canCalculateScale(view) else { return 1 }
178 | let scaleValue = calculateScaleValue(view)
179 | let finalScale = calculateFinalScaleValue(scaleValue)
180 | return finalScale
181 | }
182 | }
183 | private extension NavigationView {
184 | func canCalculateScale(_ view: AnyNavigatableView) -> Bool {
185 | guard stack.transitionAnimation.isOne(of: .scale) else { return false }
186 | guard view.isOne(of: temporaryViews.last, temporaryViews.nextToLast) else { return false }
187 | return true
188 | }
189 | func calculateScaleValue(_ view: AnyNavigatableView) -> CGFloat { switch view == temporaryViews.last {
190 | case true: stack.transitionType == .push && !gestureData.isActive ? 1 - scaleFactor + animatableData.scale : 1 - animatableData.scale * (gestureProgress == 0 ? 1 : gestureProgress)
191 | case false: stack.transitionType == .push || gestureData.isActive ? 1 - animatableData.scale * (gestureProgress - 1) : 1 + scaleFactor - animatableData.scale
192 | }}
193 | func calculateFinalScaleValue(_ scaleValue: CGFloat) -> CGFloat { stack.transitionsBlocked || gestureData.translation > 0 ? scaleValue : 1 }
194 | }
195 |
196 | // MARK: - Calculating Rotation
197 | private extension NavigationView {
198 | func getRotationAngle(_ view: AnyNavigatableView) -> Angle { guard canCalculateRotation(view) else { return .zero }
199 | let angle = calculateRotationAngleValue(view)
200 | return angle
201 | }
202 | func getRotationAnchor(_ view: AnyNavigatableView) -> UnitPoint { switch view == temporaryViews.last {
203 | case true: .trailing
204 | case false: .leading
205 | }}
206 | func getRotationTranslation(_ view: AnyNavigatableView) -> CGFloat { guard canCalculateRotation(view) else { return 0 }
207 | let rotationTranslation = calculateRotationTranslationValue(view)
208 | return rotationTranslation
209 | }
210 | func getRotationAxis() -> (x: CGFloat, y: CGFloat, z: CGFloat) { (x: 0.00000001, y: 1, z: 0.00000001) }
211 | func getRotationPerspective() -> CGFloat { switch screenManager.size.width > screenManager.size.height {
212 | case true: 0.52
213 | case false: 1
214 | }}
215 | }
216 | private extension NavigationView {
217 | func canCalculateRotation(_ view: AnyNavigatableView) -> Bool {
218 | guard stack.transitionAnimation.isOne(of: .cubeRotation) else { return false }
219 | guard view.isOne(of: temporaryViews.last, temporaryViews.nextToLast) else { return false }
220 | return true
221 | }
222 | func calculateRotationAngleValue(_ view: AnyNavigatableView) -> Angle { let rotationFactor = gestureData.isActive ? 1 - gestureProgress : animatableData.rotation
223 | switch view == temporaryViews.last {
224 | case true: return .degrees(90 - 90 * rotationFactor)
225 | case false: return .degrees(-90 * rotationFactor)
226 | }
227 | }
228 | func calculateRotationTranslationValue(_ view: AnyNavigatableView) -> CGFloat { let rotationFactor = gestureData.isActive ? 1 - gestureProgress : animatableData.rotation
229 | switch view == temporaryViews.last {
230 | case true: return screenManager.size.width - rotationFactor * screenManager.size.width
231 | case false: return -rotationFactor * screenManager.size.width
232 | }
233 | }
234 | }
235 |
236 | // MARK: - Animation
237 | private extension NavigationView {
238 | func getAnimation() -> Animation { switch stack.transitionAnimation {
239 | case .no: .easeInOut(duration: 0)
240 | case .dissolve, .horizontalSlide, .verticalSlide, .scale: .interpolatingSpring(mass: 3, stiffness: 1000, damping: 500, initialVelocity: 6.4)
241 | case .cubeRotation: .easeOut(duration: 0.52)
242 | }}
243 | }
244 |
245 | // MARK: - On Transition Begin
246 | private extension NavigationView {
247 | func onViewsChanged(_ views: [AnyNavigatableView]) {
248 | blockTransitions()
249 | updateTemporaryViews(views)
250 | resetOffsetAndOpacity()
251 | animateOffsetAndOpacityChange()
252 | }
253 | }
254 | private extension NavigationView {
255 | func blockTransitions() {
256 | NavigationManager.blockTransitions(true)
257 | }
258 | func updateTemporaryViews(_ views: [AnyNavigatableView]) { switch stack.transitionType {
259 | case .push, .replaceRoot: temporaryViews = views
260 | case .pop: temporaryViews = views + [temporaryViews.last].compactMap { $0 }
261 | }}
262 | func resetOffsetAndOpacity() {
263 | let animatableOffsetFactor = stack.transitionType == .push ? 1.0 : -1.0
264 |
265 | animatableData.offset = maxOffsetValue * animatableOffsetFactor + gestureData.translation
266 | animatableData.opacity = gestureProgress
267 | animatableData.rotation = calculateNewRotationOnReset()
268 | animatableData.scale = scaleFactor * gestureProgress
269 | gestureData.isActive = false
270 | gestureData.translation = 0
271 | }
272 | func animateOffsetAndOpacityChange() { withAnimation(getAnimation()) {
273 | animatableData.offset = 0
274 | animatableData.opacity = 1
275 | animatableData.rotation = stack.transitionType == .push ? 1 : 0
276 | animatableData.scale = scaleFactor
277 | }}
278 | }
279 | private extension NavigationView {
280 | func calculateNewRotationOnReset() -> CGFloat { switch gestureData.isActive {
281 | case true: 1 - gestureProgress
282 | case false: stack.transitionType == .push ? 0 : 1
283 | }}
284 | }
285 |
286 | // MARK: - On Transition End
287 | private extension NavigationView {
288 | func onAnimationCompleted() {
289 | resetViewOnAnimationCompleted()
290 | resetTransitionType()
291 | unblockTransitions()
292 | }
293 | }
294 | private extension NavigationView {
295 | func resetViewOnAnimationCompleted() { guard stack.transitionType == .pop else { return }
296 | temporaryViews = stack.views
297 | animatableData.offset = 0
298 | animatableData.rotation = 1
299 | gestureData.translation = 0
300 | }
301 | func resetTransitionType() {
302 | NavigationManager.setTransitionType(.push)
303 | }
304 | func unblockTransitions() {
305 | NavigationManager.blockTransitions(false)
306 | }
307 | }
308 |
309 | // MARK: - Helpers
310 | private extension NavigationView {
311 | var gestureProgress: CGFloat { gestureData.translation / (stack.transitionAnimation == .verticalSlide ? screenManager.size.height : screenManager.size.width) }
312 | var isKeyboardVisible: Bool { keyboardManager.height > 0 }
313 | }
314 |
315 | // MARK: - Configurables
316 | private extension NavigationView {
317 | var scaleFactor: CGFloat { 0.46 }
318 | var offsetXFactor: CGFloat { 1/3 }
319 | var maxOffsetValue: CGFloat { [.horizontalSlide: screenManager.size.width, .verticalSlide: screenManager.size.height][stack.transitionAnimation] ?? 0 }
320 | }
321 |
322 |
323 | // MARK: - Animatable Data
324 | fileprivate struct AnimatableData {
325 | var opacity: CGFloat = 1
326 | var offset: CGFloat = 0
327 | var rotation: CGFloat = 0
328 | var scale: CGFloat = 0
329 | }
330 |
331 | // MARK: - Gesture Data
332 | fileprivate struct GestureData {
333 | var translation: CGFloat = 0
334 | var isActive: Bool = false
335 | }
336 |
--------------------------------------------------------------------------------
/Sources/Public/Public+NavigatableView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Public+NavigatableView.swift of NavigationView
3 | //
4 | // Created by Tomasz Kurylik
5 | // - Twitter: https://twitter.com/tkurylik
6 | // - Mail: tomasz.kurylik@mijick.com
7 | //
8 | // Copyright ©2023 Mijick. Licensed under MIT License.
9 |
10 |
11 | import SwiftUI
12 |
13 | // MARK: - Initialising
14 | public extension NavigatableView {
15 | func implementNavigationView(config: NavigationGlobalConfig = .init()) -> some View { GeometryReader { reader in
16 | NavigationView(config: config)
17 | .onAppear { ScreenManager.update(reader); NavigationManager.setRoot(self) }
18 | .onChange(of: reader.size) { _ in ScreenManager.update(reader) }
19 | .onChange(of: reader.safeAreaInsets) { _ in ScreenManager.update(reader) }
20 | }}
21 | }
22 |
23 | // MARK: - Customising
24 | public extension NavigatableView {
25 | func configure(view: NavigationConfig) -> NavigationConfig { view }
26 | }
27 |
28 | // MARK: - Presenting Views
29 | public extension NavigatableView {
30 | /// Pushes a new view. Stacks previous one
31 | @discardableResult func push(with animation: TransitionAnimation) -> some NavigatableView { NavigationManager.performOperation(.insert(self, animation)); return self }
32 |
33 | /// Sets the selected view as the new navigation root
34 | @discardableResult func setAsNewRoot() -> some NavigatableView { NavigationManager.replaceRoot(self); return self }
35 |
36 | /// Supplies an observable object to a view’s hierarchy
37 | @discardableResult func environmentObject(_ object: some ObservableObject) -> any NavigatableView { AnyNavigatableView(self, object) }
38 | }
39 |
--------------------------------------------------------------------------------
/Sources/Public/Public+NavigationBackGesture.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Public+NavigationBackGesture.swift of NavigationView
3 | //
4 | // Created by Tomasz Kurylik
5 | // - Twitter: https://twitter.com/tkurylik
6 | // - Mail: tomasz.kurylik@mijick.com
7 | // - GitHub: https://github.com/FulcrumOne
8 | //
9 | // Copyright ©2024 Mijick. Licensed under MIT License.
10 |
11 |
12 | public enum NavigationBackGesture {}
13 |
14 | // MARK: - Gesture Kind
15 | extension NavigationBackGesture { public enum Kind {
16 | case no
17 | case drag
18 | }}
19 |
20 | // MARK: - Gesture Position
21 | extension NavigationBackGesture { public enum Position {
22 | case edge
23 | case anywhere
24 | }}
25 |
--------------------------------------------------------------------------------
/Sources/Public/Public+NavigationConfig.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Public+NavigationConfig.swift of NavigationView
3 | //
4 | // Created by Tomasz Kurylik
5 | // - Twitter: https://twitter.com/tkurylik
6 | // - Mail: tomasz.kurylik@mijick.com
7 | //
8 | // Copyright ©2023 Mijick. Licensed under MIT License.
9 |
10 |
11 | import SwiftUI
12 |
13 | // MARK: - Content Customisation
14 | public extension NavigationConfig {
15 | /// Ignores safe areas
16 | func ignoresSafeArea(_ regions: SafeAreaRegions = .all, _ edges: SafeAreaEdges) -> Self { changing(path: \.ignoredSafeAreas, to: (regions, edges)) }
17 |
18 | /// Changes the background colour of the selected view
19 | func backgroundColour(_ value: Color) -> Self { changing(path: \.backgroundColour, to: value) }
20 |
21 | /// Changes the gesture that can be used to move to the previous view
22 | func navigationBackGesture(_ value: NavigationBackGesture.Kind) -> Self { changing(path: \.navigationBackGesture, to: value) }
23 | }
24 |
25 | // MARK: - Internal
26 | public struct NavigationConfig: Configurable {
27 | private(set) var ignoredSafeAreas: (regions: SafeAreaRegions, edges: SafeAreaEdges)? = nil
28 | private(set) var backgroundColour: Color? = nil
29 | private(set) var navigationBackGesture: NavigationBackGesture.Kind = .no
30 | }
31 |
--------------------------------------------------------------------------------
/Sources/Public/Public+NavigationGlobalConfig.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Public+NavigationGlobalConfig.swift of NavigationView
3 | //
4 | // Created by Tomasz Kurylik
5 | // - Twitter: https://twitter.com/tkurylik
6 | // - Mail: tomasz.kurylik@mijick.com
7 | //
8 | // Copyright ©2023 Mijick. Licensed under MIT License.
9 |
10 |
11 | import SwiftUI
12 |
13 | public struct NavigationGlobalConfig { public init() {}
14 | // MARK: Navigation Gestures
15 | public var backGesturePosition: NavigationBackGesture.Position = .anywhere
16 | public var backGestureThreshold: CGFloat = 0.25
17 |
18 | // MARK: Others
19 | public var backgroundColour: Color = .clear
20 | }
21 |
--------------------------------------------------------------------------------
/Sources/Public/Public+NavigationManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Public+NavigationManager.swift of NavigationView
3 | //
4 | // Created by Tomasz Kurylik
5 | // - Twitter: https://twitter.com/tkurylik
6 | // - Mail: tomasz.kurylik@mijick.com
7 | // - GitHub: https://github.com/FulcrumOne
8 | //
9 | // Copyright ©2024 Mijick. Licensed under MIT License.
10 |
11 |
12 | import Foundation
13 |
14 | public extension NavigationManager {
15 | /// Returns to a previous view on the stack
16 | static func pop() { performOperation(.removeLast) }
17 |
18 | /// Returns to view with provided type
19 | static func pop(to view: N.Type) { performOperation(.removeAll(toID: .init(describing: view))) }
20 |
21 | /// Returns to a root view
22 | static func popToRoot() { performOperation(.removeAllExceptFirst) }
23 | }
24 |
--------------------------------------------------------------------------------
/Sources/Public/Public+SafeAreaEdges.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Public+SafeAreaEdges.swift of NavigationView
3 | //
4 | // Created by Tomasz Kurylik
5 | // - Twitter: https://twitter.com/tkurylik
6 | // - Mail: tomasz.kurylik@mijick.com
7 | // - GitHub: https://github.com/FulcrumOne
8 | //
9 | // Copyright ©2024 Mijick. Licensed under MIT License.
10 |
11 |
12 | import SwiftUI
13 |
14 | public enum SafeAreaEdges {
15 | case top
16 | case bottom
17 | case leading
18 | case trailing
19 | case all
20 | }
21 |
22 | // MARK: - Initialiser
23 | extension SafeAreaEdges {
24 | init(_ value: Edge.Set) { switch value {
25 | case .top: self = .top
26 | case .bottom: self = .bottom
27 | case .leading: self = .leading
28 | case .trailing: self = .trailing
29 | case .all: self = .all
30 | default: self = .all
31 | }}
32 | }
33 |
--------------------------------------------------------------------------------
/Sources/Public/Public+TransitionAnimation.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Public+TransitionAnimation.swift of NavigationView
3 | //
4 | // Created by Tomasz Kurylik
5 | // - Twitter: https://twitter.com/tkurylik
6 | // - Mail: tomasz.kurylik@mijick.com
7 | //
8 | // Copyright ©2023 Mijick. Licensed under MIT License.
9 |
10 |
11 | public enum TransitionAnimation {
12 | case no
13 | case dissolve
14 | case scale
15 | case horizontalSlide, verticalSlide
16 | case cubeRotation
17 | }
18 |
--------------------------------------------------------------------------------
/Sources/Public/Public+View.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Public+View.swift of NavigationView
3 | //
4 | // Created by Tomasz Kurylik
5 | // - Twitter: https://twitter.com/tkurylik
6 | // - Mail: tomasz.kurylik@mijick.com
7 | //
8 | // Copyright ©2023 Mijick. Licensed under MIT License.
9 |
10 |
11 | import SwiftUI
12 |
13 | // MARK: - Removing Views From Stack
14 | public extension View {
15 | /// Removes the presented view from the stack
16 | func pop() { NavigationManager.pop() }
17 |
18 | /// Removes all views up to the selected view in the stack. The view from the argument will be the new active view
19 | func pop(to view: N.Type) { NavigationManager.pop(to: view) }
20 |
21 | /// Removes all views from the stack. Root view will be the new active view
22 | func popToRoot() { NavigationManager.popToRoot() }
23 | }
24 |
25 | // MARK: - Actions
26 | public extension View {
27 | /// Triggers every time the popup is at the top of the stack
28 | func onFocus(_ view: some NavigatableView, perform action: @escaping () -> ()) -> some View {
29 | onReceive(NavigationManager.shared.$views) { views in
30 | if views.last?.id == view.id { action() }
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------