├── .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://swiftpackageindex.com/SwiftfulThinking/SwiftfulRouting) [](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 |
--------------------------------------------------------------------------------