├── Docs ├── Images │ ├── animated_routes.gif │ └── logo.svg └── AnimatingRoutes.md ├── Sources ├── SwiftUIRouter.docc │ ├── Resources │ │ └── logo.png │ └── SwiftUIRouter.md ├── utils.swift ├── Navigate.swift ├── SwitchRoutes.swift ├── Router.swift ├── NavLink.swift ├── Navigator.swift └── Route.swift ├── Package.swift ├── LICENSE ├── .gitignore ├── README.md └── Tests └── SwiftUIRouterTests └── SwiftUIRouterTests.swift /Docs/Images/animated_routes.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frzi/swiftui-router/HEAD/Docs/Images/animated_routes.gif -------------------------------------------------------------------------------- /Sources/SwiftUIRouter.docc/Resources/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frzi/swiftui-router/HEAD/Sources/SwiftUIRouter.docc/Resources/logo.png -------------------------------------------------------------------------------- /Sources/utils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUI Router 3 | // Created by Freek (github.com/frzi) 2021 4 | // 5 | 6 | import Foundation 7 | 8 | func normalizePath(paths: String...) -> String { 9 | NSString(string: paths.joined(separator: "/")).standardizingPath 10 | } 11 | 12 | func resolvePaths(_ lhs: String, _ rhs: String) -> String { 13 | let path = rhs.first == "/" ? rhs : lhs + "/" + rhs 14 | return NSString(string: path).standardizingPath 15 | } 16 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "SwiftUIRouter", 7 | platforms: [ 8 | .macOS(.v11), 9 | .iOS(.v14), 10 | .tvOS(.v14), 11 | .watchOS(.v7) 12 | ], 13 | products: [ 14 | .library( 15 | name: "SwiftUIRouter", 16 | targets: ["SwiftUIRouter"]), 17 | ], 18 | dependencies: [], 19 | targets: [ 20 | .target( 21 | name: "SwiftUIRouter", 22 | dependencies: [], 23 | path: "Sources"), 24 | .testTarget( 25 | name: "SwiftUIRouterTests", 26 | dependencies: ["SwiftUIRouter"]), 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /Sources/SwiftUIRouter.docc/SwiftUIRouter.md: -------------------------------------------------------------------------------- 1 | # ``SwiftUIRouter`` 2 | 3 | Easy and maintainable app navigation with path-based routing for SwiftUI. 4 | 5 | ![SwiftUI Router logo](logo) 6 | 7 | With **SwiftUI Router** you can power your SwiftUI app with path-based routing. By utilizing a path-based system, navigation in your app becomes more flexible and easier to maintain. 8 | 9 | ### Additional content 10 | - Examples can be found on [Github](https://github.com/frzi/SwiftUIRouter-Examples) 11 | - [Animating routes](https://github.com/frzi/SwiftUIRouter/blob/main/Docs/AnimatingRoutes.md) 12 | 13 | ## Topics 14 | 15 | ### Router 16 | 17 | - ``Router`` 18 | 19 | ### Routing 20 | 21 | - ``Route`` 22 | - ``SwitchRoutes`` 23 | 24 | ### Navigating 25 | 26 | - ``NavLink`` 27 | - ``Navigate`` 28 | 29 | ### Environment Objects 30 | 31 | - ``Navigator`` 32 | - ``RouteInformation`` 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 frzi 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /Sources/Navigate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUI Router 3 | // Created by Freek (github.com/frzi) 2021 4 | // 5 | 6 | import SwiftUI 7 | 8 | /// When rendered will automatically perform a navigation to the given path. 9 | /// 10 | /// This view allows you to programmatically navigate to a new path in a View's body. 11 | /// 12 | /// ```swift 13 | /// SwitchRoutes { 14 | /// Route("news", content: NewsView()) 15 | /// Route { 16 | /// // If this Route gets rendered it'll redirect 17 | /// // the user to a 'not found' screen. 18 | /// Navigate(to: "/not-found") 19 | /// } 20 | /// } 21 | /// ``` 22 | /// 23 | /// - Note: The given path is always relative to the current route environment. See the documentation for `Route` about 24 | /// the specifics of path relativity. 25 | public struct Navigate: View { 26 | 27 | @EnvironmentObject private var navigator: Navigator 28 | @Environment(\.relativePath) private var relativePath 29 | 30 | private let path: String 31 | private let replace: Bool 32 | 33 | /// - Parameter path: New path to navigate to once the View is rendered. 34 | /// - Parameter replace: if `true` will replace the last path in the history stack with the new path. 35 | public init(to path: String, replace: Bool = true) { 36 | self.path = path 37 | self.replace = replace 38 | } 39 | 40 | public var body: some View { 41 | Text("Navigating...") 42 | .hidden() 43 | .onAppear { 44 | if navigator.path != path { 45 | navigator.navigate(resolvePaths(relativePath, path), replace: replace) 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/SwitchRoutes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUI Router 3 | // Created by Freek (github.com/frzi) 2021 4 | // 5 | 6 | import Combine 7 | import SwiftUI 8 | 9 | /// Render the first matching `Route` and ignore the rest. 10 | /// 11 | /// Use this view when you want to work with 'fallbacks'. 12 | /// 13 | /// ```swift 14 | /// SwitchRoutes { 15 | /// Route("settings") { 16 | /// SettingsView() 17 | /// } 18 | /// Route(":id") { info in 19 | /// ContentView(id: info.params.id!) 20 | /// } 21 | /// Route { 22 | /// HomeView() 23 | /// } 24 | /// } 25 | /// ``` 26 | /// In the above example, if the environment path is `/settings`, only the first `Route` will be rendered. 27 | /// Because this is the first match. The `Route`s after will not be rendered, despite being a match. 28 | /// 29 | /// - Note: Using `SwitchRoute` can give a slight performance boost when working with a lot of sibling `Route`s, 30 | /// as once a path match has been found, all subsequent path matching will be skipped. 31 | public struct SwitchRoutes: View { 32 | 33 | // Required to be present, forcing the `SwitchRoutes` to re-render on path changes. 34 | @EnvironmentObject private var navigation: Navigator 35 | private let contents: () -> Content 36 | 37 | /// - Parameter contents: Routes to switch through. 38 | public init(@ViewBuilder contents: @escaping () -> Content) { 39 | self.contents = contents 40 | } 41 | 42 | public var body: some View { 43 | contents() 44 | .environmentObject(SwitchRoutesEnvironment(active: true)) 45 | } 46 | } 47 | 48 | // MARK: - SwitchRoutes environment object. 49 | final class SwitchRoutesEnvironment: ObservableObject { 50 | /// Tells `Route`s whether they're enclosed in a `SwitchRoutes`. 51 | let isActive: Bool 52 | 53 | /// Tells `Route`s they can ignore the content as a `SwitchRoutes` has found a match. 54 | var isResolved = false 55 | 56 | init(active: Bool = false) { 57 | isActive = active 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/Router.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUI Router 3 | // Created by Freek (github.com/frzi) 2021 4 | // 5 | 6 | import Combine 7 | import SwiftUI 8 | 9 | /// Entry for a routing environment. 10 | /// 11 | /// The Router holds the state of the current path (i.e. the URI). 12 | /// Wrap your entire app (or the view that initiates a routing environment) using this view. 13 | /// 14 | /// ```swift 15 | /// Router { 16 | /// HomeView() 17 | /// 18 | /// Route("/news") { 19 | /// NewsHeaderView() 20 | /// } 21 | /// } 22 | /// ``` 23 | /// 24 | /// # Routers in Routers 25 | /// It's possible to have a Router somewhere in the child hierarchy of another Router. *However*, these will 26 | /// work completely independent of each other. It is not possible to navigate from one Router to another; whether 27 | /// via `NavLink` or programmatically. 28 | /// 29 | /// - Note: A Router's base path (root) is always `/`. 30 | public struct Router: View { 31 | @StateObject private var navigator: Navigator 32 | private let content: Content 33 | 34 | /// Initialize a Router environment. 35 | /// - Parameter initialPath: The initial path the `Router` should start at once initialized. 36 | /// - Parameter content: Content views to render inside the Router environment. 37 | public init(initialPath: String = "/", @ViewBuilder content: () -> Content) { 38 | _navigator = StateObject(wrappedValue: Navigator(initialPath: initialPath)) 39 | self.content = content() 40 | } 41 | 42 | /// Initialize a Router environment. 43 | /// 44 | /// Provide an already initialized instance of `Navigator` to use inside a Router environment. 45 | /// 46 | /// - Important: This is considered an advanced usecase for *SwiftUI Router* used only for specific design patterns. 47 | /// It is stronlgy adviced to use the `init(initialPath:content:)` initializer instead. 48 | /// 49 | /// - Parameter navigator: A pre-initialized instance of `Navigator`. 50 | /// - Parameter content: Content views to render inside the Router environment. 51 | public init(navigator: Navigator, @ViewBuilder content: () -> Content) { 52 | _navigator = StateObject(wrappedValue: navigator) 53 | self.content = content() 54 | } 55 | 56 | public var body: some View { 57 | content 58 | .environmentObject(navigator) 59 | .environmentObject(SwitchRoutesEnvironment()) 60 | .environment(\.relativePath, "/") 61 | } 62 | } 63 | 64 | // MARK: - Relative path environment key 65 | struct RelativeRouteEnvironment: EnvironmentKey { 66 | #if swift(>=5.10) 67 | static nonisolated(unsafe) var defaultValue = "/" 68 | #else 69 | static var defaultValue = "/" 70 | #endif 71 | } 72 | 73 | public extension EnvironmentValues { 74 | /// The current relative path of the closest `Route`. 75 | internal(set) var relativePath: String { 76 | get { self[RelativeRouteEnvironment.self] } 77 | set { self[RelativeRouteEnvironment.self] = newValue } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Sources/NavLink.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUI Router 3 | // Created by Freek (github.com/frzi) 2021 4 | // 5 | 6 | import SwiftUI 7 | 8 | /// Convenience wrapper around a `Button` with the ability to navigate to a new path. 9 | /// 10 | /// A button that will navigate to the given path when pressed. Additionally it can provide information 11 | /// whether the current path matches the `NavLink` path. This allows the developer to apply specific styling 12 | /// when the `NavLink` is 'active'. E.g. highlighting or disabling the contents. 13 | /// 14 | /// ```swift 15 | /// NavLink(to: "/news/latest") { active in 16 | /// Text("Latest news") 17 | /// .color(active ? Color.primary : Color.secondary) 18 | /// } 19 | /// ``` 20 | /// 21 | /// - Note: The given path is always relative to the current route environment. See the documentation for `Route` about 22 | /// the specifics of path relativity. 23 | public struct NavLink: View { 24 | 25 | @EnvironmentObject private var navigator: Navigator 26 | @Environment(\.relativePath) private var relativePath 27 | 28 | private let content: (Bool) -> Content 29 | private let exact: Bool 30 | private let path: String 31 | private let replace: Bool 32 | 33 | // MARK: - Initializers. 34 | /// Button to navigate to a new path. 35 | /// 36 | /// - Parameter to: New path to navigate to when pressed. 37 | /// - Parameter replace: Replace the current entry in the history stack. 38 | /// - Parameter exact: The `Bool` in the `content` parameter will only be `true` if the current path and the 39 | /// `to` path are an *exact* match. 40 | /// - Parameter content: Content views. The passed `Bool` indicates whether the current path matches `to` path. 41 | public init( 42 | to path: String, 43 | replace: Bool = false, 44 | exact: Bool = false, 45 | @ViewBuilder content: @escaping (Bool) -> Content 46 | ) { 47 | self.path = path 48 | self.replace = replace 49 | self.exact = exact 50 | self.content = content 51 | } 52 | 53 | /// Button to navigate to a new path. 54 | /// 55 | /// - Parameter to: New path to navigate to when pressed. 56 | /// - Parameter replace: Replace the current entry in the history stack. 57 | /// - Parameter content: Content views. 58 | public init(to path: String, replace: Bool = false, @ViewBuilder content: @escaping () -> Content) { 59 | self.init(to: path, replace: replace, exact: false, content: { _ in content() }) 60 | } 61 | 62 | // MARK: - 63 | private func onPressed() { 64 | let resolvedPath = resolvePaths(relativePath, path) 65 | if navigator.path != resolvedPath { 66 | navigator.navigate(resolvedPath, replace: replace) 67 | } 68 | } 69 | 70 | public var body: some View { 71 | let absolutePath = resolvePaths(relativePath, path) 72 | let active = exact ? navigator.path == absolutePath : navigator.path.starts(with: absolutePath) 73 | 74 | return Button(action: onPressed) { 75 | content(active) 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Docs/AnimatingRoutes.md: -------------------------------------------------------------------------------- 1 | # Animating routes 2 | 3 | Simulating screen transitions à la iOS. 4 | 5 | ## Introduction 6 | 7 | On a platform like iOS, users may expect animated screen transitions when navigating through the app. (Less so the case with macOS) Apps get these transitions for free with `NavigationView`. But with SwiftUI Router, however, this is not the case. Ideally, you want a transition that differs as the user goes forward (deeper) in the app and when they go back (higher). 8 | 9 | SwiftUI Router exposes the `Navigator` environment object. An object that allows for navigation done programmatically. It also contains the property `.lastAction`, which is of type `NavigationAction?`. This object contains read-only information about the last navigation that occurred. Information like the previous path, the current path, whether the app navigated forward or back. But also the *direction* of the navigation, which is what we're interested in right now. 10 | 11 | The *direction* of a navigation action implies whether the app navigated deeper, higher or sideways in the routing hierarchy. Consider an app user currently being on the news screen (`/news`) and they press an article. The user navigates from `/news` to `/news/some-article`. This is labeled as **deeper**. The user scrolls to the bottom and presses a related news article (`/news/related-article`). This is labeled as **sideways**. Finally, when the user goes back to the news screen (`/news`), the direction is **higher**. 12 | 13 | We can use this information to decide how our views are animated as the user navigates through your app. 14 | 15 | Below an example of a very primitive `ViewModifier` that animates routes similar to views in a `NavigationView`: 16 | 17 | ```swift 18 | struct NavigationTransition: ViewModifier { 19 | @EnvironmentObject private var navigator: Navigator 20 | 21 | func body(content: Content) -> some View { 22 | content 23 | .animation(.easeInOut, value: navigator.path) 24 | .transition( 25 | navigator.lastAction?.direction == .deeper || navigator.lastAction?.direction == .sideways 26 | ? AnyTransition.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)) 27 | : AnyTransition.asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .trailing)) 28 | ) 29 | } 30 | } 31 | ``` 32 | When the user navigates either deeper or sideways in the routing hierarchy, the new view enters the screen from the right, whereas the previous view leaves the screen on the left. However, when the user navigates higher in the routing hierarchy (e.g.: pressing the back button), the transitions are reversed. 33 | 34 | To make the view modifier more accessible and user-friendlier, consider wrapping it in a View method like any other modifier: 35 | 36 | ```swift 37 | extension View { 38 | func navigationTransition() -> some View { 39 | modifier(NavigationTransition()) 40 | } 41 | } 42 | ``` 43 | 44 | The modifier can be applied to `Route` views: 45 | ```swift 46 | Route("news") { 47 | NewsScreen() 48 | } 49 | .navigationTransition() 50 | ``` 51 | 52 | The modifier can also be applied to a ``SwitchRoutes``. This will apply the animated transition to all `Route` views inside the ``SwitchRoutes``. 53 | ```swift 54 | SwitchRoutes { 55 | Route("news/:id", validator: newsIdValidator) { uuid in 56 | NewsItemScreen(uuid: uuid) 57 | } 58 | Route("news") { 59 | NewsScreen() 60 | } 61 | Route { 62 | HomeScreen() 63 | } 64 | } 65 | .navigationTransition() 66 | ``` 67 | 68 | *Tada~* 69 | 70 | ![Preview](Images/animated_routes.gif) 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/xcode,swift,macos 3 | # Edit at https://www.gitignore.io/?templates=xcode,swift,macos 4 | 5 | ### macOS ### 6 | # General 7 | .DS_Store 8 | .AppleDouble 9 | .LSOverride 10 | 11 | # Icon must end with two \r 12 | Icon 13 | 14 | # Thumbnails 15 | ._* 16 | 17 | # Files that might appear in the root of a volume 18 | .DocumentRevisions-V100 19 | .fseventsd 20 | .Spotlight-V100 21 | .TemporaryItems 22 | .Trashes 23 | .VolumeIcon.icns 24 | .com.apple.timemachine.donotpresent 25 | 26 | # Directories potentially created on remote AFP share 27 | .AppleDB 28 | .AppleDesktop 29 | Network Trash Folder 30 | Temporary Items 31 | .apdisk 32 | 33 | ### Swift ### 34 | # Xcode 35 | # 36 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 37 | 38 | ## Build generated 39 | build/ 40 | DerivedData/ 41 | 42 | ## Various settings 43 | *.pbxuser 44 | !default.pbxuser 45 | *.mode1v3 46 | !default.mode1v3 47 | *.mode2v3 48 | !default.mode2v3 49 | *.perspectivev3 50 | !default.perspectivev3 51 | xcuserdata/ 52 | 53 | ## Other 54 | *.moved-aside 55 | *.xccheckout 56 | *.xcscmblueprint 57 | 58 | ## Obj-C/Swift specific 59 | *.hmap 60 | *.ipa 61 | *.dSYM.zip 62 | *.dSYM 63 | 64 | ## Playgrounds 65 | timeline.xctimeline 66 | playground.xcworkspace 67 | 68 | # Swift Package Manager 69 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 70 | # Packages/ 71 | # Package.pins 72 | # Package.resolved 73 | .build/ 74 | .swiftpm/ 75 | 76 | # CocoaPods 77 | # We recommend against adding the Pods directory to your .gitignore. However 78 | # you should judge for yourself, the pros and cons are mentioned at: 79 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 80 | # Pods/ 81 | # Add this line if you want to avoid checking in source code from the Xcode workspace 82 | # *.xcworkspace 83 | 84 | # Carthage 85 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 86 | # Carthage/Checkouts 87 | 88 | Carthage/Build 89 | 90 | # Accio dependency management 91 | Dependencies/ 92 | .accio/ 93 | 94 | # fastlane 95 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 96 | # screenshots whenever they are needed. 97 | # For more information about the recommended setup visit: 98 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 99 | 100 | fastlane/report.xml 101 | fastlane/Preview.html 102 | fastlane/screenshots/**/*.png 103 | fastlane/test_output 104 | 105 | # Code Injection 106 | # After new code Injection tools there's a generated folder /iOSInjectionProject 107 | # https://github.com/johnno1962/injectionforxcode 108 | 109 | iOSInjectionProject/ 110 | 111 | ### Xcode ### 112 | # Xcode 113 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 114 | 115 | ## User settings 116 | 117 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 118 | 119 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 120 | 121 | ## Xcode Patch 122 | *.xcodeproj/* 123 | !*.xcodeproj/project.pbxproj 124 | !*.xcodeproj/xcshareddata/ 125 | !*.xcworkspace/contents.xcworkspacedata 126 | /*.gcno 127 | 128 | ### Xcode Patch ### 129 | **/xcshareddata/WorkspaceSettings.xcsettings 130 | 131 | # End of https://www.gitignore.io/api/xcode,swift,macos -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | SwiftUI Router 2 | 3 | > Easy and maintainable app navigation with path-based routing for SwiftUI. 4 | 5 | ![SwiftUI](https://img.shields.io/github/v/release/frzi/SwiftUIRouter?style=for-the-badge) 6 | [![SwiftUI](https://img.shields.io/badge/SwiftUI-blue.svg?style=for-the-badge&logo=swift&logoColor=black)](https://developer.apple.com/xcode/swiftui) 7 | [![Swift](https://img.shields.io/badge/Swift-5.3-orange.svg?style=for-the-badge&logo=swift)](https://swift.org) 8 | [![Xcode](https://img.shields.io/badge/Xcode-13-blue.svg?style=for-the-badge&logo=Xcode&logoColor=white)](https://developer.apple.com/xcode) 9 | [![MIT](https://img.shields.io/badge/license-MIT-black.svg?style=for-the-badge)](https://opensource.org/licenses/MIT) 10 | 11 | With **SwiftUI Router** you can power your SwiftUI app with path-based routing. By utilizing a path-based system, navigation in your app becomes more flexible and easier to maintain. 12 | 13 |
14 | 15 | :warning: During WWDC22 Apple introduced [`NavigationStack`](https://developer.apple.com/documentation/swiftui/navigationstack) to SwiftUI. This provides a similar workflow to **SwiftUI Router** as well as being type-safe. There are however some key differences. It is recommended to try out `NavigationStack` before using **SwiftUI Router** in your project. 16 | 17 |
18 | 19 | ## Index 20 | * [Installation](#installation-) 21 | * [Documentation](#documentation-) 22 | * [Examples](#examples-) 23 | * [Usage](#usage-) 24 | * [License](#license-) 25 | 26 | ## Installation 🛠 27 | In Xcode add the dependency to your project via *File > Add Packages > Search or Enter Package URL* and use the following url: 28 | ``` 29 | https://github.com/frzi/SwiftUIRouter.git 30 | ``` 31 | 32 | Once added, import the package in your code: 33 | ```swift 34 | import SwiftUIRouter 35 | ``` 36 | *Bada bing bada boom you're ready to go.* 37 | 38 |
39 | 40 | ## Documentation 📚 41 | - [Animating routes](/Docs/AnimatingRoutes.md) 42 | - [SwiftUI Router vs NavigationStack](https://github.com/frzi/SwiftUIRouter/discussions/59) 43 | 44 |
45 | 46 | ## Examples 👀 47 | - [SwiftUI Router Examples](https://github.com/frzi/SwiftUIRouter-Examples) contains: 48 | ┗ [RandomUsers](https://github.com/frzi/SwiftUIRouter-Examples/tree/main/RandomUsers) 49 | ┗ [Swiping](https://github.com/frzi/SwiftUIRouter-Examples/tree/main/Swiping) 50 | ┗ [TabViews](https://github.com/frzi/SwiftUIRouter-Examples/tree/main/TabViewRouting) 51 | 52 |
53 | 54 | ## Usage 🚀 55 | Below a quick rundown of the available views and objects and their basic features. For further details, please check out the documentation in the Swift files. 56 | 57 | ### `Router` 58 | ```swift 59 | Router { 60 | RootView() 61 | } 62 | ``` 63 | The entry of a routing environment. Wrap your entire app (or just the part that needs routing) inside a `Router`. This view will initialize all necessary environment values needed for routes. 64 | 65 |
66 | 67 | ### `Route` 68 | ```swift 69 | Route("news/*") { 70 | NewsScreen() 71 | } 72 | Route("settings") { 73 | SettingsScreen() 74 | } 75 | Route("user/:id?") { info in 76 | UserScreen(id: info.parameters["id"]) 77 | } 78 | ``` 79 | A view that will only render its contents if its path matches that of the environment. Use `/*` to also match deeper paths. E.g.: the path `news/*` will match the following environment paths: `/news`, `/news/latest`, `/news/article/1` etc. 80 | 81 | #### Parameters 82 | Paths can contain parameters (aka placeholders) that can be read individually. A parameter's name is prefixed with a colon (`:`). Additionally, a parameter can be considered optional by suffixing it with a question mark (`?`). The parameters are passed down as a `[String : String]` in an `RouteInformation` object to a `Route`'s contents. 83 | **Note**: Parameters may only exist of alphanumeric characters (A-Z, a-z and 0-9) and *must* start with a letter. 84 | 85 | #### Parameter validation 86 | ```swift 87 | func validateUserID(routeInfo: RouteInformation) -> UUID? { 88 | UUID(routeInfo.parameters["id"] ?? "") 89 | } 90 | 91 | Route("user/:id", validator: validateUserID) { userID in 92 | UserScreen(userID: userID) 93 | } 94 | ``` 95 | A `Route` provides an extra step for validating parameters in a path. 96 | 97 | Let's say your `Route` has the path `/user/:id`. By default, the `:id` parameter can be *anything*. But in this case you only want valid [UUIDs](https://developer.apple.com/documentation/foundation/uuid). Using a `Route`'s `validator` argument, you're given a chance to validate (and transform) the parameter's value. 98 | 99 | A validator is a simple function that's passed a `RouteInformation` object (containing the parameters) and returns the transformed value as an optional. The new transformed value is passed down to your view instead of the default `RouteInformation` object. If the transformed value is `nil` the `Route` will prevent rendering its contents. 100 | 101 |
102 | 103 | ### `NavLink` 104 | ```swift 105 | NavLink(to: "/news/latest") { 106 | Text("Latest news") 107 | } 108 | ``` 109 | A wrapper around a `Button` that will navigate to the given path if pressed. 110 | 111 |
112 | 113 | ### `SwitchRoutes` 114 | ```swift 115 | SwitchRoutes { 116 | Route("latest") { 117 | LatestNewsScreen() 118 | } 119 | Route("article/:id") { info in 120 | NewsArticleScreen(articleID: info.parameters["id"]!) 121 | } 122 | Route(":unknown") { 123 | ErrorScreen() 124 | } 125 | Route { 126 | NewsScreen() 127 | } 128 | } 129 | ``` 130 | A view that will only render the first `Route` whose path matches the environment path. This is useful if you wish to work with fallbacks. This view can give a slight performance boost as it prevents `Route`s from path matching once a previous `Route`'s path is already resolved. 131 | 132 |
133 | 134 | ### `Navigate` 135 | ```swift 136 | Navigate(to: "/error-404") 137 | ``` 138 | This view will automatically navigate to another path once rendered. One may consider using this view in a fallback `Route` inside a `SwitchRoutes`. 139 | 140 |
141 | 142 | ### `Navigator` 143 | ```swift 144 | @EnvironmentObject var navigator: Navigator 145 | ``` 146 | An environment object containg the data of the `Router`. With this object you can programmatically navigate to another path, go back in the history stack or go forward. 147 | 148 |
149 | 150 | ### `RouteInformation` 151 | ```swift 152 | @EnvironmentObject var routeInformation: RouteInformation 153 | ``` 154 | A lightweight object containing information of the current `Route`. A `RouteInformation` contains the relative path and a `[String : String]` with all the parsed [parameters](#parameters). 155 | 156 | This object is passed down by default in a `Route` to its contents. It's also accessible as an environment object. 157 | 158 |
159 | 160 | ## License 📄 161 | [MIT License](LICENSE). 162 | -------------------------------------------------------------------------------- /Tests/SwiftUIRouterTests/SwiftUIRouterTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftUIRouter 3 | 4 | final class SwiftUIRouterTests: XCTestCase { 5 | 6 | /// Test equitability of navigator 7 | func testNavigatorIsEquatable() { 8 | let nav1 = Navigator(initialPath: "/") 9 | let nav2: Navigator = nav1 10 | 11 | // 1. 12 | nav1.navigate("/foo") 13 | XCTAssertEqual(nav1, nav2) 14 | // 2. 15 | nav1.goBack() 16 | XCTAssertEqual(nav1, nav2) 17 | // 3. 18 | nav2.navigate("/foo") 19 | nav2.goBack() // => "/" 20 | XCTAssertEqual(nav1, nav2) 21 | 22 | let nav3 = Navigator(initialPath: "/") 23 | XCTAssertNotEqual(nav1, nav3) 24 | 25 | // Test if navigation actions are equatable. 26 | nav2.navigate("/foo") 27 | nav3.navigate("/foo") 28 | XCTAssertTrue( 29 | nav2.lastAction == nav3.lastAction, 30 | "Both navigation actions to /foo are not equal." 31 | ) 32 | 33 | nav3.goBack() 34 | XCTAssertTrue( 35 | nav2.lastAction != nav3.lastAction, 36 | "Different navigation actions are still equal." 37 | ) 38 | } 39 | 40 | /// Test cleaning/resolving of paths. 41 | func testPathResolving() { 42 | let paths: [(String, String)] = [ 43 | ("/", "/"), 44 | ("///unnecessary///slashes", "/unnecessary/slashes"), 45 | ("non/absolute", "/non/absolute"), 46 | ("home//", "/home"), 47 | ("trailing/slash/", "/trailing/slash"), 48 | ] 49 | 50 | for (dirty, cleaned) in paths { 51 | XCTAssertTrue( 52 | resolvePaths("/", dirty) == cleaned, 53 | "Path \(dirty) did not resolve to \(cleaned)" 54 | ) 55 | } 56 | } 57 | 58 | /// Test if the globs and paths match. 59 | func testCorrectMatches() { 60 | let notNil: [(String, String)] = [ 61 | ("/", "/"), 62 | ("/*", "/"), 63 | ("/*", "/hello/world"), 64 | ("/hello/*", "/hello"), 65 | ("/hello/*", "/hello/world"), 66 | ("/:id", "/hello"), 67 | ("/:id?", "/"), 68 | ("/:id?", "/hello"), 69 | ("/:id/*", "/hello"), 70 | ("/:id/*", "/hello/world"), 71 | ("/news/latest", "/news/latest"), 72 | ("/user/:id/*", "/user/1"), 73 | ("/user/:id/*", "/user/1/settings"), 74 | ("/user/:id?", "/user"), 75 | ("/user/:id?", "/user/mark"), 76 | ("/user/:id/:group?", "/user/mark"), 77 | ("/user/:id/:group?", "/user/mark/admin"), 78 | ] 79 | 80 | for (glob, path) in notNil { 81 | let pathMatcher = PathMatcher() 82 | 83 | XCTAssertNotNil( 84 | try? pathMatcher.match(glob: glob, with: path), 85 | "Glob \(glob) does not match \(path)." 86 | ) 87 | } 88 | } 89 | 90 | /// Test if the globs and paths *don't* match. 91 | func testIncorrectMatches() { 92 | let pathMatcher = PathMatcher() 93 | 94 | // Glob, path 95 | let isNil: [(String, String)] = [ 96 | ("/", "/hello"), 97 | ("/hello", "/world"), 98 | ("/foo/:bar/hello", "/foo/hello"), 99 | ("/movie", "/movies"), 100 | ("/movie/*", "/movies"), 101 | ("/movie/*", "/movies/actor"), 102 | ] 103 | 104 | for (glob, path) in isNil { 105 | XCTAssertNil( 106 | try? pathMatcher.match(glob: glob, with: path), 107 | "Glob \(glob) matches \(path), but it shouldn't." 108 | ) 109 | } 110 | } 111 | 112 | /// Tests if the variables exist and equate. 113 | func testPathVariables() { 114 | let pathMatcher = PathMatcher() 115 | 116 | let tests: [(String, String, [String : String])] = [ 117 | ("/:id?", "/", [:]), 118 | ("/:id?", "/hello", ["id": "hello"]), 119 | ("/:id", "/hello", ["id": "hello"]), 120 | ("/:foo/:bar", "/hello/world", ["foo": "hello", "bar": "world"]), 121 | ("/:foo/:bar?", "/hello", ["foo": "hello"]), 122 | ("/user/:id/*", "/user/5", ["id": "5"]), 123 | ] 124 | 125 | for (glob, path, params) in tests { 126 | guard let routeInformation = try? pathMatcher.match(glob: glob, with: path) else { 127 | XCTFail("Glob \(glob) returned `nil` for path \(path)") 128 | continue 129 | } 130 | 131 | for (expectedKey, expectedValue) in params { 132 | XCTAssertTrue( 133 | routeInformation.parameters[expectedKey] == expectedValue, 134 | "Glob \(glob) for path \(path) returns incorrect parameter for \(expectedKey). " + 135 | "Expected: \(expectedValue), got: \(routeInformation.parameters[expectedKey] ?? "`nil`")." 136 | ) 137 | } 138 | } 139 | } 140 | 141 | /// Tests whether glob to Regex compilation doesn't throw. 142 | func testRegexCompilation() { 143 | let pathMatcher = PathMatcher() 144 | 145 | // Test if the path matcher can compile valid Regex. 146 | let goodGlobs: [String] = [ 147 | "/", 148 | "/*", 149 | "/:id", 150 | "/:id?", 151 | "/:id1/:id2", 152 | "/:id1/:id2?", 153 | "/:Movie/*", 154 | "/:i", // Single character. 155 | ] 156 | 157 | for glob in goodGlobs { 158 | XCTAssertNoThrow( 159 | try pathMatcher.match(glob: glob, with: ""), 160 | "Glob \(glob) causes bad Regex." 161 | ) 162 | } 163 | 164 | // These bad globs should throw at Regex compilation. 165 | let badGlobs: [String] = [ 166 | "/:0abc", // Starting with numerics. 167 | "/:user-id", // Illegal characters. 168 | "/:foo_bar", 169 | "/:😀" 170 | ] 171 | 172 | for glob in badGlobs { 173 | XCTAssertThrowsError( 174 | try pathMatcher.match(glob: glob, with: ""), 175 | "Glob \(glob) should've thrown an error, but didn't." 176 | ) 177 | } 178 | } 179 | 180 | /// Test the `Navigator.navigate()` method. 181 | func testNavigating() { 182 | let navigator = Navigator() 183 | 184 | // 1: Simple relative navigation. 185 | navigator.navigate("news") 186 | XCTAssertTrue(navigator.path == "/news") 187 | 188 | // 2: Absolute navigation. 189 | navigator.navigate("/settings/user") 190 | XCTAssertTrue(navigator.path == "/settings/user") 191 | 192 | // 3: Going up one level. 193 | navigator.navigate("..") 194 | XCTAssertTrue(navigator.path == "/settings") 195 | 196 | // 4: Going up redundantly. 197 | navigator.navigate("../../../../..") 198 | XCTAssertTrue(navigator.path == "/") 199 | 200 | // 5: Go back. 201 | navigator.goBack() 202 | XCTAssertTrue(navigator.path == "/settings") 203 | 204 | // 6: Go back twice. 205 | navigator.goBack(total: 2) 206 | XCTAssertTrue(navigator.path == "/news") 207 | 208 | // 7: Go forward. 209 | navigator.goForward() 210 | XCTAssertTrue(navigator.path == "/settings/user") 211 | } 212 | 213 | /// Test navigation actions. 214 | func testNavigationAction() { 215 | // From, to, expected direction. 216 | let tests: [(String, String, NavigationAction.Direction)] = [ 217 | ("/", "/hello", .deeper), 218 | ("/hello", "/world", .sideways), 219 | ("/hello", "/", .higher), 220 | ("/movies/genres", "/movies", .higher), 221 | ("/movies/actors", "/movies/genres", .sideways), 222 | ("/movies/genres", "/news/latest", .higher), 223 | ] 224 | 225 | for (from, to, direction) in tests { 226 | let navigationAction = NavigationAction(currentPath: to, previousPath: from, action: .push) 227 | XCTAssertTrue( 228 | navigationAction.direction == direction, 229 | "Direction from \(from) to \(to) is: \(navigationAction.direction), expected: \(direction)" 230 | ) 231 | } 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /Sources/Navigator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUI Router 3 | // Created by Freek (github.com/frzi) 2021 4 | // 5 | 6 | import Dispatch 7 | import SwiftUI 8 | 9 | /// EnvironmentObject storing the state of a Router. 10 | /// 11 | /// Use this object to programmatically navigate to a new path, to jump forward or back in the history, to clear the 12 | /// history, or to find out whether the user can go back or forward. 13 | /// 14 | /// - Note: This EnvironmentObject is available inside the hierarchy of a `Router`. 15 | /// 16 | /// ```swift 17 | /// @EnvironmentObject var navigator: Navigator 18 | /// ``` 19 | public final class Navigator: ObservableObject, @unchecked Sendable { 20 | private let serialAccessQueue = DispatchQueue(label: "serialAccessQueue", qos: .default) 21 | 22 | @Published private var historyStack: [String] 23 | @Published private var forwardStack: [String] = [] 24 | 25 | /// Last navigation that occurred. 26 | @Published public private(set) var lastAction: NavigationAction? 27 | 28 | private let initialPath: String 29 | 30 | /// Initialize a `Navigator` to be fed to `Router` manually. 31 | /// 32 | /// Initialize an instance of `Navigator` to keep a reference to outside of the SwiftUI lifecycle. 33 | /// 34 | /// - Important: This is considered an advanced usecase for *SwiftUI Router* used for specific design patterns. 35 | /// It is strongly advised to reference the `Navigator` via the provided Environment Object instead. 36 | /// 37 | /// - Parameter initialPath: The initial path the `Navigator` should start at once initialized. 38 | public init(initialPath: String = "/") { 39 | self.initialPath = initialPath 40 | self.historyStack = [initialPath] 41 | } 42 | 43 | // MARK: Getters. 44 | /// Current navigation path of the Router environment. 45 | public var path: String { 46 | historyStack.last ?? initialPath 47 | } 48 | 49 | public var canGoBack: Bool { 50 | historyStack.count > 1 51 | } 52 | 53 | public var canGoForward: Bool { 54 | !forwardStack.isEmpty 55 | } 56 | 57 | /// The size of the history stack. 58 | /// 59 | /// The amount of times the `Navigator` 'can go back'. 60 | public var historyStackSize: Int { 61 | historyStack.count - 1 62 | } 63 | 64 | /// The size of the forward stack. 65 | /// 66 | /// The amount of times the `Navigator` 'can go forward'. 67 | public var forwardStackSize: Int { 68 | forwardStack.count 69 | } 70 | 71 | // MARK: Methods. 72 | /// Navigate to a new location. 73 | /// 74 | /// The given path is always relative to the current environment path. 75 | /// This means you can use `/` to navigate using an absolute path and `..` to go up a directory. 76 | /// 77 | /// ```swift 78 | /// navigator.navigate("news") // Relative. 79 | /// navigator.navigate("/settings/user") // Absolute. 80 | /// navigator.navigate("..") // Up one, relatively. 81 | /// ``` 82 | /// 83 | /// Navigating to the same path as the current path is a noop. If the `DEBUG` flag is enabled, a warning 84 | /// will be printed to the console. 85 | /// 86 | /// - Parameter path: Path of the new location to navigate to. 87 | /// - Parameter replace: if `true` will replace the last path in the history stack with the new path. 88 | public func navigate(_ path: String, replace: Bool = false) { 89 | serialAccessQueue.sync { 90 | let path = resolvePaths(self.path, path) 91 | let previousPath = self.path 92 | 93 | guard path != previousPath else { 94 | #if DEBUG 95 | print("SwiftUIRouter: Navigating to the same path ignored.") 96 | #endif 97 | return 98 | } 99 | 100 | forwardStack.removeAll() 101 | 102 | if replace && !historyStack.isEmpty { 103 | historyStack[historyStack.endIndex - 1] = path 104 | } 105 | else { 106 | historyStack.append(path) 107 | } 108 | 109 | lastAction = NavigationAction( 110 | currentPath: path, 111 | previousPath: previousPath, 112 | action: .push) 113 | } 114 | } 115 | 116 | /// Go back *n* steps in the navigation history. 117 | /// 118 | /// `total` will always be clamped and thus prevent from going out of bounds. 119 | /// 120 | /// - Parameter total: Total steps to go back. 121 | public func goBack(total: Int = 1) { 122 | guard canGoBack else { 123 | return 124 | } 125 | serialAccessQueue.sync { 126 | let previousPath = path 127 | 128 | let total = min(total, historyStack.count) 129 | let start = historyStack.count - total 130 | forwardStack.append(contentsOf: historyStack[start...].reversed()) 131 | historyStack.removeLast(total) 132 | 133 | lastAction = NavigationAction( 134 | currentPath: path, 135 | previousPath: previousPath, 136 | action: .back) 137 | } 138 | } 139 | 140 | /// Go forward *n* steps in the navigation history. 141 | /// 142 | /// `total` will always be clamped and thus prevent from going out of bounds. 143 | /// 144 | /// - Parameter total: Total steps to go forward. 145 | public func goForward(total: Int = 1) { 146 | guard canGoForward else { 147 | return 148 | } 149 | 150 | serialAccessQueue.sync { 151 | let previousPath = path 152 | 153 | let total = min(total, forwardStack.count) 154 | let start = forwardStack.count - total 155 | historyStack.append(contentsOf: forwardStack[start...]) 156 | forwardStack.removeLast(total) 157 | 158 | lastAction = NavigationAction( 159 | currentPath: path, 160 | previousPath: previousPath, 161 | action: .forward) 162 | } 163 | } 164 | 165 | /// Clear the entire navigation history. 166 | public func clear() { 167 | serialAccessQueue.sync { 168 | forwardStack.removeAll() 169 | historyStack = [path] 170 | lastAction = nil 171 | } 172 | } 173 | } 174 | 175 | extension Navigator: Equatable { 176 | public static func == (lhs: Navigator, rhs: Navigator) -> Bool { 177 | lhs === rhs 178 | } 179 | } 180 | 181 | // MARK: Deprecated features. 182 | extension Navigator { 183 | @available(*, deprecated, renamed: "historyStackSize") 184 | public var currentStackIndex: Int { 185 | historyStack.count - 1 186 | } 187 | } 188 | 189 | 190 | // MARK: - 191 | /// Information about a navigation that occurred. 192 | public struct NavigationAction: Equatable, Sendable { 193 | /// Directional difference between the current path and the previous path. 194 | public enum Direction: Sendable { 195 | /// The new path is higher up in the hierarchy *or* a completely different path. 196 | /// Example: `/user/settings` → `/user`. Or `/favorite/music` → `/news/latest`. 197 | case higher 198 | /// The new path is deeper in the hierarchy. Example: `/news` → `/news/latest`. 199 | case deeper 200 | /// The new path shares the same parent. Example: `/favorite/movies` → `/favorite/music`. 201 | case sideways 202 | } 203 | 204 | /// The kind of navigation that occurred. 205 | public enum Action: Sendable { 206 | /// Navigated to a new path. 207 | case push 208 | /// Navigated back in the stack. 209 | case back 210 | /// Navigated forward in the stack. 211 | case forward 212 | } 213 | 214 | public let action: Action 215 | public let currentPath: String 216 | public let previousPath: String 217 | public let direction: Direction 218 | 219 | init(currentPath: String, previousPath: String, action: Action) { 220 | self.action = action 221 | self.currentPath = currentPath 222 | self.previousPath = previousPath 223 | 224 | // Check whether the navigation went higher, deeper or sideways. 225 | if currentPath.count > previousPath.count 226 | && (currentPath.starts(with: previousPath + "/") || previousPath == "/") 227 | { 228 | direction = .deeper 229 | } 230 | else { 231 | let currentComponents = currentPath.split(separator: "/") 232 | let previousComponents = previousPath.split(separator: "/") 233 | 234 | if currentComponents.count == previousComponents.count 235 | && currentComponents.dropLast(1) == previousComponents.dropLast(1) 236 | { 237 | direction = .sideways 238 | } 239 | else { 240 | direction = .higher 241 | } 242 | } 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /Sources/Route.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUI Router 3 | // Created by Freek (github.com/frzi) 2021 4 | // 5 | 6 | import Foundation 7 | import SwiftUI 8 | 9 | /// A route showing only its content when its path matches with the environment path. 10 | /// 11 | /// When the environment path matches a `Route`'s path, its contents will be rendered. 12 | /// 13 | /// ```swift 14 | /// Route("settings") { 15 | /// SettingsView() 16 | /// } 17 | /// ``` 18 | /// 19 | /// ## Path parameters (aka placeholders) 20 | /// Paths may contain one or several parameters. Parameters are placeholders that will be replaced by the 21 | /// corresponding component of the matching path. Parameters are prefixed with a colon (:). The values of the 22 | /// parameters are provided via the `RouteInformation` object passed to the contents of the `Route`. 23 | /// Parameters can be marked as optional by postfixing them with a question mark (?). 24 | /// 25 | /// **Note:** Only alphanumeric characters (A-Z, a-z, 0-9) are valid for parameters. 26 | /// ```swift 27 | /// Route("/news/:id") { routeInfo in 28 | /// NewsItemView(id: routeInfo.parameters["id"]!) 29 | /// } 30 | /// ``` 31 | /// 32 | /// ## Validation and parameter transform 33 | /// `Route`s are given the opportunity to add an extra layer of validation. Use the `validator` argument to pass 34 | /// down a validator function. This function is given a `RouteInformation` object, containing the path parameters. 35 | /// This function can then return a new value to pass down to `content`, or return `nil` to invalidate the path 36 | /// matching. 37 | /// ```swift 38 | /// func validate(info: RouteInformation) -> UUID? { 39 | /// UUID(info.parameters["uuid"]!) 40 | /// } 41 | /// // Will only render if `uuid` is a valid UUID. 42 | /// Route("user/:uuid", validator: validate) { uuid in 43 | /// UserScreen(userId: uuid) 44 | /// } 45 | /// ``` 46 | /// 47 | /// ## Path relativity 48 | /// Every path found in a `Route`'s hierarchy is relative to the path of said `Route`. With the exception of paths 49 | /// starting with `/`. This allows you to develop parts of your app more like separate 'sub' apps. 50 | /// ```swift 51 | /// Route("/news") { 52 | /// // Goes to `/news/latest` 53 | /// NavLink(to: "latest") { Text("Latest news") } 54 | /// // Goes to `/home` 55 | /// NavLink(to: "/home") { Text("Home") } 56 | /// // Route for `/news/unknown/*` 57 | /// Route("unknown/*") { 58 | /// // Redirects to `/news/error` 59 | /// Navigate(to: "../error") 60 | /// } 61 | /// } 62 | /// ``` 63 | /// 64 | /// - Note: A `Route`'s default path is `*`, meaning it will always match. 65 | public struct Route: View { 66 | 67 | public typealias Validator = (RouteInformation) -> ValidatedData? 68 | 69 | @Environment(\.relativePath) private var relativePath 70 | @EnvironmentObject private var navigator: Navigator 71 | @EnvironmentObject private var switchEnvironment: SwitchRoutesEnvironment 72 | @StateObject private var pathMatcher = PathMatcher() 73 | 74 | private let content: (ValidatedData) -> Content 75 | private let path: String 76 | private let validator: Validator 77 | 78 | /// - Parameter path: A path glob to test with the current path. See documentation for `Route`. 79 | /// - Parameter validator: A function that validates and transforms the route parameters. 80 | /// - Parameter content: Views to render. The validated data is passed as an argument. 81 | public init( 82 | _ path: String = "*", 83 | validator: @escaping Validator, 84 | @ViewBuilder content: @escaping (ValidatedData) -> Content 85 | ) { 86 | self.content = content 87 | self.path = path 88 | self.validator = validator 89 | } 90 | 91 | @available(*, deprecated, renamed: "init(_:validator:content:)") 92 | public init( 93 | path: String, 94 | validator: @escaping Validator, 95 | @ViewBuilder content: @escaping (ValidatedData) -> Content 96 | ) { 97 | self.init(path, validator: validator, content: content) 98 | } 99 | 100 | public var body: some View { 101 | var validatedData: ValidatedData? 102 | var routeInformation: RouteInformation? 103 | 104 | if !switchEnvironment.isActive || (switchEnvironment.isActive && !switchEnvironment.isResolved) { 105 | do { 106 | if let matchInformation = try pathMatcher.match( 107 | glob: path, 108 | with: navigator.path, 109 | relative: relativePath), 110 | let validated = validator(matchInformation) 111 | { 112 | validatedData = validated 113 | routeInformation = matchInformation 114 | 115 | if switchEnvironment.isActive { 116 | switchEnvironment.isResolved = true 117 | } 118 | } 119 | } 120 | catch { 121 | fatalError("Unable to compile path glob '\(path)' to Regex. Error: \(error)") 122 | } 123 | } 124 | 125 | return Group { 126 | if let validatedData = validatedData, 127 | let routeInformation = routeInformation 128 | { 129 | content(validatedData) 130 | .environment(\.relativePath, routeInformation.path) 131 | .environmentObject(routeInformation) 132 | .environmentObject(SwitchRoutesEnvironment()) 133 | } 134 | } 135 | } 136 | } 137 | 138 | public extension Route where ValidatedData == RouteInformation { 139 | /// - Parameter path: A path glob to test with the current path. See documentation for `Route`. 140 | /// - Parameter content: Views to render. An `RouteInformation` is passed containing route parameters. 141 | init(_ path: String = "*", @ViewBuilder content: @escaping (RouteInformation) -> Content) { 142 | self.path = path 143 | self.validator = { $0 } 144 | self.content = content 145 | } 146 | 147 | /// - Parameter path: A path glob to test with the current path. See documentation for `Route`. 148 | /// - Parameter content: Views to render. 149 | init(_ path: String = "*", @ViewBuilder content: @escaping () -> Content) { 150 | self.path = path 151 | self.validator = { $0 } 152 | self.content = { _ in content() } 153 | } 154 | 155 | /// - Parameter path: A path glob to test with the current path. See documentation for `Route`. 156 | /// - Parameter content: View to render (autoclosure). 157 | init(_ path: String = "*", content: @autoclosure @escaping () -> Content) { 158 | self.path = path 159 | self.validator = { $0 } 160 | self.content = { _ in content() } 161 | } 162 | 163 | // MARK: - Deprecated initializers. 164 | // These will be completely removed in a future version. 165 | @available(*, deprecated, renamed: "init(_:content:)") 166 | init(path: String, @ViewBuilder content: @escaping (RouteInformation) -> Content) { 167 | self.init(path, content: content) 168 | } 169 | 170 | @available(*, deprecated, renamed: "init(_:content:)") 171 | init(path: String, @ViewBuilder content: @escaping () -> Content) { 172 | self.init(path, content: content) 173 | } 174 | 175 | @available(*, deprecated, renamed: "init(_:content:)") 176 | init(path: String, content: @autoclosure @escaping () -> Content) { 177 | self.init(path, content: content) 178 | } 179 | } 180 | 181 | 182 | // MARK: - 183 | /// Information passed to the contents of a `Route`. As well as accessible as an environment object 184 | /// inside the hierarchy of a `Route`. 185 | /// ```swift 186 | /// @EnvironmentObject var routeInformation: RouteInformation 187 | /// ``` 188 | /// This object contains the resolved parameters (variables) of the `Route`'s path, as well as the relative path 189 | /// for all views inside the hierarchy. 190 | public final class RouteInformation: ObservableObject { 191 | /// The resolved path component of the parent `Route`. For internal use only, at the moment. 192 | let matchedPath: String 193 | 194 | /// The current relative path. 195 | public let path: String 196 | 197 | /// Resolved parameters of the parent `Route`s path. 198 | public let parameters: [String : String] 199 | 200 | init(path: String, matchedPath: String, parameters: [String : String] = [:]) { 201 | self.matchedPath = matchedPath 202 | self.parameters = parameters 203 | self.path = path 204 | } 205 | } 206 | 207 | 208 | // MARK: - 209 | /// Object that will (lazily) compile regex from the given path glob, compare it with another path and return 210 | /// any parsed information (like identifiers). 211 | final class PathMatcher: ObservableObject { 212 | 213 | private struct CompiledRegex { 214 | let path: String 215 | let matchRegex: NSRegularExpression 216 | let parameters: Set 217 | } 218 | 219 | private enum CompileError: Error { 220 | case badParameter(String, culprit: String) 221 | } 222 | 223 | private static let variablesRegex = try! NSRegularExpression(pattern: #":([^\/\?]+)"#, options: []) 224 | 225 | // 226 | 227 | private var cached: CompiledRegex? 228 | 229 | private func compileRegex(_ glob: String) throws -> CompiledRegex { 230 | if let cached = cached, 231 | cached.path == glob 232 | { 233 | return cached 234 | } 235 | 236 | // Extract the variables from the glob. 237 | var variables = Set() 238 | 239 | let nsrange = NSRange(glob.startIndex.. 1 { 243 | if let range = Range(match.range(at: 1), in: glob) { 244 | let variable = String(glob[range]) 245 | 246 | #if DEBUG 247 | // In debug mode perform an extra check whether parameters contain invalid characters or 248 | // whether the parameters starts with something besides a letter. 249 | if let r = variable.range(of: "(^[^a-z]|[^a-z0-9])", options: [.regularExpression, .caseInsensitive]) { 250 | throw CompileError.badParameter(variable, culprit: String(variable[r])) 251 | } 252 | #endif 253 | 254 | variables.insert(variable) 255 | } 256 | } 257 | 258 | // Create a new regex that will eventually match and extract the parameters from a path. 259 | let endsWithAsterisk = glob.last == "*" 260 | 261 | var pattern = glob 262 | .replacingOccurrences(of: "^[^/]/$", with: "", options: .regularExpression) // Trailing slash. 263 | .replacingOccurrences(of: #"\/?\*"#, with: "", options: .regularExpression) // Trailing asterisk. 264 | 265 | for (index, variable) in variables.enumerated() { 266 | let isAtRoot = index == 0 && glob.starts(with: "/:" + variable) 267 | pattern = pattern.replacingOccurrences( 268 | of: "/:" + variable, 269 | with: (isAtRoot ? "/" : "") + "(?<\(variable)>" + (isAtRoot ? "" : "/?") + "[^/?]+)", 270 | options: .regularExpression) 271 | } 272 | 273 | pattern = "^" + 274 | (pattern.isEmpty ? "" : "(\(pattern))") + 275 | (endsWithAsterisk ? "(/.*)?$" : "$") 276 | 277 | let regex = try NSRegularExpression(pattern: pattern, options: []) 278 | 279 | cached = CompiledRegex(path: glob, matchRegex: regex, parameters: variables) 280 | 281 | return cached! 282 | } 283 | 284 | func match(glob: String, with path: String, relative: String = "/") throws -> RouteInformation? { 285 | let completeGlob = resolvePaths(relative, glob) 286 | let compiled = try compileRegex(completeGlob) 287 | 288 | var nsrange = NSRange(path.startIndex.. 2 | 3 | 4 | Layer 1 5 | 6 | 7 | 8 | 9 | 10 | 11 | --------------------------------------------------------------------------------