├── .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 | [![Swift Package Manager](https://img.shields.io/badge/Swift%20Package%20Manager-compatible-brightgreen.svg)](https://github.com/apple/swift-package-manager) 6 | [![GitHub stars](https://img.shields.io/github/stars/JamesSedlacek/Routing.svg)](https://github.com/JamesSedlacek/Routing/stargazers) 7 | [![GitHub forks](https://img.shields.io/github/forks/JamesSedlacek/Routing.svg?color=blue)](https://github.com/JamesSedlacek/Routing/network) 8 | [![GitHub contributors](https://img.shields.io/github/contributors/JamesSedlacek/Routing.svg?color=blue)](https://github.com/JamesSedlacek/Routing/network) 9 | Pull Requests Badge 10 | Issues Badge 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 | --------------------------------------------------------------------------------