├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── SwiftfulRouting │ ├── Components │ ├── ModalSupportView.swift │ ├── ModuleSupportView.swift │ ├── SwipeBackSupportContainer.swift │ └── TransitionSupportView.swift │ ├── Core │ ├── RouterProtocol │ │ ├── AnyRouter.swift │ │ ├── MockRouter.swift │ │ ├── RouterEnvironmentKey.swift │ │ └── RouterProtocol.swift │ └── RouterViews │ │ ├── ModuleViewModel.swift │ │ ├── RouterView.swift │ │ ├── RouterViewInternal.swift │ │ └── RouterViewModel.swift │ ├── Extensions │ ├── Array+EXT.swift │ ├── Binding+EXT.swift │ ├── Set+EXT.swift │ ├── UserDefaults+EXT.swift │ └── View+EXT.swift │ ├── Logger │ └── RoutingLogger.swift │ ├── Models │ ├── Alerts │ │ ├── AlertLocation.swift │ │ ├── AlertStyle.swift │ │ └── AnyAlert.swift │ ├── Modals │ │ └── AnyModal.swift │ ├── Screens │ │ ├── AnyDestination.swift │ │ ├── AnyDestinationStack.swift │ │ ├── SegueLocation.swift │ │ ├── SegueOption.swift │ │ └── StableAnyDestinationArray.swift │ ├── Sheets │ │ ├── EnvironmentBackgroundOption.swift │ │ ├── FullScreenCoverConfig.swift │ │ ├── PresentationDetentTransformable.swift │ │ ├── ResizableSheetConfig.swift │ │ └── VisualEffectViewRepresentable.swift │ └── Transitions │ │ ├── AnyTransitionDestination.swift │ │ ├── CustomRemovalTransition.swift │ │ ├── TransitionMemoryBehavior.swift │ │ └── TransitionOption.swift │ └── ViewModifiers │ ├── AlertViewModifier.swift │ ├── OnFirstAppearModifier.swift │ └── ResizableSheetViewModifier.swift └── Tests └── SwiftfulRoutingTests └── SwiftfulRoutingTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Swiftful Thinking, LLC 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 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "SwiftfulRecursiveUI", 6 | "repositoryURL": "https://github.com/SwiftfulThinking/SwiftfulRecursiveUI.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "d8de572e731d2f45a7abf05642893cb848bfec8c", 10 | "version": "1.0.1" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.7 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: "SwiftfulRouting", 8 | platforms: [ 9 | .macOS(.v12), .iOS(.v16), .tvOS(.v14) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, and make them visible to other packages. 13 | .library( 14 | name: "SwiftfulRouting", 15 | targets: ["SwiftfulRouting"]), 16 | ], 17 | dependencies: [ 18 | .package(url: "https://github.com/SwiftfulThinking/SwiftfulRecursiveUI.git", from: "1.0.0") 19 | ], 20 | targets: [ 21 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 22 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 23 | .target( 24 | name: "SwiftfulRouting", 25 | dependencies: [ 26 | .product(name: "SwiftfulRecursiveUI", package: "SwiftfulRecursiveUI") 27 | ]), 28 | .testTarget( 29 | name: "SwiftfulRoutingTests", 30 | dependencies: ["SwiftfulRouting"]), 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### 🚀 Learn how to build and use this package: https://www.swiftful-thinking.com/offers/REyNLwwH 2 | 3 | 4 | # SwiftfulRouting 🤙 5 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FSwiftfulThinking%2FSwiftfulRouting%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/SwiftfulThinking/SwiftfulRouting) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FSwiftfulThinking%2FSwiftfulRouting%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/SwiftfulThinking/SwiftfulRouting) 6 | 7 | ### Programmatic navigation for SwiftUI applications. 8 | - ✅ Segues 9 | - ✅ Alerts 10 | - ✅ Modals 11 | - ✅ Transitions 12 | - ✅ Modules 13 | 14 | ### How to use this package: 15 | 16 | - 1️⃣ Read the docs below 17 | - 2️⃣ Watch [YouTube Tutorial](https://www.youtube.com/watch?v=zKfhv-Yds4g&list=PLwvDm4VfkdphPRGbtiY-X3IZsUXFi6595&index=6) 18 | - 3️⃣ Practice with [Sample Project](https://github.com/SwiftfulThinking/SwiftfulRoutingExample) 19 | - 4️⃣ Test the [Starter Project](https://github.com/SwiftfulThinking/SwiftfulStarterProject) 20 | 21 | 22 | ### Versioning: 23 | 24 | - ➡️ iOS 17+ use version 6.0 or above 25 | - ➡️ iOS 14+ use version 5.3.6 26 | - ➡️ iOS 13+ use version 2.0.2 27 | 28 | ## Quick Start (TLDR) 29 | 30 |
31 | Details (Click to expand) 32 |
33 | 34 | Use a `RouterView` to replace `NavigationStack` in your SwiftUI code. 35 | 36 | Before SwiftfulRouting: 37 | ```swift 38 | NavigationStack { 39 | MyView() 40 | .navigationDestination() 41 | .sheet() 42 | .fullScreenCover() 43 | .alert() 44 | } 45 | ``` 46 | 47 | With SwiftfulRouting: 48 | ```swift 49 | RouterView { _ in 50 | MyView() 51 | } 52 | ``` 53 | 54 | Use a `router` to perform actions. 55 | 56 | ```swift 57 | struct MyView: View { 58 | 59 | @Environment(\.router) var router 60 | 61 | var body: some View { 62 | Text("Hello, world!") 63 | .onTapGesture { 64 | router.showScreen { _ in 65 | AnotherView() 66 | } 67 | } 68 | } 69 | } 70 | ``` 71 | 72 | All available methods in `router` are in [AnyRouter.swift](https://github.com/SwiftfulThinking/SwiftfulRouting/blob/development/Sources/SwiftfulRouting/Core/RouterProtocol/AnyRouter.swift). 73 | 74 | Examples: 75 | 76 | ```swift 77 | router.showScreen() 78 | router.showAlert() 79 | router.showModal() 80 | router.showTransition() 81 | router.showModule() 82 | router.dismissScreen() 83 | router.dismissAlert() 84 | router.dismissModal() 85 | router.dismissTransition() 86 | router.dismissModule() 87 | ``` 88 | 89 |
90 | 91 | 92 | ## How It Works 93 | 94 |
95 | Details (Click to expand) 96 |
97 | 98 | As you segue to a new screen, the framework adds a set view modifiers to the root of the destination View that will support all potential navigation routes. This allows declarative code to behave as programmatic code, since the view modifiers are connected in advance. Screen destinations are erased to generic types, allowing the developer to determine the destination at the time of execution. 99 | 100 | 101 | Version 6.0 adds many new features to the framework by implementing an internal RouterViewModel across the screen heirarchy that allows and screen's router to perform actions that affect the entire heirarchy. The solution introduces [AnyDestinationStack] which is a single array that holds bindings for all active segues in the heirarchy. 102 | 103 | ``` 104 | // Example of what an [AnyDestinationStack] might look like: 105 | 106 | [ 107 | [.fullScreenCover] 108 | [.push, .push, .push, .push] 109 | [.sheet] 110 | [] 111 | ] 112 | ``` 113 | 114 | In addition to adding a `router` to the Environment, every segue immedaitely returns a `router` in the View's closure. This allows the developer to have access to the screen's routing methods before the screen is created. Leave fully decouples routing logic from the View layer and is perfect for more complex app architectures, such as MVVM or VIPER. 115 | 116 | ```swift 117 | RouterView { router in 118 | MyView(router: router) 119 | } 120 | ``` 121 | 122 |
123 | 124 | ## Setup 125 | 126 |
127 | Details (Click to expand) 128 |
129 | Add the package to your Xcode project. 130 | 131 | ``` 132 | https://github.com/SwiftfulThinking/SwiftfulRouting.git 133 | ``` 134 | 135 | Import the package. 136 | 137 | ```swift 138 | import SwiftfulRouting 139 | ``` 140 | 141 | Add a `RouterView` at the top of your view heirarchy. A `RouterView` will embed your view into a NavigationStack and add modifiers to support all potential segues. This would **replace** an existing `NavigationStack` in your code. 142 | 143 | Use a `RouterView` to replace `NavigationStack` in your SwiftUI code. 144 | 145 | ```swift 146 | // Before SwiftfulRouting 147 | NavigationStack { 148 | MyView() 149 | .navigationDestination() 150 | .sheet() 151 | .fullScreenCover() 152 | .alert() 153 | } 154 | 155 | // With SwiftfulRouting 156 | RouterView { _ in 157 | MyView() 158 | } 159 | ``` 160 | 161 | All child views have access to a `Router` in the `Environment`. 162 | 163 | ```swift 164 | @Environment(\.router) var router 165 | 166 | var body: some View { 167 | Text("Hello, world!") 168 | .onTapGesture { 169 | router.showScreen(.push) { _ in 170 | Text("Another screen!") 171 | } 172 | } 173 | } 174 | } 175 | ``` 176 | 177 | Instead of relying on the `Environment`, you can also pass the `router` directly into the child views. 178 | 179 | ```swift 180 | RouterView { router in 181 | MyView(router: router) 182 | } 183 | ``` 184 | 185 | You can also use the returned `router` directly. A new `router` is created and added to the view heirarchy after each segue and are therefore unique to each screen. In the below example, the tap gesture on "View3" could call `dismissScreen()` from `router2` or `router3`, which would have different behaviors. This is done on purpose and is further explained in the docs below! 186 | 187 | ```swift 188 | RouterView { router1 in 189 | Text("View 1") 190 | .onTapGesture { 191 | router1.showScreen(.push) { router2 in 192 | Text("View 2") 193 | .onTapGesture { 194 | router2.showScreen(.push) { router3 in 195 | Text("View3") 196 | .onTapGesture { 197 | router3.dismissScreen() // Dismiss View3 198 | router2.dismissScreen() // Dismiss View2 and View 3 199 | } 200 | } 201 | } 202 | } 203 | } 204 | } 205 | ``` 206 | 207 | Refer to [AnyRouter.swift](https://github.com/SwiftfulThinking/SwiftfulRouting/blob/main/Sources/SwiftfulRouting/Core/AnyRouter.swift) to see all accessible methods. 208 | 209 |
210 | 211 | ## Setup (existing projects) 212 | 213 |
214 | Details (Click to expand) 215 |
216 | 217 | In order to enter the framework's view heirarchy, you must wrap your content in a `RouterView`, which will add a `NavigationStack` by default. 218 | 219 | Most apps should replace their existing `NavigationStack` with a `RouterView`, however, if you cannot remove it, you can add a `RouterView` but initialize it without a `NavigationStack`. 220 | 221 | The framework uses the native SwiftUI navigation bar, so all related modifiers will still work. 222 | 223 | ```swift 224 | RouterView(addNavigationView: false) { router in 225 | MyView() 226 | .navigationBarHidden(true) 227 | .toolbar { 228 | } 229 | } 230 | ``` 231 | 232 |
233 | 234 | ## Show Screens 235 | 236 |
237 | Details (Click to expand) 238 |
239 | 240 | Router supports all native SwiftUI segues. 241 | 242 | ```swift 243 | // Navigation destination 244 | router.showScreen(.push) { _ in 245 | Text("View2") 246 | } 247 | 248 | // Sheet 249 | router.showScreen(.sheet) { _ in 250 | Text("View2") 251 | } 252 | 253 | // FullScreenCover 254 | router.showScreen(.fullScreenCover) { _ in 255 | Text("View2") 256 | } 257 | ``` 258 | 259 | Segue methods also accept `AnyDestination` as a convenience. 260 | 261 | ```swift 262 | let screen = AnyDestination(segue: .push, destination: { router in 263 | Text("Hello, world!") 264 | }) 265 | 266 | router.showScreen(screen) 267 | ``` 268 | 269 | Segue to multiple screens at once. This will immediately trigger each screen in order, ending with the last screen displayed. 270 | 271 | ```swift 272 | let screen1 = AnyDestination(segue: .push, destination: { router in 273 | Text("Hello, world!") 274 | }) 275 | let screen2 = AnyDestination(segue: .sheet, destination: { router in 276 | Text("Another screen!") 277 | }) 278 | let screen3 = AnyDestination(segue: .push, destination: { router in 279 | Text("Third screen!") 280 | }) 281 | 282 | router.showScreens(destinations: [screen1, screen2, screen3]) 283 | ``` 284 | 285 | Use `.sheetConfig()` or `.fullScreenCoverConfig()` to for resizable sheets and backgrounds in new Environments. 286 | 287 | ```swift 288 | let config = ResizableSheetConfig( 289 | detents: [.medium, .large], 290 | dragIndicator: .visible 291 | ) 292 | 293 | router.showScreen(.sheetConfig(config: config)) { _ in 294 | Text("Screen2") 295 | } 296 | ``` 297 | 298 | ```swift 299 | let config = FullScreenCoverConfig( 300 | background: .clear 301 | ) 302 | 303 | router.showScreen(.fullScreenCoverConfig(config: config)) { _ in 304 | Text("Screen2") 305 | } 306 | ``` 307 | 308 | All segues have an `onDismiss` method. 309 | 310 | ```swift 311 | router.showScreen(.push, onDismiss: { 312 | // dismiss action 313 | }, destination: { _ in 314 | Text("Hello, world!") 315 | }) 316 | ``` 317 | 318 | Fully customize each segue! 319 | 320 | ```swift 321 | let screen = AnyDestination( 322 | id: "profile_screen", // id of screen (used for analytics) 323 | segue: .fullScreenCover, // segue option 324 | location: .insert, // where to add screen within the view heirarchy 325 | animates: true, // animate the segue 326 | transitionBehavior: .keepPrevious, // transition behavior (only relevant for showTransition methods) 327 | onDismiss: { 328 | // Do something when screen dismisses 329 | }, 330 | destination: { _ in 331 | Text("ProfileView") 332 | } 333 | ) 334 | ``` 335 | 336 | Additional convenience methods: 337 | 338 | ```swift 339 | router.showSafari { 340 | URL(string: "https://www.apple.com") 341 | } 342 | ``` 343 |
344 | 345 | 346 | ## Dismiss Screens 347 | 348 |
349 | Details (Click to expand) 350 |
351 | 352 | Dismiss one screen. 353 | 354 | ```swift 355 | router.dismissScreen() 356 | ``` 357 | 358 | You can also use the native SwiftUI method. 359 | 360 | ```swift 361 | @Environment(\.dismiss) var dismiss 362 | ``` 363 | 364 | Dismiss screen at id. 365 | 366 | ```swift 367 | router.dismissScreen(id: "x") 368 | ``` 369 | 370 | Dismiss screens back to, but not including, id. 371 | 372 | ```swift 373 | router.dismissScreen(upToScreenId: "x") 374 | ``` 375 | 376 | Dismiss a specific number of screens. 377 | 378 | ```swift 379 | router.dismissScreens(count: 2) 380 | ``` 381 | 382 | Dismiss all .push segues on the NavigationStack of the current screen. 383 | 384 | ```swift 385 | router.dismissPushStack() 386 | ``` 387 | 388 | Dismiss screen environment (ie. the closest .sheet or .fullScreenCover to this screen). 389 | 390 | ```swift 391 | router.dismissEnvironment() 392 | ``` 393 | 394 | Dismiss the last screen in the screen heirarchy. 395 | 396 | ```swift 397 | router.dismissLastScreen() 398 | ``` 399 | 400 | Dismiss the last push stack in the screen heirarchy. 401 | 402 | ```swift 403 | router.dismissLastPushStack() 404 | ``` 405 | 406 | Dismiss the last environment in the screen heirarchy. 407 | 408 | ```swift 409 | router.dismissLastEnvironment() 410 | ``` 411 | 412 | Dismiss all screens in the screen heirarchy. 413 | 414 | ```swift 415 | router.dismissLastEnvironment() 416 | ``` 417 |
418 | 419 | ## Screen Queue 420 | 421 |
422 | Details (Click to expand) 423 |
424 | 425 | Add screens to a queue to navigate to them later! 426 | 427 | ```swift 428 | router.addScreenToQueue(destination: screen1) 429 | router.addScreensToQueue(destinations: [screen1, screen2, screen3]) 430 | ``` 431 | 432 | Trigger segue to the first screen in queue, if available. 433 | 434 | ```swift 435 | // Show next screen if available 436 | router.showNextScreen() 437 | 438 | // show next screen, otherwise, throw error 439 | do { 440 | try router.tryShowNextScreen() 441 | } catch { 442 | // Do something else 443 | } 444 | ``` 445 | 446 | Remove screens from the queue. 447 | 448 | ```swift 449 | router.removeScreenFromQueue(id: "x") 450 | router.removeScreensFromQueue(ids: ["x", "y"]) 451 | router.removeAllScreensFromQueue() 452 | ``` 453 | 454 | For example, an onboarding flow might have a variable number of screens depending on the user's responses. As the user progresses, add screens to the queue and then the logic within each screen is "try to go to next screen (if available) otherwise dismiss onboarding" 455 | 456 | Additional convenience methods: 457 | 458 | ```swift 459 | // Segue to a the next screen in the queue (if available) otherwise dismiss the screen. 460 | router.showNextScreenOrDismissScreen() 461 | 462 | // Segue to a the next screen in the queue (if available) otherwise dismiss environment. 463 | router.showNextScreenOrDismissEnvironment() 464 | 465 | // Segue to a the next screen in the queue (if available) otherwise dismiss push stack. 466 | router.showNextScreenOrDismissPushStack() 467 | ``` 468 | 469 |
470 | 471 | 472 | ## Show Alerts 473 | 474 |
475 | Details (Click to expand) 476 |
477 | 478 | Router supports all native SwiftUI alerts. 479 | 480 | ```swift 481 | // Alert 482 | router.showAlert(.alert, title: "Title goes here", subtitle: "Subtitle goes here!") { 483 | Button("OK") { 484 | 485 | } 486 | Button("Cancel") { 487 | 488 | } 489 | } 490 | 491 | // Confirmation Dialog 492 | router.showAlert(.confirmationDialog, title: "Title goes here", subtitle: "Subtitle goes here!") { 493 | Button("A") { 494 | 495 | } 496 | Button("B") { 497 | 498 | } 499 | Button("C") { 500 | 501 | } 502 | } 503 | ``` 504 | 505 | Buttons closure supports all the same features as the native SwiftUI closure, such as TextFields. 506 | 507 | ```swift 508 | let alert = AnyAlert(style: .alert, title: "Title goes here", subtitle: "Subtitle goes here", buttons: { 509 | TextField("Enter your name", text: $textfieldText) 510 | 511 | Button("SUBMIT", action: { 512 | 513 | }) 514 | }) 515 | ``` 516 | 517 | Alert methods also accept `AnyAlert` as a convenience. 518 | 519 | ```swift 520 | let alert = AnyAlert( 521 | style: .alert, 522 | location: .currentScreen, 523 | title: "Title", 524 | subtitle: nil 525 | ) 526 | router.showAlert(alert: alert) 527 | ``` 528 | 529 | Dismiss the alert. 530 | 531 | ```swift 532 | router.dismissAlert() 533 | router.dismissAllAlerts() 534 | ``` 535 | 536 | Additional convenience methods. 537 | 538 | ```swift 539 | router.showBasicAlert(text: "Error") 540 | ``` 541 | 542 |
543 | 544 | ## Show Modals 545 | 546 |
547 | Details (Click to expand) 548 |
549 | 550 | Modals appear on top of the current screen. Router supports an **infinite** number of **simultaneous** modals. 551 | 552 | ```swift 553 | router.showModal { 554 | MyModal() 555 | .frame(width: 300, height: 300) 556 | } 557 | ``` 558 | 559 | Fully customize modal's display. 560 | 561 | ```swift 562 | router.showModal( 563 | id: "modal_1", // Id for modal 564 | transition: .move(edge: .bottom), // AnyTransition 565 | animation: .smooth, // transition animation 566 | alignment: .center, // Alignment within screen 567 | backgroundColor: Color.black.opacity(0.1), // Color behind modal 568 | backgroundEffect: BackgroundEffect(effect: UIBlurEffect(style: .systemMaterialDark), intensity: 0.1), // Blur effect behind modal 569 | dismissOnBackgroundTap: true, // Add dismiss tap gesture on background layer 570 | ignoreSafeArea: true, // Modal will safe area 571 | onDismiss: { 572 | // Do something when modal is dismissed 573 | }, 574 | destination: { 575 | MyModal() 576 | } 577 | ) 578 | ``` 579 | 580 | Modal methods also accept `AnyModal` as a convenience. 581 | 582 | ``` 583 | let modal = AnyModal { 584 | MyModal() 585 | } 586 | 587 | router.showModal(modal: modal) 588 | ``` 589 | 590 | Trigger multiple modals at the same time. 591 | 592 | ```swift 593 | router.showModals(modals: [modal1, modal2]) 594 | ``` 595 | 596 | Dismiss the last modal displayed. 597 | 598 | ```swift 599 | router.dismissModal() 600 | ``` 601 | 602 | Dismiss modal by id. 603 | 604 | ```swift 605 | router.dismissModal(id: "modal_1") 606 | ``` 607 | 608 | Dismiss modals above, but not including, id. 609 | 610 | ```swift 611 | router.dismissModals(upToModalId: "modal_1") 612 | ``` 613 | 614 | Dismiss specific number of modals. 615 | 616 | ```swift 617 | router.dismissModals(count: 2) 618 | ``` 619 | 620 | Dismiss all modals. 621 | 622 | ```swift 623 | router.dismissAllModals() 624 | ``` 625 | 626 | Additional convenience methods: 627 | 628 | ```swift 629 | router.showBasicModal { 630 | Rectangle() 631 | .frame(width: 200, height: 200) 632 | } 633 | ``` 634 | 635 | ```swift 636 | router.showBottomModal { 637 | Rectangle() 638 | .frame(width: 200, height: 200) 639 | } 640 | ``` 641 | 642 |
643 | 644 | ## Show Transitions 645 | 646 |
647 | Details (Click to expand) 648 |
649 | 650 | Transitions change the current screen WITHOUT performing a full segue. 651 | 652 | Transitions are NOT segues! 653 | 654 | Transitions are similar to using an "if-else" statement to switch between views. 655 | 656 | ```swift 657 | router.showTransition { router in 658 | MyView() 659 | } 660 | ``` 661 | 662 | **Important:** When showing a new screen via `showScreen` there is a parameter `transitionBehavior`. This will determine the UI behavior of any `showTransition` on the resulting screen. 663 | 664 | Set `transitionBehavior` to `.keepPrevious` to keep previous screens in memory. This will transition new screens ON TOP of each other. 665 | 666 | Set `transitionBehavior` to `.removePrevious` to remove previous screens from memory. This will transition a new screen on, while transitioning the old screen off. 667 | 668 | ```swift 669 | router.showScreen(transitionBehavior: .removePrevious) { _ in 670 | MyView() 671 | } 672 | ``` 673 | 674 | Transition methods also accept `AnyTransitionDestination` as a convenience. 675 | 676 | ```swift 677 | let screen = AnyTransitionDestination { _ in 678 | MyView() 679 | } 680 | 681 | router.showTransition(transition: screen) 682 | ``` 683 | 684 | Add multiple transitions on the screen and display the last one on top. 685 | 686 | ```swift 687 | router.showTransitions(transitions: [screen1, screen2, screen3]) 688 | ``` 689 | 690 | Fully customize transition's display. 691 | 692 | ```swift 693 | let transition = AnyTransitionDestination( 694 | id: "transition_1", // Id for the screen 695 | transition: .trailing, // Transition edge 696 | allowsSwipeBack: true, // Add a swipe back gesture to the screen's edge 697 | onDismiss: { 698 | // Do something when transition dismisses 699 | }, 700 | destination: { router in 701 | MyView() 702 | } 703 | ) 704 | ``` 705 | 706 | Dismiss the last transition displayed. 707 | 708 | ```swift 709 | router.dismissTransition() 710 | ``` 711 | 712 | Dismiss transition by id. 713 | 714 | ```swift 715 | router.dismissTransition(id: "transition_1") 716 | ``` 717 | 718 | Dismiss transitions above, but not including, id. 719 | 720 | ```swift 721 | router.dismissTransitions(upToId: "transition_1") 722 | ``` 723 | 724 | Dismiss specific number of transitions. 725 | 726 | ```swift 727 | router.dismissTransitions(count: 2) 728 | ``` 729 | 730 | Dismiss all transitions. 731 | 732 | ```swift 733 | router.dismissAllTransitions() 734 | ``` 735 | 736 | Additional convenience methods: 737 | 738 | ```swift 739 | // Dismiss transition (if there is one) otherwise dismiss screen. 740 | router.dismissTransitionOrDismissScreen() 741 | ``` 742 | 743 |
744 | 745 | ## Transition Queue 746 | 747 |
748 | Details (Click to expand) 749 |
750 | 751 | Add transitions to a queue to trigger them later! 752 | 753 | ```swift 754 | router.addTransitionToQueue(transition: screen1) 755 | router.addTransitionsToQueue(transitions: [screen1, screen2, screen3]) 756 | ``` 757 | 758 | Trigger transition to the first in queue, if available. 759 | 760 | ```swift 761 | // Show next transition if available 762 | router.showNextTransition() 763 | 764 | // show next transition, otherwise, throw error 765 | do { 766 | try router.tryShowNextTransition() 767 | } catch { 768 | // Do something else 769 | } 770 | ``` 771 | 772 | Remove transitinos from the queue. 773 | 774 | ```swift 775 | router.removeTransitionFromQueue(id: "x") 776 | router.removeTransitionsFromQueue(ids: ["x", "y"]) 777 | router.removeAllTransitionsFromQueue() 778 | ``` 779 | 780 | For example, an onboarding flow might have a variable number of screens depending on the user's responses. As the user progresses, add screens to the queue and then the logic within each screen is "try to go to next screen (if available) otherwise dismiss onboarding" 781 | 782 | Additional convenience methods: 783 | 784 | ```swift 785 | // Trigger next transition or trigger next screen or dismiss screen. 786 | router.showNextTransitionOrNextScreenOrDismissScreen() 787 | ``` 788 | 789 |
790 | 791 | ## Show Modules 792 | 793 |
794 | Details (Click to expand) 795 |
796 | 797 | Modules swap the ENTIRE view heirarchy and replace the existing `RouterView` with a new one. 798 | 799 | ```swift 800 | router.showModule { router in 801 | MyView() 802 | } 803 | ``` 804 | 805 | **Important:** Module support is NOT automatically included within `RouterView`. You must enable it by setting `addModuleSupport` to `true`. This is done on purpose, in case there are multiple `RouterView` in the same heirarchy. 806 | 807 | ```swift 808 | router.showScreen(addModuleSupport: true) { _ in 809 | MyView() 810 | } 811 | ``` 812 | 813 | Depending on how deep your view heirarchy is, you may want to dismiss screens before switching modules for better UX. 814 | 815 | ```swift 816 | Task { 817 | router.dismissAllScreens() 818 | try? await Task.sleep(for: .seconds(1)) 819 | router.showModule { router in 820 | MyView() 821 | } 822 | } 823 | ``` 824 | 825 | Module methods also accept `AnyTransitionDestination` as a convenience. 826 | 827 | ```swift 828 | let screen = AnyTransitionDestination { _ in 829 | MyView() 830 | } 831 | 832 | router.showModule(module: screen) 833 | ``` 834 | 835 | The user's last module is saved in UserDefaults and can be used to restore the app's state across sessions. 836 | 837 | ```swift 838 | @State private var lastModuleId = UserDefaults.lastModuleId 839 | 840 | var body: some Scene { 841 | WindowGroup { 842 | if lastModuleId == "onboarding" { 843 | RouterView(id: "onboarding", addModuleSupport: true) { router in 844 | OnboardingView() 845 | } 846 | } else { 847 | RouterView(id: "home", addModuleSupport: true) { router in 848 | HomeView() 849 | } 850 | } 851 | } 852 | } 853 | ``` 854 | 855 | Add multiple modules to the heirarchy and display the last one. 856 | 857 | ```swift 858 | router.showModules(modules: [module1, module2, module3]) 859 | ``` 860 | 861 | Fully customize module's display. 862 | 863 | ```swift 864 | let module = AnyTransitionDestination( 865 | id: "module_1", // Id for the screen 866 | transition: .trailing, // Transition edge 867 | allowsSwipeBack: true, // Add a swipe back gesture to the screen's edge 868 | onDismiss: { 869 | // Do something when transition dismisses 870 | }, 871 | destination: { router in 872 | MyView() 873 | } 874 | ) 875 | ``` 876 | 877 | **Note:** You can dismiss modules, although it is easier to use `showModule` to display the previous module again. 878 | 879 | Dismiss the last module displayed. 880 | 881 | ```swift 882 | router.dismissModule() 883 | ``` 884 | 885 | Dismiss module by id. 886 | 887 | ```swift 888 | router.dismissModule(id: "module_1") 889 | ``` 890 | 891 | Dismiss modules above, but not including, id. 892 | 893 | ```swift 894 | router.dismissModules(upToId: "module_1") 895 | ``` 896 | 897 | Dismiss specific number of modules. 898 | 899 | ```swift 900 | router.dismissModules(count: 2) 901 | ``` 902 | 903 | Dismiss all modules. 904 | 905 | ```swift 906 | router.dismissAllModules() 907 | ``` 908 | 909 |
910 | 911 | ## Logging, Analytics & Debugging 912 | 913 |
914 | Details (Click to expand) 915 |
916 | 917 | Built-in logging that can be used for debugging and analytics. 918 | 919 | ```swift 920 | // Set log level using internal logger: 921 | 922 | SwiftfulRoutingLogger.enableLogging(level: .analytic, printParameters: true) 923 | ``` 924 | 925 | Add your own implementation to handle unique events in your app. 926 | ```swift 927 | struct MyLogger: RoutingLogger { 928 | 929 | func trackEvent(event: any RoutingLogEvent) { 930 | let name = event.eventName 931 | let params = event.parameters 932 | 933 | switch event.type { 934 | case .info: 935 | break 936 | case .analytic: 937 | break 938 | case .warning: 939 | break 940 | case .severe: 941 | break 942 | } 943 | } 944 | } 945 | 946 | SwiftfulRoutingLogger.enableLogging(logger: MyLogger()) 947 | ``` 948 | 949 | Or use [SwiftfulLogging](https://github.com/SwiftfulThinking/SwiftfulLogging) directly. 950 | 951 | ```swift 952 | let logManager = LogManager(services: [ 953 | ConsoleService(printParameters: true), 954 | FirebaseCrashlyticsService(), 955 | MixpanelService() 956 | ]) 957 | 958 | SwiftfulRoutingLogger.enableLogging(logger: logManager) 959 | ``` 960 | 961 | Additional values to look into the underlying view heirarchy. 962 | 963 | ```swift 964 | 965 | // Active screen stacks in the heirarchy 966 | router.activeScreens 967 | 968 | // Active screen queue 969 | router.activeScreenQueue 970 | 971 | // Has at least 1 screen in queue 972 | router.hasScreenInQueue 973 | 974 | // Active alert 975 | router.activeAlert 976 | 977 | // Has alert displayed 978 | router.hasActiveAlert 979 | 980 | // Active modals on screen 981 | router.activeModals 982 | 983 | // Has at least 1 modal displayed 984 | router.hasActiveModal 985 | 986 | // Active transitions on screen 987 | router.activeTransitions 988 | 989 | // Has at least 1 active transtion 990 | router.hasActiveTransition 991 | 992 | // Active transition queue 993 | router.activeTransitionQueue 994 | 995 | // Has at least 1 transition in queue 996 | router.hasTransitionInQueue 997 | 998 | // Active modules 999 | router.activeModules 1000 | ``` 1001 | 1002 |
1003 | 1004 | ## Tabbar & App Structure 1005 | 1006 |
1007 | Details (Click to expand) 1008 |
1009 | 1010 | Even without SwiftfulRouting, SwiftUI developers must decide between using 1 NavigationStack for the entire application or individual NavigationStacks for each tab. 1011 | 1012 | If you use only 1 `NavigationStack`, it will be a parent to the `TabView` and therefore the tabbar will also push off screen after a segue. 1013 | 1014 | 1 NavigationStack without SwiftfulRouting: 1015 | 1016 | ```swift 1017 | NavigationStack { 1018 | TabView { 1019 | Text("Screen1") 1020 | .tabItem { Label("Home", systemImage: "house.fill") } 1021 | 1022 | Text("Screen2") 1023 | .tabItem { Label("Search", systemImage: "magnifyingglass") } 1024 | 1025 | Text("Screen3") 1026 | .tabItem { Label("Profile", systemImage: "person.fill") } 1027 | } 1028 | } 1029 | ``` 1030 | 1031 | 1 NavigationStack with SwiftfulRouting: 1032 | 1033 | ```swift 1034 | RouterView { _ in 1035 | TabView { 1036 | Text("Screen1") 1037 | .tabItem { Label("Home", systemImage: "house.fill") } 1038 | 1039 | Text("Screen2") 1040 | .tabItem { Label("Search", systemImage: "magnifyingglass") } 1041 | 1042 | Text("Screen3") 1043 | .tabItem { Label("Profile", systemImage: "person.fill") } 1044 | } 1045 | } 1046 | ``` 1047 | 1048 | Individual NavigationStacks without SwiftfulRouting: 1049 | 1050 | ```swift 1051 | TabView { 1052 | NavigationStack { 1053 | Text("Screen1") 1054 | .tabItem { Label("Home", systemImage: "house.fill") } 1055 | } 1056 | 1057 | NavigationStack { 1058 | Text("Screen2") 1059 | .tabItem { Label("Search", systemImage: "magnifyingglass") } 1060 | } 1061 | 1062 | NavigationStack { 1063 | Text("Screen3") 1064 | .tabItem { Label("Profile", systemImage: "person.fill") } 1065 | } 1066 | } 1067 | ``` 1068 | 1069 | Individual NavigationStacks with SwiftfulRouting: 1070 | 1071 | ```swift 1072 | TabView { 1073 | RouterView { _ in 1074 | Text("Screen1") 1075 | .tabItem { Label("Home", systemImage: "house.fill") } 1076 | } 1077 | 1078 | RouterView { _ in 1079 | Text("Screen2") 1080 | .tabItem { Label("Search", systemImage: "magnifyingglass") } 1081 | } 1082 | 1083 | RouterView { _ in 1084 | Text("Screen3") 1085 | .tabItem { Label("Profile", systemImage: "person.fill") } 1086 | } 1087 | } 1088 | ``` 1089 | 1090 | Regardless of your choice, you may want to add a parent `RouterView` to `addModuleSupport` that has `addNavigationStack` set to `false`. 1091 | 1092 | ```swift 1093 | struct AppRootView: View { 1094 | 1095 | var body: some View { 1096 | RouterView(addNavigationStack: false, addModuleSupport: true) { _ in 1097 | AppTabbarView() 1098 | } 1099 | } 1100 | } 1101 | 1102 | struct AppTabbarView: View { 1103 | 1104 | var body: some View { 1105 | TabView { 1106 | RouterView(addNavigationStack: true, addModuleSupport: false, content: { _ in 1107 | Text("Screen1") 1108 | }) 1109 | .tabItem { Label("Home", systemImage: "house.fill") } 1110 | 1111 | RouterView(addNavigationStack: true, addModuleSupport: false, content: { _ in 1112 | Text("Screen2") 1113 | }) 1114 | .tabItem { Label("Search", systemImage: "magnifyingglass") } 1115 | 1116 | RouterView(addNavigationStack: true, addModuleSupport: false, content: { _ in 1117 | Text("Screen3") 1118 | }) 1119 | .tabItem { Label("Profile", systemImage: "person.fill") } 1120 | } 1121 | } 1122 | } 1123 | ``` 1124 | 1125 | Therefore, a full app implementation can look like: 1126 | 1127 | ```swift 1128 | struct AppRootView: View { 1129 | 1130 | @State private var lastModuleId = UserDefaults.lastModuleId 1131 | 1132 | @ViewBuilder 1133 | var body: some View { 1134 | if lastModuleId == "onboarding" { 1135 | RouterView(id: "onboarding", addModuleSupport: true) { router in 1136 | OnboardingView() 1137 | } 1138 | } else { 1139 | RouterView(id: "tabbar", addNavigationStack: false, addModuleSupport: true) { _ in 1140 | AppTabbarView() 1141 | } 1142 | } 1143 | } 1144 | } 1145 | 1146 | struct AppTabbarView: View { 1147 | 1148 | var body: some View { 1149 | TabView { 1150 | RouterView(addNavigationStack: true, addModuleSupport: false, content: { _ in 1151 | Text("Screen1") 1152 | }) 1153 | .tabItem { Label("Home", systemImage: "house.fill") } 1154 | 1155 | RouterView(addNavigationStack: true, addModuleSupport: false, content: { _ in 1156 | Text("Screen2") 1157 | }) 1158 | .tabItem { Label("Search", systemImage: "magnifyingglass") } 1159 | 1160 | RouterView(addNavigationStack: true, addModuleSupport: false, content: { _ in 1161 | Text("Screen3") 1162 | }) 1163 | .tabItem { Label("Profile", systemImage: "person.fill") } 1164 | } 1165 | } 1166 | } 1167 | ``` 1168 | 1169 | Reference the [Starter Project](https://github.com/SwiftfulThinking/SwiftfulStarterProject) for an full implementation! 1170 | 1171 |
1172 | 1173 | ## Testing 1174 | 1175 |
1176 | Details (Click to expand) 1177 |
1178 | 1179 | Full suite of UI tests are included in the [Sample Project](https://github.com/SwiftfulThinking/SwiftfulRoutingExample). 1180 | 1181 |
1182 | 1183 | ## Contribute 1184 | 1185 |
1186 | Details (Click to expand) 1187 |
1188 | 1189 | Community contributions are encouraged! Please ensure that your code adheres to the project's existing coding style and structure. Most new features are likely to be derivatives of existing features, so many of the existing ViewModifiers and Bindings should be reused. 1190 | 1191 | - [Open an issue](https://github.com/SwiftfulThinking/SwiftfulRouting/issues) for issues with the existing codebase. 1192 | - [Open a discussion](https://github.com/SwiftfulThinking/SwiftfulRouting/discussions) for new feature requests. 1193 | - [Submit a pull request](https://github.com/SwiftfulThinking/SwiftfulRouting/pulls) when the feature is ready. 1194 | 1195 | Upcoming features: 1196 | 1197 | - [ ] Internalize tabbar support 1198 | - [ ] Add Module queue 1199 | - [ ] Add Module tests 1200 | - [ ] Add Modal queue 1201 | - [ ] Add remove(count:) to all queues 1202 | - [ ] Add support for showing in-app web browser 1203 | - [ ] Add supprot for opening other apps (email, etc.) 1204 | 1205 |
1206 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Components/ModalSupportView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 1/19/24. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | import SwiftfulRecursiveUI 10 | 11 | struct ModalSupportView: View { 12 | 13 | static let backgroundAnimationDuration: Double = 0.3 14 | static let backgroundAnimationCurve: Animation = .easeInOut 15 | static let backgroundAnimation: Animation = .easeInOut(duration: 0.3) 16 | 17 | let modals: [AnyModal] 18 | let onDismissModal: (AnyModal) -> Void 19 | 20 | var body: some View { 21 | ZStack { 22 | LazyZStack(allowSimultaneous: true, selection: nil, items: modals) { (modal: AnyModal) in 23 | let dataIndex: Double = Double(modals.firstIndex(where: { $0.id == modal.id }) ?? 99) 24 | 25 | return LazyZStack(allowSimultaneous: true, selection: true) { (showView1: Bool) in 26 | if showView1 { 27 | modal.destination 28 | .modalFrame(ignoreSafeArea: modal.ignoreSafeArea, alignment: modal.alignment) 29 | .transition(modal.transition.animation(modal.animation)) 30 | .zIndex(dataIndex + 2) 31 | } else { 32 | if modal.hasBackgroundLayer { 33 | Group { 34 | if let backgroundColor = modal.backgroundColor { 35 | backgroundColor 36 | } 37 | if let backgroundEffect = modal.backgroundEffect { 38 | UIIntensityVisualEffectViewRepresentable(effect: backgroundEffect.effect, intensity: backgroundEffect.intensity) 39 | } 40 | } 41 | .frame(maxWidth: .infinity, maxHeight: .infinity) 42 | .ignoresSafeArea() 43 | .transition(AnyTransition.opacity.animation(ModalSupportView.backgroundAnimation)) 44 | 45 | // Only add backgound tap gesture if needed 46 | .ifSatisfiesCondition(modal.dismissOnBackgroundTap, transform: { content in 47 | content 48 | .onTapGesture { 49 | onDismissModal(modal) 50 | } 51 | }) 52 | .zIndex(dataIndex + 1) 53 | } else { 54 | EmptyView() 55 | } 56 | } 57 | } 58 | } 59 | .animation(modals.last?.animation ?? .default, value: (modals.last?.id ?? "") + "\(modals.count)") 60 | } 61 | } 62 | 63 | } 64 | 65 | fileprivate extension View { 66 | 67 | @ViewBuilder 68 | func modalFrame(ignoreSafeArea: Bool, alignment: Alignment) -> some View { 69 | if ignoreSafeArea { 70 | self 71 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment) 72 | .ignoresSafeArea() 73 | } else { 74 | self 75 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment) 76 | } 77 | } 78 | 79 | } 80 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Components/ModuleSupportView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ModuleSupportView.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | import SwiftfulRecursiveUI 10 | 11 | struct ModuleSupportView: View { 12 | 13 | @StateObject private var viewModel = ModuleViewModel() 14 | 15 | var rootRouterInfo: (id: String, transitionBehavior: TransitionMemoryBehavior)? 16 | let addNavigationStack: Bool 17 | 18 | @ViewBuilder var content: (AnyRouter) -> Content 19 | 20 | @State private var viewFrame: CGRect = UIScreen.main.bounds 21 | 22 | var body: some View { 23 | ZStack { 24 | LazyZStack(allowSimultaneous: false, selection: viewModel.modules.last, items: viewModel.modules) { data in 25 | let dataIndex: Double = Double(viewModel.modules.firstIndex(where: { $0.id == data.id }) ?? 99) 26 | 27 | return Group { 28 | if data == viewModel.modules.first { 29 | RouterViewModelWrapper { 30 | RouterViewInternal( 31 | routerId: RouterViewModel.rootId, 32 | rootRouterInfo: rootRouterInfo, 33 | addNavigationStack: addNavigationStack, 34 | content: content 35 | ) 36 | } 37 | } else { 38 | RouterViewModelWrapper { 39 | RouterViewInternal( 40 | routerId: RouterViewModel.rootId, 41 | rootRouterInfo: rootRouterInfo, 42 | addNavigationStack: addNavigationStack, 43 | content: { router in 44 | AnyView(data.destination(router)) 45 | } 46 | ) 47 | } 48 | } 49 | } 50 | .transition( 51 | .asymmetric( 52 | insertion: viewModel.currentTransition.insertion, 53 | removal: .customRemoval( 54 | behavior: .removePrevious, 55 | direction: viewModel.currentTransition.reversed, 56 | frame: viewFrame 57 | ) 58 | ) 59 | ) 60 | .zIndex(dataIndex) 61 | } 62 | } 63 | .frame(maxWidth: .infinity, maxHeight: .infinity) 64 | .animation(viewModel.currentTransition.animation, value: (viewModel.modules.last?.id ?? "") + viewModel.currentTransition.rawValue) 65 | .environmentObject(viewModel) 66 | 67 | #if DEBUG 68 | .onChange(of: viewModel.modules ?? []) { newValue in 69 | viewModel.printModuleStack(modules: newValue) 70 | } 71 | #endif 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Components/SwipeBackSupportContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwipeBackSupportContainer.swift 3 | // 4 | // 5 | // Created by Nicholas Sarno on 2/27/24. 6 | // 7 | import SwiftUI 8 | 9 | struct SwipeBackSupportContainer: View { 10 | 11 | var insertionTransition: TransitionOption = .trailing 12 | var swipeThreshold: CGFloat = 30 13 | @ViewBuilder var content: () -> Content 14 | var onDidSwipeBack: (() -> Void)? = nil 15 | @State private var viewOffset: CGSize = .zero 16 | let animation: Animation = .snappy(duration: 0.15) 17 | 18 | var body: some View { 19 | ZStack { 20 | content() 21 | .offset(viewOffset) 22 | .animation(animation, value: viewOffset) 23 | 24 | Rectangle() 25 | .fill(Color.black.opacity(0.001)) 26 | .frame(width: overlayWidth, height: overlayHeight) 27 | .withDragGesture( 28 | insertionTransition.reversed.asAxis, 29 | minimumDistance: 10, 30 | resets: true, 31 | animation: animation, 32 | onChanged: { offset in 33 | if offset != .zero { 34 | setViewOffset(from: offset) 35 | } 36 | }, 37 | onEnded: { _ in 38 | handleDidSwipeBackIfNeeded() 39 | } 40 | ) 41 | .padding(.top, topPadding) 42 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: insertionTransition.reversed.asAlignment) 43 | } 44 | } 45 | 46 | private var topPadding: CGFloat? { 47 | switch insertionTransition { 48 | case .trailing, .leading: 49 | return 60 50 | case .top, .bottom, .identity: 51 | return nil 52 | } 53 | } 54 | 55 | private func handleDidSwipeBackIfNeeded() { 56 | switch insertionTransition { 57 | case .trailing, .leading: 58 | let horizontalOffset = abs(viewOffset.width) 59 | if horizontalOffset >= swipeThreshold { 60 | onDidSwipeBack?() 61 | 62 | Task { @MainActor in 63 | try? await Task.sleep(nanoseconds: 500_000_000) 64 | viewOffset = .zero 65 | } 66 | } else { 67 | viewOffset = .zero 68 | } 69 | case .top, .bottom, .identity: 70 | let verticalOffset = abs(viewOffset.height) 71 | 72 | if verticalOffset >= swipeThreshold { 73 | onDidSwipeBack?() 74 | 75 | Task { @MainActor in 76 | try? await Task.sleep(nanoseconds: 500_000_000) 77 | viewOffset = .zero 78 | } 79 | } else { 80 | viewOffset = .zero 81 | } 82 | } 83 | } 84 | 85 | private func setViewOffset(from offset: CGSize) { 86 | switch insertionTransition { 87 | case .trailing, .identity: 88 | viewOffset = CGSize(width: max(offset.width, 0), height: 0) 89 | case .leading: 90 | viewOffset = CGSize(width: min(offset.width, 0), height: 0) 91 | case .top: 92 | viewOffset = CGSize(width: 0, height: min(offset.height, 0)) 93 | case .bottom: 94 | viewOffset = CGSize(width: 0, height: max(offset.height, 0)) 95 | } 96 | } 97 | 98 | private var overlayWidth: CGFloat? { 99 | switch insertionTransition { 100 | case .trailing, .leading: 101 | return 24 102 | default: 103 | return nil 104 | } 105 | } 106 | 107 | private var overlayHeight: CGFloat? { 108 | switch insertionTransition { 109 | case .top, .bottom: 110 | return 30 111 | default: 112 | return nil 113 | } 114 | } 115 | 116 | } 117 | 118 | #Preview { 119 | SwipeBackSupportContainer(insertionTransition: .trailing) { 120 | Rectangle() 121 | .fill(Color.blue) 122 | } 123 | } 124 | 125 | private struct DragGestureViewModifier: ViewModifier { 126 | 127 | @State private var offset: CGSize = .zero 128 | @State private var lastOffset: CGSize = .zero 129 | @State private var rotation: Double = 0 130 | @State private var scale: CGFloat = 1 131 | 132 | let axes: Axis.Set 133 | let minimumDistance: CGFloat 134 | let resets: Bool 135 | let animation: Animation 136 | let rotationMultiplier: CGFloat 137 | let scaleMultiplier: CGFloat 138 | let onChanged: ((_ dragOffset: CGSize) -> ())? 139 | let onEnded: ((_ dragOffset: CGSize) -> ())? 140 | 141 | init( 142 | _ axes: Axis.Set = [.horizontal, .vertical], 143 | minimumDistance: CGFloat = 0, 144 | resets: Bool, 145 | animation: Animation, 146 | rotationMultiplier: CGFloat = 0, 147 | scaleMultiplier: CGFloat = 0, 148 | onChanged: ((_ dragOffset: CGSize) -> ())?, 149 | onEnded: ((_ dragOffset: CGSize) -> ())?) { 150 | self.axes = axes 151 | self.minimumDistance = minimumDistance 152 | self.resets = resets 153 | self.animation = animation 154 | self.rotationMultiplier = rotationMultiplier 155 | self.scaleMultiplier = scaleMultiplier 156 | self.onChanged = onChanged 157 | self.onEnded = onEnded 158 | } 159 | 160 | func body(content: Content) -> some View { 161 | content 162 | .scaleEffect(scale) 163 | .rotationEffect(Angle(degrees: rotation), anchor: .center) 164 | .offset(getOffset(offset: lastOffset)) 165 | .offset(getOffset(offset: offset)) 166 | .simultaneousGesture( 167 | DragGesture(minimumDistance: minimumDistance, coordinateSpace: .global) 168 | .onChanged({ value in 169 | onChanged?(value.translation) 170 | 171 | withAnimation(animation) { 172 | offset = value.translation 173 | rotation = getRotation(translation: value.translation) 174 | scale = getScale(translation: value.translation) 175 | } 176 | }) 177 | .onEnded({ value in 178 | if !resets { 179 | onEnded?(lastOffset) 180 | } else { 181 | onEnded?(value.translation) 182 | } 183 | 184 | withAnimation(animation) { 185 | offset = .zero 186 | rotation = 0 187 | scale = 1 188 | 189 | if !resets { 190 | lastOffset = CGSize( 191 | width: lastOffset.width + value.translation.width, 192 | height: lastOffset.height + value.translation.height) 193 | } else { 194 | onChanged?(offset) 195 | } 196 | } 197 | }) 198 | ) 199 | } 200 | 201 | private func getOffset(offset: CGSize) -> CGSize { 202 | switch axes { 203 | case .vertical: 204 | return CGSize(width: 0, height: offset.height) 205 | case .horizontal: 206 | return CGSize(width: offset.width, height: 0) 207 | default: 208 | return offset 209 | } 210 | } 211 | 212 | private func getRotation(translation: CGSize) -> CGFloat { 213 | let max = UIScreen.main.bounds.width / 2 214 | let percentage = translation.width * rotationMultiplier / max 215 | let maxRotation: CGFloat = 10 216 | return percentage * maxRotation 217 | } 218 | 219 | private func getScale(translation: CGSize) -> CGFloat { 220 | let max = UIScreen.main.bounds.width / 2 221 | 222 | var offsetAmount: CGFloat = 0 223 | switch axes { 224 | case .vertical: 225 | offsetAmount = abs(translation.height + lastOffset.height) 226 | case .horizontal: 227 | offsetAmount = abs(translation.width + lastOffset.width) 228 | default: 229 | offsetAmount = (abs(translation.width + lastOffset.width) + abs(translation.height + lastOffset.height)) / 2 230 | } 231 | 232 | let percentage = offsetAmount * scaleMultiplier / max 233 | let minScale: CGFloat = 0.8 234 | let range = 1 - minScale 235 | return 1 - (range * percentage) 236 | } 237 | 238 | } 239 | 240 | private extension View { 241 | 242 | /// Add a DragGesture to a View. 243 | /// 244 | /// DragGesture is added as a simultaneousGesture, to not interfere with other gestures Developer may add. 245 | /// 246 | /// - Parameters: 247 | /// - axes: Determines the drag axes. Default allows for both horizontal and vertical movement. 248 | /// - resets: If the View should reset to starting state onEnded. 249 | /// - animation: The drag animation. 250 | /// - rotationMultiplier: Used to rotate the View while dragging. Only applies to horizontal movement. 251 | /// - scaleMultiplier: Used to scale the View while dragging. 252 | /// - onEnded: The modifier will handle the View's offset onEnded. This escaping closure is for Developer convenience. 253 | /// 254 | func withDragGesture( 255 | _ axes: Axis.Set = [.horizontal, .vertical], 256 | minimumDistance: CGFloat = 0, 257 | resets: Bool = true, 258 | animation: Animation = .spring(response: 0.3, dampingFraction: 0.8, blendDuration: 0.0), 259 | rotationMultiplier: CGFloat = 0, 260 | scaleMultiplier: CGFloat = 0, 261 | onChanged: ((_ dragOffset: CGSize) -> ())? = nil, 262 | onEnded: ((_ dragOffset: CGSize) -> ())? = nil) -> some View { 263 | modifier(DragGestureViewModifier(axes, minimumDistance: minimumDistance, resets: resets, animation: animation, rotationMultiplier: rotationMultiplier, scaleMultiplier: scaleMultiplier, onChanged: onChanged, onEnded: onEnded)) 264 | } 265 | 266 | } 267 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Components/TransitionSupportView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TransitionSupportView.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | import SwiftfulRecursiveUI 10 | 11 | struct TransitionSupportView: View { 12 | 13 | var behavior: TransitionMemoryBehavior = .keepPrevious 14 | let router: AnyRouter 15 | let transitions: [AnyTransitionDestination] 16 | @ViewBuilder var content: (AnyRouter) -> Content 17 | let currentTransition: TransitionOption 18 | let onDidSwipeBack: () -> Void 19 | 20 | @State private var viewFrame: CGRect = UIScreen.main.bounds 21 | 22 | var body: some View { 23 | ZStack { 24 | LazyZStack(allowSimultaneous: behavior.allowSimultaneous, selection: transitions.last, items: transitions) { data in 25 | let dataIndex: Double = Double(transitions.firstIndex(where: { $0.id == data.id }) ?? 99) 26 | let allowsSwipeBack: Bool = data.transition.canSwipeBack && data.allowsSwipeBack 27 | 28 | return Group { 29 | if data == transitions.first { 30 | content(router) 31 | } else { 32 | if allowsSwipeBack { 33 | SwipeBackSupportContainer( 34 | insertionTransition: data.transition, 35 | swipeThreshold: 30, 36 | content: { 37 | AnyView(data.destination(router)) 38 | }, 39 | onDidSwipeBack: onDidSwipeBack 40 | ) 41 | } else { 42 | AnyView(data.destination(router)) 43 | } 44 | } 45 | } 46 | .transition( 47 | .asymmetric( 48 | insertion: currentTransition.insertion, 49 | removal: .customRemoval(behavior: behavior, direction: currentTransition.reversed, frame: viewFrame) 50 | ) 51 | ) 52 | .zIndex(dataIndex) 53 | } 54 | } 55 | .frame(maxWidth: .infinity, maxHeight: .infinity) 56 | .animation(currentTransition.animation, value: (transitions.last?.id ?? "") + currentTransition.rawValue) 57 | // .ifSatisfiesCondition(viewFrame == .zero, transform: { content in 58 | // content 59 | // .readingFrame(onChange: { frame in 60 | // // Add +150 to account for safe areas 61 | // self.viewFrame = frame 62 | //// self.viewFrame = UIScreen.main.bounds 63 | //// self.viewFrame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height) 64 | // }) 65 | // }) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Core/RouterProtocol/AnyRouter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnyRouter.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | 10 | /// Type-erased Router with convenience methods. 11 | public struct AnyRouter: Sendable, Router { 12 | public let id: String 13 | private let object: any Router 14 | 15 | init(id: String, object: any Router) { 16 | self.id = id 17 | self.object = object 18 | } 19 | 20 | /// Active screen stacks in this RouterView's heirarchy. 21 | /// 22 | /// Use activeScreens.allScreens for underlying screen array. 23 | @MainActor public var activeScreens: [AnyDestinationStack] { 24 | object.activeScreens 25 | } 26 | 27 | /// Available screens in this RouterView's screen queue. 28 | /// 29 | /// Use showNextScreen() to trigger the next screen. 30 | @MainActor public var activeScreenQueue: [AnyDestination] { 31 | object.activeScreenQueue 32 | } 33 | 34 | /// If there is at least 1 screen in activeScreenQueue. 35 | @MainActor public var hasScreenInQueue: Bool { 36 | !object.activeScreenQueue.isEmpty 37 | } 38 | 39 | /// The currently displayed alert on this screen. 40 | @MainActor public var activeAlert: AnyAlert? { 41 | object.activeAlert 42 | } 43 | 44 | /// If an alert is currently displayed on this screen. 45 | @MainActor public var hasActiveAlert: Bool { 46 | activeAlert != nil 47 | } 48 | 49 | /// Active modals displayed on this screen. 50 | @MainActor public var activeModals: [AnyModal] { 51 | object.activeModals 52 | } 53 | 54 | /// If a modal is currently displayed on this screen. 55 | @MainActor public var hasActiveModal: Bool { 56 | !object.activeModals.isEmpty 57 | } 58 | 59 | /// Active transition heirarchy on this screen. 60 | @MainActor public var activeTransitions: [AnyTransitionDestination] { 61 | object.activeTransitions 62 | } 63 | 64 | /// If there is an active transition on this screen. 65 | @MainActor public var hasActiveTransition: Bool { 66 | !object.activeTransitions.isEmpty 67 | } 68 | 69 | /// Available transition destinations in this screen's tranisition queue. 70 | /// 71 | /// Use showNextTransition() to trigger the next transition. 72 | @MainActor public var activeTransitionQueue: [AnyTransitionDestination] { 73 | object.activeTransitionQueue 74 | } 75 | 76 | /// If there is at least 1 transition in activeTransitionQueue. 77 | @MainActor public var hasTransitionInQueue: Bool { 78 | !object.activeTransitionQueue.isEmpty 79 | } 80 | 81 | /// Active transition heirarchy on this screen. 82 | @MainActor public var activeModules: [AnyTransitionDestination] { 83 | object.activeModules 84 | } 85 | 86 | /// Segue to a new screen. 87 | /// - Parameters: 88 | /// - segue: Push (NavigationLink), Sheet, or FullScreenCover 89 | /// - id: Identifier for the screen 90 | /// - location: Where to insert the new screen in the heirarchy (default = .insert) 91 | /// - onDismiss: Trigger closure when screen gets dismissed (note: dismiss != disappear) 92 | /// - animates: If the segue should animate or not (default = true) 93 | /// - transitionBehavior: Determines the behavior of "transition" methods on the destination screen. 94 | /// - destination: The destination screen. 95 | @MainActor public func showScreen( 96 | _ segue: SegueOption = .push, 97 | id: String = UUID().uuidString, 98 | location: SegueLocation = .insert, 99 | animates: Bool = true, 100 | transitionBehavior: TransitionMemoryBehavior = .keepPrevious, 101 | onDismiss: (() -> Void)? = nil, 102 | destination: @escaping (AnyRouter) -> T 103 | ) where T : View { 104 | let destination = AnyDestination(id: id, segue: segue, location: location, animates: animates, transitionBehavior: transitionBehavior, onDismiss: onDismiss, destination: destination) 105 | object.showScreens(destinations: [destination]) 106 | } 107 | 108 | /// Add one screen to the screen heirarchy. 109 | @MainActor public func showScreen(destination: AnyDestination) { 110 | object.showScreens(destinations: [destination]) 111 | } 112 | 113 | /// Add one screen to the screen heirarchy. 114 | @MainActor public func showScreen(_ destination: AnyDestination) { 115 | object.showScreens(destinations: [destination]) 116 | } 117 | 118 | /// Add multiple screens to the screen heirarchy. Immediately trigger screens in order, resulting with the last screen displayed to the user. 119 | /// 120 | /// Note: destination.location will be overridden to support this method. 121 | @MainActor public func showScreens(destinations: [AnyDestination]) { 122 | object.showScreens(destinations: destinations) 123 | } 124 | 125 | /// Dismiss this screen and all screens in front of it. 126 | @MainActor public func dismissScreen(animates: Bool = true) { 127 | object.dismissScreen(animates: animates) 128 | } 129 | 130 | /// Dismiss screens after and including screen at id. 131 | @MainActor public func dismissScreen(id: String, animates: Bool = true) { 132 | object.dismissScreen(id: id, animates: animates) 133 | } 134 | 135 | /// Dismiss all screens in front of (but not including) screen at id. 136 | @MainActor public func dismissScreens(upToId: String, animates: Bool = true) { 137 | object.dismissScreens(upToId: upToId, animates: animates) 138 | } 139 | 140 | /// Dismiss a specific number of screens. 141 | @MainActor public func dismissScreens(count: Int, animates: Bool = true) { 142 | object.dismissScreens(count: count, animates: animates) 143 | } 144 | 145 | /// Dismiss all .push segues on the NavigationStack for this screen. 146 | @MainActor public func dismissPushStack(animates: Bool = true) { 147 | object.dismissPushStack(animates: animates) 148 | } 149 | 150 | /// Dismiss the closest .sheet or .fullScreenCover to this screen. 151 | @MainActor public func dismissEnvironment(animates: Bool = true) { 152 | object.dismissEnvironment(animates: animates) 153 | } 154 | 155 | /// Dismiss the last screen in the heirarchy, regardless of call-site. 156 | @MainActor public func dismissLastScreen(animates: Bool = true) { 157 | object.dismissLastScreen(animates: animates) 158 | } 159 | 160 | /// Dismiss all .push segues on the last NavigationStack in the heirarchy, regardless of call-site. 161 | @MainActor public func dismissLastPushStack(animates: Bool = true) { 162 | object.dismissLastPushStack(animates: animates) 163 | } 164 | 165 | /// Dismiss the last .sheet or .fullScreenCover in the heirarchy, regardless of call-site. 166 | @MainActor public func dismissLastEnvironment(animates: Bool = true) { 167 | object.dismissLastEnvironment(animates: animates) 168 | } 169 | 170 | /// Dismiss all screens in the heirarchy. 171 | @MainActor public func dismissAllScreens(animates: Bool = true) { 172 | object.dismissAllScreens(animates: animates) 173 | } 174 | 175 | /// Add 1 screen to this RouterView's screen queue. 176 | /// 177 | /// Use showNextScreen() to trigger the next screen. 178 | @MainActor public func addScreenToQueue(destination: AnyDestination) { 179 | object.addScreensToQueue(destinations: [destination]) 180 | } 181 | 182 | /// Add multiple screens to this RouterView's screen queue. 183 | /// 184 | /// Use showNextScreen() to trigger the next screen. 185 | @MainActor public func addScreensToQueue(destinations: [AnyDestination]) { 186 | object.addScreensToQueue(destinations: destinations) 187 | } 188 | 189 | /// Remove 1 screen from this RouterView's screen queue. 190 | @MainActor public func removeScreenFromQueue(id: String) { 191 | object.removeScreensFromQueue(ids: [id]) 192 | } 193 | 194 | /// Remove multiple screens from this RouterView's screen queue. 195 | @MainActor public func removeScreensFromQueue(ids: [String]) { 196 | object.removeScreensFromQueue(ids: ids) 197 | } 198 | 199 | /// Remove all screens from this RouterView's screen queue. 200 | @MainActor public func removeAllScreensFromQueue() { 201 | object.removeAllScreensFromQueue() 202 | } 203 | 204 | /// Segue to a the first screen in this RouterView's screen queue, if available. 205 | @MainActor public func showNextScreen() { 206 | object.showNextScreen() 207 | } 208 | 209 | /// Segue to a the first screen in this RouterView's screen queue, otherwise throw an error. 210 | @MainActor public func tryShowNextScreen() throws { 211 | guard hasScreenInQueue else { 212 | throw AnyRouterError.noScreensInQueue 213 | } 214 | 215 | object.showNextScreen() 216 | } 217 | 218 | /// Segue to a the first screen in this RouterView's screen queue, if available, otherwise dismiss the screen. 219 | @MainActor public func showNextScreenOrDismissScreen(animateDismiss: Bool = true) { 220 | do { 221 | try tryShowNextScreen() 222 | } catch { 223 | object.dismissScreen(animates: animateDismiss) 224 | } 225 | } 226 | 227 | /// Segue to a the first screen in this RouterView's screen queue, if available, otherwise dismiss the environment. 228 | @MainActor public func showNextScreenOrDismissEnvironment(animateDismiss: Bool = true) { 229 | do { 230 | try tryShowNextScreen() 231 | } catch { 232 | object.dismissEnvironment(animates: animateDismiss) 233 | } 234 | } 235 | 236 | /// Segue to a the first screen in this RouterView's screen queue, if available, otherwise dismiss the .push stack. 237 | @MainActor public func showNextScreenOrDismissPushStack(animateDismiss: Bool = true) { 238 | do { 239 | try tryShowNextScreen() 240 | } catch { 241 | object.dismissPushStack(animates: animateDismiss) 242 | } 243 | } 244 | 245 | 246 | // MARK: ALERTS 247 | 248 | 249 | /// Display an alert. 250 | /// - Parameters: 251 | /// - style: Type of alert. 252 | /// - location: Which screen to display alert on. 253 | /// - title: Title of alert. 254 | /// - subtitle: Subtitle of alert (optional) 255 | /// - buttons: Buttons within alert (hint: use Group with multiple Button inside). 256 | @MainActor public func showAlert( 257 | _ style: AlertStyle = .alert, 258 | location: AlertLocation = .topScreen, 259 | title: String, 260 | subtitle: String? = nil, 261 | @ViewBuilder buttons: @escaping () -> T 262 | ) where T : View { 263 | let alert = AnyAlert(style: style, location: location, title: title, subtitle: subtitle, buttons: buttons) 264 | object.showAlert(alert: alert) 265 | } 266 | 267 | /// Display an alert with "OK" button. 268 | /// - Parameters: 269 | /// - style: Type of alert. 270 | /// - location: Which screen to display alert on. 271 | /// - title: Title of alert. 272 | /// - subtitle: Subtitle of alert (optional) 273 | @MainActor public func showAlert( 274 | _ style: AlertStyle = .alert, 275 | location: AlertLocation = .topScreen, 276 | title: String, 277 | subtitle: String? = nil 278 | ) { 279 | let alert = AnyAlert(style: style, location: location, title: title, subtitle: subtitle) 280 | object.showAlert(alert: alert) 281 | } 282 | 283 | /// Display an alert. 284 | @MainActor public func showAlert(alert: AnyAlert) { 285 | object.showAlert(alert: alert) 286 | } 287 | 288 | /// Display an alert. 289 | @MainActor public func showAlert(_ alert: AnyAlert) { 290 | object.showAlert(alert: alert) 291 | } 292 | 293 | /// Display a simple alert with title and "OK" button. 294 | @MainActor public func showBasicAlert(text: String, action: (() -> Void)? = nil) { 295 | showAlert(.alert, title: text) { 296 | Button("OK") { 297 | action?() 298 | } 299 | } 300 | } 301 | 302 | /// Dismiss alert displayed on this screen. 303 | @MainActor public func dismissAlert() { 304 | object.dismissAlert() 305 | } 306 | 307 | /// Dismiss all alert displayed on all screens. 308 | @MainActor public func dismissAllAlerts() { 309 | object.dismissAllAlerts() 310 | } 311 | 312 | // MARK: MODALS 313 | 314 | 315 | /// Show a modal. 316 | /// - Parameters: 317 | /// - id: Identifier for modal. 318 | /// - transition: Transition to show and hide modal. 319 | /// - animation: Animation to show and hide modal. 320 | /// - alignment: Alignment within the screen. 321 | /// - backgroundColor: Background color behind the modal, if applicable. 322 | /// - backgroundEffect: Background effect behind the modal, if applicable. 323 | /// - dismissOnBackgroundTap: If there is a background color/effect, add tap gesture that dismisses the modal. 324 | /// - ignoreSafeArea: Ignore screen's safe area when displayed. 325 | /// - onDismiss: Closure that triggers when modal dismisses. 326 | /// - destination: The modal View. 327 | @MainActor public func showModal( 328 | id: String = UUID().uuidString, 329 | transition: AnyTransition = .identity, 330 | animation: Animation = .smooth, 331 | alignment: Alignment = .center, 332 | backgroundColor: Color? = nil, 333 | backgroundEffect: BackgroundEffect? = nil, 334 | dismissOnBackgroundTap: Bool = true, 335 | ignoreSafeArea: Bool = true, 336 | onDismiss: (() -> Void)? = nil, 337 | @ViewBuilder destination: @escaping () -> T 338 | ) where T : View { 339 | let modal = AnyModal( 340 | id: id, 341 | transition: transition, 342 | animation: animation, 343 | alignment: alignment, 344 | backgroundColor: backgroundColor, 345 | backgroundEffect: backgroundEffect, 346 | dismissOnBackgroundTap: dismissOnBackgroundTap, 347 | ignoreSafeArea: ignoreSafeArea, 348 | destination: destination, 349 | onDismiss: onDismiss 350 | ) 351 | object.showModal(modal: modal) 352 | } 353 | 354 | /// Convenience method to show a modal with basic animation and display logic. 355 | @MainActor public func showBasicModal(@ViewBuilder destination: @escaping () -> T) where T : View { 356 | showModal( 357 | transition: AnyTransition.opacity.animation(.easeInOut), 358 | animation: .easeInOut, 359 | alignment: .center, 360 | backgroundColor: Color.black.opacity(0.3), 361 | dismissOnBackgroundTap: true, 362 | ignoreSafeArea: true, 363 | destination: destination 364 | ) 365 | } 366 | 367 | /// Convenience method to show a modal with basic animation and display logic. 368 | @MainActor public func showBottomModal(@ViewBuilder destination: @escaping () -> T) where T : View { 369 | showModal( 370 | transition: AnyTransition.move(edge: .bottom), 371 | animation: .easeInOut, 372 | alignment: .bottom, 373 | backgroundColor: Color.black.opacity(0.3), 374 | dismissOnBackgroundTap: true, 375 | ignoreSafeArea: true, 376 | destination: destination 377 | ) 378 | } 379 | 380 | /// Show a modal. 381 | @MainActor public func showModal(modal: AnyModal) { 382 | object.showModal(modal: modal) 383 | } 384 | 385 | /// Show a modal. 386 | @MainActor public func showModal(_ modal: AnyModal) { 387 | object.showModal(modal: modal) 388 | } 389 | 390 | /// Show multiple modals. 391 | @MainActor public func showModals(modals: [AnyModal]) { 392 | for modal in modals { 393 | object.showModal(modal: modal) 394 | } 395 | } 396 | 397 | /// Dismiss the last modal displayed on this screen. 398 | @MainActor public func dismissModal() { 399 | object.dismissModal() 400 | } 401 | 402 | /// Dismiss the modal at id on this screen. 403 | @MainActor public func dismissModal(id: String) { 404 | object.dismissModal(id: id) 405 | } 406 | 407 | /// Dismiss all modals in front of, but not including, modal id on this screen. 408 | @MainActor public func dismissModals(upToId: String) { 409 | object.dismissModals(upToId: upToId) 410 | } 411 | 412 | /// Dismiss specific number modals on this screen. 413 | @MainActor public func dismissModals(count: Int) { 414 | object.dismissModals(count: count) 415 | } 416 | 417 | /// Dismiss all modals on this screen. 418 | @MainActor public func dismissAllModals() { 419 | object.dismissAllModals() 420 | } 421 | 422 | /// Transition current screen. 423 | /// - Parameters: 424 | /// - transition: Transition animation option. 425 | /// - id: Identifier for transition id. 426 | /// - allowsSwipeBack: Add a swipe-back gesture to the edge of the screen. Note: only works with .trailing or .leading transitions. 427 | /// - onDismiss: Closure that triggers when transition is dismissed. 428 | /// - destination: Destination screen. 429 | @MainActor public func showTransition( 430 | _ transition: TransitionOption = .trailing, 431 | id: String = UUID().uuidString, 432 | allowsSwipeBack: Bool = false, 433 | onDismiss: (() -> Void)? = nil, 434 | destination: @escaping (AnyRouter) -> T 435 | ) where T : View { 436 | let transition = AnyTransitionDestination(id: id, transition: transition, allowsSwipeBack: allowsSwipeBack, destination: destination) 437 | object.showTransition(transition: transition) 438 | } 439 | 440 | /// Transition current screen. 441 | @MainActor public func showTransition(transition: AnyTransitionDestination) { 442 | object.showTransition(transition: transition) 443 | } 444 | 445 | /// Transition current screen. 446 | @MainActor public func showTransition(_ transition: AnyTransitionDestination) { 447 | object.showTransition(transition: transition) 448 | } 449 | 450 | /// Transition current screen, adding multiple transitions to heirarchy, and displaying the last one. 451 | @MainActor public func showTransitions(transitions: [AnyTransitionDestination]) { 452 | object.showTransitions(transitions: transitions) 453 | } 454 | 455 | /// Dismiss the last transition on this screen. 456 | @MainActor public func dismissTransition() { 457 | object.dismissTransition() 458 | } 459 | 460 | /// Dismiss all transitions after and including id on this screen. 461 | @MainActor public func dismissTransition(id: String) { 462 | object.dismissTransition(id: id) 463 | } 464 | 465 | /// Dismiss all transitions after, but not including id, on this screen. 466 | @MainActor public func dismissTransitions(upToId: String) { 467 | object.dismissTransitions(upToId: upToId) 468 | } 469 | 470 | /// Dismiss specific number of transitions on this screen. 471 | @MainActor public func dismissTransitions(count: Int) { 472 | object.dismissTransitions(count: count) 473 | } 474 | 475 | /// Dismiss transition, if available, or dismiss screen. 476 | @MainActor public func dismissTransitionOrDismissScreen() { 477 | if hasActiveTransition { 478 | dismissTransition() 479 | } else { 480 | dismissScreen() 481 | } 482 | } 483 | 484 | /// Dismiss all transitions on this screen. 485 | @MainActor public func dismissAllTransitions() { 486 | object.dismissAllTransitions() 487 | } 488 | 489 | /// Add 1 transition to this RouterView's transition queue. 490 | /// 491 | /// Use showNextTransition() to trigger the next transition. 492 | @MainActor public func addTransitionToQueue(transition: AnyTransitionDestination) { 493 | object.addTransitionsToQueue(transitions: [transition]) 494 | } 495 | 496 | /// Add multiple transitions to this RouterView's transition queue. 497 | /// 498 | /// Use showNextTransition() to trigger the next transition. 499 | @MainActor public func addTransitionsToQueue(transitions: [AnyTransitionDestination]) { 500 | object.addTransitionsToQueue(transitions: transitions) 501 | } 502 | 503 | /// Remove 1 transition from this RouterView's transition queue. 504 | @MainActor public func removeTransitionFromQueue(id: String) { 505 | object.removeTransitionsFromQueue(ids: [id]) 506 | } 507 | 508 | /// Remove mulitple transitions from this RouterView's transition queue. 509 | @MainActor public func removeTransitionsFromQueue(ids: [String]) { 510 | object.removeTransitionsFromQueue(ids: ids) 511 | } 512 | 513 | /// Remove all transitions from this RouterView's transition queue. 514 | @MainActor public func removeAllTransitionsFromQueue() { 515 | object.removeAllTransitionsFromQueue() 516 | } 517 | 518 | /// Show the first transition in this RouterView's transition queue, if available. 519 | @MainActor public func showNextTransition() { 520 | object.showNextTransition() 521 | } 522 | 523 | /// Show the first transition in this RouterView's transition queue, otherwise throw an error. 524 | @MainActor public func tryShowNextTransition() throws { 525 | guard hasTransitionInQueue else { 526 | throw AnyRouterError.noTransitionsInQueue 527 | } 528 | 529 | object.showNextTransition() 530 | } 531 | 532 | /// Show the first transition in this RouterView's transition queue, otherwise show next screen, otherwise dismiss screen. 533 | @MainActor public func showNextTransitionOrNextScreenOrDismissScreen() { 534 | do { 535 | try tryShowNextTransition() 536 | } catch { 537 | do { 538 | try tryShowNextScreen() 539 | } catch { 540 | dismissScreen() 541 | } 542 | } 543 | } 544 | 545 | enum AnyRouterError: Error { 546 | case noTransitionsInQueue 547 | case noScreensInQueue 548 | } 549 | 550 | /// Transition current module. 551 | /// - Parameters: 552 | /// - transition: Transition animation option. 553 | /// - id: Identifier for transition id. 554 | /// - allowsSwipeBack: Add a swipe-back gesture to the edge of the screen. Note: only works with .trailing or .leading transitions. 555 | /// - onDismiss: Closure that triggers when transition is dismissed. 556 | /// - destination: Destination screen. 557 | @MainActor public func showModule( 558 | _ transition: TransitionOption, 559 | id: String = UUID().uuidString, 560 | onDismiss: (() -> Void)? = nil, 561 | destination: @escaping (AnyRouter) -> T 562 | ) where T : View { 563 | let module = AnyTransitionDestination(id: id, transition: transition, destination: destination) 564 | object.showModule(module: module) 565 | } 566 | 567 | /// Transition current module. 568 | @MainActor public func showModule(module: AnyTransitionDestination) { 569 | object.showModule(module: module) 570 | } 571 | 572 | /// Transition current module. 573 | @MainActor public func showModule(_ module: AnyTransitionDestination) { 574 | object.showModule(module: module) 575 | } 576 | 577 | /// Transition current module, adding multiple modules to heirarchy, and displaying the last one. 578 | @MainActor public func showModules(modules: [AnyTransitionDestination]) { 579 | object.showModules(modules: modules) 580 | } 581 | 582 | /// Dismiss the last module in this RouterView's heirarchy. 583 | @MainActor public func dismissModule() { 584 | object.dismissModule() 585 | } 586 | 587 | /// Dismiss all modules after and including module id. 588 | @MainActor public func dismissModule(id: String) { 589 | object.dismissModule(id: id) 590 | } 591 | 592 | /// Dismiss all modules after, but not including module id. 593 | @MainActor public func dismissModules(upToId: String) { 594 | object.dismissModules(upToId: upToId) 595 | } 596 | 597 | /// Dismiss specific number of modules. 598 | @MainActor public func dismissModules(count: Int) { 599 | object.dismissModules(count: count) 600 | } 601 | 602 | /// Dismiss all modules. 603 | @MainActor public func dismissAllModules() { 604 | object.dismissAllModules() 605 | } 606 | 607 | /// Open URL in Safari app. To open url in in-app browser, use showSheet with a WebView. 608 | func showSafari(_ url: @escaping () -> URL) { 609 | object.showSafari(url) 610 | } 611 | 612 | } 613 | 614 | // Used to stabilize View updates (Issue #92) 615 | extension AnyRouter: Identifiable, Hashable, Equatable { 616 | 617 | public static func == (lhs: AnyRouter, rhs: AnyRouter) -> Bool { 618 | lhs.id == rhs.id 619 | } 620 | 621 | public func hash(into hasher: inout Hasher) { 622 | hasher.combine(id) 623 | } 624 | } 625 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Core/RouterProtocol/MockRouter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockRouter.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | 10 | struct MockRouter: Router { 11 | let printPrefix = "🕊️ SwiftfulRouting 🕊️ -> " 12 | 13 | private func printError() { 14 | #if DEBUG 15 | print(printPrefix + "Please add a RouterView to the View heirarchy before using Router. There is no Router in the environment!") 16 | #endif 17 | } 18 | 19 | init() { 20 | 21 | } 22 | 23 | var activeScreens: [AnyDestinationStack] { 24 | [] 25 | } 26 | 27 | var activeScreenQueue: [AnyDestination] { 28 | [] 29 | } 30 | 31 | var activeAlert: AnyAlert? { 32 | nil 33 | } 34 | 35 | var activeModals: [AnyModal] { 36 | [] 37 | } 38 | 39 | var activeTransitions: [AnyTransitionDestination] { 40 | [] 41 | } 42 | 43 | var activeTransitionQueue: [AnyTransitionDestination] { 44 | [] 45 | } 46 | 47 | var activeModules: [AnyTransitionDestination] { 48 | [] 49 | } 50 | 51 | func showScreens(destinations: [AnyDestination]) { 52 | printError() 53 | } 54 | 55 | func dismissScreen(animates: Bool) { 56 | printError() 57 | } 58 | 59 | func dismissScreen(id: String, animates: Bool) { 60 | printError() 61 | } 62 | 63 | func dismissScreens(upToId: String, animates: Bool) { 64 | printError() 65 | } 66 | 67 | func dismissScreens(count: Int, animates: Bool) { 68 | printError() 69 | } 70 | 71 | func dismissPushStack(animates: Bool) { 72 | printError() 73 | } 74 | 75 | func dismissEnvironment(animates: Bool) { 76 | printError() 77 | } 78 | 79 | func dismissLastScreen(animates: Bool) { 80 | printError() 81 | } 82 | 83 | func dismissLastPushStack(animates: Bool) { 84 | printError() 85 | } 86 | 87 | func dismissLastEnvironment(animates: Bool) { 88 | printError() 89 | } 90 | 91 | func dismissAllScreens(animates: Bool) { 92 | printError() 93 | } 94 | 95 | func addScreensToQueue(destinations: [AnyDestination]) { 96 | printError() 97 | } 98 | 99 | func removeScreensFromQueue(ids: [String]) { 100 | printError() 101 | } 102 | 103 | func removeAllScreensFromQueue() { 104 | printError() 105 | } 106 | 107 | func showNextScreen() { 108 | printError() 109 | } 110 | 111 | func showAlert(alert: AnyAlert) { 112 | printError() 113 | } 114 | 115 | func dismissAlert() { 116 | printError() 117 | } 118 | 119 | func dismissAllAlerts() { 120 | printError() 121 | } 122 | 123 | func showModal(modal: AnyModal) { 124 | printError() 125 | } 126 | 127 | func dismissModal() { 128 | printError() 129 | } 130 | 131 | func dismissModal(id: String) { 132 | printError() 133 | } 134 | 135 | func dismissModals(upToId: String) { 136 | printError() 137 | } 138 | 139 | func dismissModals(count: Int) { 140 | printError() 141 | } 142 | 143 | func dismissAllModals() { 144 | printError() 145 | } 146 | 147 | func showTransition(transition: AnyTransitionDestination) { 148 | printError() 149 | } 150 | 151 | func showTransitions(transitions: [AnyTransitionDestination]) { 152 | printError() 153 | } 154 | 155 | func dismissTransition() { 156 | printError() 157 | } 158 | 159 | func dismissTransition(id: String) { 160 | printError() 161 | } 162 | 163 | func dismissTransitions(upToId: String) { 164 | printError() 165 | } 166 | 167 | func dismissTransitions(count: Int) { 168 | printError() 169 | } 170 | 171 | func dismissAllTransitions() { 172 | printError() 173 | } 174 | 175 | func addTransitionsToQueue(transitions: [AnyTransitionDestination]) { 176 | printError() 177 | } 178 | 179 | func removeTransitionsFromQueue(ids: [String]) { 180 | printError() 181 | } 182 | 183 | func removeAllTransitionsFromQueue() { 184 | printError() 185 | } 186 | 187 | func showNextTransition() { 188 | printError() 189 | } 190 | 191 | func showModule(module: AnyTransitionDestination) { 192 | printError() 193 | } 194 | 195 | func showModules(modules: [AnyTransitionDestination]) { 196 | printError() 197 | } 198 | 199 | func dismissModule() { 200 | printError() 201 | } 202 | 203 | func dismissModule(id: String) { 204 | printError() 205 | } 206 | 207 | func dismissModules(upToId: String) { 208 | printError() 209 | } 210 | 211 | func dismissModules(count: Int) { 212 | printError() 213 | } 214 | 215 | func dismissAllModules() { 216 | printError() 217 | } 218 | 219 | func showSafari(_ url: @escaping () -> URL) { 220 | printError() 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Core/RouterProtocol/RouterEnvironmentKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RouterEnvironmentKey.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | import SwiftUI 8 | 9 | public struct RouterEnvironmentKey: EnvironmentKey { 10 | public static let defaultValue: AnyRouter = AnyRouter(id: "mock", object: MockRouter()) 11 | } 12 | 13 | public extension EnvironmentValues { 14 | var router: AnyRouter { 15 | get { self[RouterEnvironmentKey.self] } 16 | set { self[RouterEnvironmentKey.self] = newValue } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Core/RouterProtocol/RouterProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Router.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | 8 | 9 | import SwiftUI 10 | 11 | protocol Router: Sendable { 12 | @MainActor var activeScreens: [AnyDestinationStack] { get } 13 | @MainActor var activeScreenQueue: [AnyDestination] { get } 14 | @MainActor var activeAlert: AnyAlert? { get } 15 | @MainActor var activeModals: [AnyModal] { get } 16 | @MainActor var activeTransitions: [AnyTransitionDestination] { get } 17 | @MainActor var activeTransitionQueue: [AnyTransitionDestination] { get } 18 | @MainActor var activeModules: [AnyTransitionDestination] { get } 19 | 20 | @MainActor func showScreens(destinations: [AnyDestination]) 21 | @MainActor func dismissScreen(animates: Bool) 22 | @MainActor func dismissScreen(id: String, animates: Bool) 23 | @MainActor func dismissScreens(upToId: String, animates: Bool) 24 | @MainActor func dismissScreens(count: Int, animates: Bool) 25 | @MainActor func dismissPushStack(animates: Bool) 26 | @MainActor func dismissEnvironment(animates: Bool) 27 | @MainActor func dismissLastScreen(animates: Bool) 28 | @MainActor func dismissLastPushStack(animates: Bool) 29 | @MainActor func dismissLastEnvironment(animates: Bool) 30 | @MainActor func dismissAllScreens(animates: Bool) 31 | 32 | @MainActor func addScreensToQueue(destinations: [AnyDestination]) 33 | @MainActor func removeScreensFromQueue(ids: [String]) 34 | @MainActor func removeAllScreensFromQueue() 35 | @MainActor func showNextScreen() 36 | 37 | @MainActor func showAlert(alert: AnyAlert) 38 | @MainActor func dismissAlert() 39 | @MainActor func dismissAllAlerts() 40 | 41 | @MainActor func showModal(modal: AnyModal) 42 | @MainActor func dismissModal() 43 | @MainActor func dismissModal(id: String) 44 | @MainActor func dismissModals(upToId: String) 45 | @MainActor func dismissModals(count: Int) 46 | @MainActor func dismissAllModals() 47 | 48 | @MainActor func showTransition(transition: AnyTransitionDestination) 49 | @MainActor func showTransitions(transitions: [AnyTransitionDestination]) 50 | @MainActor func dismissTransition() 51 | @MainActor func dismissTransition(id: String) 52 | @MainActor func dismissTransitions(upToId: String) 53 | @MainActor func dismissTransitions(count: Int) 54 | @MainActor func dismissAllTransitions() 55 | 56 | @MainActor func addTransitionsToQueue(transitions: [AnyTransitionDestination]) 57 | @MainActor func removeTransitionsFromQueue(ids: [String]) 58 | @MainActor func removeAllTransitionsFromQueue() 59 | @MainActor func showNextTransition() 60 | 61 | @MainActor func showModule(module: AnyTransitionDestination) 62 | @MainActor func showModules(modules: [AnyTransitionDestination]) 63 | @MainActor func dismissModule() 64 | @MainActor func dismissModule(id: String) 65 | @MainActor func dismissModules(upToId: String) 66 | @MainActor func dismissModules(count: Int) 67 | @MainActor func dismissAllModules() 68 | 69 | @MainActor func showSafari(_ url: @escaping () -> URL) 70 | } 71 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Core/RouterViews/ModuleViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ModuleViewModel.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | import SwiftUI 8 | 9 | @MainActor 10 | final class ModuleViewModel: ObservableObject { 11 | 12 | @Published private(set) var rootModuleIdFromDeveloper: String? = nil 13 | 14 | // All modules 15 | // Modules are removed from the array when dismissed. 16 | @Published private(set) var modules: [AnyTransitionDestination] = [.root] 17 | 18 | // The current TransitionOption for changing modules. 19 | @Published private(set) var currentTransition: TransitionOption = .trailing 20 | 21 | } 22 | 23 | extension ModuleViewModel { 24 | 25 | func showModule(module: AnyTransitionDestination) { 26 | // Set the current transition before triggering the UI update 27 | // This can change the existing screen's "removal" transition, based on the incomign screens transition 28 | self.currentTransition = module.transition 29 | 30 | Task { @MainActor in 31 | // The OS needs a slight delay to update the existing screen's transition 32 | try? await Task.sleep(nanoseconds: 1_000_000) 33 | 34 | // Trigger the UI update 35 | // allTransitions[routerId] should never be nil since it's added in showScreen 36 | self.modules.append(module) 37 | self.setLastModuleId() 38 | 39 | logger.trackEvent(event: Event.moduleShow(module: module)) 40 | } 41 | } 42 | 43 | func showModules(modules: [AnyTransitionDestination]) { 44 | guard let lastModule = modules.last else { return } 45 | self.currentTransition = lastModule.transition 46 | 47 | Task { @MainActor in 48 | try? await Task.sleep(nanoseconds: 1_000_000) 49 | self.modules.append(contentsOf: modules) 50 | self.setLastModuleId() 51 | 52 | logger.trackEvent(event: Event.moduleShow(module: lastModule)) 53 | } 54 | } 55 | 56 | func dismissModule() { 57 | guard let index = modules.indices.last, modules.indices.contains(index - 1) else { 58 | // no transition to dismiss 59 | logger.trackEvent(event: Event.dismissModule_notFound) 60 | return 61 | } 62 | 63 | triggerAndRemoveModules( 64 | newCurrentTransition: modules[index].transition.reversed, 65 | screensToDismiss: [modules[index]], 66 | removeModulesAtRange: index..) { 71 | // Set current transition 72 | self.currentTransition = newCurrentTransition 73 | 74 | // Task is needed for UI 75 | Task { @MainActor in 76 | // Not required but doesn't hurt? 77 | try? await Task.sleep(nanoseconds: 1_000_000) 78 | 79 | defer { 80 | for screen in screensToDismiss.reversed() { 81 | // Trigger onDismiss for screens 82 | screen.onDismiss?() 83 | logger.trackEvent(event: Event.moduleDismiss(module: screen)) 84 | } 85 | } 86 | 87 | // Trigger UI update 88 | self.modules.removeSubrange(removeModulesAtRange) 89 | self.setLastModuleId() 90 | } 91 | } 92 | 93 | private func setLastModuleId() { 94 | let lastModuleId = modules.last?.id ?? RouterViewModel.rootId 95 | 96 | UserDefaults.lastModuleId = lastModuleId 97 | logger.trackEvent(event: Event.setLastModuleId(moduleId: lastModuleId)) 98 | } 99 | 100 | func dismissModules(moduleId: String) { 101 | // Dismiss to the screen before id 102 | guard 103 | let requestedIndex = modules.firstIndex(where: { $0.id == moduleId }) else { 104 | logger.trackEvent(event: Event.dismissModules_notFound(moduleId: moduleId)) 105 | return 106 | } 107 | 108 | // If there are no transitions before requestedIndex 109 | // Then fall-back to dismiss back to root 110 | var resultingScreenId = RouterViewModel.rootId 111 | if modules.indices.contains(requestedIndex - 1) { 112 | resultingScreenId = modules[requestedIndex - 1].id 113 | } 114 | 115 | dismissModules(toModuleId: resultingScreenId) 116 | } 117 | 118 | // Dismiss transitions after, but not including, toTransitionId 119 | func dismissModules(toModuleId: String) { 120 | guard 121 | // Get the last transition (array shoudl not be empty) 122 | let lastIndex = modules.indices.last, 123 | // Array must contain more than 1 item, otherwise nothing to dismiss 124 | modules.indices.contains(lastIndex - 1), 125 | // Find screen to dismiss to 126 | let screenIndex = modules.firstIndex(where: { $0.id == toModuleId }) 127 | else { 128 | logger.trackEvent(event: Event.dismissModulesTo_notFound(moduleId: toModuleId)) 129 | return 130 | } 131 | 132 | let screensToDismissStartingIndex = (screenIndex + 1) 133 | let screensToDismiss = Array(modules[screensToDismissStartingIndex...]) 134 | 135 | guard !screensToDismiss.isEmpty else { 136 | logger.trackEvent(event: Event.dismissModulesTo_empty(moduleId: toModuleId)) 137 | return 138 | } 139 | 140 | triggerAndRemoveModules( 141 | newCurrentTransition: modules[lastIndex].transition.reversed, 142 | screensToDismiss: screensToDismiss, 143 | removeModulesAtRange: screensToDismissStartingIndex..: View { 32 | 33 | let id: String 34 | let addNavigationStack: Bool 35 | let addModuleSupport: Bool 36 | let transitionBehavior: TransitionMemoryBehavior 37 | @ViewBuilder var content: (AnyRouter) -> Content 38 | 39 | public init( 40 | id: String? = nil, 41 | addNavigationStack: Bool = true, 42 | addModuleSupport: Bool = false, 43 | transitionBehavior: TransitionMemoryBehavior = .keepPrevious, 44 | content: @escaping (AnyRouter) -> Content 45 | ) { 46 | self.id = id ?? RouterViewModel.rootId 47 | self.addNavigationStack = addNavigationStack 48 | self.addModuleSupport = addModuleSupport 49 | self.transitionBehavior = transitionBehavior 50 | self.content = content 51 | } 52 | 53 | public var body: some View { 54 | Group { 55 | if addModuleSupport { 56 | ModuleSupportView( 57 | rootRouterInfo: (id, transitionBehavior), 58 | addNavigationStack: addNavigationStack, 59 | content: content 60 | ) 61 | } else { 62 | RouterViewModelWrapper { 63 | RouterViewInternal( 64 | routerId: RouterViewModel.rootId, 65 | rootRouterInfo: (id, transitionBehavior), 66 | addNavigationStack: addNavigationStack, 67 | content: content 68 | ) 69 | } 70 | } 71 | } 72 | } 73 | } 74 | 75 | struct RouterViewModelWrapper: View { 76 | 77 | @StateObject private var viewModel = RouterViewModel() 78 | @ViewBuilder var content: Content 79 | 80 | var body: some View { 81 | content 82 | .environmentObject(viewModel) 83 | 84 | #if DEBUG 85 | .onChange(of: viewModel.activeScreenStacks) { newValue in 86 | viewModel.printScreenStack(screenStack: newValue) 87 | } 88 | .onChange(of: viewModel.availableScreenQueue) { newValue in 89 | viewModel.printScreenQueue(screenQueue: newValue) 90 | } 91 | #endif 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Core/RouterViews/RouterViewInternal.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RouterViewInternal.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | import SwiftUI 8 | 9 | @MainActor 10 | struct RouterViewInternal: View, Router { 11 | 12 | @Environment(\.openURL) var openURL 13 | 14 | @EnvironmentObject var viewModel: RouterViewModel 15 | @EnvironmentObject var moduleViewModel: ModuleViewModel 16 | var routerId: String 17 | var rootRouterInfo: (id: String, transitionBehavior: TransitionMemoryBehavior)? 18 | var addNavigationStack: Bool = false 19 | var content: (AnyRouter) -> Content 20 | 21 | @StateObject private var stableScreenStack = StableAnyDestinationArray(destinations: []) 22 | 23 | private var currentRouter: AnyRouter { 24 | AnyRouter(id: routerId, object: self) 25 | } 26 | 27 | var body: some View { 28 | // Wrap starting content for Transition support 29 | TransitionSupportView( 30 | behavior: parentDestination?.transitionBehavior ?? .keepPrevious, 31 | router: currentRouter, 32 | transitions: viewModel.allTransitions[routerId] ?? [], 33 | content: content, 34 | currentTransition: viewModel.currentTransitions[routerId] ?? .trailing, 35 | onDidSwipeBack: { 36 | dismissTransition() 37 | } 38 | ) 39 | .id(routerId) 40 | 41 | // Add NavigationStack if needed 42 | .ifSatisfiesCondition(addNavigationStack, transform: { content in 43 | NavigationStack(path: $stableScreenStack.destinations) { 44 | content 45 | .navigationDestination(for: AnyDestination.self) { value in 46 | value.destination 47 | } 48 | .onChange(of: stableScreenStack.destinations, perform: { screenStack in 49 | // User manually swiped back on screen 50 | handleStableScreenStackDidChange(screenStack: screenStack) 51 | }) 52 | .onChange(of: viewModel.activeScreenStacks) { newStack in 53 | handleActiveScreenStackDidChange(newStack: newStack) 54 | } 55 | 56 | // There's a weird behavior (bug?) where the presentationDetent is not calculated 57 | // If the .sheet modifier is outside of the NavigationStack 58 | // Therefore, if we add NavigationStack, we add these as children of it 59 | .sheetBackgroundModifier(viewModel: viewModel, routerId: routerId) 60 | .fullScreenCoverBackgroundModifer(viewModel: viewModel, routerId: routerId) 61 | } 62 | }) 63 | 64 | // If we don't add NavigationStack, add .sheet modifiers here instead 65 | .ifSatisfiesCondition(!addNavigationStack, transform: { content in 66 | content 67 | .sheetBackgroundModifier(viewModel: viewModel, routerId: routerId) 68 | .fullScreenCoverBackgroundModifer(viewModel: viewModel, routerId: routerId) 69 | }) 70 | 71 | // If this is the root router, add "root" stack to the array 72 | .ifSatisfiesCondition(routerId == RouterViewModel.rootId, transform: { content in 73 | content 74 | .onFirstAppear { 75 | let view = AnyDestination( 76 | id: routerId, 77 | segue: .fullScreenCover, 78 | location: .insert, 79 | animates: false, 80 | transitionBehavior: rootRouterInfo?.transitionBehavior ?? .keepPrevious, 81 | onDismiss: nil, 82 | destination: { _ in self }) 83 | viewModel.insertRootView(rootRouterId: rootRouterInfo?.id, view: view) 84 | } 85 | }) 86 | 87 | // Add Alert modifier. 88 | .modifier(AlertViewModifier(alert: Binding(get: { 89 | viewModel.activeAlert[routerId] 90 | }, set: { newValue in 91 | if newValue == nil { 92 | viewModel.dismissAlert(routerId: routerId) 93 | } 94 | }))) 95 | 96 | // Add Modals modifier. 97 | .overlay( 98 | ModalSupportView( 99 | modals: viewModel.allModals[routerId] ?? [], 100 | onDismissModal: { modal in 101 | viewModel.dismissModal(routerId: routerId, modalId: modal.id) 102 | } 103 | ) 104 | ) 105 | 106 | #if DEBUG 107 | // logging on every router 108 | .onChange(of: viewModel.allModals[routerId] ?? []) { newValue in 109 | viewModel.printModalStack(routerId: routerId, modals: newValue) 110 | } 111 | .onChange(of: viewModel.allTransitions[routerId] ?? []) { newValue in 112 | viewModel.printTransitionStack(routerId: routerId, transitions: newValue) 113 | } 114 | .onChange(of: viewModel.availableTransitionQueue[routerId] ?? []) { newValue in 115 | viewModel.printTransitionQueue(routerId: routerId, transitionQueue: newValue) 116 | } 117 | #endif 118 | 119 | // Add to environment for convenience 120 | .environment(\.router, currentRouter) 121 | } 122 | 123 | private var parentDestination: AnyDestination? { 124 | guard let index = viewModel.activeScreenStacks.lastIndex(where: { stack in 125 | return stack.screens.contains(where: { $0.id == routerId }) 126 | }) else { 127 | return nil 128 | } 129 | 130 | return viewModel.activeScreenStacks[index].screens.first(where: { $0.id == routerId }) 131 | } 132 | 133 | private func handleStableScreenStackDidChange(screenStack: [AnyDestination]) { 134 | let activeStack = viewModel.activeScreenStacks 135 | let index = activeStack.firstIndex { subStack in 136 | return subStack.screens.contains(where: { $0.id == routerId }) 137 | } 138 | guard let index, activeStack.indices.contains(index + 1) else { 139 | return 140 | } 141 | 142 | if screenStack.count < activeStack[index + 1].screens.count { 143 | if let lastScreen = screenStack.last { 144 | viewModel.dismissScreens(to: lastScreen.id, animates: true) 145 | } else { 146 | viewModel.dismissPushStack(routeId: routerId, animates: true) 147 | } 148 | } 149 | } 150 | 151 | private func handleActiveScreenStackDidChange(newStack: [AnyDestinationStack]) { 152 | let index = newStack.firstIndex { subStack in 153 | return subStack.screens.contains(where: { $0.id == routerId }) 154 | } 155 | guard let index, newStack.indices.contains(index + 1) else { 156 | stableScreenStack.setNewValueIfNeeded(newValue: []) 157 | return 158 | } 159 | 160 | let activeStack = newStack[index + 1].screens 161 | stableScreenStack.setNewValueIfNeeded(newValue: activeStack) 162 | } 163 | 164 | var activeScreens: [AnyDestinationStack] { 165 | viewModel.activeScreenStacks 166 | } 167 | 168 | var activeScreenQueue: [AnyDestination] { 169 | viewModel.availableScreenQueue 170 | } 171 | 172 | var activeAlert: AnyAlert? { 173 | viewModel.activeAlert[routerId] 174 | } 175 | 176 | var activeModals: [AnyModal] { 177 | viewModel.allModals[routerId]?.active ?? [] 178 | } 179 | 180 | var activeTransitions: [AnyTransitionDestination] { 181 | viewModel.allTransitions[routerId] ?? [] 182 | } 183 | 184 | var activeModules: [AnyTransitionDestination] { 185 | moduleViewModel.modules 186 | } 187 | 188 | var activeTransitionQueue: [AnyTransitionDestination] { 189 | viewModel.availableTransitionQueue[routerId] ?? [] 190 | } 191 | 192 | func showScreens(destinations: [AnyDestination]) { 193 | viewModel.showScreens(routerId: routerId, destinations: destinations) 194 | } 195 | 196 | func showScreen(destination: AnyDestination) { 197 | viewModel.showScreens(routerId: routerId, destinations: [destination]) 198 | } 199 | 200 | func dismissScreen(animates: Bool) { 201 | viewModel.dismissScreen(routeId: routerId, animates: animates) 202 | } 203 | 204 | func dismissScreen(id: String, animates: Bool) { 205 | viewModel.dismissScreen(routeId: id, animates: animates) 206 | } 207 | 208 | func dismissScreens(upToId: String, animates: Bool) { 209 | viewModel.dismissScreens(to: upToId, animates: animates) 210 | } 211 | 212 | func dismissScreens(count: Int, animates: Bool) { 213 | viewModel.dismissScreens(count: count, animates: animates) 214 | } 215 | 216 | func dismissLastScreen(animates: Bool) { 217 | viewModel.dismissLastScreen(animates: animates) 218 | } 219 | 220 | func dismissEnvironment(animates: Bool) { 221 | viewModel.dismissEnvironment(routeId: routerId, animates: animates) 222 | } 223 | 224 | func dismissLastEnvironment(animates: Bool) { 225 | viewModel.dismissLastEnvironment(animates: animates) 226 | } 227 | 228 | func dismissLastPushStack(animates: Bool) { 229 | viewModel.dismissLastPushStack(animates: animates) 230 | } 231 | 232 | func dismissPushStack(animates: Bool) { 233 | viewModel.dismissPushStack(routeId: routerId, animates: animates) 234 | } 235 | 236 | func dismissAllScreens(animates: Bool) { 237 | viewModel.dismissAllScreens(animates: animates) 238 | } 239 | 240 | func addScreensToQueue(destinations: [AnyDestination]) { 241 | viewModel.addScreensToQueue(routerId: routerId, destinations: destinations) 242 | } 243 | 244 | func removeScreensFromQueue(ids: [String]) { 245 | viewModel.removeScreensFromQueue(screenIds: ids) 246 | } 247 | 248 | func removeAllScreensFromQueue() { 249 | viewModel.removeAllScreensFromQueue() 250 | } 251 | 252 | func showNextScreen() { 253 | viewModel.showNextScreen(routerId: routerId) 254 | } 255 | 256 | func showAlert(alert: AnyAlert) { 257 | viewModel.showAlert(routerId: routerId, alert: alert) 258 | } 259 | 260 | func dismissAlert() { 261 | viewModel.dismissAlert(routerId: routerId) 262 | } 263 | 264 | func dismissAllAlerts() { 265 | viewModel.dismissAllAlerts() 266 | } 267 | 268 | func showModal(modal: AnyModal) { 269 | viewModel.showModal(routerId: routerId, modal: modal) 270 | } 271 | 272 | func dismissModal() { 273 | viewModel.dismissLastModal(onRouterId: routerId) 274 | } 275 | 276 | func dismissModal(id: String) { 277 | viewModel.dismissModal(routerId: routerId, modalId: id) 278 | } 279 | 280 | func dismissModals(upToId: String) { 281 | viewModel.dismissModals(routerId: routerId, to: upToId) 282 | } 283 | 284 | func dismissModals(count: Int) { 285 | viewModel.dismissModals(routerId: routerId, count: count) 286 | } 287 | 288 | func dismissAllModals() { 289 | viewModel.dismissAllModals(routerId: routerId) 290 | } 291 | 292 | func showTransition(transition: AnyTransitionDestination) { 293 | viewModel.showTransition(routerId: routerId, transition: transition) 294 | } 295 | 296 | func showTransitions(transitions: [AnyTransitionDestination]) { 297 | viewModel.showTransitions(routerId: routerId, transitions: transitions) 298 | } 299 | 300 | func dismissTransition() { 301 | viewModel.dismissTransition(routerId: routerId) 302 | } 303 | 304 | func dismissTransition(id: String) { 305 | viewModel.dismissTransitions(routerId: routerId, transitionId: id) 306 | } 307 | 308 | func dismissTransitions(upToId id: String) { 309 | viewModel.dismissTransitions(routerId: routerId, toTransitionId: id) 310 | } 311 | 312 | func dismissTransitions(count: Int) { 313 | viewModel.dismissTransitions(routerId: routerId, count: count) 314 | } 315 | 316 | func dismissAllTransitions() { 317 | viewModel.dismissAllTransitions(routerId: routerId) 318 | } 319 | 320 | func addTransitionsToQueue(transitions: [AnyTransitionDestination]) { 321 | viewModel.addTransitionsToQueue(routerId: routerId, transitions: transitions) 322 | } 323 | 324 | func removeTransitionsFromQueue(ids: [String]) { 325 | viewModel.removeTransitionsFromQueue(routerId: routerId, transitionIds: ids) 326 | } 327 | 328 | func removeAllTransitionsFromQueue() { 329 | viewModel.removeAllTransitionsFromQueue(routerId: routerId) 330 | } 331 | 332 | func showNextTransition() { 333 | viewModel.showNextTransition(routerId: routerId) 334 | } 335 | 336 | func showModule(module: AnyTransitionDestination) { 337 | moduleViewModel.showModule(module: module) 338 | } 339 | 340 | func showModules(modules: [AnyTransitionDestination]) { 341 | moduleViewModel.showModules(modules: modules) 342 | } 343 | 344 | func dismissModule() { 345 | moduleViewModel.dismissModule() 346 | } 347 | 348 | func dismissModule(id: String) { 349 | moduleViewModel.dismissModules(moduleId: id) 350 | } 351 | 352 | func dismissModules(upToId: String) { 353 | moduleViewModel.dismissModules(toModuleId: upToId) 354 | } 355 | 356 | func dismissModules(count: Int) { 357 | moduleViewModel.dismissModules(count: count) 358 | } 359 | 360 | func dismissAllModules() { 361 | moduleViewModel.dismissAllModules() 362 | } 363 | 364 | func showSafari(_ url: @escaping () -> URL) { 365 | let url = url() 366 | openURL(url) 367 | logger.trackEvent(event: RouterViewModel.Event.showSafari(url: url)) 368 | } 369 | } 370 | 371 | extension View { 372 | 373 | func sheetBackgroundModifier(viewModel: RouterViewModel, routerId: String) -> some View { 374 | self 375 | .background( 376 | Text("") 377 | .sheet(item: Binding(stack: viewModel.activeScreenStacks, routerId: routerId, segue: .sheet, onDidDismiss: { 378 | // This triggers if the user swipes down to dismiss the screen 379 | // Now we must update activeScreenStacks to match that behavior 380 | viewModel.dismissScreens(toEnvironmentId: routerId, animates: true) 381 | }), onDismiss: nil) { destination in 382 | destination.destination 383 | .applyResizableSheetModifiersIfNeeded(segue: destination.segue) 384 | } 385 | ) 386 | } 387 | 388 | func fullScreenCoverBackgroundModifer(viewModel: RouterViewModel, routerId: String) -> some View { 389 | self 390 | .background( 391 | Text("") 392 | .fullScreenCover(item: Binding(stack: viewModel.activeScreenStacks, routerId: routerId, segue: .fullScreenCover, onDidDismiss: { 393 | // This triggers if the user swipes down to dismiss the screen 394 | // Now we must update activeScreenStacks to match that behavior 395 | viewModel.dismissScreens(toEnvironmentId: routerId, animates: true) 396 | }), onDismiss: nil) { destination in 397 | destination.destination 398 | .applyResizableSheetModifiersIfNeeded(segue: destination.segue) 399 | } 400 | ) 401 | } 402 | } 403 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Extensions/Array+EXT.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Array+EXT.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 9/3/23. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Array where Element: Identifiable { 11 | 12 | func firstAfter(_ element: Element, where condition: (Element) -> Bool) -> Element? { 13 | var didFindElement: Bool = false 14 | for item in self { 15 | if didFindElement { 16 | if condition(item) { 17 | return item 18 | } 19 | } 20 | 21 | if item.id == element.id { 22 | didFindElement = true 23 | } 24 | } 25 | 26 | return nil 27 | } 28 | 29 | func firstAfter(_ element: Element) -> Element? { 30 | if let index = self.firstIndex(where: { $0.id == element.id }), index + 1 < self.count { 31 | return self[index + 1] 32 | } 33 | return nil 34 | } 35 | 36 | func firstBefore(_ element: Element) -> Element? { 37 | if let index = self.firstIndex(where: { $0.id == element.id }), index > 0 { 38 | return self[index - 1] 39 | } 40 | return nil 41 | } 42 | 43 | mutating func insertAfter(_ element: Element, after: Element) { 44 | if let index = self.firstIndex(where: { $0.id == after.id }), self.count > index { 45 | let nextIndex = index + 1 46 | self.insert(element, at: nextIndex) 47 | } else { 48 | // If the element is not found, append the new element at the end. 49 | self.append(element) 50 | } 51 | } 52 | 53 | mutating func insertBefore(_ element: Element, before: Element) { 54 | if let index = self.firstIndex(where: { $0.id == before.id }) { 55 | self.insert(element, at: index) 56 | } else { 57 | // If the element is not found, append the new element at the end. 58 | self.append(element) 59 | } 60 | } 61 | 62 | mutating func insertAfter(_ elements: [Element], after: Element) { 63 | if let index = self.firstIndex(where: { $0.id == after.id }), (index + 1) < self.count { 64 | let nextIndex = index + 1 65 | self.insert(contentsOf: elements, at: nextIndex) 66 | } else { 67 | // If the element is not found, append the new element at the end. 68 | self.append(contentsOf: elements) 69 | } 70 | } 71 | 72 | mutating func insertBefore(_ elements: [Element], before: Element) { 73 | if let index = self.firstIndex(where: { $0.id == before.id }) { 74 | self.insert(contentsOf: elements, at: index) 75 | } else { 76 | // If the element is not found, append the new element at the end. 77 | self.append(contentsOf: elements) 78 | } 79 | } 80 | 81 | func allAfter(_ element: Element) -> [Element]? { 82 | guard let index = self.firstIndex(where: { $0.id == element.id }), index < self.count - 1 else { 83 | return nil 84 | } 85 | return Array(self[(index + 1)...]) 86 | } 87 | 88 | func allBefore(_ element: Element) -> [Element]? { 89 | guard let index = self.firstIndex(where: { $0.id == element.id }), index > 0 else { 90 | return nil 91 | } 92 | return Array(self[.. Void) { 15 | // self.init { 16 | // let index = stack.firstIndex { subStack in 17 | // return subStack.screens.contains(where: { $0.id == routerId }) 18 | // } 19 | // guard let index, stack.indices.contains(index + 1) else { 20 | // return [] 21 | // } 22 | // return stack[index + 1].screens 23 | // } set: { newValue in 24 | // // User manually swiped back on screen 25 | // 26 | // let index = stack.firstIndex { subStack in 27 | // return subStack.screens.contains(where: { $0.id == routerId }) 28 | // } 29 | // guard let index, stack.indices.contains(index + 1) else { 30 | // return 31 | // } 32 | // 33 | // if newValue.count < stack[index + 1].screens.count { 34 | // onDidDismiss(newValue.last) 35 | // } 36 | // } 37 | // } 38 | //} 39 | 40 | @MainActor 41 | extension Binding where Value == AnyDestination? { 42 | 43 | init(stack: [AnyDestinationStack], routerId: String, segue: SegueOption, onDidDismiss: @escaping () -> Void) { 44 | self.init { 45 | let routerStackIndex = stack.firstIndex { subStack in 46 | return subStack.screens.contains(where: { $0.id == routerId }) 47 | } 48 | 49 | guard let routerStackIndex else { 50 | return nil 51 | } 52 | 53 | let routerStack = stack[routerStackIndex] 54 | 55 | if routerStack.segue == .push, routerStack.screens.last?.id != routerId { 56 | return nil 57 | } 58 | 59 | var nextSheetStack: AnyDestinationStack? 60 | if routerStack.segue == .push, stack.indices.contains(routerStackIndex + 1) { 61 | nextSheetStack = stack[routerStackIndex + 1] 62 | } else if stack.indices.contains(routerStackIndex + 2) { 63 | nextSheetStack = stack[routerStackIndex + 2] 64 | } 65 | 66 | if let nextSegue = nextSheetStack?.segue, nextSegue == segue, let screen = nextSheetStack?.screens.first { 67 | return screen 68 | } 69 | 70 | return nil 71 | } set: { newValue in 72 | // User manually swiped down on environment 73 | if newValue == nil { 74 | onDidDismiss() 75 | } 76 | } 77 | } 78 | } 79 | 80 | @MainActor 81 | extension Binding where Value == Bool { 82 | 83 | init(ifAlert alert: Binding, isStyle style: AlertStyle) { 84 | self.init(get: { 85 | if let alertStyle = alert.wrappedValue?.style, alertStyle == style { 86 | return true 87 | } 88 | return false 89 | }, set: { newValue in 90 | if newValue == false { 91 | alert.wrappedValue = nil 92 | } 93 | }) 94 | } 95 | } 96 | 97 | @MainActor 98 | extension Binding where Value == PresentationDetent { 99 | 100 | init(selection: Binding) { 101 | self.init { 102 | selection.wrappedValue.asPresentationDetent 103 | } set: { newValue in 104 | selection.wrappedValue = PresentationDetentTransformable(detent: newValue) 105 | } 106 | } 107 | 108 | } 109 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Extensions/Set+EXT.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Set+EXT.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 1/28/23. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Set { 11 | func setMap(_ transform: (Element) -> U) -> Set { 12 | return Set(self.lazy.map(transform)) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Extensions/UserDefaults+EXT.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDefaults+EXT.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | import Foundation 8 | 9 | public extension UserDefaults { 10 | 11 | @MainActor 12 | static var lastModuleId: String { 13 | get { 14 | standard.string(forKey: "last_module_id") ?? RouterViewModel.rootId 15 | } 16 | set { 17 | standard.set(newValue, forKey: "last_module_id") 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Extensions/View+EXT.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 5/12/24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | extension View { 12 | 13 | @ViewBuilder func ifSatisfiesCondition(_ condition: Bool, transform: @escaping (Self) -> Content) -> some View { 14 | if condition { 15 | transform(self) 16 | } else { 17 | self 18 | } 19 | } 20 | 21 | @ViewBuilder func ifLetCondition(_ value: T?, transform: @escaping (Self, T) -> Content) -> some View { 22 | if let value { 23 | transform(self, value) 24 | } else { 25 | self 26 | } 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Logger/RoutingLogger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RoutingLogger.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | 10 | @MainActor var logger: (any RoutingLogger) = MockRoutingLogger(logLevel: .warning, printParameters: false) 11 | 12 | @MainActor 13 | public protocol RoutingLogger { 14 | func trackEvent(event: RoutingLogEvent) 15 | func trackScreenView(event: RoutingLogEvent) 16 | } 17 | 18 | public enum SwiftfulRoutingLogger { 19 | 20 | // RoutingLogger.enableLogging(logger: logger) 21 | @MainActor static public func enableLogging(logger newValue: RoutingLogger) { 22 | logger = newValue 23 | } 24 | 25 | // RoutingLogger.enableLogging(level: .info) 26 | @MainActor static public func enableLogging(level newValue: RoutingLogType, printParameters: Bool = false) { 27 | logger = MockRoutingLogger(logLevel: newValue, printParameters: printParameters) 28 | } 29 | 30 | } 31 | 32 | struct MockRoutingLogger: RoutingLogger { 33 | 34 | var logLevel: RoutingLogType 35 | var printParameters: Bool 36 | 37 | func trackEvent(event: any RoutingLogEvent) { 38 | #if DEBUG 39 | if event.type.rawValue >= logLevel.rawValue { 40 | var value = "\(event.type.emoji) \(event.eventName)" 41 | 42 | if printParameters, let params = event.parameters, !params.isEmpty { 43 | let sortedKeys = params.keys.sorted() 44 | for key in sortedKeys { 45 | if let paramValue = params[key] { 46 | value += "\n (key: \"\(key)\", value: \(paramValue))" 47 | } 48 | } 49 | } 50 | 51 | print(value) 52 | } 53 | #endif 54 | } 55 | 56 | func trackScreenView(event: any RoutingLogEvent) { 57 | trackEvent(event: event) 58 | } 59 | } 60 | 61 | @MainActor 62 | public protocol RoutingLogEvent { 63 | var eventName: String { get } 64 | var parameters: [String: Any]? { get } 65 | var type: RoutingLogType { get } 66 | } 67 | 68 | public enum RoutingLogType: Int, CaseIterable, Sendable { 69 | case info // 0 70 | case analytic // 1 71 | case warning // 2 72 | case severe // 3 73 | 74 | var emoji: String { 75 | switch self { 76 | case .info: 77 | return "👋" 78 | case .analytic: 79 | return "📈" 80 | case .warning: 81 | return "⚠️" 82 | case .severe: 83 | return "🚨" 84 | } 85 | } 86 | 87 | var asString: String { 88 | switch self { 89 | case .info: return "info" 90 | case .analytic: return "analytic" 91 | case .warning: return "warning" 92 | case .severe: return "severe" 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Models/Alerts/AlertLocation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlertLocation.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | 10 | public enum AlertLocation: String { 11 | case currentScreen, topScreen 12 | } 13 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Models/Alerts/AlertStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlertStyle.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | 10 | public enum AlertStyle: String, CaseIterable, Hashable { 11 | case alert, confirmationDialog 12 | 13 | public var codeString: String { 14 | ".\(rawValue)" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Models/Alerts/AnyAlert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnyAlert.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 5/1/22. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | 10 | public struct AnyAlert: Identifiable { 11 | public let id = UUID().uuidString 12 | public let style: AlertStyle 13 | public let location: AlertLocation 14 | public let title: String 15 | public let subtitle: String? 16 | public let buttons: AnyView 17 | 18 | /// Display an alert. 19 | /// - Parameters: 20 | /// - style: Type of alert. 21 | /// - location: Which screen to display alert on. 22 | /// - title: Title of alert. 23 | /// - subtitle: Subtitle of alert (optional) 24 | /// - buttons: Buttons within alert (hint: use Group with multiple Button inside). 25 | public init( 26 | style: AlertStyle = .alert, 27 | location: AlertLocation = .topScreen, 28 | title: String, 29 | subtitle: String? = nil, 30 | @ViewBuilder buttons: () -> T 31 | ) { 32 | self.style = style 33 | self.location = location 34 | self.title = title 35 | self.subtitle = subtitle 36 | self.buttons = AnyView(buttons()) 37 | } 38 | 39 | /// Display an alert with "OK" button. 40 | /// - Parameters: 41 | /// - style: Type of alert. 42 | /// - location: Which screen to display alert on. 43 | /// - title: Title of alert. 44 | /// - subtitle: Subtitle of alert (optional) 45 | public init( 46 | style: AlertStyle = .alert, 47 | location: AlertLocation = .topScreen, 48 | title: String, 49 | subtitle: String? = nil 50 | ) { 51 | self.style = style 52 | self.location = location 53 | self.title = title 54 | self.subtitle = subtitle 55 | self.buttons = AnyView( 56 | Button("OK", action: { }) 57 | ) 58 | } 59 | 60 | public var eventParameters: [String: Any] { 61 | [ 62 | "alert_id": id, 63 | "alert_style": style.rawValue, 64 | "alert_location": location.rawValue, 65 | "alert_title": title, 66 | "alert_subtitle": subtitle ?? "", 67 | ] 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Models/Modals/AnyModal.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnyModal.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | 10 | public struct AnyModal: Identifiable, Equatable { 11 | public private(set) var id: String 12 | public private(set) var transition: AnyTransition 13 | public private(set) var animation: Animation 14 | public private(set) var alignment: Alignment 15 | public private(set) var backgroundColor: Color? 16 | public private(set) var backgroundEffect: BackgroundEffect? 17 | public private(set) var dismissOnBackgroundTap: Bool 18 | public private(set) var ignoreSafeArea: Bool 19 | public private(set) var destination: AnyView 20 | public private(set) var onDismiss: (() -> Void)? 21 | public private(set) var isRemoved: Bool = false 22 | 23 | /// Show a modal. 24 | /// - Parameters: 25 | /// - id: Identifier for modal. 26 | /// - transition: Transition to show and hide modal. 27 | /// - animation: Animation to show and hide modal. 28 | /// - alignment: Alignment within the screen. 29 | /// - backgroundColor: Background color behind the modal, if applicable. 30 | /// - backgroundEffect: Background effect behind the modal, if applicable. 31 | /// - dismissOnBackgroundTap: If there is a background color/effect, add tap gesture that dismisses the modal. 32 | /// - ignoreSafeArea: Ignore screen's safe area when displayed. 33 | /// - onDismiss: Closure that triggers when modal dismisses. 34 | /// - destination: The modal View. 35 | public init( 36 | id: String = UUID().uuidString, 37 | transition: AnyTransition = .identity, 38 | animation: Animation = .smooth, 39 | alignment: Alignment = .center, 40 | backgroundColor: Color? = nil, 41 | backgroundEffect: BackgroundEffect? = nil, 42 | dismissOnBackgroundTap: Bool = true, 43 | ignoreSafeArea: Bool = true, 44 | destination: @escaping () -> T, 45 | onDismiss: (() -> Void)? = nil 46 | ) { 47 | self.id = id 48 | self.transition = transition 49 | self.animation = animation 50 | self.alignment = alignment 51 | self.backgroundColor = backgroundColor 52 | self.backgroundEffect = backgroundEffect 53 | self.dismissOnBackgroundTap = dismissOnBackgroundTap 54 | self.ignoreSafeArea = ignoreSafeArea 55 | self.destination = AnyView( 56 | destination() 57 | ) 58 | self.onDismiss = onDismiss 59 | } 60 | 61 | var hasBackgroundLayer: Bool { 62 | backgroundColor != nil || backgroundEffect != nil 63 | } 64 | 65 | public func hash(into hasher: inout Hasher) { 66 | hasher.combine(id) 67 | } 68 | 69 | public static func == (lhs: AnyModal, rhs: AnyModal) -> Bool { 70 | lhs.id == rhs.id 71 | } 72 | 73 | mutating func convertToEmptyRemovedModal() { 74 | id = "removed_\(id)" 75 | backgroundColor = nil 76 | backgroundEffect = nil 77 | dismissOnBackgroundTap = false 78 | destination = AnyView( 79 | EmptyView().allowsHitTesting(false) 80 | ) 81 | onDismiss = nil 82 | isRemoved = true 83 | } 84 | 85 | public var eventParameters: [String: Any] { 86 | [ 87 | "modal_id": id, 88 | "modal_is_removed": isRemoved, 89 | "modal_dismiss_bg_tap": dismissOnBackgroundTap, 90 | "modal_has_background_color": backgroundColor != nil, 91 | "modal_has_background_effect": backgroundEffect != nil, 92 | "modal_has_on_dismiss": onDismiss != nil, 93 | ] 94 | } 95 | } 96 | 97 | public extension Array where Element == AnyModal { 98 | 99 | var active: Self { 100 | filter({ !$0.isRemoved }) 101 | } 102 | 103 | var removed: Self { 104 | filter({ $0.isRemoved }) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Models/Screens/AnyDestination.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnyDestination.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 5/1/22. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | 10 | @MainActor 11 | public struct AnyDestination: Identifiable, Hashable { 12 | public private(set) var id: String 13 | public let segue: SegueOption 14 | public let location: SegueLocation 15 | public let animates: Bool 16 | public let destination: AnyView 17 | public let onDismiss: (() -> Void)? 18 | public let transitionBehavior: TransitionMemoryBehavior 19 | 20 | /// - Parameters: 21 | /// - id: Identifier for the screen 22 | /// - segue: Push (NavigationLink), Sheet, or FullScreenCover 23 | /// - location: Where to insert the new screen in the heirarchy (default = .insert) 24 | /// - animates: If the segue should animate or not (default = true) 25 | /// - transitionBehavior: Determines the behavior of "transition" methods on the destination screen. 26 | /// - onDismiss: Trigger closure when screen gets dismissed (note: dismiss != disappear) 27 | /// - destination: The destination screen. 28 | public init( 29 | id: String = UUID().uuidString, 30 | segue: SegueOption = .push, 31 | location: SegueLocation = .insert, 32 | animates: Bool = true, 33 | transitionBehavior: TransitionMemoryBehavior = .keepPrevious, 34 | onDismiss: (() -> Void)? = nil, 35 | destination: @escaping (AnyRouter) -> T 36 | ) { 37 | self.id = id 38 | self.segue = segue 39 | self.location = location 40 | self.animates = animates 41 | self.transitionBehavior = transitionBehavior 42 | self.destination = AnyView( 43 | RouterViewInternal( 44 | routerId: id, 45 | rootRouterInfo: nil, 46 | addNavigationStack: segue != .push, 47 | content: destination 48 | ) 49 | ) 50 | self.onDismiss = onDismiss 51 | } 52 | 53 | nonisolated public func hash(into hasher: inout Hasher) { 54 | hasher.combine(id) 55 | } 56 | 57 | nonisolated public static func == (lhs: AnyDestination, rhs: AnyDestination) -> Bool { 58 | lhs.id == rhs.id 59 | } 60 | 61 | mutating func updateScreenId(newValue: String) { 62 | id = newValue 63 | } 64 | 65 | public var eventParameters: [String: Any] { 66 | [ 67 | "destination_id": id, 68 | "destination_segue": segue.stringValue, 69 | "destination_location": location.stringValue, 70 | "destination_animates": animates, 71 | "destination_has_on_dismiss": onDismiss != nil, 72 | "destination_transition_behavior": transitionBehavior.rawValue, 73 | ] 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Models/Screens/AnyDestinationStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnyDestinationStack.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | 10 | public struct AnyDestinationStack: Equatable { 11 | public private(set) var segue: SegueOption 12 | public var screens: [AnyDestination] 13 | } 14 | 15 | extension Array where Element == AnyDestinationStack { 16 | 17 | func lastIndexWhereChildStackContains(routerId: String) -> Int? { 18 | self.lastIndex { stack in 19 | return stack.screens.contains(where: { $0.id == routerId }) 20 | } 21 | } 22 | 23 | public var allScreens: [AnyDestination] { 24 | flatMap({ $0.screens }) 25 | } 26 | } 27 | 28 | /* 29 | HOW IT WORKS: 30 | 31 | AnyDestinationStack is an array that will contain either: 32 | - 1 .sheet 33 | - 1 .fullScreenCover 34 | - any number of .push 35 | 36 | When the user goes to a new environment (a sheet or fullScreenCover), the system will add 2 AnyDestinationStacks to the heirarchy. 37 | First will be the environment stack (ie. [.sheet]), followed by a push stack that begins as a blank array. 38 | 39 | A typical new environment would looke like: 40 | 41 | [ 42 | [.fullScreenCover] 43 | [] 44 | ] 45 | 46 | If the user segues via .push, then the push stack will populate. 47 | 48 | [ 49 | [.fullScreenCover] 50 | [.push, .push, .push, .push] 51 | ] 52 | 53 | This will continue until the user enters another new environment. 54 | 55 | [ 56 | [.fullScreenCover] 57 | [.push, .push, .push, .push] 58 | [.sheet] 59 | [] 60 | ] 61 | 62 | And the pattern continues indefinately... 63 | 64 | [ 65 | [.fullScreenCover] 66 | [.push, .push, .push, .push] 67 | [.sheet] 68 | [] <- these empty stacks in-between would be for .pushes that never occured 69 | [.sheet] 70 | [.push] 71 | [.fullScreenCover] 72 | [.push, .push] 73 | ] 74 | 75 | 76 | Here are some more examples: 77 | 78 | STACK: .fullScreenCover, .push, .push, .push: 79 | 80 | [ 81 | [.fullScreenCover] 82 | [.push, .push, .push] 83 | ] 84 | 85 | 86 | STACK .fullScreenCover, .push, .push, .sheet, .push: 87 | 88 | [ 89 | [.fullScreenCover] 90 | [.push, .push] 91 | [.sheet] 92 | [.push] 93 | ] 94 | 95 | STACK .fullScreenCover, .sheet, .push, .sheet: 96 | 97 | [ 98 | [.fullScreenCover] 99 | [] 100 | [.sheet] 101 | [.push] 102 | [.sheet] 103 | [] 104 | ] 105 | 106 | STACK .fullScreenCover, .fullScreenCover, .fullScreenCover: 107 | 108 | [ 109 | [.fullScreenCover] 110 | [] 111 | [.fullScreenCover] 112 | [] 113 | [.fullScreenCover] 114 | [] 115 | ] 116 | */ 117 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Models/Screens/SegueLocation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SegueLocation.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | 10 | public enum SegueLocation { 11 | /// Insert screen at the location of the call-site's router 12 | case insert 13 | /// Append screen to the end of the active stack 14 | case append 15 | /// Insert screen after the location injected screen's router 16 | case insertAfter(id: String) 17 | 18 | public var stringValue: String { 19 | switch self { 20 | case .insert: 21 | return "insert" 22 | case .append: 23 | return "append" 24 | case .insertAfter: 25 | return "insert_after" 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Models/Screens/SegueOption.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SegueOption.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | 10 | public enum SegueOption: Equatable, CaseIterable, Hashable { 11 | case push 12 | case fullScreenCoverConfig(config: FullScreenCoverConfig = FullScreenCoverConfig()) 13 | case sheetConfig(config: ResizableSheetConfig = ResizableSheetConfig()) 14 | 15 | public static var fullScreenCover: Self { 16 | .fullScreenCoverConfig(config: FullScreenCoverConfig()) 17 | } 18 | 19 | public static var sheet: Self { 20 | .sheetConfig(config: ResizableSheetConfig()) 21 | } 22 | 23 | public static var allCases: [SegueOption] { 24 | [.push, .fullScreenCover, .sheet] 25 | } 26 | 27 | public func hash(into hasher: inout Hasher) { 28 | hasher.combine(stringValue) 29 | } 30 | 31 | public static func == (lhs: SegueOption, rhs: SegueOption) -> Bool { 32 | lhs.stringValue == rhs.stringValue 33 | } 34 | 35 | public var codeString: String { 36 | switch self { 37 | case .push: 38 | return ".push" 39 | case .sheetConfig: 40 | return ".sheet" 41 | case .fullScreenCoverConfig: 42 | return ".fullScreenCover" 43 | } 44 | } 45 | 46 | public var stringValue: String { 47 | switch self { 48 | case .push: 49 | return "push" 50 | case .sheetConfig: 51 | return "sheet" 52 | case .fullScreenCoverConfig: 53 | return "fullScreenCover" 54 | } 55 | } 56 | 57 | public var presentsNewEnvironment: Bool { 58 | switch self { 59 | case .push: 60 | return false 61 | case .sheetConfig, .fullScreenCoverConfig: 62 | return true 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Models/Screens/StableAnyDestinationArray.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StablePath.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 5/22/25. 6 | // 7 | import SwiftUI 8 | 9 | final class StableAnyDestinationArray: ObservableObject, Equatable { 10 | @Published var destinations: [AnyDestination] 11 | 12 | init(destinations: [AnyDestination]) { 13 | self.destinations = destinations 14 | } 15 | 16 | func setNewValueIfNeeded(newValue: [AnyDestination]) { 17 | if destinations != newValue { 18 | destinations = newValue 19 | } 20 | } 21 | 22 | static func == (lhs: StableAnyDestinationArray, rhs: StableAnyDestinationArray) -> Bool { 23 | lhs === rhs 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Models/Sheets/EnvironmentBackgroundOption.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EnvironmentBackgroundOption.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | 10 | public enum EnvironmentBackgroundOption { 11 | case automatic 12 | case clear 13 | case custom(any ShapeStyle) 14 | } 15 | 16 | extension View { 17 | 18 | @ViewBuilder 19 | func applyEnvironmentBackgroundIfAvailable(option: EnvironmentBackgroundOption) -> some View { 20 | if #available(iOS 16.4, *) { 21 | switch option { 22 | case .automatic: 23 | self 24 | case .clear: 25 | self 26 | .presentationBackground(.clear) 27 | .background(RemoveSheetShadow()) 28 | case .custom(let value): 29 | self 30 | .presentationBackground(AnyShapeStyle(value)) 31 | } 32 | } else { 33 | switch option { 34 | case .automatic: 35 | self 36 | case .clear: 37 | self 38 | .background(RemoveSheetShadow()) 39 | case .custom(let value): 40 | self 41 | } 42 | } 43 | } 44 | } 45 | 46 | fileprivate struct RemoveSheetShadow: UIViewRepresentable { 47 | func makeUIView(context: Context) -> UIView { 48 | let view = UIView(frame: .zero) 49 | view.backgroundColor = .clear 50 | 51 | DispatchQueue.main.async { 52 | if let shadowView = view.dropShadowView { 53 | shadowView.layer.shadowColor = UIColor.clear.cgColor 54 | } 55 | } 56 | 57 | return view 58 | } 59 | 60 | func updateUIView(_ uiView: UIView, context: Context) { 61 | 62 | } 63 | } 64 | 65 | extension UIView { 66 | var dropShadowView: UIView? { 67 | if let superview, String(describing: type(of: superview)) == "UIDropShadowView" { 68 | return superview 69 | } 70 | 71 | return superview?.dropShadowView 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Models/Sheets/FullScreenCoverConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FullScreenCoverConfig.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | 10 | public struct FullScreenCoverConfig { 11 | var background: EnvironmentBackgroundOption 12 | 13 | public init( 14 | background: EnvironmentBackgroundOption = .automatic 15 | ) { 16 | self.background = background 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Models/Sheets/PresentationDetentTransformable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PresentationDetentTransformable.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | 10 | public enum PresentationDetentTransformable: Hashable { 11 | case medium 12 | case large 13 | case height(CGFloat) 14 | case fraction(CGFloat) 15 | case unknown 16 | 17 | init(detent: PresentationDetent) { 18 | // FIXME: Unable to convert .height(CGFloat) and .fraction(CGFloat) back from PresentationDetent to PresentationDetentTransformable 19 | switch detent { 20 | case .medium: 21 | self = .medium 22 | case .large: 23 | self = .large 24 | default: 25 | self = .unknown 26 | } 27 | } 28 | 29 | var asPresentationDetent: PresentationDetent { 30 | switch self { 31 | case .medium: 32 | return .medium 33 | case .large: 34 | return .large 35 | case .height(let height): 36 | return .height(height) 37 | case .fraction(let fraction): 38 | return .fraction(fraction) 39 | case .unknown: 40 | return .large 41 | } 42 | } 43 | 44 | public var title: String { 45 | switch self { 46 | case .medium: 47 | return "Medium" 48 | case .large: 49 | return "Large" 50 | case .height(let height): 51 | return "Height: \(height) px" 52 | case .fraction(let fraction): 53 | return "Fraction: \((fraction * 100))%" 54 | case .unknown: 55 | return "unknown" 56 | } 57 | } 58 | 59 | public func hash(into hasher: inout Hasher) { 60 | hasher.combine(title) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Models/Sheets/ResizableSheetConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResizableSheetConfig.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | 10 | public struct ResizableSheetConfig { 11 | var detents: Set 12 | var selection: Binding? 13 | var dragIndicator: Visibility 14 | var background: EnvironmentBackgroundOption 15 | var cornerRadius: CGFloat? 16 | var backgroundInteraction: PresentationBackgroundInteractionBackSupport 17 | var contentInteraction: PresentationContentInteractionBackSupport 18 | 19 | /// Resizable sheet settings. 20 | /// - Parameters: 21 | /// - detents: Array of sizes sheet can be. 22 | /// - selection: Programatically set the selection. If nil, user can still swipe between sizes. 23 | /// - dragIndicator: Show notch on top of sheet. 24 | /// - background: Background of sheet. (supported on iOS 16.4 only!) 25 | /// - cornerRadius: Corner radius of sheet. (supported on iOS 16.4 only!) 26 | /// - backgroundInteraction: Background interaction of sheet (supported on iOS 16.4 only!) 27 | /// - contentInteraction: Content interaction of sheet (supported on iOS 16.4 only!) 28 | public init( 29 | detents: Set = [.large], 30 | selection: Binding? = nil, 31 | dragIndicator: Visibility = .automatic, 32 | background: EnvironmentBackgroundOption = .automatic, 33 | cornerRadius: CGFloat? = nil, 34 | backgroundInteraction: PresentationBackgroundInteractionBackSupport = .automatic, 35 | contentInteraction: PresentationContentInteractionBackSupport = .automatic 36 | ) { 37 | self.detents = detents 38 | self.selection = selection 39 | self.dragIndicator = dragIndicator 40 | self.background = background 41 | self.cornerRadius = cornerRadius 42 | self.backgroundInteraction = backgroundInteraction 43 | self.contentInteraction = contentInteraction 44 | } 45 | } 46 | 47 | public enum PresentationBackgroundInteractionBackSupport { 48 | case automatic, disabled, enabled 49 | case enabledUpThrough(PresentationDetent) 50 | 51 | @available(iOS 16.4, *) 52 | var backgroundInteraction: PresentationBackgroundInteraction { 53 | switch self { 54 | case .automatic: 55 | return .automatic 56 | case .disabled: 57 | return .disabled 58 | case .enabled: 59 | return .enabled 60 | case .enabledUpThrough(let upThrough): 61 | return .enabled(upThrough: upThrough) 62 | } 63 | } 64 | } 65 | 66 | public enum PresentationContentInteractionBackSupport { 67 | case automatic, resizes, scrolls 68 | 69 | @available(iOS 16.4, *) 70 | var contentInteraction: PresentationContentInteraction { 71 | switch self { 72 | case .automatic: 73 | return .automatic 74 | case .resizes: 75 | return .resizes 76 | case .scrolls: 77 | return .scrolls 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Models/Sheets/VisualEffectViewRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIIntensityVisualEffectViewRepresentable.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | import UIKit 10 | 11 | struct UIIntensityVisualEffectViewRepresentable: UIViewRepresentable { 12 | 13 | let effect: UIVisualEffect 14 | let intensity: CGFloat 15 | 16 | func makeUIView(context: UIViewRepresentableContext) -> UIVisualEffectView { 17 | IntensityVisualEffectView(effect: effect, intensity: intensity) 18 | } 19 | 20 | func updateUIView(_ uiView: UIVisualEffectView, context: UIViewRepresentableContext) { 21 | 22 | } 23 | 24 | } 25 | 26 | extension Animation { 27 | 28 | var asUIViewAnimationCurve: UIView.AnimationCurve { 29 | switch self { 30 | case .easeInOut: 31 | return .easeInOut 32 | default: 33 | return .linear 34 | } 35 | } 36 | } 37 | 38 | final class IntensityVisualEffectView: UIVisualEffectView { 39 | 40 | private var animator: UIViewPropertyAnimator! 41 | 42 | init(effect: UIVisualEffect?, intensity: CGFloat) { 43 | super.init(effect: nil) 44 | 45 | animator = UIViewPropertyAnimator( 46 | duration: ModalSupportView.backgroundAnimationDuration, 47 | curve: ModalSupportView.backgroundAnimationCurve.asUIViewAnimationCurve, 48 | animations: { [weak self] in 49 | self?.effect = effect 50 | } 51 | ) 52 | animator.pausesOnCompletion = true 53 | animator.fractionComplete = intensity 54 | } 55 | 56 | required init?(coder: NSCoder) { 57 | fatalError("init(coder:) has not been implemented") 58 | } 59 | } 60 | 61 | public struct BackgroundEffect { 62 | let effect: UIVisualEffect 63 | let intensity: CGFloat 64 | 65 | public init(effect: UIVisualEffect, intensity: CGFloat) { 66 | self.effect = effect 67 | self.intensity = intensity 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Models/Transitions/AnyTransitionDestination.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnyTransitionDestination.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | import SwiftUI 8 | import SwiftfulRecursiveUI 9 | 10 | public struct AnyTransitionDestination: Identifiable, Equatable { 11 | public private(set) var id: String 12 | public private(set) var transition: TransitionOption 13 | public private(set) var allowsSwipeBack: Bool 14 | public private(set) var onDismiss: (() -> Void)? 15 | public private(set) var destination: (AnyRouter) -> any View 16 | 17 | /// Transition current screen. 18 | /// - Parameters: 19 | /// - transition: Transition animation option. 20 | /// - id: Identifier for transition id. 21 | /// - allowsSwipeBack: Add a swipe-back gesture to the edge of the screen. Note: only works with .trailing or .leading transitions. 22 | /// - onDismiss: Closure that triggers when transition is dismissed. 23 | /// - destination: Destination screen. 24 | public init( 25 | id: String = UUID().uuidString, 26 | transition: TransitionOption = .trailing, 27 | allowsSwipeBack: Bool = false, 28 | onDismiss: (() -> Void)? = nil, 29 | destination: @escaping (AnyRouter) -> any View 30 | ) { 31 | self.id = id 32 | self.transition = transition 33 | self.allowsSwipeBack = allowsSwipeBack 34 | self.onDismiss = onDismiss 35 | self.destination = destination 36 | } 37 | 38 | static var root: AnyTransitionDestination { 39 | AnyTransitionDestination(id: "root", transition: .trailing, destination: { _ in 40 | EmptyView() 41 | }) 42 | } 43 | 44 | public func hash(into hasher: inout Hasher) { 45 | hasher.combine(id) 46 | } 47 | 48 | public static func == (lhs: AnyTransitionDestination, rhs: AnyTransitionDestination) -> Bool { 49 | lhs.id == rhs.id 50 | } 51 | 52 | public var eventParameters: [String: Any] { 53 | [ 54 | "destination_id": id, 55 | "destination_transition": transition.rawValue, 56 | "destination_allow_swipe_back": allowsSwipeBack, 57 | "destination_has_on_dismiss": onDismiss != nil, 58 | ] 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Models/Transitions/CustomRemovalTransition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomRemovalTransition.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | 10 | struct CustomRemovalTransition: ViewModifier { 11 | var behavior: TransitionMemoryBehavior 12 | let option: TransitionOption? 13 | var frame: CGRect 14 | 15 | func body(content: Content) -> some View { 16 | content 17 | .offset(x: xOffset, y: yOffset) 18 | } 19 | 20 | private var xOffset: CGFloat { 21 | switch option { 22 | case .trailing: 23 | return frame.width 24 | case .leading: 25 | return -frame.width 26 | default: 27 | return 0 28 | } 29 | } 30 | 31 | private var yOffset: CGFloat { 32 | switch option { 33 | case .top: 34 | return -frame.height 35 | case .bottom: 36 | return frame.height 37 | default: 38 | return 0 39 | } 40 | } 41 | } 42 | 43 | extension AnyTransition { 44 | 45 | @MainActor 46 | static func customRemoval( 47 | behavior: TransitionMemoryBehavior, 48 | direction: TransitionOption, 49 | frame: CGRect 50 | ) -> AnyTransition { 51 | .modifier( 52 | active: CustomRemovalTransition(behavior: behavior, option: direction, frame: frame), 53 | identity: CustomRemovalTransition(behavior: behavior, option: nil, frame: frame) 54 | ) 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Models/Transitions/TransitionMemoryBehavior.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TransitionMemoryBehavior.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | 10 | public enum TransitionMemoryBehavior: String { 11 | case removePrevious 12 | case keepPrevious 13 | 14 | var allowSimultaneous: Bool { 15 | switch self { 16 | case .removePrevious: 17 | return false 18 | case .keepPrevious: 19 | return true 20 | } 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/Models/Transitions/TransitionOption.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TransitionOption.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | 10 | public enum TransitionOption: String, CaseIterable { 11 | case trailing, leading, top, bottom, identity 12 | 13 | var canSwipeBack: Bool { 14 | switch self { 15 | case .trailing, .leading: 16 | return true 17 | default: 18 | return false 19 | } 20 | } 21 | 22 | var animation: Animation? { 23 | switch self { 24 | case .identity: 25 | return .none 26 | default: 27 | return .smooth 28 | } 29 | } 30 | 31 | var insertion: AnyTransition { 32 | switch self { 33 | case .trailing: 34 | return .move(edge: .trailing) 35 | case .leading: 36 | return .move(edge: .leading) 37 | case .top: 38 | return .move(edge: .top) 39 | case .bottom: 40 | return .move(edge: .bottom) 41 | case .identity: 42 | // Note: This will NOT work with .identity (idk why) 43 | // SwiftUI renders .identity differently than .move transitions 44 | // Instead, we keep this as .move(.leading) and will set animation = .none 45 | // to get the same result! 46 | return .move(edge: .leading) 47 | } 48 | } 49 | 50 | var reversed: TransitionOption { 51 | switch self { 52 | case .trailing: return .leading 53 | case .leading: return .trailing 54 | case .top: return .bottom 55 | case .bottom: return .top 56 | case .identity: return .identity 57 | } 58 | } 59 | 60 | var asAlignment: Alignment { 61 | switch self { 62 | case .trailing: 63 | return .trailing 64 | case .leading: 65 | return .leading 66 | case .top: 67 | return .top 68 | case .bottom: 69 | return .bottom 70 | case .identity: 71 | return .center 72 | } 73 | } 74 | 75 | var asAxis: Axis.Set { 76 | switch self { 77 | case .trailing: 78 | return .horizontal 79 | case .leading: 80 | return .horizontal 81 | case .top: 82 | return .vertical 83 | case .bottom: 84 | return .vertical 85 | case .identity: 86 | return .horizontal 87 | } 88 | } 89 | 90 | } 91 | 92 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/ViewModifiers/AlertViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlertViewModifier.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | 10 | struct AlertViewModifier: ViewModifier { 11 | 12 | let alert: Binding 13 | 14 | func body(content: Content) -> some View { 15 | content 16 | .alert( 17 | alert.wrappedValue?.title ?? "", 18 | isPresented: Binding(ifAlert: alert, isStyle: .alert), 19 | actions: { 20 | alert.wrappedValue?.buttons 21 | }, 22 | message: { 23 | if let subtitle = alert.wrappedValue?.subtitle { 24 | Text(subtitle) 25 | } 26 | } 27 | ) 28 | .confirmationDialog( 29 | alert.wrappedValue?.title ?? "", 30 | isPresented: Binding(ifAlert: alert, isStyle: .confirmationDialog), 31 | titleVisibility: alert.wrappedValue?.title.isEmpty ?? true ? .hidden : .visible, 32 | actions: { 33 | alert.wrappedValue?.buttons 34 | }, 35 | message: { 36 | if let subtitle = alert.wrappedValue?.subtitle { 37 | Text(subtitle) 38 | } 39 | } 40 | ) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/ViewModifiers/OnFirstAppearModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 1/15/24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct OnFirstAppearModifier: ViewModifier { 12 | let action: @MainActor () -> Void 13 | @State private var isFirstAppear = true 14 | 15 | func body(content: Content) -> some View { 16 | content 17 | .onAppear { 18 | if isFirstAppear { 19 | action() 20 | isFirstAppear = false 21 | } 22 | } 23 | } 24 | } 25 | 26 | extension View { 27 | func onFirstAppear(perform action: @escaping @MainActor () -> Void) -> some View { 28 | self.modifier(OnFirstAppearModifier(action: action)) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/SwiftfulRouting/ViewModifiers/ResizableSheetViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResizableSheetViewModifier.swift 3 | // SwiftfulRouting 4 | // 5 | // Created by Nick Sarno on 4/19/25. 6 | // 7 | import SwiftUI 8 | 9 | extension View { 10 | 11 | @ViewBuilder func applyResizableSheetModifiersIfNeeded(segue: SegueOption) -> some View { 12 | switch segue { 13 | case .push: 14 | self 15 | case .sheetConfig(config: let config): 16 | self 17 | // If a selection is passed in, bind to it 18 | .ifLetCondition(config.selection) { content, value in 19 | content 20 | .presentationDetents(config.detents.setMap({ $0.asPresentationDetent }), selection: Binding(selection: value)) 21 | } 22 | // Otherwise, don't pass in anything for the selection 23 | .ifSatisfiesCondition(config.selection == nil) { content in 24 | content 25 | .presentationDetents(config.detents.setMap({ $0.asPresentationDetent })) 26 | } 27 | 28 | // Value for showing drag indicator 29 | .presentationDragIndicator(config.dragIndicator) 30 | 31 | // Add background color if needed 32 | .applyEnvironmentBackgroundIfAvailable(option: config.background) 33 | 34 | // Value for background corner radius 35 | .ifLetCondition(config.cornerRadius, transform: { content, value in 36 | content 37 | .presentationCornerRadiusIfAvailable(value) 38 | }) 39 | 40 | // Background interaction 41 | .presentationBackgroundInteractionIfAvailable(config.backgroundInteraction) 42 | 43 | // Content interaction 44 | .presentationContentInteractionIfAvailable(config.contentInteraction) 45 | case .fullScreenCoverConfig(config: let config): 46 | self 47 | // Add background color if needed 48 | .applyEnvironmentBackgroundIfAvailable(option: config.background) 49 | } 50 | } 51 | 52 | } 53 | 54 | extension View { 55 | 56 | @ViewBuilder 57 | func presentationCornerRadiusIfAvailable(_ value: CGFloat) -> some View { 58 | if #available(iOS 16.4, *) { 59 | self.presentationCornerRadius(value) 60 | } else { 61 | self 62 | } 63 | } 64 | 65 | @ViewBuilder 66 | func presentationBackgroundInteractionIfAvailable(_ interaction: PresentationBackgroundInteractionBackSupport) -> some View { 67 | if #available(iOS 16.4, *) { 68 | self.presentationBackgroundInteraction(interaction.backgroundInteraction) 69 | } else { 70 | self 71 | } 72 | } 73 | 74 | @ViewBuilder 75 | func presentationContentInteractionIfAvailable(_ interaction: PresentationContentInteractionBackSupport) -> some View { 76 | if #available(iOS 16.4, *) { 77 | self.presentationContentInteraction(interaction.contentInteraction) 78 | } else { 79 | self 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Tests/SwiftfulRoutingTests/SwiftfulRoutingTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftfulRouting 3 | 4 | final class SwiftfulRoutingTests: XCTestCase { 5 | func testExample() throws { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | // XCTAssertEqual(SwiftfulRouting().text, "Hello, World!") 10 | } 11 | } 12 | --------------------------------------------------------------------------------