├── .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 | NavigationView Logo 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 | Designed for SwiftUI 31 | Platforms: iOS 32 | Current Version 33 | License: MIT 34 |

35 | 36 |

37 | Made in Kraków 38 | 39 | Follow us on X 40 | 41 | 42 | Let's work together 43 | 44 | 45 | Stargazers 46 | 47 |

48 | 49 |

50 | NavigationView Examples 51 | NavigationView Examples 52 | NavigationView Examples 53 | NavigationView Examples 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 | --------------------------------------------------------------------------------