├── .github
└── workflows
│ └── swift.yml
├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── Assets
└── RoutingBannerArt.png
├── ExampleApp
├── ContentView.swift
├── ExampleApp.swift
├── ExampleView.swift
└── TestRoute.swift
├── LICENSE
├── Package.swift
├── README.md
├── Sources
└── Routing
│ ├── Extensions
│ ├── Array+Routing.swift
│ └── Array+Truncate.swift
│ ├── PropertyWrappers
│ └── Router.swift
│ ├── Protocols
│ └── Routable.swift
│ └── Views
│ ├── RoutingView.swift
│ └── View+Extensions.swift
└── Tests
└── RoutingTests
├── ArrayRoutingTests.swift
├── ArrayTruncationTests.swift
└── Mocks
└── MockRoute.swift
/.github/workflows/swift.yml:
--------------------------------------------------------------------------------
1 | # This workflow will build a Swift project
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift
3 |
4 | name: Swift
5 |
6 | on:
7 | push:
8 | branches: [ "main" ]
9 | pull_request:
10 | branches: [ "main" ]
11 |
12 | jobs:
13 | build:
14 |
15 | runs-on: macos-latest
16 |
17 | steps:
18 | - name: Checkout code
19 | uses: actions/checkout@v3
20 |
21 | - name: Setup Swift
22 | uses: SwiftyLab/setup-swift@latest
23 |
24 | - name: Build
25 | run: swift build -v
26 |
27 | - name: Run tests
28 | run: swift test -v
29 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Assets/RoutingBannerArt.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JamesSedlacek/Routing/6ee8949c39ee85ea9dbe4e726eb874abade6c53b/Assets/RoutingBannerArt.png
--------------------------------------------------------------------------------
/ExampleApp/ContentView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ContentView.swift
3 | // Routing
4 | //
5 | // Created by James Sedlacek on 5/7/25.
6 | //
7 |
8 | import Routing
9 | import SwiftUI
10 |
11 | @MainActor
12 | public struct ContentView: View {
13 | @Router private var router: [TestRoute] = []
14 |
15 | public init() {}
16 |
17 | public var body: some View {
18 | RoutingView(path: $router) {
19 | VStack {
20 | Button("Push Screen", action: pushScreenAction)
21 | }
22 | }
23 | }
24 |
25 | @MainActor
26 | private func pushScreenAction() {
27 | router.navigate(to: .example("Hello World!"))
28 | }
29 | }
30 |
31 | #Preview {
32 | ContentView()
33 | }
34 |
--------------------------------------------------------------------------------
/ExampleApp/ExampleApp.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ExampleApp.swift
3 | // Routing
4 | //
5 | // Created by James Sedlacek on 5/7/25.
6 | //
7 |
8 | import SwiftUI
9 |
10 | @main
11 | struct ExampleApp: App {
12 | public var body: some Scene {
13 | WindowGroup {
14 | ContentView()
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/ExampleApp/ExampleView.swift:
--------------------------------------------------------------------------------
1 | import Routing
2 | import SwiftUI
3 |
4 | public struct ExampleView: View {
5 | @Router private var router: [TestRoute] = []
6 | @State private var sheetRoute: SheetRoute? = nil
7 | private let title: String
8 |
9 | @MainActor
10 | public init(title: String) {
11 | self.title = title
12 | }
13 |
14 | public var body: some View {
15 | VStack(spacing: 40) {
16 | Button("Push Screen", action: pushScreenAction)
17 |
18 | Text(title)
19 |
20 | Button("Present Sheet", action: presentSheetAction)
21 | }
22 | .sheet(item: $sheetRoute)
23 | }
24 |
25 | @MainActor
26 | private func pushScreenAction() {
27 | router.navigate(to: .lastExample)
28 | }
29 |
30 | private func presentSheetAction() {
31 | sheetRoute = .sheetExample("It's a whole new world!")
32 | }
33 | }
34 |
35 | public struct SheetExampleView: View {
36 | @State private var route: AnotherRoute? = nil
37 | private let title: String
38 |
39 | public init(title: String) {
40 | self.title = title
41 | }
42 |
43 | public var body: some View {
44 | if #available(iOS 17.0, macOS 14.0, *) {
45 | VStack(spacing: 40) {
46 | Button("Push Screen", action: pushScreenAction)
47 |
48 | Text(title)
49 | }
50 | .navigationDestination(item: $route)
51 | }
52 | }
53 |
54 | private func pushScreenAction() {
55 | route = .anotherExample("Testing")
56 | }
57 | }
58 |
59 | public struct AnotherExampleView: View {
60 | private let title: String
61 |
62 | public init(title: String) {
63 | self.title = title
64 | }
65 |
66 | public var body: some View {
67 | Text(title)
68 | }
69 | }
70 |
71 | public struct LastExampleView: View {
72 | @Router private var router: [TestRoute] = []
73 |
74 | @MainActor
75 | public init() {}
76 |
77 | public var body: some View {
78 | Button("Navigate to Root", action: navigateToRootAction)
79 | }
80 |
81 | @MainActor
82 | private func navigateToRootAction() {
83 | router.navigateToRoot()
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/ExampleApp/TestRoute.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TestRoute.swift
3 | // Routing
4 | //
5 | // Created by James Sedlacek on 5/7/25.
6 | //
7 |
8 | import Routing
9 | import SwiftUI
10 |
11 | public enum TestRoute: Routable {
12 | case example(String)
13 | case lastExample
14 |
15 | public var body: some View {
16 | switch self {
17 | case .example(let title):
18 | ExampleView(title: title)
19 | case .lastExample:
20 | LastExampleView()
21 | }
22 | }
23 | }
24 |
25 | public enum SheetRoute: Routable {
26 | case sheetExample(String)
27 |
28 | public var body: some View {
29 | switch self {
30 | case .sheetExample(let title):
31 | SheetExampleView(title: title)
32 | }
33 | }
34 | }
35 |
36 | extension SheetRoute: Identifiable {
37 | public nonisolated var id: Self { self }
38 | }
39 |
40 | public enum AnotherRoute: Routable {
41 | case anotherExample(String)
42 |
43 | public var body: some View {
44 | switch self {
45 | case .anotherExample(let title):
46 | AnotherExampleView(title: title)
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 James Sedlacek
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.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 6.0
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: "Routing",
8 | platforms: [
9 | .iOS(.v16),
10 | .macOS(.v13),
11 | .tvOS(.v16),
12 | .watchOS(.v9)
13 | ],
14 | products: [
15 | .library(
16 | name: "Routing",
17 | targets: ["Routing"]
18 | ),
19 | .executable(
20 | name: "ExampleApp",
21 | targets: ["ExampleApp"]
22 | )
23 | ],
24 | targets: [
25 | .target(name: "Routing"),
26 | .executableTarget(
27 | name: "ExampleApp",
28 | dependencies: ["Routing"],
29 | path: "ExampleApp"
30 | ),
31 | .testTarget(
32 | name: "RoutingTests",
33 | dependencies: ["Routing"]
34 | ),
35 | ]
36 | )
37 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | [](https://github.com/apple/swift-package-manager)
6 | [](https://github.com/JamesSedlacek/Routing/stargazers)
7 | [](https://github.com/JamesSedlacek/Routing/network)
8 | [](https://github.com/JamesSedlacek/Routing/network)
9 |
10 |
11 |
12 | ## Description
13 |
14 | `Routing` is a **lightweight** SwiftUI navigation library.
15 | - Leverages 1st-party APIs `NavigationStack` & `NavigationDestination`.
16 | - Never be confused about `NavigationLink` or `NavigationPath` again! (You don't need them)
17 | - Type-Safe Navigation (better performance than type-erasing).
18 | - Centralized Navigation Logic.
19 | - Dynamic Navigation Stack Management.
20 | - Unit Tested protocol implementations.
21 | - Zero 3rd party dependencies.
22 |
23 |
24 |
25 | ## Table of Contents
26 | 1. [Requirements](#requirements)
27 | 2. [Installation](#installation)
28 | 3. [Getting Started](#getting-started)
29 | 4. [Passing Data Example](#passing-data-example)
30 | 5. [View Extensions](#view-extensions)
31 | 6. [Under the hood](#under-the-hood)
32 | 7. [Author](#author)
33 |
34 |
35 |
36 | ## Requirements
37 |
38 | | Platform | Minimum Version |
39 | |----------|-----------------|
40 | | iOS | 16.0 |
41 | | macOS | 13.0 |
42 | | tvOS | 16.0 |
43 | | watchOS | 9.0 |
44 |
45 |
46 |
47 | ## Installation
48 |
49 | You can install `Routing` using the Swift Package Manager.
50 |
51 | 1. In Xcode, select `File` > `Add Package Dependencies`.
52 |
53 |
54 | 2. Copy & paste the following into the `Search or Enter Package URL` search bar.
55 | ```
56 | https://github.com/JamesSedlacek/Routing.git
57 | ```
58 |
59 |
60 | 3. Xcode will fetch the repository & the `Routing` library will be added to your project.
61 |
62 |
63 |
64 | ## Getting Started
65 |
66 | 1. Create a `Route` enum that conforms to the `Routable` protocol.
67 |
68 | ``` swift
69 | import Routing
70 | import SwiftUI
71 |
72 | enum ExampleRoute: Routable {
73 | case detail
74 | case settings
75 |
76 | var body: some View {
77 | switch self {
78 | case .detail:
79 | DetailView()
80 | case .settings:
81 | SettingsView()
82 | }
83 | }
84 | }
85 | ```
86 |
87 | 2. Create a `Router` object and wrap your `RootView` with a `RoutingView`.
88 |
89 | ``` swift
90 | import SwiftUI
91 | import Routing
92 |
93 | struct ContentView: View {
94 | @Router private var router: [ExampleRoute] = []
95 |
96 | var body: some View {
97 | RoutingView(path: $router) {
98 | Button("Go to Settings") {
99 | router.navigate(to: .settings)
100 | }
101 | }
102 | }
103 | }
104 | ```
105 |
106 | 3. Handle navigation using the `Router` functions
107 |
108 | ```swift
109 | /// Navigate back in the stack by a specified count.
110 | func navigateBack(_ count: Int)
111 |
112 | /// Navigate back to a specific destination in the stack.
113 | func navigateBack(to destination: Destination)
114 |
115 | /// Navigate to the root of the stack by emptying it.
116 | func navigateToRoot()
117 |
118 | /// Navigate to a specific destination by appending it to the stack.
119 | func navigate(to destination: Destination)
120 |
121 | /// Navigate to multiple destinations by appending them to the stack.
122 | func navigate(to destinations: [Destination])
123 |
124 | /// Replace the current stack with new destinations.
125 | func replace(with destinations: [Destination])
126 | ```
127 |
128 |
129 |
130 | ## Passing Data Example
131 |
132 | ```swift
133 | import Routing
134 | import SwiftUI
135 |
136 | enum ContentRoute: Routable {
137 | case detail(Color)
138 | case settings
139 |
140 | var body: some View {
141 | switch self {
142 | case .detail(let color):
143 | ColorDetail(color: color)
144 | case .settings:
145 | SettingsView()
146 | }
147 | }
148 | }
149 |
150 | struct ContentView: View {
151 | @Router private var router: [ContentRoute] = []
152 | private let colors: [Color] = [.red, .green, .blue]
153 |
154 | var body: some View {
155 | RoutingView(path: $router) {
156 | List(colors, id: \.self) { color in
157 | color
158 | .onTapGesture {
159 | router.navigate(to: .detail(color))
160 | }
161 | }
162 | }
163 | }
164 | }
165 |
166 | struct ColorDetail: View {
167 | private let color: Color
168 |
169 | init(color: Color) {
170 | self.color = color
171 | }
172 |
173 | var body: some View {
174 | color.frame(maxWidth: .infinity, maxHeight: .infinity)
175 | }
176 | }
177 | ```
178 |
179 |
180 |
181 | ## View Extensions
182 |
183 | `Routing` provides several `View` extensions to simplify common navigation and presentation patterns when working with `Routable` types.
184 |
185 | ### `navigationDestination(for: RouteType.self)`
186 |
187 | This extension is a convenience wrapper around the standard SwiftUI `navigationDestination(for:destination:)` modifier. It's tailored for use with types conforming to `Routable`, automatically using the `Routable` instance itself as the destination view.
188 |
189 | ```swift
190 | // Usage within a view:
191 | // SomeView().navigationDestination(for: MyRoute.self)
192 | // This is often handled automatically by RoutingView.
193 | ```
194 | `RoutingView` uses this extension internally to set up navigation for your `Routable` enum.
195 |
196 | ### `sheet(item:onDismiss:)`
197 |
198 | Presents a sheet when a binding to an optional `Routable & Identifiable` item becomes non-nil. The content of the sheet is the `Routable` item itself.
199 |
200 | - `item`: A `Binding` to an optional `Routable & Identifiable` item.
201 | - `onDismiss`: An optional closure executed when the sheet dismisses.
202 |
203 | **Note:** The `Routable` type used with this modifier must also conform to `Identifiable`.
204 | ```swift
205 | import SwiftUI
206 | import Routing
207 |
208 | // Ensure your Routable enum conforms to Identifiable.
209 | // For enums with associated values, you might need to add an explicit `id`.
210 | enum ModalRoute: Routable, Identifiable {
211 | case helpPage
212 | case userDetails(id: String)
213 |
214 | // Example of making it Identifiable
215 | var id: String {
216 | switch self {
217 | case .helpPage:
218 | return "helpPage"
219 | case .userDetails(let id):
220 | return "userDetails-\(id)"
221 | }
222 | }
223 |
224 | var body: some View {
225 | switch self {
226 | case .helpPage:
227 | HelpView() // Placeholder
228 | case .userDetails(let id):
229 | UserDetailsView(userID: id) // Placeholder
230 | }
231 | }
232 | }
233 |
234 | struct MyContentView: View {
235 | @State private var sheetItem: ModalRoute?
236 |
237 | var body: some View {
238 | Button("Show Help Sheet") {
239 | sheetItem = .helpPage
240 | }
241 | .sheet(item: $sheetItem)
242 | }
243 | }
244 |
245 | // Placeholder Views for example
246 | struct HelpView: View { var body: some View { Text("Help Information") } }
247 | struct UserDetailsView: View {
248 | let userID: String
249 | var body: some View { Text("Details for user \(userID)") }
250 | }
251 | ```
252 |
253 | ### `navigationDestination(item:)` (iOS 17.0+)
254 |
255 | Available on iOS 17.0+, macOS 14.0+, tvOS 17.0+, watchOS 10.0+.
256 |
257 | Presents a view using `navigationDestination(item:destination:)` when a binding to an optional `Routable` item becomes non-nil. The destination view is the `Routable` item itself. This is useful for modal-style presentations or alternative navigation flows that don't necessarily push onto the main `NavigationStack`.
258 |
259 | - `item`: A `Binding` to an optional `Routable` item.
260 | ```swift
261 | import SwiftUI
262 | import Routing
263 |
264 | // Assuming MyDetailRoute is a Routable enum
265 | // enum MyDetailRoute: Routable { case info, settings ... }
266 |
267 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
268 | struct AnotherScreen: View {
269 | @State private var presentedDetail: MyDetailRoute? // MyDetailRoute conforms to Routable
270 |
271 | var body: some View {
272 | Button("Show Info Modally") {
273 | presentedDetail = .info // Assuming .info is a case in MyDetailRoute
274 | }
275 | .navigationDestination(item: $presentedDetail)
276 | }
277 | }
278 | ```
279 |
280 |
281 |
282 | ## Under the hood
283 |
284 | The `RoutingView` essentially wraps your view with a `NavigationStack`. It uses the `navigationDestination(for: RouteType.self)` view extension (detailed in the "View Extensions" section) to automatically handle presenting the views associated with your `Routable` types.
285 | ```swift
286 | // Simplified structure of RoutingView's body:
287 | NavigationStack(path: $path) { // $path is your @Router's binding
288 | rootContent()
289 | .navigationDestination(for: RouteType.self) // Uses the Routable-specific extension
290 | }
291 | ```
292 |
293 | ## Author
294 |
295 | James Sedlacek, find me on [X/Twitter](https://twitter.com/jsedlacekjr) or [LinkedIn](https://www.linkedin.com/in/jamessedlacekjr/)
296 |
--------------------------------------------------------------------------------
/Sources/Routing/Extensions/Array+Routing.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Array+Routing.swift
3 | //
4 | // Created by James Sedlacek on 12/16/23.
5 | //
6 |
7 | import SwiftUI
8 |
9 | @MainActor
10 | public extension Array where Element: Routable {
11 |
12 | /// Navigate back in the navigation stack by a specified number of destinations.
13 | ///
14 | /// - Parameter count: The number of destinations to navigate back by.
15 | /// If the count exceeds the number of destinations in the stack, the stack is emptied.
16 | mutating func navigateBack(_ count: Int = 1) {
17 | guard count > 0 else { return }
18 | guard count <= self.count else {
19 | self = .init()
20 | return
21 | }
22 | self.removeLast(count)
23 | }
24 |
25 | /// Navigate back to a specific destination in the stack, removing all destinations that come after it.
26 | ///
27 | /// - Parameter destination: The destination to navigate back to.
28 | /// If the destination does not exist in the stack, no action is taken.
29 | mutating func navigateBack(to destination: Element) {
30 | // Check if the destination exists in the stack
31 | if let index = self.lastIndex(where: { $0 == destination }) {
32 | // Remove destinations above the specified destination
33 | self.truncate(to: index)
34 | }
35 | }
36 |
37 | /// Resets the navigation stack to its initial state, effectively navigating to the root destination.
38 | mutating func navigateToRoot() {
39 | self = []
40 | }
41 |
42 | /// Appends a new destination to the navigation stack, moving forward in the navigation flow.
43 | ///
44 | /// - Parameter destination: The destination to navigate to.
45 | mutating func navigate(to destination: Element) {
46 | self.append(destination)
47 | }
48 |
49 | /// Appends multiple new destinations to the navigation stack.
50 | ///
51 | /// - Parameter destinations: An array of destinations to append to the navigation stack.
52 | mutating func navigate(to destinations: [Element]) {
53 | self += destinations
54 | }
55 |
56 | /// Replaces the current navigation stack with a new set of destinations.
57 | ///
58 | /// - Parameter destinations: An array of new destinations to set as the navigation stack.
59 | mutating func replace(with destinations: [Element]) {
60 | self = destinations
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/Sources/Routing/Extensions/Array+Truncate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Array+Truncate.swift
3 | //
4 | // Created by James Sedlacek on 12/29/23.
5 | //
6 |
7 | import Foundation
8 |
9 | extension Array {
10 | /// Truncates the array up to the specified index.
11 | ///
12 | /// This method mutates the original array, keeping the elements up to the given index (inclusive).
13 | /// If the index is out of bounds (negative, or greater than or equal to the array's count),
14 | /// the array remains unchanged.
15 | ///
16 | /// - Parameter index: The index up to which the array should be truncated.
17 | ///
18 | /// Example Usage:
19 | /// ```
20 | /// var numbers = [1, 2, 3, 4, 5]
21 | /// numbers.truncate(to: 2)
22 | /// // numbers is now [1, 2, 3] (elements at indices 0, 1, 2)
23 | /// ```
24 | mutating func truncate(to index: Int) {
25 | guard index >= 0 && index < self.count else {
26 | // If index is invalid (e.g., negative, or >= count), or if the array is empty,
27 | // do nothing. This maintains the array if truncation isn't sensible.
28 | return
29 | }
30 | // Keep elements from index 0 up to and including 'index'.
31 | // The slice self[0...index] is equivalent to self[..<(index + 1)].
32 | // The guard ensures that 'index + 1' will not exceed 'self.count'.
33 | self = Array(self[..<(index + 1)])
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/Sources/Routing/PropertyWrappers/Router.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Router.swift
3 | //
4 | // Created by James Sedlacek on 12/15/23.
5 | //
6 |
7 | import SwiftUI
8 |
9 | /// A property-wrapper that keeps a stack of `Routable` values in `UserDefaults`
10 | /// (via `@AppStorage`) and exposes it to SwiftUI as mutable state.
11 | ///
12 | /// The encoded array is stored under the supplied key (default: `"RouterKey"`),
13 | /// allowing the navigation stack to survive app launches or scene re-creation.
14 | ///
15 | /// Basic usage:
16 | ///
17 | /// ```swift
18 | /// enum AppRoute: String, Routable {
19 | /// case home, detail, settings
20 | /// }
21 | ///
22 | /// struct ContentView: View {
23 | /// @Router var routes: [AppRoute] = [.home] // stored under "RouterKey"
24 | ///
25 | /// var body: some View { /* … */ }
26 | /// }
27 | ///
28 | /// // Custom key / UserDefaults instance
29 | /// @Router("OnboardingRoutes", store: .standard)
30 | /// var onboarding: [OnboardingRoute] = [.welcome]
31 | /// ```
32 | ///
33 | /// Access patterns:
34 | /// • Read/Write: `routes.append(.detail)`
35 | /// • Bindable: `$routes` to drive a `NavigationStack` or similar.
36 | ///
37 | /// - Note: Encoding/decoding uses `JSONEncoder` / `JSONDecoder`. If encoding
38 | /// fails the `defaultValue` is returned silently.
39 | @MainActor
40 | @propertyWrapper
41 | public struct Router: DynamicProperty {
42 | @AppStorage private var storage: Data
43 | private let encoder: JSONEncoder = .init()
44 | private let decoder: JSONDecoder = .init()
45 | private let defaultValue: [RouteType]
46 |
47 | public var wrappedValue: [RouteType] {
48 | get {
49 | guard let decoded = try? decoder.decode([RouteType].self, from: storage) else {
50 | return defaultValue
51 | }
52 | return decoded
53 | }
54 | nonmutating set {
55 | guard let encoded = try? encoder.encode(newValue) else { return }
56 | storage = encoded
57 | }
58 | }
59 |
60 | public var projectedValue: Binding<[RouteType]> {
61 | Binding(
62 | get: { wrappedValue },
63 | set: { wrappedValue = $0 }
64 | )
65 | }
66 |
67 | public init(
68 | wrappedValue: [RouteType],
69 | _ key: String = "RouterKey",
70 | store: UserDefaults? = nil
71 | ) {
72 | defaultValue = wrappedValue
73 | let initialData = (try? encoder.encode(wrappedValue)) ?? Data()
74 | _storage = .init(wrappedValue: initialData, key, store: store)
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/Sources/Routing/Protocols/Routable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Routable.swift
3 | //
4 | // Created by James Sedlacek on 12/14/23.
5 | //
6 |
7 | import SwiftUI
8 |
9 | /// A convenience type-alias used throughout the routing system.
10 | ///
11 | /// It bundles the three capabilities a “route” typically needs:
12 | /// • `View` – supplies the screen’s UI
13 | /// • `Hashable` – lets the route live inside navigation paths/sets
14 | /// • `Codable` – enables persistence & deep-link restoration
15 | ///
16 | /// Usage:
17 | /// ```swift
18 | ///public enum TestRoute: Routable {
19 | /// case example(String)
20 | /// case lastExample
21 | ///
22 | /// public var body: some View {
23 | /// switch self {
24 | /// case .example(let title):
25 | /// ExampleView(title: title)
26 | /// case .lastExample:
27 | /// LastExampleView()
28 | /// }
29 | /// }
30 | ///}
31 | /// ```
32 | public typealias Routable = View & Hashable & Codable
33 |
--------------------------------------------------------------------------------
/Sources/Routing/Views/RoutingView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RoutingView.swift
3 | // Routing
4 | //
5 |
6 | import SwiftUI
7 |
8 | /// A thin convenience wrapper around `NavigationStack` that
9 | /// drives navigation from an external `[RouteType]` array.
10 | ///
11 | /// `RoutingView` lets you keep the navigation “path” outside
12 | /// of your view hierarchy (e.g. in `@AppStorage` via `@Router`)
13 | /// while still enjoying type-safe `NavigationStack` behaviour.
14 | /// The view creates its own `NavigationStack` under-the-hood
15 | /// and forwards a binding to the caller-supplied `path`.
16 | ///
17 | /// Usage:
18 | /// ```swift
19 | /// enum MyRoute: Routable { // 1️⃣ Conform to Routable
20 | /// case profile(Int) // Your route cases
21 | ///
22 | /// var body: some View { // Each case returns a view
23 | /// switch self {
24 | /// case .profile(let id):
25 | /// ProfileView(id: id)
26 | /// }
27 | /// }
28 | /// }
29 | ///
30 | /// struct RootView: View {
31 | /// @Router private var path: [MyRoute] = [] // 2️⃣ Persist the path
32 | ///
33 | /// var body: some View {
34 | /// RoutingView(path: $path) { // 3️⃣ Wrap your root UI
35 | /// VStack {
36 | /// Button("Show profile") {
37 | /// path.navigate(to: .profile(42))
38 | /// }
39 | /// }
40 | /// }
41 | /// }
42 | /// }
43 | /// ```
44 | ///
45 | /// - Parameters:
46 | /// - RouteType: The enum/struct you use to describe destinations.
47 | /// Must conform to `Routable`.
48 | /// - RootContent: The root view shown at the bottom of the stack.
49 | public struct RoutingView: View {
50 | @Binding private var path: [RouteType]
51 | private let rootContent: () -> RootContent
52 |
53 | public init(
54 | path: Binding<[RouteType]>,
55 | @ViewBuilder rootContent: @escaping () -> RootContent
56 | ) {
57 | self._path = path
58 | self.rootContent = rootContent
59 | }
60 |
61 | public var body: some View {
62 | NavigationStack(path: $path) {
63 | rootContent()
64 | .navigationDestination(for: RouteType.self)
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/Sources/Routing/Views/View+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // View+Extensions.swift
3 | // Routing
4 | //
5 | // Created by James Sedlacek on 5/6/25.
6 | //
7 |
8 | import SwiftUI
9 |
10 | public extension View {
11 | /// Associates a destination view with a presented data type for use within a navigation stack.
12 | ///
13 | /// This is a convenience wrapper around the standard SwiftUI `navigationDestination(for:destination:)` modifier,
14 | /// tailored for use with types conforming to `Routable`. The destination view is the presented `Routable` instance itself.
15 | ///
16 | /// - Parameter routeType: The type of `Routable` data that this destination binding supports.
17 | /// This should be the metatype (e.g., `MyRoute.self`).
18 | /// - Returns: A view that has a navigation destination associated with the specified `Routable` data type.
19 | func navigationDestination(for routeType: D.Type) -> some View {
20 | self.navigationDestination(for: D.self, destination: { $0 })
21 | }
22 |
23 | /// Presents a sheet when a binding to an optional `Routable` item becomes non-nil.
24 | ///
25 | /// This is a convenience wrapper around the standard SwiftUI `sheet(item:onDismiss:content:)` modifier,
26 | /// tailored for use with types conforming to `Routable`. The content of the sheet is the item itself.
27 | ///
28 | /// - Parameters:
29 | /// - item: A `Binding` to an optional `Routable` item. When `item` is non-nil,
30 | /// the system passes the unwrapped item to the `content` closure, which then provides
31 | /// the view for the sheet. If `item` becomes `nil`, the system dismisses the sheet.
32 | /// - onDismiss: An optional closure that SwiftUI executes when the sheet dismisses.
33 | /// - Returns: A view that presents a sheet when the bound item is non-nil.
34 | func sheet(item: Binding, onDismiss: (() -> Void)? = nil) -> some View {
35 | self.sheet(item: item, onDismiss: onDismiss, content: { $0 })
36 | }
37 | }
38 |
39 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
40 | public extension View {
41 | /// Presents a view when a binding to an optional `Routable` item becomes non-nil.
42 | ///
43 | /// This is a convenience wrapper around the standard SwiftUI `navigationDestination(item:destination:)` modifier,
44 | /// tailored for use with types conforming to `Routable`. The destination view is the item itself.
45 | ///
46 | /// - Parameter item: A `Binding` to an optional `Routable` item. When `item` is non-nil,
47 | /// the system passes the unwrapped item to the `destination` closure, which then provides
48 | /// the view to display. If `item` becomes `nil`, the system dismisses the presented view.
49 | /// - Returns: A view that presents another view when the bound item is non-nil.
50 | func navigationDestination(item: Binding) -> some View {
51 | self.navigationDestination(item: item, destination: { $0 })
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/Tests/RoutingTests/ArrayRoutingTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ArrayRoutingTests.swift
3 | //
4 | // Created by James Sedlacek on 12/16/23.
5 | //
6 |
7 | import Testing
8 | @testable import Routing
9 |
10 | @MainActor
11 | struct ArrayRoutingTests {
12 | @Test("Navigate to a single destination")
13 | func navigateToSingleDestination() {
14 | var stack: [MockRoute] = []
15 | stack.navigate(to: .settings)
16 | #expect(stack.count == 1)
17 | #expect(stack.last == .settings)
18 | }
19 |
20 | @Test("Navigate to multiple destinations")
21 | func navigateToMultipleDestinations() {
22 | var stack: [MockRoute] = []
23 | stack.navigate(to: [.settings, .profile, .settings])
24 | #expect(stack.count == 3)
25 | #expect(stack == [.settings, .profile, .settings])
26 | }
27 |
28 | @Test("Navigate back one step")
29 | func navigateBackOneStep() {
30 | var stack: [MockRoute] = [.settings, .profile]
31 | stack.navigateBack()
32 | #expect(stack.count == 1)
33 | #expect(stack == [.settings])
34 | }
35 |
36 | @Test("Navigate back zero steps")
37 | func navigateBackZeroSteps() {
38 | var stack: [MockRoute] = [.settings, .profile]
39 | stack.navigateBack(0)
40 | #expect(stack.count == 2)
41 | #expect(stack == [.settings, .profile])
42 | }
43 |
44 | @Test("Navigate back negative steps")
45 | func navigateBackNegativeSteps() {
46 | var stack: [MockRoute] = [.settings, .profile]
47 | stack.navigateBack(-1)
48 | #expect(stack.count == 2)
49 | #expect(stack == [.settings, .profile])
50 | }
51 |
52 | @Test("Navigate back multiple steps")
53 | func navigateBackMultipleSteps() {
54 | var stack: [MockRoute] = [.settings, .profile, .settings, .profile]
55 | stack.navigateBack(2)
56 | #expect(stack.count == 2)
57 | #expect(stack == [.settings, .profile])
58 | }
59 |
60 | @Test("Navigate back too many steps")
61 | func navigateBackTooManySteps() {
62 | var stack: [MockRoute] = [.settings, .profile]
63 | stack.navigateBack(3)
64 | #expect(stack.isEmpty)
65 | }
66 |
67 | @Test("Navigate to root")
68 | func navigateToRoot() {
69 | var stack: [MockRoute] = [.settings, .profile, .settings]
70 | stack.navigateToRoot()
71 | #expect(stack.isEmpty)
72 | }
73 |
74 | @Test("Navigate back to a specific destination")
75 | func navigateBackToSpecificDestination() {
76 | var stack: [MockRoute] = [.settings, .profile, .settings, .profile, .settings]
77 | stack.navigateBack(to: .profile) // Navigates back to the last occurrence of .profile
78 | #expect(stack.count == 4)
79 | #expect(stack == [.settings, .profile, .settings, .profile])
80 | }
81 |
82 | @Test("Navigate back to a non-existent destination in the stack")
83 | func navigateBackToNonExistentDestination() {
84 | var stack: [MockRoute] = [.settings, .settings, .settings]
85 | // .profile is not in the stack, so it should do nothing.
86 | stack.navigateBack(to: .profile)
87 | #expect(stack.count == 3)
88 | #expect(stack == [.settings, .settings, .settings])
89 | }
90 |
91 | @Test("Navigate back to a specific destination that is currently on top")
92 | func navigateBackToDestinationOnTop() {
93 | var stack: [MockRoute] = [.settings, .profile]
94 | stack.navigateBack(to: .profile)
95 | #expect(stack.count == 2)
96 | #expect(stack == [.settings, .profile])
97 | }
98 |
99 | @Test("Navigate back to the first occurrence of a destination")
100 | func navigateBackToFirstOccurrence() {
101 | var stack: [MockRoute] = [.profile, .settings, .profile, .settings]
102 | stack.navigateBack(to: .profile) // Should go to the second .profile
103 | #expect(stack == [.profile, .settings, .profile])
104 | }
105 |
106 | @Test("Replace stack with empty destinations")
107 | func replaceWithEmptyDestinations() {
108 | var stack: [MockRoute] = [.settings, .profile, .settings]
109 | stack.replace(with: [])
110 | #expect(stack.isEmpty)
111 | }
112 |
113 | @Test("Replace stack with new destinations")
114 | func replaceWithNewDestinations() {
115 | var stack: [MockRoute] = [.settings, .settings]
116 | stack.replace(with: [.profile, .settings, .profile])
117 | #expect(stack.count == 3)
118 | #expect(stack == [.profile, .settings, .profile])
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/Tests/RoutingTests/ArrayTruncationTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ArrayTruncationTests.swift
3 | //
4 | //
5 | // Created by James Sedlacek on 2/10/24.
6 | //
7 |
8 | import Testing
9 | @testable import Routing
10 |
11 | struct ArrayTruncationTests {
12 | @Test func truncateToValidIndex() {
13 | var numbers = [1, 2, 3, 4, 5]
14 | numbers.truncate(to: 2)
15 | #expect(numbers == [1, 2, 3], "Array should be truncated to [1, 2, 3]")
16 | }
17 |
18 | @Test func truncateToIndexBeyondCount() {
19 | var numbers = [1, 2, 3, 4, 5]
20 | numbers.truncate(to: 10)
21 | #expect(numbers == [1, 2, 3, 4, 5], "Array should remain unchanged")
22 | }
23 |
24 | @Test func truncateEmptyArray() {
25 | var emptyArray: [Int] = []
26 | emptyArray.truncate(to: 2)
27 | #expect(emptyArray == [], "Empty array should remain unchanged")
28 | }
29 |
30 | @Test func truncateToIndexZero() {
31 | var numbers = [1, 2, 3, 4, 5]
32 | numbers.truncate(to: 0)
33 | #expect(numbers == [1], "Array should be truncated to [1]")
34 | }
35 |
36 | @Test func truncateWithNegativeIndex() {
37 | var numbers = [1, 2, 3, 4, 5]
38 | numbers.truncate(to: -2)
39 | #expect(numbers == [1, 2, 3, 4, 5], "Array should remain unchanged")
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/Tests/RoutingTests/Mocks/MockRoute.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MockRoute.swift
3 | //
4 | //
5 | // Created by James Sedlacek on 2/10/24.
6 | //
7 |
8 | import Routing
9 | import SwiftUI
10 |
11 | enum MockRoute: Routable {
12 | case settings
13 | case profile
14 |
15 | var body: some View {
16 | switch self {
17 | case .settings:
18 | MockSettingsView()
19 | case .profile:
20 | MockProfileView()
21 | }
22 | }
23 | }
24 |
25 | struct MockSettingsView: View {
26 | var body: some View {
27 | Text("Mock Settings View")
28 | }
29 | }
30 |
31 | struct MockProfileView: View {
32 | var body: some View {
33 | Text("Mock Profile View")
34 | }
35 | }
36 |
--------------------------------------------------------------------------------