├── .github └── workflows │ ├── Building.yml │ └── UnitTesting.yml ├── .gitignore ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── sRouting │ ├── DocsRouting.docc │ │ ├── Articles │ │ │ └── GettingStarted.md │ │ ├── Extensions │ │ │ ├── RootView.md │ │ │ ├── Router.md │ │ │ └── TriggerType.md │ │ ├── Resources │ │ │ ├── Bookie │ │ │ │ ├── SectionOne │ │ │ │ │ ├── bookie_add_srouting.png │ │ │ │ │ ├── bookie_create.png │ │ │ │ │ ├── bookie_enter_product_name.png │ │ │ │ │ ├── bookie_save_place.png │ │ │ │ │ └── bookie_section1_intro.png │ │ │ │ ├── SectionTwo │ │ │ │ │ ├── bookcell.png │ │ │ │ │ ├── bookdetailscreen.jpeg │ │ │ │ │ ├── bookienavigationview.png │ │ │ │ │ ├── homescreen.jpeg │ │ │ │ │ ├── randombubleview.png │ │ │ │ │ ├── ratingview.png │ │ │ │ │ ├── section2icon.png │ │ │ │ │ └── startscreen.jpeg │ │ │ │ ├── bookie_banner.png │ │ │ │ ├── bookie_intro.png │ │ │ │ ├── bookie_logo.png │ │ │ │ └── bookie_meet_banner.png │ │ │ ├── Codes │ │ │ │ ├── AppRoute.swift │ │ │ │ ├── BookCell.swift │ │ │ │ ├── BookDetailScreen.swift │ │ │ │ ├── BookDetailViewModel.swift │ │ │ │ ├── BookModel.swift │ │ │ │ ├── BookieApp.swift │ │ │ │ ├── BookieNavigationView.swift │ │ │ │ ├── EmptyObjectType.swift │ │ │ │ ├── FontModifier.swift │ │ │ │ ├── HomeRoute.swift │ │ │ │ ├── HomeScreen.swift │ │ │ │ ├── HomeViewModel.swift │ │ │ │ ├── MockBookData.swift │ │ │ │ ├── RandomBubbleView.swift │ │ │ │ ├── RatingView.swift │ │ │ │ └── StartScreen.swift │ │ │ └── sRouting │ │ │ │ └── srouting_banner.png │ │ ├── Tutorials │ │ │ ├── Bookie.tutorial │ │ │ └── MeetsRouting.tutorial │ │ └── sRouting.md │ ├── Helpers │ │ ├── AsyncAction.swift │ │ ├── CancelBag.swift │ │ ├── SRAsyncStream.swift │ │ ├── SRRoutingError.swift │ │ └── UnitTestActions.swift │ ├── Models │ │ ├── AnyRoute.swift │ │ ├── CoordinatorRoute.swift │ │ ├── CustomRepresentable.swift │ │ ├── SRContext.swift │ │ ├── SRCoordinatorEmitter.swift │ │ ├── SRNavigationPath.swift │ │ ├── SRRouter.swift │ │ ├── SRSwitcher.swift │ │ ├── SRTransition.swift │ │ ├── SRWindowTransition.swift │ │ └── TimeIdentifier.swift │ ├── PrivacyInfo.xcprivacy │ ├── Prototype │ │ ├── Global.swift │ │ ├── SRRoute.swift │ │ ├── SRRouteCoordinatorType.swift │ │ ├── SRRouteObserverType.swift │ │ └── SRTransitionKind.swift │ ├── ViewModifiers │ │ ├── OnDialogOfRouter.swift │ │ ├── OnDismissAllChange.swift │ │ ├── OnDoubleTapTabItem.swift │ │ ├── OnNavigationStackChange.swift │ │ ├── OnPopoverOfRouter.swift │ │ ├── OnRoutingCoordinator.swift │ │ ├── OnRoutingOfRouter.swift │ │ └── OnTabSelectionChange.swift │ ├── Views │ │ ├── NavigationRootView.swift │ │ ├── SRRootView.swift │ │ └── SRSwitchView.swift │ └── sRouting.swift ├── sRoutingClient │ └── main.swift └── sRoutingMacros │ ├── RouteCoordinatorMacro.swift │ ├── RouteMacro.swift │ ├── RouteObserverMacro.swift │ └── sRoutingPlugin.swift └── Tests └── sRoutingTests ├── Route └── Routes.swift ├── Testcases ├── AsyncActionTests.swift ├── CoordinatorRouteTests.swift ├── NavigationPathTests.swift ├── RouterModifierTests.swift ├── RouterTests.swift ├── SwitcherTests.swift ├── TestContext.swift ├── TestInitializers.swift ├── TestModifiers.swift ├── TestSwitchView.swift └── TypeTests.swift ├── Views └── TestScreen.swift ├── Waiter.swift └── sRoutingMacrosTests ├── CoordinatorMacroTest.swift ├── RouteMacroTest.swift ├── RouteObserverMacroTest.swift └── TestingMacro.swift /.github/workflows/Building.yml: -------------------------------------------------------------------------------- 1 | name: Building 2 | 3 | on: 4 | push: 5 | branches: [ develop, main ] 6 | pull_request: 7 | branches: [ develop ] 8 | 9 | jobs: 10 | Building_The_Package: 11 | runs-on: macos-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: List available Xcode versions 15 | run: ls /Applications | grep Xcode 16 | - name: Select Xcode version 17 | run: sudo xcode-select -s '/Applications/Xcode_16.2.app/Contents/Developer' 18 | - name: Show current version of Xcode 19 | run: xcodebuild -version 20 | - name: Build macOS 21 | run: swift build -v 22 | - name: Build iOS 23 | run: xcodebuild build -scheme 'sRouting' -destination 'platform=iOS Simulator,OS=18.1,name=iPhone 16' 24 | -------------------------------------------------------------------------------- /.github/workflows/UnitTesting.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Unit Testing 3 | 4 | on: 5 | 6 | push: 7 | branches: [ develop, main ] 8 | pull_request: 9 | branches: [ develop ] 10 | jobs: 11 | Run_Unit_Tests: 12 | runs-on: macOS-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Select Xcode version 16 | run: sudo xcode-select -s '/Applications/Xcode_16.2.app/Contents/Developer' 17 | - name: Show current version of Xcode 18 | run: xcodebuild -version 19 | - name: Run macOS tests 20 | run: swift test --enable-code-coverage 21 | - name: Run iOS tests 22 | run: xcodebuild test -destination 'platform=iOS Simulator,OS=18.1,name=iPhone 16' -scheme 'sRouting-Package' -enableCodeCoverage YES 23 | - name: Run iPadOS tests 24 | run: xcodebuild test -destination 'platform=iOS Simulator,OS=18.1,name=iPad Pro 11-inch (M4)' -scheme 'sRouting-Package' -enableCodeCoverage YES 25 | - name: Prepare Code Coverage 26 | run: xcrun llvm-cov export -format="lcov" .build/debug/sRoutingPackageTests.xctest/Contents/MacOS/sRoutingPackageTests -instr-profile .build/debug/codecov/default.profdata > info.lcov 27 | - name: Upload coverage to Codecov 28 | uses: codecov/codecov-action@v4 29 | with: 30 | token: ${{ secrets.CODECOV_TOKEN }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/ 8 | .docc-build 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Thang Kieu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "91101e7aebea1b2a793c5a2b3c02491ba4d79dfdb437ee58b2efd5de2e303313", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-syntax", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/swiftlang/swift-syntax.git", 8 | "state" : { 9 | "revision" : "0687f71944021d616d34d922343dcef086855920", 10 | "version" : "600.0.1" 11 | } 12 | }, 13 | { 14 | "identity" : "viewinspector", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/nalexn/ViewInspector", 17 | "state" : { 18 | "revision" : "788e7879d38a839c4e348ab0762dcc0364e646a2", 19 | "version" : "0.10.1" 20 | } 21 | } 22 | ], 23 | "version" : 3 24 | } 25 | -------------------------------------------------------------------------------- /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 | import CompilerPluginSupport 6 | 7 | let package = Package( 8 | name: "sRouting", 9 | 10 | platforms: [ 11 | .iOS(.v17), 12 | .macOS(.v14), 13 | .visionOS(.v1) 14 | ], 15 | 16 | products: [ 17 | // Products define the executables and libraries a package produces, and make them visible to other packages. 18 | .library( 19 | name: "sRouting", 20 | targets: ["sRouting"]), 21 | .executable( 22 | name: "sRoutingClient", 23 | targets: ["sRoutingClient"] 24 | ), 25 | ], 26 | dependencies: [ 27 | // Dependencies declare other packages that this package depends on. 28 | .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "600.0.1"), 29 | .package(url: "https://github.com/nalexn/ViewInspector", from: .init(0, 10, 1)) 30 | ], 31 | targets: [ 32 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 33 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 34 | .macro( 35 | name: "sRoutingMacros", 36 | dependencies: [ 37 | .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), 38 | .product(name: "SwiftCompilerPlugin", package: "swift-syntax") 39 | ] 40 | ), 41 | 42 | // Library that exposes a macro as part of its API, which is used in client programs. 43 | .target(name: "sRouting", 44 | dependencies: ["sRoutingMacros"], 45 | resources: [.copy("PrivacyInfo.xcprivacy")]), 46 | 47 | // A client of the library, which is able to use the macro in its own code. 48 | .executableTarget(name: "sRoutingClient", dependencies: ["sRouting"]), 49 | 50 | .testTarget( 51 | name: "sRoutingTests", 52 | dependencies: ["sRouting","ViewInspector","sRoutingMacros", 53 | .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),]), 54 | ] 55 | ) 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # sRouting 3 | 4 | [![Building Actions Status](https://github.com/ThangKM/sRouting/workflows/Building/badge.svg)](https://github.com/ThangKM/sRouting/actions) 5 | ![Platforms](https://img.shields.io/badge/Platforms-macOS_iOS-blue?style=flat-square) 6 | [![codecov.io](https://codecov.io/gh/ThangKM/sRouting/branch/main/graphs/badge.svg?branch=main)](https://codecov.io/github/ThangKM/sRouting?branch=main) 7 | [![Swift Package Manager compatible](https://img.shields.io/badge/Swift%20Package%20Manager-compatible-brightgreen.svg)](https://github.com/apple/swift-package-manager) 8 | 9 | The navigation framework for SwiftUI. 10 | 11 | ## Overview 12 | 13 | sRouting provides a native navigation mechanism that simplifies handling navigation between screens. 14 | 15 | ![A sRouting banner.](https://github.com/ThangKM/sRouting/blob/main/Sources/sRouting/DocsRouting.docc/Resources/sRouting/srouting_banner.png) 16 | 17 | ## Requirements 18 | 19 | - iOS 17 or above 20 | - Xcode 16 or above 21 | 22 | ## 📚 Documentation 23 | Explore DocC to find rich tutorials and get started with sRouting. 24 | See [this WWDC presentation](https://developer.apple.com/videos/play/wwdc2021/10166/) about more information. 25 | 26 | From xCode select Product -> Build Doccumentation -> Explore. 27 | 28 | ## 🛠 Installation 29 | 30 | Add `sRouting` as a dependency to the project. 31 | See [this WWDC presentation](https://developer.apple.com/videos/play/wwdc2019/408/) for more information on how to adopt Swift packages in your app. 32 | Specify `https://github.com/ThangKM/sRouting.git` as the `sRouting` package link. 33 | 34 | ![](https://github.com/ThangKM/sRouting/blob/main/Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionOne/bookie_add_srouting.png) 35 | 36 | ## 🌀 Example: 37 | Explore the example brach: [Example](https://github.com/ThangKM/sRouting/tree/example) 38 | 39 | ## 🏃‍♂️ Getting Started with sRouting 40 | 41 | Set up the `SRRootView` and interact with macros. 42 | 43 | ## Overview 44 | 45 | Create your root view using `SRRootView`. 46 | Declare your `SRRoute`. 47 | Learn about macros and ViewModifers. 48 | 49 | ### Create a Route 50 | 51 | To create a route, we must adhere to the `SRRoute` Protocol. 52 | 53 | ```swift 54 | @sRoute 55 | enum HomeRoute { 56 | 57 | typealias AlertRoute = YourAlertRoute // Optional declarations 58 | typealias ConfirmationDialogRoute = YourConfirmationDialogRoute // Optional declarations 59 | typealias PopoverRoute = YourPopoverRoute // Optional declarations 60 | 61 | case pastry 62 | case cake 63 | 64 | @sSubRoute 65 | case detail(DetailRoute) 66 | 67 | @ViewBuilder @MainActor 68 | var screen: some View { 69 | switch self { 70 | case .pastry: PastryScreen() 71 | case .cake: CakeScreen() 72 | case .detail(let route): route.screen 73 | } 74 | } 75 | } 76 | ``` 77 | 78 | ### Setting Up Your Root View 79 | 80 | Start by configuring a coordinator and SRRootView for your application. 81 | 82 | Declaring a Coordinator: 83 | 84 | ```swift 85 | @sRouteCoordinator(tabs: ["home", "setting"], stacks: "home", "setting") 86 | @Observable 87 | final class AppCoordinator { } 88 | ``` 89 | 90 | Declaring the View for Navigation Destinations: 91 | 92 | ```swift 93 | @sRouteObserver(HomeRoute.self, SettingRoute.self) 94 | struct RouteObserver { } 95 | ``` 96 | 97 | Configuring Your App: 98 | 99 | ```swift 100 | @sRoute 101 | enum AppRoute { 102 | 103 | case startScreen 104 | case mainTabbar 105 | 106 | @ViewBuilder @MainActor 107 | var screen: some View { 108 | switch self { 109 | case .startScreen: 110 | StartScreen() 111 | .transition(.scale(scale: 0.1).combined(with: .opacity)) 112 | case .mainTabbar: 113 | MainScreen() 114 | .transition(.opacity) 115 | } 116 | } 117 | } 118 | 119 | struct MainScreen: View { 120 | @Environment(AppCoordinator.self) var coordinator 121 | var body: some View { 122 | @Bindable var emitter = coordinator.emitter 123 | TabView(selection: $emitter.tabSelection) { 124 | NavigationStack(path: coordinator.homePath) { 125 | HomeScreen() 126 | .routeObserver(RouteObserver.self) 127 | } 128 | .tag(AppCoordinator.SRTabItem.homeItem.rawValue) 129 | 130 | NavigationStack(path: coordinator.settingPath) { 131 | SettingScreen() 132 | .routeObserver(RouteObserver.self) 133 | } 134 | .tag(AppCoordinator.SRTabItem.settingItem.rawValue) 135 | } 136 | } 137 | } 138 | 139 | @main 140 | struct BookieApp: App { 141 | 142 | @State private var appCoordinator = AppCoordinator() 143 | @State private var context = SRContext() 144 | 145 | var body: some Scene { 146 | WindowGroup { 147 | SRRootView(context: context, coordinator: appCoordinator) { 148 | SRSwitchView(startingWith: AppRoute.startScreen) 149 | } 150 | .environment(appCoordinator) 151 | } 152 | } 153 | } 154 | ``` 155 | ### Creating a Screen and Working with the Router 156 | 157 | Use the `onRouting(of:)` view modifier to observe route transitions. 158 | 159 | ```swift 160 | @sRoute 161 | enum HomeRoute { 162 | case detail 163 | ... 164 | } 165 | 166 | struct HomeScreen: View { 167 | 168 | @State private var homeRouter = SRRouter(HomeRoute.self) 169 | 170 | var body: some View { 171 | VStack { 172 | ... 173 | } 174 | .onRouting(of: homeRouter) 175 | } 176 | ``` 177 | 178 | DeepLink: 179 | ```swift 180 | ... 181 | .onOpenURL { url in 182 | Task { 183 | ... 184 | await context.routing(.resetAll, 185 | .select(tabItem: .home), 186 | .push(route: HomeRoute.cake)) 187 | } 188 | } 189 | ``` 190 | 191 | ### Using Multiple Coordinators 192 | 193 | To observe and open a new coordinator from the router, use `onRoutingCoordinator(_:context:)`. 194 | 195 | Declaring Coordinator Routes: 196 | 197 | ```swift 198 | @sRouteCoordinator(stacks: "newStack") 199 | final class AnyCoordinator { } 200 | 201 | struct AnyCoordinatorView: View where Content: View { 202 | 203 | @Environment(SRContext.self) var context 204 | @State private var coordinator: AnyCoordinator = .init() 205 | let content: () -> Content 206 | 207 | var body: some View { 208 | SRRootView(context: context, coordinator: coordinator) { 209 | NavigationStack(path: coordinator.newStackPath) { 210 | content() 211 | .routeObserver(YourRouteObserver.self) 212 | } 213 | } 214 | } 215 | } 216 | 217 | @sRoute 218 | enum CoordinatorsRoute { 219 | 220 | case notifications 221 | case settings 222 | 223 | @MainActor @ViewBuilder 224 | var screen: some View { 225 | switch self { 226 | case .notifications: 227 | AnyCoordinatorView { NotificationsScreen() } 228 | case .settings: 229 | AnyCoordinatorView { SettingsScreen() } 230 | } 231 | } 232 | } 233 | ``` 234 | 235 | Handling Coordinator Routing in the Root View: 236 | `Coordinators should be triggered from the root view using .onRoutingCoordinator.` 237 | 238 | ```swift 239 | @main 240 | struct BookieApp: App { 241 | 242 | @State private var appCoordinator = AppCoordinator() 243 | @State private var context = SRContext() 244 | 245 | var body: some Scene { 246 | WindowGroup { 247 | SRRootView(context: context, coordinator: appCoordinator) { 248 | SRSwitchView(startingWith: AppRoute.startScreen) 249 | } 250 | .environment(appCoordinator) 251 | .onRoutingCoordinator(CoordinatorsRoute.self, context: context) 252 | } 253 | } 254 | } 255 | ``` 256 | ### Routing Actions 257 | 258 | Present a new coordinator: 259 | ```swift 260 | router.openCoordinator(route: CoordinatorsRoute.notifications, with: .present) 261 | ``` 262 | Change Root: 263 | ```swift 264 | router.switchTo(route: AppRoute.mainTabbar) 265 | ``` 266 | Push: 267 | ```swift 268 | router.trigger(to: .cake, with: .push) 269 | ``` 270 | NavigationLink: 271 | ```swift 272 | NavigationLink(route: HomeRoute.pastry) { 273 | ... 274 | } 275 | ``` 276 | Present full screen: 277 | ```swift 278 | router.trigger(to: .cake, with: .present) 279 | ``` 280 | Sheet: 281 | ```swift 282 | router.trigger(to: .cake, with: .sheet) 283 | ``` 284 | To show an alert we use the `show(alert:)` function. 285 | ```swift 286 | router.show(alert: YourAlertRoute.alert) 287 | ``` 288 | To dismiss a screen we use the `dismiss()` function. 289 | 290 | ```swift 291 | router.dismiss() 292 | ``` 293 | 294 | To dismiss to root view we use the `dismissAll()` function. 295 | Required the root view is a `SRRootView` 296 | 297 | ```swift 298 | router.dismissAll() 299 | ``` 300 | To seclect the Tabbar item we use the `selectTabbar(at:)` function. 301 | 302 | ```swift 303 | router.selectTabbar(at: AppCoordinator.SRTabItem.home) 304 | ``` 305 | 306 | Pop Actions in NavigationStack 307 | 308 | ```swift 309 | router.pop() 310 | 311 | router.popToRoot() 312 | 313 | router.pop(to: HomeRoute.Paths.cake) 314 | ``` 315 | 316 | ## 📃 License 317 | 318 | `sRouting` is released under an MIT license. See [License.md](https://github.com/ThangKM/sRouting/blob/main/LICENSE) for more information. 319 | -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Articles/GettingStarted.md: -------------------------------------------------------------------------------- 1 | ## 🏃‍♂️ Getting Started with sRouting 2 | 3 | Set up the `SRRootView` and interact with macros. 4 | 5 | ## Overview 6 | 7 | Create your root view using `SRRootView`. 8 | Declare your `SRRoute`. 9 | Learn about macros and ViewModifers. 10 | 11 | ### Create a Route 12 | 13 | To create a route, we must adhere to the `SRRoute` Protocol. 14 | 15 | ```swift 16 | @sRoute 17 | enum HomeRoute { 18 | 19 | typealias AlertRoute = YourAlertRoute // Optional declarations 20 | typealias ConfirmationDialogRoute = YourConfirmationDialogRoute // Optional declarations 21 | typealias PopoverRoute = YourPopoverRoute // Optional declarations 22 | 23 | case pastry 24 | case cake 25 | 26 | @sSubRoute 27 | case detail(DetailRoute) 28 | 29 | @ViewBuilder @MainActor 30 | var screen: some View { 31 | switch self { 32 | case .pastry: PastryScreen() 33 | case .cake: CakeScreen() 34 | case .detail(let route): route.screen 35 | } 36 | } 37 | } 38 | ``` 39 | 40 | ### Setting Up Your Root View 41 | 42 | Start by configuring a coordinator and SRRootView for your application. 43 | 44 | Declaring a Coordinator: 45 | 46 | ```swift 47 | @sRouteCoordinator(tabs: ["home", "setting"], stacks: "home", "setting") 48 | @Observable 49 | final class AppCoordinator { } 50 | ``` 51 | 52 | Declaring the View for Navigation Destinations: 53 | 54 | ```swift 55 | @sRouteObserver(HomeRoute.self, SettingRoute.self) 56 | struct RouteObserver { } 57 | ``` 58 | 59 | Configuring Your App: 60 | 61 | ```swift 62 | @sRoute 63 | enum AppRoute { 64 | 65 | case startScreen 66 | case mainTabbar 67 | 68 | @ViewBuilder @MainActor 69 | var screen: some View { 70 | switch self { 71 | case .startScreen: 72 | StartScreen() 73 | .transition(.scale(scale: 0.1).combined(with: .opacity)) 74 | case .mainTabbar: 75 | MainScreen() 76 | .transition(.opacity) 77 | } 78 | } 79 | } 80 | 81 | struct MainScreen: View { 82 | @Environment(AppCoordinator.self) var coordinator 83 | var body: some View { 84 | @Bindable var emitter = coordinator.emitter 85 | TabView(selection: $emitter.tabSelection) { 86 | NavigationStack(path: coordinator.homePath) { 87 | HomeScreen() 88 | .routeObserver(RouteObserver.self) 89 | } 90 | .tag(AppCoordinator.SRTabItem.homeItem.rawValue) 91 | 92 | NavigationStack(path: coordinator.settingPath) { 93 | SettingScreen() 94 | .routeObserver(RouteObserver.self) 95 | } 96 | .tag(AppCoordinator.SRTabItem.settingItem.rawValue) 97 | } 98 | } 99 | } 100 | 101 | @main 102 | struct BookieApp: App { 103 | 104 | @State private var appCoordinator = AppCoordinator() 105 | @State private var context = SRContext() 106 | 107 | var body: some Scene { 108 | WindowGroup { 109 | SRRootView(context: context, coordinator: appCoordinator) { 110 | SRSwitchView(startingWith: AppRoute.startScreen) 111 | } 112 | .environment(appCoordinator) 113 | } 114 | } 115 | } 116 | ``` 117 | ### Creating a Screen and Working with the Router 118 | 119 | Use the `onRouting(of:)` view modifier to observe route transitions. 120 | 121 | ```swift 122 | @sRoute 123 | enum HomeRoute { 124 | case detail 125 | ... 126 | } 127 | 128 | struct HomeScreen: View { 129 | 130 | @State private var homeRouter = SRRouter(HomeRoute.self) 131 | 132 | var body: some View { 133 | VStack { 134 | ... 135 | } 136 | .onRouting(of: homeRouter) 137 | } 138 | ``` 139 | 140 | DeepLink: 141 | ```swift 142 | ... 143 | .onOpenURL { url in 144 | Task { 145 | ... 146 | await context.routing(.resetAll, 147 | .select(tabItem: .home), 148 | .push(route: HomeRoute.cake)) 149 | } 150 | } 151 | ``` 152 | 153 | ### Using Multiple Coordinators 154 | 155 | To observe and open a new coordinator from the router, use `onRoutingCoordinator(_:context:)`. 156 | 157 | Declaring Coordinator Routes: 158 | 159 | ```swift 160 | @sRouteCoordinator(stacks: "newStack") 161 | final class AnyCoordinator { } 162 | 163 | struct AnyCoordinatorView: View where Content: View { 164 | 165 | @Environment(SRContext.self) var context 166 | @State private var coordinator: AnyCoordinator = .init() 167 | let content: () -> Content 168 | 169 | var body: some View { 170 | SRRootView(context: context, coordinator: coordinator) { 171 | NavigationStack(path: coordinator.newStackPath) { 172 | content() 173 | .routeObserver(YourRouteObserver.self) 174 | } 175 | } 176 | } 177 | } 178 | 179 | @sRoute 180 | enum CoordinatorsRoute { 181 | 182 | case notifications 183 | case settings 184 | 185 | @MainActor @ViewBuilder 186 | var screen: some View { 187 | switch self { 188 | case .notifications: 189 | AnyCoordinatorView { NotificationsScreen() } 190 | case .settings: 191 | AnyCoordinatorView { SettingsScreen() } 192 | } 193 | } 194 | } 195 | ``` 196 | 197 | Handling Coordinator Routing in the Root View: 198 | `Coordinators should be triggered from the root view using .onRoutingCoordinator.` 199 | 200 | ```swift 201 | @main 202 | struct BookieApp: App { 203 | 204 | @State private var appCoordinator = AppCoordinator() 205 | @State private var context = SRContext() 206 | 207 | var body: some Scene { 208 | WindowGroup { 209 | SRRootView(context: context, coordinator: appCoordinator) { 210 | SRSwitchView(startingWith: AppRoute.startScreen) 211 | } 212 | .environment(appCoordinator) 213 | .onRoutingCoordinator(CoordinatorsRoute.self, context: context) 214 | } 215 | } 216 | } 217 | ``` 218 | ### Routing Actions 219 | 220 | Present a new coordinator: 221 | ```swift 222 | router.openCoordinator(route: CoordinatorsRoute.notifications, with: .present) 223 | ``` 224 | Change Root: 225 | ```swift 226 | router.switchTo(route: AppRoute.mainTabbar) 227 | ``` 228 | Push: 229 | ```swift 230 | router.trigger(to: .cake, with: .push) 231 | ``` 232 | NavigationLink: 233 | ```swift 234 | NavigationLink(route: HomeRoute.pastry) { 235 | ... 236 | } 237 | ``` 238 | Present full screen: 239 | ```swift 240 | router.trigger(to: .cake, with: .present) 241 | ``` 242 | Sheet: 243 | ```swift 244 | router.trigger(to: .cake, with: .sheet) 245 | ``` 246 | To show an alert we use the `show(alert:)` function. 247 | ```swift 248 | router.show(alert: YourAlertRoute.alert) 249 | ``` 250 | To dismiss a screen we use the `dismiss()` function. 251 | 252 | ```swift 253 | router.dismiss() 254 | ``` 255 | 256 | To dismiss to root view we use the `dismissAll()` function. 257 | Required the root view is a `SRRootView` 258 | 259 | ```swift 260 | router.dismissAll() 261 | ``` 262 | To seclect the Tabbar item we use the `selectTabbar(at:)` function. 263 | 264 | ```swift 265 | router.selectTabbar(at: AppCoordinator.SRTabItem.home) 266 | ``` 267 | 268 | Pop Actions in NavigationStack 269 | 270 | ```swift 271 | router.pop() 272 | 273 | router.popToRoot() 274 | 275 | router.pop(to: HomeRoute.Paths.cake) 276 | ``` 277 | 278 | ## 📃 License 279 | 280 | `sRouting` is released under an MIT license. See [License.md](https://github.com/ThangKM/sRouting/blob/main/LICENSE) for more information. 281 | -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Extensions/RootView.md: -------------------------------------------------------------------------------- 1 | # ``sRouting/SRRootView`` 2 | 3 | ## Topics 4 | 5 | -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Extensions/Router.md: -------------------------------------------------------------------------------- 1 | # ``sRouting/SRRouter`` 2 | 3 | ## Topics 4 | 5 | ### Navigation Activities 6 | 7 | - ``selectTabbar(at:)`` 8 | - ``trigger(to:with:)`` 9 | - ``show(alert:)`` 10 | - ``show(error:and:)`` 11 | - ``dismiss()`` 12 | - ``dismissAll()`` 13 | - ``show(popover:)`` 14 | - ``show(dialog:)`` 15 | - ``switchTo(route:)`` 16 | -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Extensions/TriggerType.md: -------------------------------------------------------------------------------- 1 | # ``sRouting/SRTriggerType`` 2 | 3 | ## Topics 4 | 5 | ### Power Categories 6 | 7 | - ``push`` 8 | - ``present`` 9 | - ``sheet`` 10 | 11 | ### Comparing Powers 12 | 13 | - ``!=(_:_:)`` 14 | -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionOne/bookie_add_srouting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThangKM/sRouting/eb839b80aa4fc791d02af379e9c073e94df34ea8/Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionOne/bookie_add_srouting.png -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionOne/bookie_create.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThangKM/sRouting/eb839b80aa4fc791d02af379e9c073e94df34ea8/Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionOne/bookie_create.png -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionOne/bookie_enter_product_name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThangKM/sRouting/eb839b80aa4fc791d02af379e9c073e94df34ea8/Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionOne/bookie_enter_product_name.png -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionOne/bookie_save_place.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThangKM/sRouting/eb839b80aa4fc791d02af379e9c073e94df34ea8/Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionOne/bookie_save_place.png -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionOne/bookie_section1_intro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThangKM/sRouting/eb839b80aa4fc791d02af379e9c073e94df34ea8/Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionOne/bookie_section1_intro.png -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionTwo/bookcell.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThangKM/sRouting/eb839b80aa4fc791d02af379e9c073e94df34ea8/Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionTwo/bookcell.png -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionTwo/bookdetailscreen.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThangKM/sRouting/eb839b80aa4fc791d02af379e9c073e94df34ea8/Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionTwo/bookdetailscreen.jpeg -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionTwo/bookienavigationview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThangKM/sRouting/eb839b80aa4fc791d02af379e9c073e94df34ea8/Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionTwo/bookienavigationview.png -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionTwo/homescreen.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThangKM/sRouting/eb839b80aa4fc791d02af379e9c073e94df34ea8/Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionTwo/homescreen.jpeg -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionTwo/randombubleview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThangKM/sRouting/eb839b80aa4fc791d02af379e9c073e94df34ea8/Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionTwo/randombubleview.png -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionTwo/ratingview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThangKM/sRouting/eb839b80aa4fc791d02af379e9c073e94df34ea8/Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionTwo/ratingview.png -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionTwo/section2icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThangKM/sRouting/eb839b80aa4fc791d02af379e9c073e94df34ea8/Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionTwo/section2icon.png -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionTwo/startscreen.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThangKM/sRouting/eb839b80aa4fc791d02af379e9c073e94df34ea8/Sources/sRouting/DocsRouting.docc/Resources/Bookie/SectionTwo/startscreen.jpeg -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Bookie/bookie_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThangKM/sRouting/eb839b80aa4fc791d02af379e9c073e94df34ea8/Sources/sRouting/DocsRouting.docc/Resources/Bookie/bookie_banner.png -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Bookie/bookie_intro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThangKM/sRouting/eb839b80aa4fc791d02af379e9c073e94df34ea8/Sources/sRouting/DocsRouting.docc/Resources/Bookie/bookie_intro.png -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Bookie/bookie_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThangKM/sRouting/eb839b80aa4fc791d02af379e9c073e94df34ea8/Sources/sRouting/DocsRouting.docc/Resources/Bookie/bookie_logo.png -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Bookie/bookie_meet_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThangKM/sRouting/eb839b80aa4fc791d02af379e9c073e94df34ea8/Sources/sRouting/DocsRouting.docc/Resources/Bookie/bookie_meet_banner.png -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Codes/AppRoute.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppRoute.swift 3 | // Bookie 4 | // 5 | // Created by ThangKieu on 7/6/21. 6 | // 7 | 8 | import SwiftUI 9 | import sRouting 10 | 11 | 12 | @sRoute 13 | enum AppRoute { 14 | 15 | case startScreen 16 | case homeScreen 17 | 18 | @ViewBuilder @MainActor 19 | var screen: some View { 20 | switch self { 21 | case .startScreen: 22 | StartScreen() 23 | .transition(.scale(scale: 0.1).combined(with: .opacity)) 24 | case .homeScreen: 25 | MainScreen() 26 | .transition(.opacity) 27 | } 28 | } 29 | } 30 | 31 | struct MainScreen: View { 32 | @Environment(AppCoordinator.self) var coordinator 33 | var body: some View { 34 | NavigationStack(path: coordinator.rootStackPath) { 35 | HomeScreen() 36 | .routeObserver(RouteObserver.self) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Codes/BookCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookCell.swift 3 | // Bookie 4 | // 5 | // Created by ThangKieu on 7/6/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BookCell: View { 11 | 12 | let book: BookModel 13 | 14 | var body: some View { 15 | HStack { 16 | Image(book.imageName.isEmpty ? "image.default" : book.imageName) 17 | .resizable() 18 | .aspectRatio(contentMode: .fit) 19 | .frame(width: 109, alignment: .leading) 20 | .clipped() 21 | 22 | VStack(alignment: .leading) { 23 | Text(book.name) 24 | Text(book.author) 25 | Spacer() 26 | RatingView(rating: .constant(book.rating)) 27 | } 28 | Spacer() 29 | } 30 | .padding() 31 | .frame(height: 147) 32 | .background(Color.white) 33 | .clipShape(RoundedRectangle(cornerRadius: 12)) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Codes/BookDetailScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookDetailScreen.swift 3 | // Bookie 4 | // 5 | // Created by ThangKieu on 7/6/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BookDetailScreen: View { 11 | 12 | @State 13 | private var viewModel: BookDetailViewModel = .init() 14 | 15 | @State 16 | private var router = SRRouter(HomeRoute.self) 17 | 18 | @Environment(MockBookData.self) 19 | private var mockData 20 | 21 | let book: BookModel 22 | 23 | var body: some View { 24 | BookieNavigationView(title: viewModel.book.name, 25 | router: router, 26 | isBackType: true) { 27 | GeometryReader { geo in 28 | ScrollView { 29 | LazyVStack(alignment: .leading) { 30 | HStack(spacing: 10) { 31 | Image(viewModel.book.imageName.isEmpty 32 | ? "image.default" 33 | : viewModel.book.imageName) 34 | .resizable() 35 | .frame(width: 130, height: 203) 36 | .scaledToFit() 37 | .clipShape(RoundedRectangle(cornerRadius: 8)) 38 | 39 | VStack(alignment: .leading, spacing: 12) { 40 | Text(viewModel.book.name) 41 | .abeeFont(size: 20, style: .italic) 42 | Text(viewModel.book.author) 43 | .abeeFont(size: 16, style: .italic) 44 | HStack(alignment:.center ,spacing: 3) { 45 | Text("Rating:") 46 | Text("\(viewModel.book.rating)") 47 | Image(systemName:"star.fill") 48 | } 49 | .abeeFont(size: 12, style: .italic) 50 | } 51 | } 52 | .padding(.horizontal) 53 | 54 | Text(viewModel.book.description) 55 | .abeeFont(size: 14, style: .italic) 56 | .padding() 57 | 58 | Divider() 59 | 60 | VStack(spacing: 8) { 61 | Text("TAP TO ADD RATING") 62 | RatingView(rating: $viewModel.book.rating) 63 | 64 | } 65 | .frame(maxWidth: .infinity) 66 | .abeeFont(size: 20, style: .italic) 67 | .padding() 68 | } 69 | } 70 | .padding(.bottom, geo.safeAreaInsets.bottom + 20) 71 | } 72 | } 73 | .foregroundColor(.accentColor) 74 | .onAppear { 75 | viewModel.updateBook(book) 76 | } 77 | .onDisappear { 78 | mockData.updateBook(book: viewModel.book) 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Codes/BookDetailViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookDetailViewModel.swift 3 | // Bookie 4 | // 5 | // Created by ThangKieu on 7/7/21. 6 | // 7 | 8 | import SwiftUI 9 | import sRouting 10 | 11 | @Observable 12 | final class BookDetailViewModel { 13 | 14 | var book: BookModel = .empty 15 | 16 | func updateBook(_ book: BookModel, isForceUpdate: Bool = false) { 17 | guard self.book.isEmptyObject || isForceUpdate else { return } 18 | self.book = book 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Codes/BookModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookModel.swift 3 | // Bookie 4 | // 5 | // Created by ThangKieu on 7/7/21. 6 | // 7 | 8 | import Foundation 9 | 10 | struct BookModel: Identifiable, Sendable { 11 | let id: Int 12 | let name: String 13 | let imageName: String 14 | let author :String 15 | let description: String 16 | var rating: Int 17 | } 18 | 19 | extension BookModel: Equatable { 20 | static func == (lhs: BookModel, rhs: BookModel) -> Bool { 21 | return lhs.id == rhs.id && 22 | lhs.name == rhs.name && 23 | lhs.imageName == rhs.imageName && 24 | lhs.author == rhs.author && 25 | lhs.description == rhs.description && 26 | lhs.rating == rhs.rating 27 | } 28 | } 29 | 30 | extension BookModel: EmptyObjectType { 31 | 32 | static var empty: BookModel { 33 | .init(id: -999, 34 | name: "", 35 | imageName: "", 36 | author: "", 37 | description: "", 38 | rating: 0) 39 | } 40 | 41 | var isEmptyObject: Bool { id == -999 } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Codes/BookieApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookieApp.swift 3 | // Bookie 4 | // 5 | // Created by ThangKieu on 7/1/21. 6 | // 7 | 8 | import SwiftUI 9 | import sRouting 10 | 11 | 12 | @sRouteCoordinator(stacks: "rootStack") @Observable 13 | final class AppCoordinator { } 14 | 15 | @sRouteObserver(HomeRoute.self) 16 | struct RouteObserver { } 17 | 18 | @main 19 | struct BookieApp: App { 20 | 21 | @State private var appCoordinator = AppCoordinator() 22 | @State private var context = SRContext() 23 | 24 | var body: some Scene { 25 | WindowGroup { 26 | SRRootView(context: context, coordinator: appCoordinator) { 27 | SRSwitchView(startingWith: AppRoute.startScreen) 28 | } 29 | .environment(appCoordinator) 30 | .modelContainer(DatabaseProvider.shared.container) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Codes/BookieNavigationView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookieNavigationView.swift 3 | // Bookie 4 | // 5 | // Created by ThangKieu on 7/6/21. 6 | // 7 | 8 | import SwiftUI 9 | import sRouting 10 | 11 | struct BookieNavigationView: View where Content: View, Route: SRRoute { 12 | 13 | @Environment(\.dismiss) 14 | private var dismissAction 15 | 16 | let title: String 17 | let router: SRRouter 18 | let isBackType: Bool 19 | 20 | @ViewBuilder 21 | let content: Content 22 | 23 | var body: some View { 24 | ZStack { 25 | GeometryReader { geo in 26 | LinearGradient(colors: [Color("purple.F66EB4"), Color("orgrian.FEB665")], startPoint: .leading, endPoint: .trailing) 27 | .frame(height: 152) 28 | .clipShape(Ellipse().path(in: .init(x:-((787 - geo.size.width)/2), y: -210/2, width: 787, height: 239))) 29 | } 30 | .clipped() 31 | .edgesIgnoringSafeArea(.top) 32 | 33 | VStack { 34 | Text(title) 35 | .frame(maxWidth: .infinity) 36 | .foregroundColor(Color.white) 37 | .abeeFont(size: 19, style: .italic) 38 | .frame(height: 44) 39 | .overlay( 40 | Image("ic.navi.back") 41 | .frame(width: 24) 42 | .opacity( isBackType ? 1 : 0) 43 | .onTapGesture { 44 | router.dismiss() 45 | }, 46 | alignment: .leading) 47 | .fixedSize(horizontal: false, vertical: true) 48 | .padding(.horizontal) 49 | 50 | content() 51 | Spacer() 52 | } 53 | } 54 | .onRouting(of: router) 55 | .background(Color("backgournd.EEECFF")) 56 | .edgesIgnoringSafeArea(.bottom) 57 | .navigationBarHidden(true) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Codes/EmptyObjectType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmptyObjectType.swift 3 | // Bookie 4 | // 5 | // Created by ThangKieu on 7/7/21. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol EmptyObjectType { 11 | 12 | static var empty: Self { get } 13 | 14 | var isEmptyObject: Bool { get } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Codes/FontModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FontModifier.swift 3 | // Bookie 4 | // 5 | // Created by ThangKieu on 7/6/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FontModifier: ViewModifier { 11 | 12 | enum Style { 13 | case regular 14 | case italic 15 | } 16 | 17 | let size: CGFloat 18 | let style: Style 19 | 20 | func body(content: Content) -> some View { 21 | content.font(.custom(_abbeezeeName(ofStyle: style), size: size)) 22 | } 23 | 24 | private func _abbeezeeName(ofStyle style: Style) -> String { 25 | switch style { 26 | case .regular: 27 | "ABeeZee-Regular" 28 | case .italic: 29 | "ABeeZee-italic" 30 | } 31 | } 32 | } 33 | 34 | extension View { 35 | func abeeFont(size: CGFloat, style: FontModifier.Style) -> some View { 36 | self.modifier(FontModifier(size: size, style: style)) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Codes/HomeRoute.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeRoute.swift 3 | // Bookie 4 | // 5 | // Created by ThangKieu on 7/6/21. 6 | // 7 | 8 | import SwiftUI 9 | import sRouting 10 | 11 | enum HomeRoute: SRRoute { 12 | 13 | case bookDetailScreen(book: BookModel) 14 | 15 | var path: String { "detailScreen" } 16 | 17 | var screen: some View { 18 | switch self { 19 | case .bookDetailScreen(let book): BookDetailScreen(book: book) 20 | } 21 | } 22 | } 23 | 24 | -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Codes/HomeScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeScreen.swift 3 | // Bookie 4 | // 5 | // Created by ThangKieu on 7/6/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct HomeScreen: View { 11 | 12 | @State 13 | private var viewModel: HomeViewModel = .init() 14 | 15 | @State 16 | private var router = SRRouter(HomeRoute.self) 17 | 18 | @Environment(MockBookData.self) private var mockData 19 | 20 | var body: some View { 21 | BookieNavigationView(title: "My Book List", 22 | router: viewModel, 23 | isBackType: false) { 24 | VStack { 25 | Group { 26 | HStack { 27 | Image(systemName: "magnifyingglass") 28 | .opacity(0.4) 29 | TextField("Search books", text: $viewModel.textInSearch) 30 | .keyboardType(.webSearch) 31 | .abeeFont(size: 14, style: .italic) 32 | Spacer() 33 | } 34 | .padding(.horizontal) 35 | } 36 | .frame(height: 48) 37 | .frame(maxWidth: .infinity) 38 | .background(Color.white) 39 | .clipShape(RoundedRectangle(cornerRadius: 8)) 40 | .padding(.horizontal) 41 | 42 | Text("BOOKS REVIEWED BY YOU") 43 | .abeeFont(size: 12, style: .italic) 44 | .padding() 45 | .frame(maxWidth: .infinity, alignment: .leading) 46 | 47 | List(viewModel.books, id: \.id) { book in 48 | BookCell(book: book) 49 | .overlay { 50 | NavigationLink(route: HomeRoute.bookDetailScreen(book: book)) { 51 | EmptyView() 52 | }.opacity(0) 53 | } 54 | 55 | } 56 | .listRowSpacing(15) 57 | .scrollContentBackground(.hidden) 58 | .contentMargins(.all, 59 | EdgeInsets(top: .zero, leading: 10, bottom: 20, trailing: 15), 60 | for: .scrollContent) 61 | } 62 | } 63 | .refreshable { 64 | viewModel.updateAllBooks(books: mockData.books) 65 | } 66 | .onAppear { 67 | viewModel.updateAllBooks(books: mockData.books) 68 | } 69 | .onChange(of: mockData.books) { _, newValue in 70 | viewModel.updateAllBooks(books: newValue, isForceUpdate: true) 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Codes/HomeViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeViewModel.swift 3 | // Bookie 4 | // 5 | // Created by ThangKieu on 7/6/21. 6 | // 7 | 8 | import Foundation 9 | import sRouting 10 | import Combine 11 | 12 | @Observable 13 | final class HomeViewModel { 14 | 15 | var textInSearch: String = "" { 16 | didSet { findBooks(withText: textInSearch) } 17 | } 18 | 19 | private(set) var books: [BookModel] = [] 20 | 21 | private var allBooks: [BookModel] = [] { 22 | didSet { findBooks(withText: textInSearch) } 23 | } 24 | 25 | func updateAllBooks(books: [BookModel], 26 | isForceUpdate isForce: Bool = false) { 27 | guard allBooks.isEmpty || isForce else { return } 28 | allBooks = books 29 | } 30 | 31 | @MainActor 32 | func pushDetail(of book: BookModel) { 33 | trigger(to: .bookDetailScreen(book: book), with: .allCases.randomElement() ?? .push) 34 | } 35 | } 36 | 37 | extension HomeViewModel { 38 | 39 | private func findBooks(withText text: String) { 40 | guard !text.isEmpty else { books = allBooks; return } 41 | books = allBooks.filter { $0.name.lowercased().contains(text.lowercased()) || $0.author.lowercased().contains(text.lowercased()) } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Codes/MockBookData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockBookData.swift 3 | // Bookie 4 | // 5 | // Created by ThangKieu on 7/7/21. 6 | // 7 | 8 | import Foundation 9 | import Observation 10 | 11 | @MainActor @Observable 12 | final class MockBookData { 13 | 14 | private(set) var books: [BookModel] = [ 15 | .init(id: 1, 16 | name: "The Fountainhead", 17 | imageName: "book_cover_fountainhead", 18 | author: "Ayn Rand", 19 | description: """ 20 | The Fountainhead is a 1943 novel by Russian-American author Ayn Rand, her first major literary success. The novel's protagonist, Howard Roark, is an intransigent young architect, who battles against conventional standards and refuses to compromise with an architectural establishment unwilling to accept innovation. Roark embodies what Rand believed to be the ideal man, and his struggle reflects Rand's belief that individualism is superior to collectivism. 21 | """, 22 | rating: 5), 23 | .init(id: 2, name: "The Godfather", imageName: "book_cover_godfather", author: "Mario Puzo", description: """ 24 | The Godfather is a 1972 American crime film directed by Francis Ford Coppola, who co-wrote the screenplay with Mario Puzo, based on Puzo's best-selling 1969 novel of the same name. The film stars Marlon Brando, Al Pacino, James Caan, Richard Castellano, Robert Duvall, Sterling Hayden, John Marley, Richard Conte, and Diane Keaton. It is the first installment in The Godfather trilogy. The story, spanning from 1945 to 1955, chronicles the Corleone family under patriarch Vito Corleone (Brando), focusing on the transformation of his youngest son, Michael Corleone (Pacino), from reluctant family outsider to ruthless mafia boss. 25 | """, rating: 5), 26 | .init(id: 3, name: "Red Dragon", imageName: "book_cover_red_dragon", author: "Thomas Harris", description: """ 27 | Red Dragon is a novel by American author Thomas Harris, first published in 1981. The plot follows former FBI profiler Will Graham, who comes out of retirement to find and apprehend an enigmatic serial-killer nicknamed "The Tooth Fairy". The novel introduced the character Dr. Hannibal Lecter, a brilliant psychiatrist and cannibalistic serial-killer, whom Graham reluctantly turns to for advice and with whom he has a dark past. The title refers to the figure from William Blake's painting The Great Red Dragon and the Woman Clothed in Sun. 28 | """, 29 | rating: 5), 30 | .init(id: 4, name: "Hannibal", imageName: "book_cover_hannibal", author: "Thomas Harris", description: """ 31 | Hannibal is a novel by American author Thomas Harris, published in 1999. It is the third in his series featuring Dr. Hannibal Lecter and the second to feature FBI Special Agent Clarice Starling. The novel takes place seven years after the events of The Silence of the Lambs and deals with the intended revenge of one of Lecter's victims. It was adapted as a film of the same name in 2001, directed by Ridley Scott. Elements of the novel were incorporated into the second season of the NBC television series Hannibal, while the show's third season adapted the plot of the novel. 32 | """, rating: 4), 33 | .init(id: 5, name: "Hannibal Rising", imageName: "book_cover_hannibal_rising", author: "Thomas Harris", description: """ 34 | Hannibal Rising is a novel by American author Thomas Harris, published in 2006. It is a prequel to his three previous books featuring his most famous character, the cannibalistic serial killer Dr. Hannibal Lecter. The novel was released with an initial printing of at least 1.5 million copies[1] and met with a mixed critical response. Audiobook versions have also been released, with Harris reading the text. The novel was adapted (by Harris himself) into a film of the same name in 2007, directed by Peter Webber. Producer Dino De Laurentis implied around the time of the novel's release that he had coerced Harris into writing it under threat of losing control over the Hannibal Lecter character, accounting for the perceived diminished quality from Harris' previous books. 35 | """, rating: 4) 36 | ] 37 | } 38 | 39 | 40 | extension MockBookData { 41 | func updateBook(book: BookModel) { 42 | guard let old = books.first(where: { $0.id == book.id }), 43 | let index = books.firstIndex(of: old) 44 | else { return } 45 | books[index] = book 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/sRouting/DocsRouting.docc/Resources/Codes/RandomBubbleView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RandomBubbleView.swift 3 | // Bookie 4 | // 5 | // Created by ThangKieu on 7/6/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct RandomBubbleView: View { 11 | 12 | let bubbles: [[Color]] 13 | let minWidth: CGFloat 14 | let maxWidth: CGFloat 15 | 16 | var body: some View { 17 | GeometryReader { geometry in 18 | ForEach(0.. 17 | - 18 | - ``SRRoute`` 19 | 20 | ### Routing 21 | 22 | - ``SRRouterType`` 23 | - ``SRRouteCoordinatorType`` 24 | - ``SRTriggerType`` 25 | - ``SRRouteObserverType`` 26 | - ``SRNavigationPath`` 27 | - ``SRTabbarSelection`` 28 | 29 | ### Viewing 30 | 31 | - ``SRRootView`` 32 | -------------------------------------------------------------------------------- /Sources/sRouting/Helpers/AsyncAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncAction.swift 3 | // sRouting 4 | // 5 | // Created by Thang Kieu on 8/1/25. 6 | // 7 | 8 | import Foundation 9 | 10 | public typealias AsyncActionVoid = AsyncAction 11 | public typealias AsyncActionGet = AsyncAction 12 | public typealias AsyncActionPut = AsyncAction 13 | 14 | public struct AsyncAction: Sendable 15 | where Input: Sendable, Output: Sendable { 16 | 17 | public typealias WorkAction = @Sendable (Input) async throws -> Output 18 | 19 | private let identifier = UUID().uuidString 20 | private let action: WorkAction 21 | 22 | public init (_ action: @escaping WorkAction) { 23 | self.action = action 24 | } 25 | 26 | @discardableResult 27 | public func asyncExecute(_ input: Input) async throws -> Output { 28 | try await action(input) 29 | } 30 | } 31 | 32 | extension AsyncAction where Input == Void { 33 | 34 | @discardableResult 35 | public func asyncExecute() async throws -> Output { 36 | try await action(Void()) 37 | } 38 | } 39 | 40 | extension AsyncAction where Output == Void { 41 | 42 | public func execute(_ input: Input) { 43 | Task { 44 | try await action(input) 45 | } 46 | } 47 | } 48 | 49 | extension AsyncAction where Output == Void, Input == Void { 50 | 51 | public func execute() { 52 | Task { 53 | try await action(Void()) 54 | } 55 | } 56 | } 57 | 58 | extension AsyncAction: Hashable { 59 | 60 | public static func == (lhs: AsyncAction, rhs: AsyncAction) -> Bool { 61 | lhs.identifier == rhs.identifier 62 | } 63 | 64 | public func hash(into hasher: inout Hasher) { 65 | hasher.combine(identifier) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sources/sRouting/Helpers/CancelBag.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CancelBag.swift 3 | // 4 | // 5 | // Created by Thang Kieu on 9/4/24. 6 | // 7 | 8 | import Foundation 9 | 10 | public actor CancelBag { 11 | 12 | private var cancellers: [String:Canceller] 13 | 14 | public init() { 15 | cancellers = .init() 16 | } 17 | 18 | public func cancelAll() { 19 | let runningTasks = cancellers.values.filter({ !$0.isCancelled }) 20 | runningTasks.forEach{ $0.cancel() } 21 | cancellers.removeAll() 22 | } 23 | 24 | public func cancel(forIdentifier identifier: String) { 25 | guard let task = cancellers[identifier] else { return } 26 | task.cancel() 27 | cancellers.removeValue(forKey: identifier) 28 | } 29 | 30 | nonisolated public func cancelAllInTask() { 31 | Task(priority: .high) { 32 | await cancelAll() 33 | } 34 | } 35 | 36 | private func store(_ canceller: Canceller) { 37 | cancel(forIdentifier: canceller.id) 38 | guard !canceller.isCancelled else { return } 39 | cancellers.updateValue(canceller, forKey: canceller.id) 40 | } 41 | 42 | nonisolated fileprivate func append(canceller: Canceller) { 43 | Task(priority: .high) { 44 | await store(canceller) 45 | } 46 | } 47 | } 48 | 49 | private struct Canceller: Identifiable, Sendable { 50 | 51 | let cancel: @Sendable () -> Void 52 | let id: String 53 | var isCancelled: Bool { isCancelledBock() } 54 | 55 | private let isCancelledBock: @Sendable () -> Bool 56 | 57 | init(_ task: Task, identifier: String = UUID().uuidString) { 58 | cancel = { task.cancel() } 59 | isCancelledBock = { task.isCancelled } 60 | id = identifier 61 | } 62 | } 63 | 64 | extension Task { 65 | 66 | public func store(in bag: CancelBag) { 67 | let canceller = Canceller(self) 68 | bag.append(canceller: canceller) 69 | } 70 | 71 | public func store(in bag: CancelBag, withIdentifier identifier: String) { 72 | let canceller = Canceller(self, identifier: identifier) 73 | bag.append(canceller: canceller) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/sRouting/Helpers/SRAsyncStream.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SRAsyncStream.swift 3 | // 4 | // 5 | // Created by Thang Kieu on 9/4/24. 6 | // 7 | 8 | import Foundation 9 | 10 | actor SRAsyncStream where Value: Sendable { 11 | 12 | typealias Continuation = AsyncStream.Continuation 13 | 14 | private var continuations: [Continuation] = [] 15 | private let defaultValue: Value 16 | private(set) var currenValue: Value 17 | 18 | init(defaultValue: Value) { 19 | self.currenValue = defaultValue 20 | self.defaultValue = defaultValue 21 | } 22 | 23 | /// Events stream 24 | var stream: AsyncStream { 25 | AsyncStream { continuation in 26 | append(continuation) 27 | } 28 | } 29 | 30 | private func append(_ continuation: Continuation) { 31 | continuations.append(continuation) 32 | } 33 | 34 | func emit(_ value: Value) { 35 | currenValue = value 36 | continuations.forEach({ $0.yield(currenValue) }) 37 | } 38 | 39 | func reset() { 40 | emit(defaultValue) 41 | } 42 | 43 | func finish() { 44 | currenValue = defaultValue 45 | continuations.forEach({ $0.finish() }) 46 | continuations.removeAll() 47 | } 48 | } 49 | 50 | extension SRAsyncStream where Value == Int { 51 | 52 | func increase() { 53 | currenValue += 1 54 | emit(currenValue) 55 | } 56 | 57 | func decrease() { 58 | currenValue -= 1 59 | emit(currenValue) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/sRouting/Helpers/SRRoutingError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SRRoutingError.swift 3 | // 4 | // 5 | // Created by Thang Kieu on 20/8/24. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum SRRoutingError: Error, CustomStringConvertible, CustomNSError { 11 | 12 | case unsupportedDecodable 13 | 14 | public static var errorDomain: String { "com.srouting" } 15 | 16 | public var errorCode: Int { 17 | switch self { 18 | case .unsupportedDecodable: 19 | -600 20 | } 21 | } 22 | 23 | public var description: String { 24 | switch self { 25 | case .unsupportedDecodable: 26 | "SRRoute don't support Decodable!" 27 | } 28 | } 29 | 30 | public var errorUserInfo: [String : Any] { 31 | [NSLocalizedDescriptionKey: description] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/sRouting/Helpers/UnitTestActions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UnitTestActions.swift 3 | // 4 | // 5 | // Created by ThangKieu on 7/8/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// The test callbacks action of navigator views 11 | struct UnitTestActions 12 | where TargetView: ViewModifier { 13 | 14 | typealias ViewReturnAction = (TargetView) -> Void 15 | typealias DidOpenWindow = (SRWindowTransition) -> Void 16 | 17 | var didChangeTransition: ViewReturnAction? 18 | var didOpenWindow: DidOpenWindow? 19 | } 20 | -------------------------------------------------------------------------------- /Sources/sRouting/Models/AnyRoute.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnyRoute.swift 3 | // 4 | // 5 | // Created by Thang Kieu on 20/03/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// A type-erased SRRoute. 11 | public struct AnyRoute: SRRoute { 12 | 13 | public let path: String 14 | private let viewBuilder: @MainActor @Sendable () -> AnyView 15 | 16 | public var screen: some View { 17 | viewBuilder().id(path) 18 | } 19 | 20 | public init(route: some SRRoute) { 21 | self.path = route.path + "_" + TimeIdentifier().id 22 | self.viewBuilder = { 23 | AnyView(route.screen) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/sRouting/Models/CoordinatorRoute.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoordinatorRoute.swift 3 | // sRouting 4 | // 5 | // Created by Thang Kieu on 3/5/25. 6 | // 7 | 8 | struct CoordinatorRoute { 9 | 10 | let route: any SRRoute 11 | let triggerKind: SRTriggerType 12 | let identifier: TimeIdentifier 13 | 14 | init(route: some SRRoute, triggerKind: SRTriggerType) { 15 | self.route = route 16 | self.triggerKind = triggerKind 17 | self.identifier = TimeIdentifier() 18 | } 19 | } 20 | 21 | extension CoordinatorRoute: Hashable { 22 | 23 | static func == (lhs: CoordinatorRoute, rhs: CoordinatorRoute) -> Bool { 24 | lhs.route.path == rhs.route.path && lhs.identifier == rhs.identifier 25 | } 26 | 27 | func hash(into hasher: inout Hasher) { 28 | hasher.combine(route.path) 29 | hasher.combine(identifier) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/sRouting/Models/CustomRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IntRawRepresentable.swift 3 | // sRouting 4 | // 5 | // Created by Thang Kieu on 10/4/25. 6 | // 7 | 8 | public protocol IntRawRepresentable: RawRepresentable, CaseIterable, Sendable 9 | where RawValue == Int { } 10 | 11 | extension IntRawRepresentable { 12 | public var intValue: Int { rawValue } 13 | } 14 | 15 | public protocol StringRawRepresentable: RawRepresentable, CaseIterable, Sendable 16 | where RawValue == String { } 17 | 18 | extension StringRawRepresentable { 19 | public var stringValue: String { rawValue } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/sRouting/Models/SRContext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SRContext.swift 3 | // sRouting 4 | // 5 | // Created by Thang Kieu on 6/4/25. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | @Observable @MainActor 12 | public final class SRContext: Sendable { 13 | 14 | @ObservationIgnored 15 | private var coordinators: [WeakCoordinatorReference] = [] 16 | 17 | private(set) var dismissAllSignal: SignalChange = false 18 | 19 | private(set) var coordinatorRoute: CoordinatorRoute? 20 | 21 | public var topCoordinator: SRRouteCoordinatorType? { 22 | coordinators.last?.coordinator 23 | } 24 | 25 | public var coordinatorCount: Int { 26 | coordinators.count 27 | } 28 | 29 | public init() { } 30 | 31 | public func dismissAll() { 32 | dismissAllSignal.toggle() 33 | } 34 | 35 | public func routing(_ routes: RoutingRoute..., 36 | waitDuration duration: Duration = .milliseconds(600)) async { 37 | let routeStream = AsyncStream { continuation in 38 | for route in routes { 39 | continuation.yield(route) 40 | } 41 | continuation.finish() 42 | } 43 | 44 | for await route in routeStream { 45 | await _routing(for: route, duration: max(duration, .milliseconds(400))) 46 | } 47 | } 48 | } 49 | 50 | extension SRContext { 51 | 52 | public enum RoutingRoute: SRRoute { 53 | case resetAll 54 | case dismissAll 55 | case popToRoot 56 | case selectTabView(any IntRawRepresentable) 57 | case push(route: any SRRoute) 58 | case sheet(any SRRoute) 59 | case window(SRWindowTransition) 60 | case wait(Duration) 61 | #if os(iOS) 62 | case present(any SRRoute) 63 | #endif 64 | 65 | public var screen: some View { 66 | fatalError("sRouting.SRRootRoute doesn't have screen") 67 | } 68 | 69 | public var path: String { 70 | switch self { 71 | case .resetAll: 72 | return "routingRoute.resetall" 73 | case .dismissAll: 74 | return "routingRoute.dismissall" 75 | case .selectTabView(_): 76 | return "routingRoute.selecttab" 77 | case .push(let route): 78 | return "routingRoute.push.\(route.path)" 79 | case .sheet(let route): 80 | return "routingRoute.sheet.\(route.path)" 81 | case .window(let transition): 82 | if let id = transition.windowId { 83 | return "routingRoute.window.\(id)" 84 | } else if let value = transition.windowValue { 85 | return "routingRoute.window.\(value.hashValue)" 86 | } else { 87 | return "routingRoute.window" 88 | } 89 | case .popToRoot: 90 | return "routingRoute.popToRoot" 91 | case .wait(_): 92 | return "routingRoute.waiting" 93 | #if os(iOS) 94 | case .present(let route): 95 | return "routingRoute.present.\(route.path)" 96 | #endif 97 | } 98 | } 99 | } 100 | 101 | private func _routing(for route: RoutingRoute, duration: Duration) async { 102 | 103 | switch route { 104 | case .resetAll: 105 | resetAll() 106 | case .dismissAll: 107 | dismissAll() 108 | case .popToRoot: 109 | topCoordinator?.activeNavigation?.popToRoot() 110 | case .selectTabView(let tab): 111 | topCoordinator?.emitter.select(tag: tab.intValue) 112 | case .push(route: let route): 113 | topCoordinator?.activeNavigation?.push(to: route) 114 | case .sheet(let route): 115 | topCoordinator?.rootRouter.trigger(to: .init(route: route), with: .sheet) 116 | case .window(let windowTrans): 117 | topCoordinator?.rootRouter.openWindow(windowTrans: windowTrans) 118 | case .wait(let duration): 119 | try? await Task.sleep(for: duration) 120 | #if os(iOS) 121 | case .present(let route): 122 | topCoordinator?.rootRouter.trigger(to: .init(route: route), with: .present) 123 | #endif 124 | } 125 | 126 | guard route.path != RoutingRoute.wait(.zero).path else { return } 127 | try? await Task.sleep(for: duration) 128 | } 129 | } 130 | 131 | //MARK: - Coordinators Handling 132 | extension SRContext { 133 | 134 | internal func openCoordinator(_ coordinatorRoute: CoordinatorRoute) { 135 | assert(coordinatorRoute.triggerKind != .push, "Open a new coordinator not allowed for push trigger") 136 | guard coordinatorRoute.triggerKind != .push else { return } 137 | guard coordinatorRoute != self.coordinatorRoute else { return } 138 | self.coordinatorRoute = coordinatorRoute 139 | } 140 | 141 | internal func registerActiveCoordinator(_ coordinator: SRRouteCoordinatorType) { 142 | cleanCoordinates() 143 | guard coordinators.contains(where: { $0.coordinator?.identifier == coordinator.identifier }) == false else { return } 144 | coordinators.append(.init(coordinator: coordinator)) 145 | } 146 | 147 | internal func resignActiveCoordinator(identifier: String) { 148 | guard !coordinators.isEmpty else { return } 149 | cleanCoordinates() 150 | coordinators.removeAll(where: { $0.coordinator?.identifier == identifier }) 151 | } 152 | 153 | private func cleanCoordinates() { 154 | guard !coordinators.isEmpty else { return } 155 | coordinators.removeAll(where: { $0.coordinator == nil }) 156 | } 157 | 158 | internal func resetAll() { 159 | dismissAll() 160 | coordinators.forEach { coors in 161 | coors.coordinator?.navigationStacks.forEach { 162 | $0.popToRoot() 163 | } 164 | } 165 | } 166 | } 167 | 168 | //MARK: - Helpers 169 | private final class WeakCoordinatorReference { 170 | weak var coordinator: SRRouteCoordinatorType? 171 | 172 | init(coordinator: SRRouteCoordinatorType) { 173 | self.coordinator = coordinator 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /Sources/sRouting/Models/SRCoordinatorEmitter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SRCoordinatorEmitter.swift 3 | // 4 | // 5 | // Created by Thang Kieu on 31/03/2024. 6 | // 7 | 8 | import Foundation 9 | import Observation 10 | 11 | /// `Dismiss the coordinator` signal emitter 12 | @Observable @MainActor 13 | public final class SRCoordinatorEmitter { 14 | 15 | internal var doubleTapTabItemEmmiter: SignalChange = false 16 | 17 | private(set) var dismissEmitter: SignalChange = false 18 | 19 | public var tabSelection: Int = .zero { 20 | willSet { 21 | if newValue == _tabSelection { 22 | _increaseTapCount() 23 | _autoCancelTapCount() 24 | } else { 25 | _resetTapCount() 26 | } 27 | } 28 | } 29 | 30 | nonisolated private let tapCountStream = SRAsyncStream(defaultValue: 0) 31 | nonisolated private let cancelBag = CancelBag() 32 | nonisolated private let autoCancelTapIdentifier = "autoCancelTapIdentifier" 33 | 34 | public init() { 35 | _observeTapCountStream() 36 | } 37 | 38 | public func select(tag: Int) { 39 | tabSelection = tag 40 | } 41 | 42 | /// Dismiss the coordinator 43 | internal func dismiss() { 44 | dismissEmitter.toggle() 45 | } 46 | 47 | private func _emmitDoubleTap() { 48 | doubleTapTabItemEmmiter.toggle() 49 | } 50 | 51 | deinit { 52 | cancelBag.cancelAllInTask() 53 | } 54 | } 55 | 56 | 57 | extension SRCoordinatorEmitter { 58 | 59 | nonisolated private func _increaseTapCount() { 60 | Task(priority: .high) { 61 | await tapCountStream.increase() 62 | } 63 | } 64 | 65 | nonisolated private func _resetTapCount() { 66 | Task(priority: .high) { 67 | await tapCountStream.reset() 68 | } 69 | } 70 | 71 | nonisolated private func _observeTapCountStream() { 72 | Task.detached {[weak self] in 73 | guard let stream = await self?.tapCountStream.stream, 74 | let cancelTapId = self?.autoCancelTapIdentifier 75 | else { return } 76 | 77 | for await _ in stream.filter({ $0 == 2 }) { 78 | try Task.checkCancellation() 79 | await self?._emmitDoubleTap() 80 | await self?.tapCountStream.reset() 81 | await self?.cancelBag.cancel(forIdentifier: cancelTapId) 82 | } 83 | }.store(in: cancelBag) 84 | } 85 | 86 | nonisolated private func _autoCancelTapCount() { 87 | Task.detached {[weak self] in 88 | try await Task.sleep(for: .milliseconds(400)) 89 | try Task.checkCancellation() 90 | await self?.tapCountStream.reset() 91 | guard let cancelId = self?.autoCancelTapIdentifier else { return } 92 | await self?.cancelBag.cancel(forIdentifier: cancelId) 93 | }.store(in: cancelBag, withIdentifier: autoCancelTapIdentifier) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Sources/sRouting/Models/SRNavigationPath.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SRNavigationPath.swift 3 | // 4 | // 5 | // Created by Thang Kieu on 31/03/2024. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | /// NavigationStack's path 12 | @Observable @MainActor 13 | public final class SRNavigationPath { 14 | 15 | internal var stack: [String] = [] 16 | 17 | internal var navPath: NavigationPath = .init() { 18 | willSet { 19 | _matchingStack(from: newValue.codable) 20 | } 21 | } 22 | 23 | @ObservationIgnored 24 | private(set) weak var coordinator: SRRouteCoordinatorType? 25 | 26 | public var pathsCount: Int { 27 | navPath.count 28 | } 29 | 30 | public init(coordinator: SRRouteCoordinatorType? = nil) { 31 | self.coordinator = coordinator 32 | } 33 | 34 | public func pop() { 35 | guard !navPath.isEmpty else { return } 36 | navPath.removeLast() 37 | } 38 | 39 | public func pop(to path: some StringRawRepresentable) { 40 | guard navPath.count == stack.count, navPath.count > 1 else { return } 41 | guard let index = stack.lastIndex(where: {$0.contains(path.stringValue)}) 42 | else { return } 43 | let dropCount = (stack.count - 1) - index 44 | guard dropCount > 0 && navPath.count >= dropCount else { return } 45 | navPath.removeLast(dropCount) 46 | } 47 | 48 | public func popToRoot() { 49 | guard !navPath.isEmpty else { return } 50 | let count = navPath.count 51 | navPath.removeLast(count) 52 | } 53 | 54 | public func push(to route: some SRRoute) { 55 | navPath.append(route) 56 | } 57 | 58 | private func _matchingStack(from navCodable: NavigationPath.CodableRepresentation?) { 59 | 60 | guard let navCodable else { return } 61 | guard let data = try? JSONEncoder().encode(navCodable) else { return } 62 | guard let array = try? JSONDecoder().decode([String].self, from: data) else { return } 63 | 64 | let matchedArray = array.chunked(into: 2) 65 | .map( { $0.joined(separator: ".").replacingOccurrences(of: "\"", with: "") }) 66 | if matchedArray.count < 2 { 67 | self.stack = matchedArray 68 | } else { 69 | self.stack = Array(matchedArray.reversed()) 70 | } 71 | } 72 | } 73 | 74 | extension Array { 75 | fileprivate func chunked(into size: Int) -> [[Element]] { 76 | return stride(from: 0, to: count, by: size).map { 77 | Array(self[$0 ..< Swift.min($0 + size, count)]) 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/sRouting/Models/SRRouter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SRRouter.swift 3 | // sRouting 4 | // 5 | // Created by Thang Kieu on 1/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @Observable @MainActor 11 | public final class SRRouter where Route: SRRoute { 12 | 13 | public typealias AcceptionCallback = @Sendable (_ accepted: Bool) -> Void 14 | public typealias ErrorHandler = @Sendable (_ error: Error?) -> Void 15 | 16 | private(set) var transition: SRTransition = .none 17 | 18 | public init(_ route: Route.Type) { } 19 | 20 | 21 | /// Open a new coordinator 22 | /// - Parameter route: the coordinator route 23 | public func openCoordinator(route: some SRRoute, with action: SRTriggerType) { 24 | transition = .init(coordinator: .init(route:route, triggerKind: action)) 25 | } 26 | 27 | /// Switch root to route 28 | /// - Parameter route: Root route 29 | public func switchTo(route: some SRRoute) { 30 | transition = .init(switchTo: route) 31 | } 32 | 33 | /// Show confirmation dialog 34 | /// - Parameter dialog: ``SRConfirmationDialogRoute`` 35 | public func show(dialog: Route.ConfirmationDialogRoute) { 36 | transition = .init(with: dialog) 37 | } 38 | 39 | /// Show popover 40 | /// - Parameter dialog: ``SRPopoverRoute`` 41 | public func show(popover: Route.PopoverRoute) { 42 | transition = .init(with: popover) 43 | } 44 | 45 | /// Select tabbar item at index 46 | /// - Parameter index: Index of tabbar item 47 | /// 48 | /// ### Example 49 | /// ```swift 50 | /// router.selectTabbar(at: 0) 51 | /// ``` 52 | public func selectTabbar(at tab: any IntRawRepresentable, with transaction: WithTransaction? = .none) { 53 | transition = .init(selectTab: tab, and: transaction) 54 | } 55 | 56 | /// Trigger to new screen 57 | /// - Parameters: 58 | /// - route: Type of ``SRRoute`` 59 | /// - action: ``SRTriggerType`` 60 | /// 61 | /// ### Example 62 | /// ```swift 63 | /// router.trigger(to: .detailScreen, with: .push) 64 | /// ``` 65 | public func trigger(to route: Route, with action: SRTriggerType, and transaction: WithTransaction? = .none) { 66 | transition = .init(with: route, and: .init(with: action), transaction: transaction) 67 | } 68 | 69 | /// Show an alert 70 | /// - Parameter alert: Alert 71 | /// 72 | /// ### Example 73 | /// ```swift 74 | /// router.show(alert: AppAlertErrors.lossConnection) 75 | /// ``` 76 | public func show(alert: Route.AlertRoute, withTransaction transaction: WithTransaction? = .none) { 77 | transition = .init(with: alert, and: transaction) 78 | } 79 | 80 | /// Dismiss or pop current screen 81 | /// 82 | /// ### Example 83 | /// ```swift 84 | /// router.dismiss() 85 | /// ``` 86 | public func dismiss() { 87 | transition = .init(with: .dismiss) 88 | } 89 | 90 | /// Dismiss to root view 91 | /// 92 | /// ### Example 93 | /// ```swift 94 | /// router.dismissAll() 95 | /// ``` 96 | public func dismissAll() { 97 | transition = .init(with: .dismissAll) 98 | } 99 | 100 | /// Dismiss the presenting coordinator 101 | public func dismissCoordinator() { 102 | transition = .init(with: .dismissCoordinator) 103 | } 104 | 105 | /// Navigation pop action. 106 | /// - Parameter transaction: `Transaction` 107 | public func pop(with transaction: WithTransaction? = .none) { 108 | transition = .init(with: .pop, and: transaction) 109 | } 110 | 111 | /// Navigation pop to root action. 112 | /// - Parameter transaction: `Transaction` 113 | public func popToRoot(with transaction: WithTransaction? = .none) { 114 | transition = .init(with: .popToRoot, and: transaction) 115 | } 116 | 117 | /// Navigation pop to target action. 118 | /// - Parameters: 119 | /// - path: ``SRRoute.PathsType`` 120 | /// - transaction: `Transaction` 121 | public func pop(to path: some StringRawRepresentable, with transaction: WithTransaction? = .none) { 122 | transition = .init(popTo: path, and: transaction) 123 | } 124 | 125 | /// Opens a window that's associated with the specified transition. 126 | /// - Parameter windowTrans: ``SRWindowTransition`` 127 | /// 128 | /// ### Example 129 | /// ```swif 130 | /// openWindow(windowTrans: windowTrans) 131 | /// ``` 132 | public func openWindow(windowTrans: SRWindowTransition) { 133 | transition = .init(with: .openWindow, windowTransition: windowTrans) 134 | } 135 | 136 | /// Reset the transition to release anything the route holds. 137 | internal func resetTransition() { 138 | guard transition != .none else { return } 139 | transition = .none 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /Sources/sRouting/Models/SRSwitcher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SRSwitcher.swift 3 | // sRouting 4 | // 5 | // Created by Thang Kieu on 26/4/25. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol RouteSwitchable { 11 | 12 | associatedtype Route: SRRoute 13 | 14 | var route: Route { get } 15 | 16 | func switchTo(route: some SRRoute) 17 | } 18 | 19 | @Observable 20 | final class SRSwitcher: RouteSwitchable where Route: SRRoute { 21 | 22 | private(set) var route: Route 23 | 24 | init(route: Route) { 25 | self.route = route 26 | } 27 | 28 | func switchTo(route: some SRRoute) { 29 | guard let root = route as? Route else { 30 | assertionFailure("SRSwitcher: Cannot switch route to \(String(describing: route)), must be of type \(String(describing: Route.self))") 31 | return 32 | } 33 | guard root != self.route else { 34 | return 35 | } 36 | self.route = root 37 | } 38 | } 39 | 40 | @Observable 41 | final class SwitcherBox { 42 | 43 | let switcher: any RouteSwitchable 44 | 45 | init(switcher: some RouteSwitchable) { 46 | self.switcher = switcher 47 | } 48 | 49 | func switchTo(route: some SRRoute) { 50 | switcher.switchTo(route: route) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/sRouting/Models/SRTransition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SRTransition.swift 3 | // 4 | // 5 | // Created by ThangKieu on 6/30/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct SRTransition: Sendable 11 | where Route: SRRoute { 12 | 13 | let transitionId: TimeIdentifier = .now 14 | let type: SRTransitionKind 15 | let transaction: WithTransaction? 16 | 17 | private(set) var route: Route? 18 | private(set) var alert: Route.AlertRoute? 19 | private(set) var tabIndex: (any IntRawRepresentable)? 20 | private(set) var popToPath: (any StringRawRepresentable)? 21 | private(set) var windowTransition: SRWindowTransition? 22 | private(set) var confirmationDialog: Route.ConfirmationDialogRoute? 23 | private(set) var popover: Route.PopoverRoute? 24 | private(set) var rootRoute: (any SRRoute)? 25 | private(set) var coordinator: CoordinatorRoute? 26 | 27 | init(coordinator: CoordinatorRoute) { 28 | self.coordinator = coordinator 29 | transaction = nil 30 | type = .openCoordinator 31 | } 32 | 33 | init(switchTo route: some SRRoute, and transaction: WithTransaction? = .none) { 34 | self.type = .switchRoot 35 | self.rootRoute = route 36 | self.transaction = transaction 37 | } 38 | 39 | init(with route: Route.PopoverRoute?, and transaction: WithTransaction? = .none) { 40 | self.type = .popover 41 | self.popover = route 42 | self.transaction = transaction 43 | } 44 | 45 | init(with route: Route.ConfirmationDialogRoute?, and transaction: WithTransaction? = .none) { 46 | self.type = .confirmationDialog 47 | self.confirmationDialog = route 48 | self.transaction = transaction 49 | } 50 | 51 | init(with type: SRTransitionKind, and transaction: WithTransaction? = .none) { 52 | self.type = type 53 | self.transaction = transaction 54 | } 55 | 56 | init(selectTab tab: any IntRawRepresentable, and transaction: WithTransaction? = .none) { 57 | self.type = .selectTab 58 | self.tabIndex = tab 59 | self.transaction = transaction 60 | } 61 | 62 | init(with alert: Route.AlertRoute, and transaction: WithTransaction? = .none) { 63 | self.type = .alert 64 | self.alert = alert 65 | self.transaction = transaction 66 | } 67 | 68 | init(with route: Route, and action: SRTransitionKind, transaction: WithTransaction? = .none) { 69 | self.type = action 70 | self.route = route 71 | self.transaction = transaction 72 | } 73 | 74 | init(popTo path: some StringRawRepresentable, and transaction: WithTransaction? = .none) { 75 | self.type = .popToRoute 76 | self.popToPath = path 77 | self.transaction = transaction 78 | } 79 | 80 | init(with type: SRTransitionKind, 81 | windowTransition: SRWindowTransition, 82 | transaction: WithTransaction? = .none) { 83 | self.type = type 84 | self.windowTransition = windowTransition 85 | self.transaction = transaction 86 | } 87 | } 88 | 89 | extension SRTransition { 90 | public static var none: SRTransition { 91 | SRTransition(with: SRTransitionKind.none) 92 | } 93 | } 94 | 95 | extension SRTransition: Equatable { 96 | /// Conform Equatable 97 | /// - Parameters: 98 | /// - lhs: left value 99 | /// - rhs: right value 100 | public static func == (lhs: Self, rhs: Self) -> Bool { 101 | if lhs.type == .none && rhs.type == .none { 102 | return true 103 | } 104 | return lhs.type == rhs.type 105 | && lhs.tabIndex?.intValue == rhs.tabIndex?.intValue 106 | && lhs.route == rhs.route 107 | && lhs.transitionId == rhs.transitionId 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Sources/sRouting/Models/SRWindowTransition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SRWindowTransition.swift 3 | // 4 | // 5 | // Created by Thang Kieu on 29/03/2024. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | public struct SRWindowTransition: Sendable { 12 | 13 | private(set) var acception: (@Sendable (_ aception: Bool) -> Void)? 14 | private(set) var errorHandler: (@Sendable (_ error: Error?) -> Void)? 15 | private(set) var url: URL? 16 | public private(set) var windowId: String? 17 | public private(set) var windowValue: (any (Codable & Hashable & Sendable))? 18 | 19 | public init(url: URL, 20 | acceoption: (@Sendable (_ aception: Bool) -> Void)? = .none, 21 | errorHandler: ( @Sendable (_ error: Error?) -> Void)? = .none) { 22 | self.url = url 23 | self.acception = acceoption 24 | self.errorHandler = errorHandler 25 | } 26 | 27 | public init(windowId: String, value: C) where C: Codable, C: Hashable, C:Sendable { 28 | self.windowId = windowId 29 | self.windowValue = value 30 | } 31 | 32 | public init(value: C) where C: Codable, C: Hashable, C:Sendable { 33 | self.windowValue = value 34 | } 35 | 36 | public init(windowId: String) { 37 | self.windowId = windowId 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/sRouting/Models/TimeIdentifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimeIdentifier.swift 3 | // 4 | // 5 | // Created by Thang Kieu on 29/03/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct TimeIdentifier: Sendable, Hashable, CustomStringConvertible, Identifiable { 11 | 12 | public let id: String 13 | 14 | public var description: String { 15 | id 16 | } 17 | 18 | public static var now: TimeIdentifier { 19 | .init() 20 | } 21 | 22 | private static var formatter: DateFormatter { 23 | let formater = DateFormatter() 24 | formater.dateFormat = "yyyy-MM-dd, HH:mm:ss.S" 25 | return formater 26 | } 27 | 28 | public init() { 29 | self.id = Self.formatter.string(from: .now) 30 | } 31 | 32 | public func hash(into hasher: inout Hasher) { 33 | hasher.combine(id) 34 | } 35 | 36 | public static func == (lhs: TimeIdentifier, rhs: TimeIdentifier) -> Bool { 37 | lhs.id == rhs.id 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/sRouting/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyAccessedAPITypes 6 | 7 | NSPrivacyCollectedDataTypes 8 | 9 | NSPrivacyTrackingDomains 10 | 11 | NSPrivacyTracking 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Sources/sRouting/Prototype/Global.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Global.swift 3 | // 4 | // 5 | // Created by Thang Kieu on 20/8/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | internal typealias SignalChange = Bool 11 | 12 | public typealias WithTransaction = @MainActor @Sendable () -> SwiftUI.Transaction 13 | -------------------------------------------------------------------------------- /Sources/sRouting/Prototype/SRRoute.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SRRoute.swift 3 | // sRouting 4 | // 5 | // Created by ThangKieu on 6/30/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | //MARK: - SRPopoverRoute 11 | public protocol SRPopoverRoute: Sendable, Equatable { 12 | 13 | associatedtype Content: View 14 | 15 | var identifier: String { get } 16 | var attachmentAnchor: PopoverAttachmentAnchor { get } 17 | var arrowEdge: Edge? { get } 18 | 19 | @ViewBuilder @MainActor 20 | var content: Content { get } 21 | } 22 | 23 | extension SRPopoverRoute { 24 | public static func == (lhs: Self, rhs: Self) -> Bool { 25 | lhs.identifier == rhs.identifier 26 | } 27 | } 28 | 29 | extension SRPopoverRoute { 30 | 31 | public var attachmentAnchor: PopoverAttachmentAnchor { .rect(.bounds) } 32 | public var arrowEdge: Edge? { .none } 33 | } 34 | 35 | public struct PopoverEmptyRoute: SRPopoverRoute { 36 | public var identifier: String { "default popover route" } 37 | public var content: some View { Text("Defualt Popover") } 38 | } 39 | 40 | //MARK: - SRConfirmationDialogRoute 41 | public protocol SRConfirmationDialogRoute: Sendable, Equatable { 42 | 43 | associatedtype Message: View 44 | associatedtype Actions: View 45 | 46 | var titleKey: LocalizedStringKey { get } 47 | 48 | var titleVisibility: Visibility { get } 49 | 50 | var identifier: String { get } 51 | 52 | @ViewBuilder @MainActor 53 | var message: Message { get } 54 | 55 | @ViewBuilder @MainActor 56 | var actions: Actions { get } 57 | } 58 | 59 | extension SRConfirmationDialogRoute { 60 | public static func == (lhs: Self, rhs: Self) -> Bool { 61 | lhs.identifier == rhs.identifier 62 | } 63 | } 64 | 65 | public struct ConfirmationDialogEmptyRoute: SRConfirmationDialogRoute { 66 | 67 | public var titleKey: LocalizedStringKey { "" } 68 | public var identifier: String { "Default Confirmation Dialog!" } 69 | public var message: some View { Text(identifier) } 70 | public var actions: some View { Button("OK"){ } } 71 | public var titleVisibility: Visibility = .hidden 72 | } 73 | 74 | //MARK: - SRAlertRoute 75 | public protocol SRAlertRoute: Sendable { 76 | 77 | associatedtype Message: View 78 | associatedtype Actions: View 79 | 80 | var titleKey: LocalizedStringKey { get } 81 | 82 | @ViewBuilder @MainActor 83 | var message: Message { get } 84 | 85 | @ViewBuilder @MainActor 86 | var actions: Actions { get } 87 | } 88 | 89 | public struct AlertEmptyRoute: SRAlertRoute { 90 | public var titleKey: LocalizedStringKey { "Alert" } 91 | public var message: some View { Text("Default Alert!") } 92 | public var actions: some View { Button("OK"){ } } 93 | } 94 | 95 | //MARK: - SRRoute 96 | /// Protocol to build screens for the route. 97 | public protocol SRRoute: Hashable, Codable, Sendable { 98 | 99 | associatedtype Screen: View 100 | associatedtype AlertRoute: SRAlertRoute 101 | associatedtype ConfirmationDialogRoute: SRConfirmationDialogRoute 102 | associatedtype PopoverRoute: SRPopoverRoute 103 | 104 | var path: String { get } 105 | 106 | /// Screen builder 107 | @ViewBuilder @MainActor 108 | var screen: Screen { get } 109 | } 110 | 111 | extension SRRoute { 112 | 113 | /// Provide default type for the ``SRAlertRoute`` 114 | public typealias AlertRoute = AlertEmptyRoute 115 | 116 | /// Provide default type for the ``SRConfirmationDialogRoute`` 117 | public typealias ConfirmationDialogRoute = ConfirmationDialogEmptyRoute 118 | 119 | /// Provide default type for the ``SRPopoverRoute`` 120 | public typealias PopoverRoute = PopoverEmptyRoute 121 | 122 | public var transaction: SwiftUI.Transaction? { .none } 123 | 124 | public static func == (lhs: Self, rhs: Self) -> Bool { 125 | lhs.path == rhs.path 126 | } 127 | 128 | public func hash(into hasher: inout Hasher) { 129 | hasher.combine(path) 130 | } 131 | } 132 | 133 | extension SRRoute { 134 | 135 | public init(from decoder: any Decoder) throws { 136 | throw SRRoutingError.unsupportedDecodable 137 | } 138 | 139 | public func encode(to encoder: any Encoder) throws { 140 | var container = encoder.singleValueContainer() 141 | try container.encode(path) 142 | } 143 | } 144 | 145 | public enum EmptyPaths: String, StringRawRepresentable { 146 | case none 147 | } 148 | -------------------------------------------------------------------------------- /Sources/sRouting/Prototype/SRRouteCoordinatorType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SRRouteCoordinatorType.swift 3 | // 4 | // 5 | // Created by Thang Kieu on 02/04/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | @MainActor 11 | public protocol SRRouteCoordinatorType: AnyObject { 12 | 13 | var identifier: String { get } 14 | var rootRouter: SRRouter { get } 15 | var emitter: SRCoordinatorEmitter { get } 16 | var navigationStacks: [SRNavigationPath] { get } 17 | var activeNavigation: SRNavigationPath? { get } 18 | 19 | func registerActiveNavigation(_ navigationPath: SRNavigationPath) 20 | } 21 | -------------------------------------------------------------------------------- /Sources/sRouting/Prototype/SRRouteObserverType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SRRouteObserverType.swift 3 | // 4 | // 5 | // Created by Thang Kieu on 19/8/24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | public protocol SRRouteObserverType: ViewModifier { 12 | init() 13 | } 14 | 15 | extension View { 16 | 17 | public func routeObserver(_ observer: Observer.Type) -> some View where Observer: SRRouteObserverType { 18 | modifier(observer.init()) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/sRouting/Prototype/SRTransitionKind.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SRTransitionKind.swift 3 | // sRouting 4 | // 5 | // Created by ThangKieu on 6/30/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Transition type of navigation for the trigger action. 11 | public enum SRTriggerType: String, CaseIterable, Sendable { 12 | /// Push a screen 13 | case push 14 | /// Present a screen 15 | case sheet 16 | #if os(iOS) || os(tvOS) 17 | /// Present full screen 18 | case present 19 | #endif 20 | public var description: String { 21 | "TriggerType - \(self)" 22 | } 23 | } 24 | 25 | /// Transition type of navigation that using internal. 26 | enum SRTransitionKind: String, CaseIterable, Sendable { 27 | case none 28 | /// Open a new coordinator 29 | case openCoordinator 30 | /// Switch root routes 31 | case switchRoot 32 | /// Push a screen 33 | case push 34 | /// Present full screen 35 | case present 36 | /// Select a tabbar item 37 | case selectTab 38 | /// Show alert 39 | case alert 40 | /// Show actions sheet on iOS & iPad 41 | case confirmationDialog 42 | /// Show actions popover on iOS & iPad 43 | case popover 44 | /// Present a screen 45 | case sheet 46 | /// Dismiss(pop) screen 47 | case dismiss 48 | /// Dismiss to root screen 49 | case dismissAll 50 | /// Dismiss the presenting coordinator 51 | case dismissCoordinator 52 | /// Naivation pop action 53 | case pop 54 | /// Navigation pop to screen action 55 | case popToRoute 56 | /// Navigation pop to root action 57 | case popToRoot 58 | /// Open window 59 | case openWindow 60 | 61 | init(with triggerType: SRTriggerType) { 62 | switch triggerType { 63 | case .push: self = .push 64 | case .sheet: self = .sheet 65 | #if os(iOS) || os(tvOS) 66 | case .present: self = .present 67 | #endif 68 | } 69 | } 70 | 71 | var description: String { 72 | "TransitionType - \(self)" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/sRouting/ViewModifiers/OnDialogOfRouter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnDialogOfRouter.swift 3 | // sRouting 4 | // 5 | // Created by Thang Kieu on 24/1/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DialogRouterModifier: ViewModifier where Route: SRRoute { 11 | 12 | private let dialogRoute: Route.ConfirmationDialogRoute 13 | private let router: SRRouter 14 | @Environment(\.scenePhase) private var scenePhase 15 | 16 | ///Action test holder 17 | private let tests: UnitTestActions? 18 | 19 | /// Active state of action sheet 20 | @State private(set) var isActiveDialog: Bool = false 21 | 22 | @MainActor 23 | private var dialogTitleKey: LocalizedStringKey { 24 | router.transition.confirmationDialog?.titleKey ?? "" 25 | } 26 | 27 | @MainActor 28 | private var dialogTitleVisibility: Visibility { 29 | router.transition.confirmationDialog?.titleVisibility ?? .hidden 30 | } 31 | 32 | @MainActor 33 | private var dialogActions: some View { 34 | router.transition.confirmationDialog?.actions 35 | } 36 | 37 | @MainActor 38 | private var dialogMessage: some View { 39 | router.transition.confirmationDialog?.message 40 | } 41 | 42 | init(router: SRRouter, dialog: Route.ConfirmationDialogRoute, tests: UnitTestActions? = nil) { 43 | self.router = router 44 | self.dialogRoute = dialog 45 | self.tests = tests 46 | } 47 | 48 | func body(content: Content) -> some View { 49 | #if os(iOS) 50 | if UIDevice.current.userInterfaceIdiom == .pad { 51 | content 52 | .confirmationDialog(dialogTitleKey, 53 | isPresented: $isActiveDialog, 54 | titleVisibility: dialogTitleVisibility, 55 | actions: { 56 | dialogActions 57 | }, message: { 58 | dialogMessage 59 | }) 60 | .onChange(of: router.transition, { oldValue, newValue in 61 | guard newValue.type == .confirmationDialog 62 | && UIDevice.current.userInterfaceIdiom == .pad 63 | && newValue.confirmationDialog == dialogRoute else { return } 64 | isActiveDialog = true 65 | tests?.didChangeTransition?(self) 66 | }) 67 | .onChange(of: isActiveDialog, { oldValue, newValue in 68 | guard oldValue && !newValue else { return } 69 | resetRouterTransiton() 70 | }) 71 | } else { 72 | content 73 | } 74 | #else 75 | content 76 | #endif 77 | } 78 | } 79 | 80 | extension DialogRouterModifier { 81 | /// Reset all active state to false 82 | @MainActor 83 | func resetActiveState() { 84 | guard scenePhase != .background || tests != nil else { return } 85 | isActiveDialog = false 86 | } 87 | 88 | @MainActor 89 | private func resetRouterTransiton() { 90 | guard scenePhase != .background || tests != nil else { return } 91 | router.resetTransition() 92 | } 93 | } 94 | 95 | extension View { 96 | 97 | /// Show confirmation dialog on iPad at the anchor view. 98 | /// - Parameters: 99 | /// - router: ``SRRouter`` 100 | /// - dialog: ``SRConfirmationDialogRoute`` 101 | /// - Returns: `some View` 102 | public func onDialogRouting(of router: SRRouter, for dialog: Route.ConfirmationDialogRoute) -> some View { 103 | self.modifier(DialogRouterModifier(router: router, dialog: dialog)) 104 | } 105 | 106 | 107 | /// Show confirmation dialog on iPad at the anchor view (on test purpose). 108 | /// - Parameters: 109 | /// - router: ``SRRouter`` 110 | /// - dialog: ``SRConfirmationDialogRoute`` 111 | /// - Returns: `some View` 112 | func onDialogRouting(of router: SRRouter, 113 | for dialog: Route.ConfirmationDialogRoute, 114 | tests: UnitTestActions>?) -> some View { 115 | self.modifier(DialogRouterModifier(router: router, dialog: dialog, tests: tests)) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Sources/sRouting/ViewModifiers/OnDismissAllChange.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnDismissAllChange.swift 3 | // 4 | // 5 | // Created by Thang Kieu on 28/03/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | private typealias OnChangeBlock = @MainActor () -> Void 11 | 12 | private struct RootModifier: ViewModifier { 13 | 14 | @Environment(SRContext.self) 15 | private var context: SRContext? 16 | 17 | private let onChange: OnChangeBlock 18 | 19 | init(_ onChange: @escaping OnChangeBlock) { 20 | self.onChange = onChange 21 | } 22 | 23 | func body(content: Content) -> some View { 24 | content.onChange(of: context?.dismissAllSignal) { oldValue, newValue in 25 | guard newValue != .none else { return } 26 | onChange() 27 | } 28 | } 29 | } 30 | 31 | extension View { 32 | 33 | /// Observe Dismiss all change 34 | /// - Parameter onChange: action 35 | /// - Returns: some `View` 36 | public func onDismissAllChange(_ onChange: @escaping @MainActor () -> Void) -> some View { 37 | self.modifier(RootModifier(onChange)) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/sRouting/ViewModifiers/OnDoubleTapTabItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnDoubleTapTabItem.swift 3 | // 4 | // 5 | // Created by Thang Kieu on 9/4/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | private typealias OnChangeBlock = @MainActor (Int) -> Void 11 | 12 | private struct OnDoubleTapTabItem: ViewModifier { 13 | 14 | @Environment(SRCoordinatorEmitter.self) 15 | private var coordinatorEmitter: SRCoordinatorEmitter? 16 | 17 | private let onChange: OnChangeBlock 18 | 19 | init(_ onChange: @escaping OnChangeBlock) { 20 | self.onChange = onChange 21 | } 22 | 23 | func body(content: Content) -> some View { 24 | content.onChange(of: coordinatorEmitter?.doubleTapTabItemEmmiter) { _, _ in 25 | guard let selection = coordinatorEmitter?.tabSelection else { return } 26 | onChange(selection) 27 | } 28 | } 29 | } 30 | 31 | extension View { 32 | 33 | /// Observe double tap event on tabItem 34 | /// - Parameter onChange: action 35 | /// - Returns: some `View` 36 | public func onDoubleTapTabItem(_ onChange: @escaping @MainActor (_ selection: Int) -> Void) -> some View { 37 | self.modifier(OnDoubleTapTabItem(onChange)) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/sRouting/ViewModifiers/OnNavigationStackChange.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnNavigationStackChange.swift 3 | // 4 | // 5 | // Created by Thang Kieu on 28/03/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | private typealias OnChangeBlock = @MainActor (_ oldPaths: [String], _ newPaths: [String]) -> Void 11 | 12 | private struct NavigationModifier: ViewModifier { 13 | 14 | @Environment(SRNavigationPath.self) 15 | private var navigationPath: SRNavigationPath? 16 | 17 | private let onChange: OnChangeBlock 18 | 19 | init(_ onChange: @escaping OnChangeBlock) { 20 | self.onChange = onChange 21 | } 22 | 23 | func body(content: Content) -> some View { 24 | content.onChange(of: navigationPath?.stack) { oldValue, newValue in 25 | let oldPaths = (oldValue ?? []) 26 | let newPahts = (newValue ?? []) 27 | onChange(oldPaths, newPahts) 28 | } 29 | } 30 | } 31 | 32 | extension View { 33 | 34 | /// Observe Navigation stack change 35 | /// - Parameter onChange: action 36 | /// - Returns: some `View` 37 | public func onNaviStackChange(_ onChange: @escaping @MainActor (_ oldPaths: [String], _ newPaths: [String]) -> Void) -> some View { 38 | self.modifier(NavigationModifier(onChange)) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/sRouting/ViewModifiers/OnPopoverOfRouter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnPopoverOfRouter.swift 3 | // sRouting 4 | // 5 | // Created by Thang Kieu on 23/3/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct OnPopoverOfRouter: ViewModifier where Route: SRRoute { 11 | 12 | private let popoverRoute: Route.PopoverRoute 13 | private let router: SRRouter 14 | @Environment(\.scenePhase) private var scenePhase 15 | 16 | ///Action test holder 17 | private let tests: UnitTestActions? 18 | 19 | /// Active state of popover 20 | @State private(set) var isActivePopover: Bool = false 21 | 22 | @MainActor 23 | private var popoverAnchor: PopoverAttachmentAnchor { 24 | router.transition.popover?.attachmentAnchor ?? .rect(.bounds) 25 | } 26 | 27 | @MainActor 28 | private var popoverEdge: Edge? { 29 | router.transition.popover?.arrowEdge 30 | } 31 | 32 | @MainActor 33 | private var popoverContent: some View { 34 | router.transition.popover?.content 35 | } 36 | 37 | init(router: SRRouter, popover: Route.PopoverRoute, tests: UnitTestActions? = nil) { 38 | self.router = router 39 | self.popoverRoute = popover 40 | self.tests = tests 41 | } 42 | 43 | func body(content: Content) -> some View { 44 | #if os(iOS) 45 | if UIDevice.current.userInterfaceIdiom == .pad { 46 | main(content: content) 47 | } else { 48 | content 49 | } 50 | #else 51 | main(content: content) 52 | #endif 53 | } 54 | 55 | @ViewBuilder 56 | private func main(content: Content) -> some View { 57 | content 58 | .popover(isPresented: $isActivePopover, 59 | attachmentAnchor: popoverAnchor, 60 | arrowEdge: popoverEdge, 61 | content: { 62 | popoverContent 63 | }) 64 | .onChange(of: router.transition, { oldValue, newValue in 65 | #if os(iOS) 66 | guard newValue.type == .popover 67 | && UIDevice.current.userInterfaceIdiom == .pad 68 | && newValue.popover == popoverRoute else { return } 69 | isActivePopover = true 70 | tests?.didChangeTransition?(self) 71 | #else 72 | guard newValue.type == .popover 73 | && newValue.popover == popoverRoute else { return } 74 | isActivePopover = true 75 | tests?.didChangeTransition?(self) 76 | #endif 77 | }) 78 | .onChange(of: isActivePopover, { oldValue, newValue in 79 | guard oldValue && !newValue else { return } 80 | resetRouterTransiton() 81 | }) 82 | } 83 | } 84 | 85 | extension OnPopoverOfRouter { 86 | /// Reset all active state to false 87 | @MainActor 88 | func resetActiveState() { 89 | guard scenePhase != .background || tests != nil else { return } 90 | isActivePopover = false 91 | } 92 | 93 | @MainActor 94 | private func resetRouterTransiton() { 95 | guard scenePhase != .background || tests != nil else { return } 96 | router.resetTransition() 97 | } 98 | } 99 | 100 | extension View { 101 | 102 | /// Show Popover on iPad at the anchor view. 103 | /// - Parameters: 104 | /// - router: ``SRRouter`` 105 | /// - popover: ``SRPopoverRoute`` 106 | /// - Returns: `some View` 107 | public func onPopoverRouting(of router: SRRouter, for popover: Route.PopoverRoute) -> some View { 108 | self.modifier(OnPopoverOfRouter(router: router, popover: popover)) 109 | } 110 | 111 | 112 | /// Show Popover on iPad at the anchor view (on test purpose). 113 | /// - Parameters: 114 | /// - router: ``SRRouter`` 115 | /// - popover: ``SRPopoverRoute`` 116 | /// - Returns: `some View` 117 | func onPopoverRouting(of router: SRRouter, 118 | for popover: Route.PopoverRoute, 119 | tests: UnitTestActions>?) -> some View { 120 | self.modifier(OnPopoverOfRouter(router: router, popover: popover, tests: tests)) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Sources/sRouting/ViewModifiers/OnRoutingCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnRoutingCoordinator.swift 3 | // sRouting 4 | // 5 | // Created by Thang Kieu on 3/5/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct OnRoutingCoordinator: ViewModifier where Route: SRRoute { 11 | 12 | @State private var router: SRRouter 13 | private let context: SRContext 14 | 15 | init(_ routeType: Route.Type, context: SRContext) { 16 | self._router = .init(initialValue: .init(routeType)) 17 | self.context = context 18 | } 19 | 20 | func body(content: Content) -> some View { 21 | content 22 | .onChange(of: context.coordinatorRoute, { oldValue, newValue in 23 | guard let coordinatorRoute = newValue else { return } 24 | openCoordinator(coordinatorRoute) 25 | }) 26 | .onRouting(of: router) 27 | .environment(context) 28 | } 29 | 30 | private func openCoordinator(_ coordiantor: CoordinatorRoute) { 31 | Task { @MainActor in 32 | guard let route = coordiantor.route as? Route else { 33 | assertionFailure("Coordinator Route must be type of \(Route.self)") 34 | return 35 | } 36 | router.trigger(to: route, with: coordiantor.triggerKind) 37 | } 38 | } 39 | } 40 | 41 | extension View { 42 | 43 | public func onRoutingCoordinator(_ routeType: Route.Type, context: SRContext) 44 | -> some View where Route: SRRoute { 45 | modifier(OnRoutingCoordinator(routeType, context: context)) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/sRouting/ViewModifiers/OnRoutingOfRouter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnRoutingOfRouter.swift 3 | // sRouting 4 | // 5 | // Created by Thang Kieu on 23/9/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct RouterModifier: ViewModifier where Route: SRRoute { 11 | 12 | typealias VoidAction = () -> Void 13 | 14 | /// A screen's ``Router`` 15 | private let router: SRRouter 16 | 17 | @Environment(SRCoordinatorEmitter.self) 18 | private var coordinatorEmitter: SRCoordinatorEmitter? 19 | 20 | @Environment(SRNavigationPath.self) 21 | private var navigationPath: SRNavigationPath? 22 | 23 | @Environment(SRContext.self) 24 | private var context: SRContext? 25 | 26 | @Environment(SwitcherBox.self) 27 | private var switcher: SwitcherBox? 28 | 29 | @Environment(\.openWindow) private var openWindow 30 | @Environment(\.scenePhase) private var scenePhase 31 | @Environment(\.dismiss) private var dismissAction 32 | 33 | /// Active state of a full screen presentation 34 | @State private(set) var isActivePresent: Bool = false 35 | /// Active state of a sheet presentation 36 | @State private(set) var isActiveSheet: Bool = false 37 | /// Active state of a alert 38 | @State private(set) var isActiveAlert: Bool = false 39 | /// Active state of action sheet 40 | @State private(set) var isActiveDialog: Bool = false 41 | /// Active state of popover 42 | @State private(set) var isActivePopover: Bool = false 43 | 44 | /// The destination screen from transition 45 | @MainActor 46 | private var destinationView: some View { 47 | router.transition.route?.screen 48 | } 49 | 50 | @MainActor 51 | private var alertTitle: LocalizedStringKey { 52 | router.transition.alert?.titleKey ?? "" 53 | } 54 | 55 | @MainActor 56 | private var alertActions: some View { 57 | router.transition.alert?.actions 58 | } 59 | 60 | @MainActor 61 | private var alertMessage: some View { 62 | router.transition.alert?.message 63 | } 64 | 65 | @MainActor 66 | private var dialogTitleKey: LocalizedStringKey { 67 | router.transition.confirmationDialog?.titleKey ?? "" 68 | } 69 | 70 | @MainActor 71 | private var dialogTitleVisibility: Visibility { 72 | router.transition.confirmationDialog?.titleVisibility ?? .hidden 73 | } 74 | 75 | @MainActor 76 | private var dialogActions: some View { 77 | router.transition.confirmationDialog?.actions 78 | } 79 | 80 | @MainActor 81 | private var dialogMessage: some View { 82 | router.transition.confirmationDialog?.message 83 | } 84 | 85 | @MainActor 86 | private var popoverAnchor: PopoverAttachmentAnchor { 87 | router.transition.popover?.attachmentAnchor ?? .rect(.bounds) 88 | } 89 | 90 | @MainActor 91 | private var popoverEdge: Edge? { 92 | router.transition.popover?.arrowEdge 93 | } 94 | 95 | @MainActor 96 | private var popoverContent: some View { 97 | router.transition.popover?.content 98 | } 99 | 100 | ///Action test holder 101 | private let tests: UnitTestActions? 102 | 103 | init(router: SRRouter, tests: UnitTestActions? = .none) { 104 | self.router = router 105 | self.tests = tests 106 | } 107 | 108 | func body(content: Content) -> some View { 109 | #if os(macOS) 110 | content 111 | .sheet(isPresented: $isActiveSheet, 112 | content: { 113 | destinationView 114 | .environment(context) 115 | }) 116 | .alert(alertTitle, isPresented: $isActiveAlert, actions: { 117 | alertActions 118 | }, message: { 119 | alertMessage 120 | }) 121 | .confirmationDialog(dialogTitleKey, 122 | isPresented: $isActiveDialog, 123 | titleVisibility: dialogTitleVisibility, 124 | actions: { 125 | dialogActions 126 | }, message: { 127 | dialogMessage 128 | }) 129 | .onChange(of: context?.dismissAllSignal, { oldValue, newValue in 130 | resetActiveState() 131 | }) 132 | .onChange(of: coordinatorEmitter?.dismissEmitter, { oldValue, newValue in 133 | dismissCoordinator() 134 | }) 135 | .onChange(of: router.transition, { oldValue, newValue in 136 | let transaction = newValue.transaction?() 137 | if let transaction { 138 | withTransaction(transaction) { 139 | updateActiveState(from: newValue) 140 | } 141 | } else { 142 | updateActiveState(from: newValue) 143 | } 144 | }) 145 | .onChange(of: isActiveAlert, { oldValue, newValue in 146 | guard oldValue && !newValue else { return } 147 | resetRouterTransiton() 148 | }) 149 | .onChange(of: isActiveSheet, { oldValue, newValue in 150 | guard oldValue && !newValue else { return } 151 | resetRouterTransiton() 152 | }) 153 | .onChange(of: isActiveDialog, { oldValue, newValue in 154 | guard oldValue && !newValue else { return } 155 | resetRouterTransiton() 156 | }) 157 | .onAppear() { 158 | resetRouterTransiton() 159 | } 160 | #else 161 | content 162 | .fullScreenCover(isPresented: $isActivePresent) { 163 | destinationView 164 | .environment(context) 165 | } 166 | .sheet(isPresented: $isActiveSheet, 167 | content: { 168 | destinationView 169 | .environment(context) 170 | }) 171 | .alert(alertTitle, isPresented: $isActiveAlert, actions: { 172 | alertActions 173 | }, message: { 174 | alertMessage 175 | }) 176 | .confirmationDialog(dialogTitleKey, 177 | isPresented: $isActiveDialog, 178 | titleVisibility: dialogTitleVisibility, 179 | actions: { 180 | dialogActions 181 | }, message: { 182 | dialogMessage 183 | }) 184 | .popover(isPresented: $isActivePopover, 185 | attachmentAnchor: popoverAnchor, 186 | arrowEdge: popoverEdge, 187 | content: { 188 | popoverContent 189 | .environment(context) 190 | }) 191 | .onChange(of: context?.dismissAllSignal, { oldValue, newValue in 192 | resetActiveState() 193 | }) 194 | .onChange(of: coordinatorEmitter?.dismissEmitter, { oldValue, newValue in 195 | dismissCoordinator() 196 | }) 197 | .onChange(of: router.transition, { oldValue, newValue in 198 | let transaction = newValue.transaction?() 199 | if let transaction { 200 | withTransaction(transaction) { 201 | updateActiveState(from: newValue) 202 | } 203 | } else { 204 | updateActiveState(from: newValue) 205 | } 206 | }) 207 | .onChange(of: isActiveAlert, { oldValue, newValue in 208 | guard oldValue && !newValue else { return } 209 | resetRouterTransiton() 210 | }) 211 | .onChange(of: isActiveSheet, { oldValue, newValue in 212 | guard oldValue && !newValue else { return } 213 | resetRouterTransiton() 214 | }) 215 | .onChange(of: isActiveDialog, { oldValue, newValue in 216 | guard oldValue && !newValue else { return } 217 | resetRouterTransiton() 218 | }) 219 | .onChange(of: isActivePresent, { oldValue, newValue in 220 | guard oldValue && !newValue else { return } 221 | resetRouterTransiton() 222 | }) 223 | .onChange(of: isActivePopover, { oldValue, newValue in 224 | guard oldValue && !newValue else { return } 225 | resetRouterTransiton() 226 | }) 227 | .onAppear() { 228 | resetRouterTransiton() 229 | } 230 | #endif 231 | } 232 | } 233 | 234 | extension RouterModifier { 235 | 236 | @MainActor 237 | private func resetRouterTransiton() { 238 | guard scenePhase != .background || tests != nil else { return } 239 | router.resetTransition() 240 | } 241 | 242 | @MainActor 243 | private func dismissCoordinator() { 244 | resetActiveState() 245 | guard let context, context.topCoordinator?.emitter === coordinatorEmitter && context.coordinatorCount > 1 else { return } 246 | dismissAction() 247 | } 248 | 249 | /// Reset all active state to false 250 | @MainActor 251 | func resetActiveState() { 252 | guard scenePhase != .background || tests != nil else { return } 253 | isActivePresent = false 254 | isActiveAlert = false 255 | isActiveSheet = false 256 | isActiveDialog = false 257 | isActivePopover = false 258 | } 259 | 260 | /// Observe the transition change from router 261 | /// - Parameter transition: ``Transiton`` 262 | @MainActor 263 | private func updateActiveState(from transition: SRTransition) { 264 | switch transition.type { 265 | case .push: 266 | guard let route = transition.route else { break } 267 | navigationPath?.push(to: route) 268 | case .present: 269 | isActivePresent = true 270 | case .sheet: 271 | isActiveSheet = true 272 | case .alert: 273 | isActiveAlert = true 274 | case .confirmationDialog: 275 | #if os(iOS) 276 | if UIDevice.current.userInterfaceIdiom == .pad { 277 | break 278 | } else { 279 | isActiveDialog = true 280 | } 281 | #else 282 | isActiveDialog = true 283 | #endif 284 | case .popover: 285 | #if os(iOS) 286 | if UIDevice.current.userInterfaceIdiom == .pad { 287 | break 288 | } else { 289 | isActivePopover = true 290 | } 291 | #else 292 | break 293 | #endif 294 | case .dismiss: 295 | dismissAction() 296 | case .selectTab: 297 | coordinatorEmitter?.select(tag: transition.tabIndex?.intValue ?? .zero) 298 | case .dismissAll: 299 | context?.dismissAll() 300 | case .dismissCoordinator: 301 | coordinatorEmitter?.dismiss() 302 | case .pop: 303 | navigationPath?.pop() 304 | case .popToRoot: 305 | navigationPath?.popToRoot() 306 | case .popToRoute: 307 | guard let path = transition.popToPath else { break } 308 | navigationPath?.pop(to: path) 309 | case .openWindow: 310 | openWindow(transition: transition.windowTransition) 311 | case .switchRoot: 312 | guard let route = transition.rootRoute else { break } 313 | switcher?.switchTo(route: route) 314 | case .openCoordinator: 315 | guard let coordinatorRoute = transition.coordinator else { break } 316 | context?.openCoordinator(coordinatorRoute) 317 | case .none: break 318 | } 319 | 320 | tests?.didChangeTransition?(self) 321 | } 322 | 323 | @MainActor 324 | private func openWindow(transition: SRWindowTransition?) { 325 | guard let transition else { return } 326 | guard tests == nil else { 327 | tests?.didOpenWindow?(transition) 328 | return 329 | } 330 | 331 | switch (transition.windowId, transition.windowValue) { 332 | case (.some(let id), .none): 333 | openWindow(id: id) 334 | case (.none, .some(let value)): 335 | openWindow(value: value) 336 | case (.some(let id), .some(let value)): 337 | openWindow(id: id, value: value) 338 | case (.none, .none): 339 | break 340 | } 341 | } 342 | } 343 | 344 | extension View { 345 | 346 | /// Observe router transitions 347 | /// - Parameter router: ``SRRouterType`` 348 | /// - Returns: some `View` 349 | public func onRouting(of router: SRRouter) -> some View { 350 | self.modifier(RouterModifier(router: router)) 351 | } 352 | 353 | /// Observe router transition (on test purpose) 354 | /// - Parameters: 355 | /// - router: ``SRRouterType`` 356 | /// - tests: Unit test action 357 | /// - Returns: some `View` 358 | func onRouting(of router: SRRouter, 359 | tests: UnitTestActions>?) -> some View { 360 | self.modifier(RouterModifier(router: router, tests: tests)) 361 | } 362 | } 363 | -------------------------------------------------------------------------------- /Sources/sRouting/ViewModifiers/OnTabSelectionChange.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnTabSelectionChange.swift 3 | // 4 | // 5 | // Created by Thang Kieu on 28/03/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | private typealias OnChangeBlock = @MainActor (Int) -> Void 11 | 12 | private struct TabbarModifier: ViewModifier { 13 | 14 | @Environment(SRCoordinatorEmitter.self) 15 | private var emitter: SRCoordinatorEmitter? 16 | 17 | private let onChange: OnChangeBlock 18 | 19 | init(_ onChange: @escaping OnChangeBlock) { 20 | self.onChange = onChange 21 | } 22 | 23 | func body(content: Content) -> some View { 24 | content.onChange(of: emitter?.tabSelection) { oldValue, newValue in 25 | guard let newValue else { return } 26 | onChange(newValue) 27 | } 28 | } 29 | } 30 | 31 | extension View { 32 | 33 | /// Observe `TabView`'s selection 34 | /// - Parameter onChange: action 35 | /// - Returns: some `View` 36 | public func onTabSelectionChange(_ onChange: @escaping @MainActor (_ selection: Int) -> Void) -> some View { 37 | self.modifier(TabbarModifier(onChange)) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/sRouting/Views/NavigationRootView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NavigationRootView.swift 3 | // 4 | // 5 | // Created by Thang Kieu on 20/03/2024. 6 | // 7 | 8 | import SwiftUI 9 | import Observation 10 | 11 | /// Inject ``SRNavigationPath`` environment value before observing the navigation's route transitions 12 | public struct NavigationRootView: View 13 | where Content: View { 14 | 15 | private let path: SRNavigationPath 16 | private let content: () -> Content 17 | 18 | /// Initalizer of ``NavigationRootView`` 19 | /// - Parameters: 20 | /// - path: ``SRNavigationPath`` 21 | /// - content: Content view builder 22 | public init(path: SRNavigationPath, 23 | @ViewBuilder content: @escaping () -> Content) { 24 | self.content = content 25 | self.path = path 26 | } 27 | 28 | public var body: some View { 29 | content() 30 | .environment(path) 31 | .onAppear(perform: { 32 | path.coordinator?.registerActiveNavigation(path) 33 | }) 34 | } 35 | } 36 | 37 | extension NavigationStack where Data == NavigationPath { 38 | 39 | public init(path: SRNavigationPath, @ViewBuilder root: @escaping () -> Content) 40 | where Root == NavigationRootView { 41 | @Bindable var bindPath = path 42 | self.init(path: $bindPath.navPath) { 43 | NavigationRootView(path: path, content: root) 44 | } 45 | } 46 | } 47 | 48 | extension NavigationLink where Destination == Never { 49 | 50 | public init(route: R, @ViewBuilder content: () -> Label) where R: SRRoute { 51 | self.init(value: route, label: content) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/sRouting/Views/SRRootView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SRRootView.swift 3 | // 4 | // 5 | // Created by Thang Kieu on 28/03/2024. 6 | // 7 | 8 | import SwiftUI 9 | import Observation 10 | 11 | /// The root view of a window 12 | public struct SRRootView: View 13 | where Content: View, Coordinator: SRRouteCoordinatorType { 14 | 15 | private let coordinator: Coordinator 16 | private let context: SRContext 17 | private let content: () -> Content 18 | 19 | public init(context: SRContext, 20 | coordinator: Coordinator, 21 | @ViewBuilder content: @escaping () -> Content) { 22 | self.content = content 23 | self.coordinator = coordinator 24 | self.context = context 25 | } 26 | 27 | public var body: some View { 28 | content() 29 | .onAppear { 30 | context.registerActiveCoordinator(coordinator) 31 | } 32 | .onDisappear(perform: { 33 | context.resignActiveCoordinator(identifier: coordinator.identifier) 34 | }) 35 | .onRouting(of: coordinator.rootRouter) 36 | .environment(context) 37 | .environment(coordinator.emitter) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/sRouting/Views/SRSwitchView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SRSwitchView.swift 3 | // sRouting 4 | // 5 | // Created by Thang Kieu on 26/4/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | /// This view facilitates switching between root-level routes, such as transitioning between the main tab bar and the login view. 12 | public struct SRSwitchView: View where R: SRRoute { 13 | 14 | @Environment(SRContext.self) private var context: SRContext? 15 | @State private var switcher: SRSwitcher 16 | @State private var route: R 17 | 18 | public init(startingWith route: R) { 19 | self._switcher = .init(initialValue: .init(route: route)) 20 | self._route = .init(initialValue: route) 21 | } 22 | 23 | public var body: some View { 24 | route.screen 25 | .onChange(of: switcher.route, { _, newValue in 26 | Task { 27 | context?.resetAll() 28 | withAnimation { 29 | route = newValue 30 | } 31 | } 32 | }) 33 | .environment(SwitcherBox(switcher: switcher)) 34 | } 35 | } 36 | 37 | 38 | /// This view is similar to ``SRSwitchView``, but it provides the ability to modify the route.screen. 39 | public struct SRSwitchRouteView: View where R: SRRoute, C: View { 40 | 41 | @Environment(SRContext.self) private var context: SRContext? 42 | @State private var switcher: SRSwitcher 43 | @State private var route: R 44 | let content: (R) -> C 45 | 46 | public init(startingWith route: R, @ViewBuilder content: @escaping (R) -> C) { 47 | self._switcher = .init(initialValue: .init(route: route)) 48 | self._route = .init(initialValue: route) 49 | self.content = content 50 | } 51 | 52 | public var body: some View { 53 | content(route) 54 | .onChange(of: switcher.route, { _, newValue in 55 | Task { 56 | context?.resetAll() 57 | withAnimation { 58 | route = newValue 59 | } 60 | } 61 | }) 62 | .environment(SwitcherBox(switcher: switcher)) 63 | } 64 | } 65 | 66 | 67 | -------------------------------------------------------------------------------- /Sources/sRouting/sRouting.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | /// Create a coordinator that includes navigations, selections... 5 | @attached(member, names: arbitrary) 6 | @attached(extension, conformances: SRRouteCoordinatorType, names: named(SRRootRoute), named(SRNavStack), named(SRTabItem), named(SRRootRouter)) 7 | public macro sRouteCoordinator(tabs: [String] = [], stacks: String...) = #externalMacro(module: "sRoutingMacros", type: "RouteCoordinatorMacro") 8 | 9 | /// Generate a `ViewModifier` of navigation destinations that observing routes 10 | @attached(member, names: named(path), arbitrary) 11 | @attached(extension, conformances: SRRouteObserverType) 12 | public macro sRouteObserver(_ routes: (any SRRoute.Type)...) = #externalMacro(module: "sRoutingMacros", type: "RouteObserverMacro") 13 | 14 | 15 | /// Generate Paths for ``SRRoute`` 16 | @attached(extension, conformances: SRRoute, names: named(PathsType), named(Paths), named(path), arbitrary) 17 | public macro sRoute() = #externalMacro(module: "sRoutingMacros", type: "RouteMacro") 18 | 19 | /// Indicates a subroute within a route. 20 | @attached(peer) 21 | public macro sSubRoute() = #externalMacro(module: "sRoutingMacros", type: "SubRouteMacro") 22 | -------------------------------------------------------------------------------- /Sources/sRoutingClient/main.swift: -------------------------------------------------------------------------------- 1 | // 2 | // main.swift 3 | // 4 | // 5 | // Created by Thang Kieu on 18/03/2024. 6 | // 7 | 8 | import Foundation 9 | import sRouting 10 | import SwiftUI 11 | import Observation 12 | 13 | 14 | enum AppPopover: SRPopoverRoute { 15 | 16 | case testPopover 17 | 18 | var identifier: String { "indentifier" } 19 | 20 | var content: some View { 21 | Text("Hello, World!") 22 | } 23 | } 24 | 25 | enum AppConfirmationDialog: SRConfirmationDialogRoute { 26 | 27 | case testConfirmation 28 | 29 | var titleKey: LocalizedStringKey { 30 | "This is Title" 31 | } 32 | 33 | var identifier: String { 34 | "This is Message" 35 | } 36 | 37 | var message: some View { 38 | Text(identifier) 39 | } 40 | 41 | var actions: some View { 42 | Button("Yes") { } 43 | Button("No", role: .destructive) { } 44 | } 45 | 46 | var titleVisibility: Visibility { 47 | .visible 48 | } 49 | } 50 | 51 | enum AppAlerts: SRAlertRoute { 52 | 53 | case lossConnection 54 | 55 | var titleKey: LocalizedStringKey { 56 | switch self { 57 | case .lossConnection: 58 | return "Loss Connection" 59 | } 60 | } 61 | 62 | var actions: some View { 63 | Button("OK") { 64 | 65 | } 66 | } 67 | 68 | var message: some View { 69 | Text("Please check your connection") 70 | } 71 | } 72 | 73 | @sRoute 74 | enum DetailRoute { 75 | case deteail 76 | 77 | var screen: some View { 78 | Text("Hello World") 79 | } 80 | } 81 | 82 | @sRoute 83 | enum HomeRoute { 84 | 85 | typealias AlertRoute = AppAlerts 86 | typealias ConfirmationDialogRoute = AppConfirmationDialog 87 | typealias PopoverRoute = AppPopover 88 | 89 | case home 90 | case detail(String) 91 | 92 | var screen: some View { 93 | Text("Hello World") 94 | } 95 | } 96 | 97 | @sRoute 98 | enum SettingRoute { 99 | 100 | case setting 101 | @sSubRoute 102 | case detail(DetailRoute) 103 | 104 | var screen: some View { Text("Setting") } 105 | } 106 | 107 | let router = SRRouter(HomeRoute.self) 108 | router.trigger(to: .home, with: .sheet) { 109 | var trans = Transaction() 110 | trans.disablesAnimations = true 111 | return trans 112 | } 113 | 114 | router.show(alert: .lossConnection) 115 | router.show(dialog: .testConfirmation) 116 | router.show(popover: .testPopover) 117 | 118 | @sRouteObserver(HomeRoute.self, SettingRoute.self) 119 | struct RouteObserver { } 120 | 121 | @sRouteCoordinator(tabs: ["homeItem", "settingItem"], stacks: "home", "setting") 122 | @Observable 123 | final class AppCoordinator { } 124 | 125 | @sRouteCoordinator(stacks: "subcoordinator") 126 | final class OtherCoordinator { } 127 | 128 | @sRoute 129 | enum AppRoute { 130 | 131 | case startScreen 132 | case homeScreen 133 | 134 | @ViewBuilder @MainActor 135 | var screen: some View { 136 | switch self { 137 | case .startScreen: 138 | EmptyView() 139 | .transition(.scale(scale: 0.1).combined(with: .opacity)) 140 | case .homeScreen: 141 | MainScreen() 142 | .transition(.opacity) 143 | } 144 | } 145 | } 146 | 147 | struct MainScreen: View { 148 | @Environment(AppCoordinator.self) var coordinator 149 | var body: some View { 150 | @Bindable var emitter = coordinator.emitter 151 | TabView(selection: $emitter.tabSelection) { 152 | NavigationStack(path: coordinator.homePath) { 153 | EmptyView() 154 | .routeObserver(RouteObserver.self) 155 | } 156 | .tag(AppCoordinator.SRTabItem.homeItem.rawValue) 157 | 158 | NavigationStack(path: coordinator.homePath) { 159 | EmptyView() 160 | .routeObserver(RouteObserver.self) 161 | } 162 | .tag(AppCoordinator.SRTabItem.settingItem.rawValue) 163 | } 164 | } 165 | } 166 | 167 | @sRouteCoordinator(stacks: "newStack") 168 | final class AnyCoordinator { } 169 | 170 | struct AnyCoordinatorView: View where Content: View { 171 | 172 | @Environment(SRContext.self) var context 173 | @State private var coordinator: AnyCoordinator = .init() 174 | let content: () -> Content 175 | 176 | var body: some View { 177 | SRRootView(context: context, coordinator: coordinator) { 178 | NavigationStack(path: coordinator.newStackPath) { 179 | content() 180 | } 181 | } 182 | } 183 | } 184 | 185 | @sRoute 186 | enum CoordinatorsRoute { 187 | 188 | case notifications 189 | case settings 190 | 191 | @MainActor @ViewBuilder 192 | var screen: some View { 193 | switch self { 194 | case .notifications: 195 | AnyCoordinatorView { EmptyView() } 196 | case .settings: 197 | AnyCoordinatorView { EmptyView() } 198 | } 199 | } 200 | } 201 | 202 | 203 | struct BookieApp: App { 204 | 205 | @State private var appCoordinator = AppCoordinator() 206 | @State private var context = SRContext() 207 | 208 | var body: some Scene { 209 | WindowGroup { 210 | SRRootView(context: context, coordinator: appCoordinator) { 211 | SRSwitchView(startingWith: AppRoute.startScreen) 212 | } 213 | .environment(appCoordinator) 214 | .onRoutingCoordinator(CoordinatorsRoute.self, context: context) 215 | } 216 | } 217 | } 218 | 219 | struct BookieApp_OtherSetup: App { 220 | 221 | @State private var appCoordinator = AppCoordinator() 222 | @State private var context = SRContext() 223 | 224 | var body: some Scene { 225 | WindowGroup { 226 | SRRootView(context: context, coordinator: appCoordinator) { 227 | SRSwitchRouteView(startingWith: AppRoute.startScreen) { route in 228 | NavigationStack(path: appCoordinator.homePath) { 229 | route.screen 230 | .routeObserver(RouteObserver.self) 231 | } 232 | } 233 | } 234 | .onRoutingCoordinator(CoordinatorsRoute.self, context: context) 235 | } 236 | } 237 | } 238 | 239 | 240 | -------------------------------------------------------------------------------- /Sources/sRoutingMacros/RouteCoordinatorMacro.swift: -------------------------------------------------------------------------------- 1 | 2 | // 3 | // RouteCoordinatorMacro.swift 4 | // 5 | // 6 | // Created by Thang Kieu on 31/03/2024. 7 | // 8 | 9 | import SwiftSyntaxBuilder 10 | import SwiftCompilerPlugin 11 | import SwiftSyntax 12 | import SwiftSyntaxMacros 13 | import Foundation 14 | 15 | private let tabsParam = "tabs" 16 | private let stacksParam = "stacks" 17 | 18 | package struct RouteCoordinatorMacro: MemberMacro { 19 | 20 | package static func expansion(of node: AttributeSyntax, 21 | providingMembersOf declaration: some DeclGroupSyntax, 22 | in context: some MacroExpansionContext) throws -> [DeclSyntax] { 23 | 24 | guard let classDecl = declaration.as(ClassDeclSyntax.self), declaration.kind == SwiftSyntax.SyntaxKind.classDecl 25 | else { throw SRMacroError.onlyClass } 26 | 27 | let className = classDecl.name.text.trimmingCharacters(in: .whitespaces) 28 | let arguments = try Self._arguments(of: node) 29 | 30 | var result: [DeclSyntax] = [] 31 | 32 | let identifier: DeclSyntax = "let identifier: String" 33 | result.append(identifier) 34 | 35 | let rootRouter: DeclSyntax = "@MainActor let rootRouter = SRRouter(AnyRoute.self)" 36 | result.append(rootRouter) 37 | 38 | let dsaEmiiter: DeclSyntax = "@MainActor let emitter = SRCoordinatorEmitter()" 39 | result.append(dsaEmiiter) 40 | 41 | let indexLastStack = arguments.stacks.count - 1 42 | var initStacks = "[" 43 | for (index,stack) in arguments.stacks.enumerated() { 44 | if index == indexLastStack { 45 | initStacks += "SRNavStack.\(stack):SRNavigationPath(coordinator: self)" 46 | } else { 47 | initStacks += "SRNavStack.\(stack):SRNavigationPath(coordinator: self), " 48 | } 49 | 50 | } 51 | initStacks += "]" 52 | 53 | let navStacks: DeclSyntax = "@MainActor private lazy var navStacks = \(raw: initStacks)" 54 | result.append(navStacks) 55 | 56 | for stack in arguments.stacks { 57 | let shortPath: DeclSyntax = """ 58 | @MainActor 59 | var \(raw: stack)Path: SRNavigationPath { 60 | navStacks[SRNavStack.\(raw:stack)]! 61 | } 62 | """ 63 | result.append(shortPath) 64 | } 65 | 66 | let navigationStacks: DeclSyntax = "@MainActor var navigationStacks: [SRNavigationPath] { navStacks.map(\\.value) }" 67 | result.append(navigationStacks) 68 | 69 | let activeNavigaiton: DeclSyntax = "@MainActor private(set) var activeNavigation: SRNavigationPath?" 70 | result.append(activeNavigaiton) 71 | 72 | let defaultInit: DeclSyntax = """ 73 | @MainActor init() { 74 | self.identifier = \"\(raw: className)\" + \"_\" + TimeIdentifier.now.id 75 | } 76 | """ 77 | result.append(defaultInit) 78 | 79 | let resgisterFunction: DeclSyntax = """ 80 | @MainActor 81 | func registerActiveNavigation(_ navigationPath: SRNavigationPath) { 82 | activeNavigation = navigationPath 83 | } 84 | """ 85 | result.append(resgisterFunction) 86 | 87 | return result 88 | } 89 | } 90 | 91 | extension RouteCoordinatorMacro: ExtensionMacro { 92 | 93 | package static func expansion( 94 | of node: AttributeSyntax, 95 | attachedTo declaration: some DeclGroupSyntax, 96 | providingExtensionsOf type: some TypeSyntaxProtocol, 97 | conformingTo protocols: [TypeSyntax], 98 | in context: some MacroExpansionContext 99 | ) throws -> [ExtensionDeclSyntax] { 100 | 101 | guard declaration.kind == SwiftSyntax.SyntaxKind.classDecl 102 | else { throw SRMacroError.onlyClass } 103 | 104 | let arguments = try Self._arguments(of: node) 105 | 106 | var caseTabItems = "" 107 | if arguments.tabs.isEmpty { 108 | caseTabItems = "case none" 109 | } else { 110 | for item in arguments.tabs { 111 | caseTabItems += "case \(item)" 112 | if item != arguments.tabs.last { 113 | caseTabItems += "\n" 114 | } 115 | } 116 | } 117 | 118 | var caseStackItems = "" 119 | for stack in arguments.stacks { 120 | caseStackItems += "case \(stack)" 121 | if stack != arguments.stacks.last { 122 | caseStackItems += "\n" 123 | } 124 | } 125 | 126 | let declCoordinator: DeclSyntax = """ 127 | extension \(raw: type.trimmedDescription): sRouting.SRRouteCoordinatorType { 128 | 129 | enum SRTabItem: Int, IntRawRepresentable { 130 | \(raw: caseTabItems) 131 | } 132 | 133 | enum SRNavStack: String, Sendable { 134 | \(raw: caseStackItems) 135 | } 136 | } 137 | """ 138 | let extCoordinator = declCoordinator.cast(ExtensionDeclSyntax.self) 139 | return [extCoordinator] 140 | } 141 | } 142 | 143 | extension RouteCoordinatorMacro { 144 | 145 | private static func _arguments(of node: AttributeSyntax) throws -> (tabs: [String], stacks: [String]) { 146 | 147 | guard case let .argumentList(arguments) = node.arguments, !arguments.isEmpty 148 | else { throw SRMacroError.missingArguments } 149 | 150 | var tabs = [String]() 151 | var stacks = [String]() 152 | var currentLabel = tabsParam 153 | for labeled in arguments { 154 | 155 | if labeled.label?.trimmedDescription == tabsParam { 156 | currentLabel = tabsParam 157 | } else if labeled.label?.trimmedDescription == stacksParam { 158 | currentLabel = stacksParam 159 | } 160 | 161 | switch currentLabel { 162 | case tabsParam: 163 | guard let exp = labeled.expression.as(ArrayExprSyntax.self) 164 | else { throw SRMacroError.missingArguments } 165 | let elements = exp.elements.map(\.expression).compactMap({ $0.as(StringLiteralExprSyntax.self) }) 166 | let contents = elements.compactMap(\.segments.first).compactMap({ $0.as(StringSegmentSyntax.self)}) 167 | let items = contents.map(\.content.text) 168 | let tabItems = items.filter({ !$0.isEmpty }) 169 | guard !tabItems.isEmpty else { continue } 170 | tabs.append(contentsOf: items) 171 | case stacksParam: 172 | guard let exp = labeled.expression.as(StringLiteralExprSyntax.self), 173 | let segment = exp.segments.first?.as(StringSegmentSyntax.self) 174 | else { throw SRMacroError.missingArguments } 175 | 176 | let input = segment.content.text 177 | guard !input.isEmpty else { continue } 178 | stacks.append(input) 179 | default: continue 180 | } 181 | } 182 | 183 | guard !stacks.isEmpty else { throw SRMacroError.missingArguments } 184 | if !tabs.isEmpty && tabs.count != Set(tabs).count { 185 | throw SRMacroError.duplication 186 | } 187 | guard stacks.count == Set(stacks).count else { throw SRMacroError.duplication } 188 | 189 | return (tabs,stacks) 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /Sources/sRoutingMacros/RouteMacro.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RouteMacro.swift 3 | // sRouting 4 | // 5 | // Created by Thang Kieu on 15/4/25. 6 | // 7 | 8 | import SwiftSyntaxBuilder 9 | import SwiftCompilerPlugin 10 | import SwiftSyntax 11 | import SwiftSyntaxMacros 12 | 13 | private let srrouteType = "SRRoute" 14 | private let subrouteMacro = "sSubRoute" 15 | 16 | package struct RouteMacro: ExtensionMacro { 17 | 18 | package static func expansion( 19 | of node: AttributeSyntax, 20 | attachedTo declaration: some DeclGroupSyntax, 21 | providingExtensionsOf type: some TypeSyntaxProtocol, 22 | conformingTo protocols: [TypeSyntax], 23 | in context: some MacroExpansionContext 24 | ) throws -> [ExtensionDeclSyntax] { 25 | 26 | guard let enumDecl = declaration.as(EnumDeclSyntax.self) 27 | else { throw SRMacroError.onlyEnum } 28 | 29 | let inheritedTypes = Self.extractInheritedTypes(from: enumDecl) 30 | guard !inheritedTypes.contains(srrouteType) else { throw SRMacroError.redundantConformance } 31 | 32 | let arguments = try Self.extractEnumCases(from: enumDecl) 33 | let prefixPath = type.trimmedDescription.filter(\.isUppercase).lowercased() 34 | 35 | var caseItems = "" 36 | let pathCases = arguments.filter({ !$0.hasPrefix(subrouteMacro) }) 37 | for caseName in pathCases { 38 | caseItems += "case \(caseName) = \"\(prefixPath)_\(caseName.lowercased())\"" 39 | if caseName != pathCases.last { 40 | caseItems += "\n" 41 | } 42 | } 43 | 44 | var casePaths = "" 45 | for caseName in arguments { 46 | if caseName.hasPrefix(subrouteMacro) { 47 | guard let name = caseName.split(separator: "_").last else { continue } 48 | casePaths += "case .\(name)(let route): return route.path" 49 | } else { 50 | casePaths += "case .\(caseName): return Paths.\(caseName).rawValue" 51 | if caseName != arguments.last { 52 | casePaths += "\n" 53 | } 54 | } 55 | } 56 | 57 | let declExtension: DeclSyntax 58 | if pathCases.isEmpty { 59 | declExtension = """ 60 | extension \(raw: type.trimmedDescription): sRouting.SRRoute { 61 | 62 | nonisolated var path: String { 63 | switch self { 64 | \(raw: casePaths) 65 | } 66 | } 67 | } 68 | """ 69 | } else { 70 | declExtension = """ 71 | extension \(raw: type.trimmedDescription): sRouting.SRRoute { 72 | 73 | enum Paths: String, StringRawRepresentable { 74 | \(raw: caseItems) 75 | } 76 | 77 | nonisolated var path: String { 78 | switch self { 79 | \(raw: casePaths) 80 | } 81 | } 82 | } 83 | """ 84 | } 85 | 86 | let result = declExtension.cast(ExtensionDeclSyntax.self) 87 | return [result] 88 | } 89 | } 90 | 91 | 92 | //MARK: - Helpers 93 | extension RouteMacro { 94 | 95 | package static func extractEnumCases(from enumDecl: EnumDeclSyntax) throws -> [String]{ 96 | 97 | var caseNames: [String] = [] 98 | for member in enumDecl.memberBlock.members { 99 | if let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) { 100 | var casename: String = "" 101 | if let subRoute = caseDecl.attributes.first?.as(AttributeSyntax.self)?.attributeName.as(IdentifierTypeSyntax.self)?.name.text.trimmingCharacters(in: .whitespacesAndNewlines), 102 | subRoute == subrouteMacro { 103 | casename = "\(subrouteMacro)_" 104 | } 105 | 106 | guard let element = caseDecl.elements.first else { continue } 107 | let name = element.name.text.trimmingCharacters(in: .whitespacesAndNewlines) 108 | casename += name 109 | caseNames.append(casename) 110 | } 111 | } 112 | 113 | guard caseNames.count == Set(caseNames).count else { 114 | throw SRMacroError.duplication 115 | } 116 | 117 | guard !caseNames.isEmpty else { 118 | throw SRMacroError.noneRoutes 119 | } 120 | 121 | return caseNames 122 | } 123 | 124 | package static func extractInheritedTypes(from decl: EnumDeclSyntax) -> [String] { 125 | guard let inheritanceClause = decl.inheritanceClause else { 126 | return [] 127 | } 128 | return inheritanceClause.inheritedTypes.map { 129 | $0.type.trimmedDescription 130 | } 131 | } 132 | } 133 | 134 | 135 | //MARK: - SubRouteMacro 136 | package struct SubRouteMacro: PeerMacro { 137 | package static func expansion(of node: SwiftSyntax.AttributeSyntax, 138 | providingPeersOf declaration: some SwiftSyntax.DeclSyntaxProtocol, 139 | in context: some SwiftSyntaxMacros.MacroExpansionContext) throws -> [SwiftSyntax.DeclSyntax] { 140 | try validate(of: declaration) 141 | return [] 142 | } 143 | 144 | package static func validate(of declaration: some SwiftSyntax.DeclSyntaxProtocol) throws { 145 | guard let enumcaseDecl = declaration.as(EnumCaseDeclSyntax.self) else { 146 | throw SRMacroError.onlyCaseinAnEnum 147 | } 148 | guard let element = enumcaseDecl.elements.first else { throw SRMacroError.onlyCaseinAnEnum } 149 | guard let params = element.parameterClause?.parameters, !params.isEmpty else { 150 | throw SRMacroError.subRouteNotFound 151 | } 152 | guard params.count == 1 else { 153 | throw SRMacroError.declareSubRouteMustBeOnlyOne 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /Sources/sRoutingMacros/RouteObserverMacro.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RouteObserverMacro.swift 3 | // 4 | // 5 | // Created by Thang Kieu on 19/8/24. 6 | // 7 | 8 | import SwiftSyntaxBuilder 9 | import SwiftCompilerPlugin 10 | import SwiftSyntax 11 | import SwiftSyntaxMacros 12 | import Foundation 13 | 14 | private let genericContent = "Content" 15 | 16 | package struct RouteObserverMacro: MemberMacro { 17 | 18 | 19 | package static func expansion(of node: AttributeSyntax, 20 | providingMembersOf declaration: some DeclGroupSyntax, 21 | in context: some MacroExpansionContext) throws -> [DeclSyntax] { 22 | 23 | try _validateDeclaration(declaration) 24 | 25 | let routes = try Self._arguments(of: node) 26 | 27 | var destinationObserve = "" 28 | for route in routes { 29 | destinationObserve += ".navigationDestination(for: \(route).self) { route in route.screen.environment(path) }\n" 30 | } 31 | 32 | let decl: DeclSyntax = """ 33 | @Environment(SRNavigationPath.self) 34 | private var path 35 | 36 | init() { } 37 | 38 | @MainActor 39 | func body(content: Content) -> some View { 40 | content 41 | \(raw: destinationObserve) 42 | } 43 | """ 44 | return [decl] 45 | } 46 | } 47 | 48 | extension RouteObserverMacro: ExtensionMacro { 49 | 50 | package static func expansion( 51 | of node: AttributeSyntax, 52 | attachedTo declaration: some DeclGroupSyntax, 53 | providingExtensionsOf type: some TypeSyntaxProtocol, 54 | conformingTo protocols: [TypeSyntax], 55 | in context: some MacroExpansionContext 56 | ) throws -> [ExtensionDeclSyntax] { 57 | 58 | let decl: DeclSyntax = """ 59 | extension \(raw: type.trimmedDescription): sRouting.SRRouteObserverType {} 60 | """ 61 | let ext = decl.cast(ExtensionDeclSyntax.self) 62 | 63 | return [ext] 64 | } 65 | } 66 | 67 | 68 | extension RouteObserverMacro { 69 | 70 | private static func _arguments(of node: AttributeSyntax) throws -> [String] { 71 | 72 | guard case let .argumentList(arguments) = node.arguments, !arguments.isEmpty 73 | else { throw SRMacroError.missingArguments } 74 | 75 | var routes = [String]() 76 | for labeled in arguments { 77 | guard let exp = labeled.expression.as(MemberAccessExprSyntax.self), 78 | let base = exp.base?.as(DeclReferenceExprSyntax.self) 79 | else { throw SRMacroError.haveToUseMemberAccess } 80 | 81 | let declName = exp.declName.baseName 82 | guard declName.text == "self" 83 | else { throw SRMacroError.haveToUseMemberAccess } 84 | 85 | let input = base.baseName.text 86 | routes.append(input) 87 | } 88 | 89 | guard !routes.isEmpty else { throw SRMacroError.missingArguments } 90 | guard Set(routes).count == routes.count else { throw SRMacroError.duplication } 91 | 92 | return routes 93 | } 94 | 95 | private static func _validateDeclaration(_ declaration: DeclGroupSyntax) throws { 96 | 97 | guard declaration.kind == SwiftSyntax.SyntaxKind.structDecl 98 | else { throw SRMacroError.onlyStruct } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Sources/sRoutingMacros/sRoutingPlugin.swift: -------------------------------------------------------------------------------- 1 | // 2 | // sRoutingPlugin.swift 3 | // 4 | // 5 | // Created by Thang Kieu on 18/03/2024. 6 | // 7 | 8 | import Foundation 9 | import SwiftCompilerPlugin 10 | import SwiftSyntaxMacros 11 | 12 | @main 13 | struct sRoutingPlugin: CompilerPlugin { 14 | let providingMacros: [Macro.Type] = [ 15 | RouteCoordinatorMacro.self, RouteObserverMacro.self, RouteMacro.self, SubRouteMacro.self 16 | ] 17 | } 18 | 19 | package enum SRMacroError: Error, CustomStringConvertible, CustomNSError { 20 | 21 | case onlyStruct 22 | case onlyEnum 23 | case missingArguments 24 | case invalidGenericFormat(String) 25 | case haveToUseMemberAccess 26 | case duplication 27 | case structOrClass 28 | case onlyClass 29 | case invalidRouteType 30 | case missingObservable 31 | case noneRoutes 32 | case redundantConformance 33 | case onlyCaseinAnEnum 34 | case subRouteNotFound 35 | case declareSubRouteMustBeOnlyOne 36 | 37 | package static var errorDomain: String { "com.srouting.macro" } 38 | 39 | package var errorCode: Int { 40 | switch self { 41 | case .onlyStruct: 42 | -500 43 | case .missingArguments: 44 | -501 45 | case .invalidGenericFormat: 46 | -502 47 | case .haveToUseMemberAccess: 48 | -503 49 | case .duplication: 50 | -504 51 | case .structOrClass: 52 | -505 53 | case .onlyClass: 54 | -506 55 | case .invalidRouteType: 56 | -507 57 | case .missingObservable: 58 | -508 59 | case .onlyEnum: 60 | -509 61 | case .noneRoutes: 62 | -510 63 | case .redundantConformance: 64 | -511 65 | case .onlyCaseinAnEnum: 66 | -512 67 | case .subRouteNotFound: 68 | -513 69 | case .declareSubRouteMustBeOnlyOne: 70 | -514 71 | } 72 | } 73 | 74 | package var description: String { 75 | switch self { 76 | case .onlyEnum: 77 | return "Only enums are supported." 78 | case .onlyStruct: 79 | return "Only structs are supported." 80 | case .missingArguments: 81 | return "Missing arguments." 82 | case .invalidGenericFormat(let name): 83 | return "Use 'struct \(name): View where Content: View' instead." 84 | case .haveToUseMemberAccess: 85 | return "Use `YourRoute.self` instead." 86 | case .duplication: 87 | return "Duplicate definition." 88 | case .structOrClass: 89 | return "Only structs or classes are supported." 90 | case .onlyClass: 91 | return "Only classes are supported." 92 | case .invalidRouteType: 93 | return "Route type must conform to SRRoute." 94 | case .missingObservable: 95 | return "Missing @Observable macro." 96 | case .noneRoutes: 97 | return "Empty route declaration." 98 | case .redundantConformance: 99 | return "Redundant conformance to SRRoute." 100 | case .onlyCaseinAnEnum: 101 | return "Can only be attached to a case inside an enum" 102 | case .subRouteNotFound: 103 | return "No subroute was found in the current enum case. Did you forget to add an associated value that conforms to SRRoute" 104 | case .declareSubRouteMustBeOnlyOne: 105 | return "Enum cases annotated with @sSubRoute must have exactly one associated value of a type that conforms to SRRoute." 106 | } 107 | } 108 | 109 | package var errorUserInfo: [String : Any] { 110 | [NSLocalizedDescriptionKey: description] 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Tests/sRoutingTests/Route/Routes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Routes.swift 3 | // 4 | // 5 | // Created by ThangKieu on 7/1/21. 6 | // 7 | 8 | import SwiftUI 9 | @testable import sRouting 10 | 11 | enum TestErrorRoute: SRAlertRoute { 12 | case timeOut 13 | var titleKey: LocalizedStringKey { "Error" } 14 | var actions: some View { Button("Ok") { } } 15 | var message: some View { Text("Time out!") } 16 | } 17 | 18 | enum TestPopover: SRPopoverRoute { 19 | 20 | case testPopover 21 | 22 | var identifier: String { "Popover identifier" } 23 | var content: some View { 24 | VStack { 25 | Button("OK") { 26 | 27 | } 28 | Button("Cancel") { 29 | } 30 | } 31 | } 32 | } 33 | 34 | enum TestDialog: SRConfirmationDialogRoute { 35 | case confirmOK 36 | var titleKey: LocalizedStringKey { "Confirm" } 37 | var identifier: String { "Your question?" } 38 | var actions: some View { 39 | VStack { 40 | Button("OK") { 41 | 42 | } 43 | Button("Cancel") { 44 | } 45 | } 46 | } 47 | var message: some View { Text(identifier) } 48 | var titleVisibility: Visibility { .visible } 49 | } 50 | 51 | @sRoute 52 | enum TestRoute { 53 | 54 | typealias AlertRoute = TestErrorRoute 55 | typealias ConfirmationDialogRoute = TestDialog 56 | typealias PopoverRoute = TestPopover 57 | 58 | case home 59 | case emptyScreen 60 | case setting 61 | 62 | var screen: some View { 63 | EmptyView() 64 | } 65 | } 66 | 67 | @sRoute 68 | enum AppRoute { 69 | case main(SRRouter) 70 | case login 71 | 72 | @MainActor @ViewBuilder 73 | var screen: some View { 74 | switch self { 75 | case .main(let route): 76 | TestScreen(router: route, tests: nil) 77 | case .login: 78 | EmptyView() 79 | } 80 | 81 | } 82 | } 83 | 84 | @sRouteCoordinator(tabs:["home", "setting"], stacks: "testStack") 85 | final class Coordinator { } 86 | 87 | @sRouteObserver(TestRoute.self) 88 | struct RouteObserver { } 89 | -------------------------------------------------------------------------------- /Tests/sRoutingTests/Testcases/AsyncActionTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncActionTests.swift 3 | // sRouting 4 | // 5 | // Created by Thang Kieu on 14/1/25. 6 | // 7 | 8 | @testable import sRouting 9 | import Foundation 10 | import Testing 11 | 12 | @Suite("AsyncAction Tests") 13 | struct AsyncActionTests { 14 | 15 | @Test 16 | func asyncAtion() async throws { 17 | let action = AsyncAction { value in 18 | String(value) 19 | } 20 | let result = try await action.asyncExecute(1) 21 | #expect(result == "1") 22 | } 23 | 24 | @Test 25 | func asyncActionInput() async throws { 26 | let action = AsyncActionPut { value in 27 | #expect(value == 1) 28 | } 29 | try await action.asyncExecute(1) 30 | 31 | } 32 | 33 | @Test 34 | func asynActionOutput() async throws { 35 | let action = AsyncActionGet { 36 | 1 37 | } 38 | let result = try await action.asyncExecute() 39 | #expect(result == 1) 40 | } 41 | 42 | @Test 43 | func testAsyncActionVoidOutput() async throws { 44 | let waiter = Waiter() 45 | let asyncAction = AsyncAction { input in 46 | #expect(input == 42) 47 | waiter.fulfill() 48 | } 49 | asyncAction.execute(42) 50 | try await waiter.waiting() 51 | } 52 | 53 | @Test 54 | func testAsyncActionVoidInputAndOutput() async throws { 55 | let waiter = Waiter() 56 | let asyncAction = AsyncAction { 57 | waiter.fulfill() 58 | } 59 | asyncAction.execute() 60 | try await waiter.waiting() 61 | } 62 | 63 | @Test 64 | func testAsyncActionNotEqual() { 65 | let action1 = AsyncAction { _ in "Action1" } 66 | let action2 = AsyncAction { _ in "Action2" } 67 | let isEqual = action1 == action2 68 | #expect(isEqual == false) 69 | } 70 | 71 | @Test 72 | func testAsyncActionHashable() { 73 | let action1 = AsyncAction { _ in "Action1" } 74 | let action2 = action1 75 | let isEqual = action1 == action2 76 | #expect(isEqual) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Tests/sRoutingTests/Testcases/CoordinatorRouteTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoordinatorRouteTests.swift 3 | // sRouting 4 | // 5 | // Created by Thang Kieu on 3/5/25. 6 | // 7 | 8 | import Testing 9 | import ViewInspector 10 | @testable import sRouting 11 | 12 | 13 | @Suite("Test CoordinatorRoute") 14 | @MainActor 15 | struct CoordinatorRouteTests { 16 | 17 | let context = SRContext() 18 | let coordinator = Coordinator() 19 | let router = SRRouter(TestRoute.self) 20 | 21 | @Test 22 | func testOpenCoordinator() async throws { 23 | let sut = TestCoordinatorView(context: context, coordinator: coordinator, router: router) 24 | ViewHosting.host(view: sut) 25 | try await Task.sleep(for: .milliseconds(100)) 26 | router.openCoordinator(route: TestRoute.home, with: .sheet) 27 | try await Task.sleep(for: .milliseconds(100)) 28 | #expect(context.coordinatorRoute?.route.path == TestRoute.home.path) 29 | #expect(context.coordinatorRoute?.triggerKind == .sheet) 30 | } 31 | 32 | @Test 33 | func testInitializer() { 34 | let route = CoordinatorRoute(route: TestRoute.home, triggerKind: .sheet) 35 | #expect(route.route.path == TestRoute.home.path) 36 | #expect(route.triggerKind == .sheet) 37 | } 38 | 39 | @Test 40 | func testEqulity() { 41 | let route = CoordinatorRoute(route: TestRoute.home, triggerKind: .sheet) 42 | let route2 = CoordinatorRoute(route: TestRoute.home, triggerKind: .sheet) 43 | #expect(route == route2) 44 | } 45 | 46 | @Test 47 | func testNoneEqulity() async throws { 48 | let route = CoordinatorRoute(route: TestRoute.home, triggerKind: .sheet) 49 | try await Task.sleep(for: .milliseconds(100)) 50 | let route2 = CoordinatorRoute(route: TestRoute.home, triggerKind: .sheet) 51 | #expect(route != route2) 52 | } 53 | 54 | @Test 55 | func testHashable() async throws { 56 | let route = CoordinatorRoute(route: TestRoute.home, triggerKind: .sheet) 57 | try await Task.sleep(for: .milliseconds(100)) 58 | let route2 = CoordinatorRoute(route: TestRoute.home, triggerKind: .sheet) 59 | #expect(route.hashValue != route2.hashValue) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Tests/sRoutingTests/Testcases/NavigationPathTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NavigationPathTests.swift 3 | // 4 | // 5 | // Created by Thang Kieu on 20/8/24. 6 | // 7 | 8 | import Foundation 9 | import Testing 10 | @testable import sRouting 11 | 12 | @Suite("Testing SRNavigationPath") 13 | @MainActor 14 | struct NavigationPathTests { 15 | 16 | let path = SRNavigationPath() 17 | 18 | @Test 19 | func testMatchingStack() throws { 20 | path.push(to: TestRoute.home) 21 | path.push(to: TestRoute.emptyScreen) 22 | let stack = path.stack 23 | #expect(stack.count == 2) 24 | let firstPath = try #require(stack.first) 25 | #expect(firstPath.contains(TestRoute.Paths.home.rawValue)) 26 | let lastPath = try #require(stack.last) 27 | #expect(lastPath.contains(TestRoute.Paths.emptyScreen.rawValue)) 28 | } 29 | 30 | @Test 31 | func testPopToTarget() throws { 32 | path.push(to: TestRoute.emptyScreen) 33 | path.push(to: TestRoute.home) 34 | path.push(to: TestRoute.emptyScreen) 35 | path.push(to: TestRoute.emptyScreen) 36 | path.push(to: TestRoute.emptyScreen) 37 | 38 | path.pop(to: TestRoute.Paths.home) 39 | 40 | let stack = path.stack 41 | #expect(stack.count == 2) 42 | let lastPath = try #require(stack.last) 43 | #expect(lastPath.contains(TestRoute.Paths.home.rawValue)) 44 | } 45 | 46 | @Test 47 | func testPopToRoot() { 48 | path.push(to: TestRoute.emptyScreen) 49 | path.push(to: TestRoute.home) 50 | path.push(to: TestRoute.emptyScreen) 51 | path.push(to: TestRoute.emptyScreen) 52 | path.push(to: TestRoute.emptyScreen) 53 | 54 | path.popToRoot() 55 | #expect(path.stack.isEmpty) 56 | } 57 | 58 | @Test 59 | func testPop() throws { 60 | path.push(to: TestRoute.home) 61 | path.push(to: TestRoute.emptyScreen) 62 | 63 | path.pop() 64 | let stack = path.stack 65 | let firstPath = try #require(stack.first) 66 | #expect(firstPath.contains(TestRoute.Paths.home.stringValue)) 67 | #expect(stack.count == 1) 68 | } 69 | } 70 | 71 | 72 | -------------------------------------------------------------------------------- /Tests/sRoutingTests/Testcases/RouterTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RouterTests.swift 3 | // 4 | // 5 | // Created by ThangKieu on 7/2/21. 6 | // 7 | 8 | import SwiftUI 9 | import ViewInspector 10 | import Testing 11 | 12 | @testable import sRouting 13 | 14 | @Suite("Test Router functionality") 15 | @MainActor 16 | struct RouterTests { 17 | 18 | let router = SRRouter(TestRoute.self) 19 | let coordinator = Coordinator() 20 | let context = SRContext() 21 | 22 | @Test 23 | func testSelectTabbarItem() async throws { 24 | var tabIndex = 0 25 | let sut = SRRootView(context: context, coordinator: coordinator) { 26 | TestScreen(router: router, tests: .none) 27 | .onChange(of: router.transition) { oldValue, newValue in 28 | tabIndex = newValue.tabIndex?.intValue ?? -1 29 | } 30 | } 31 | ViewHosting.host(view: sut) 32 | router.selectTabbar(at: Coordinator.SRTabItem.setting) 33 | try await Task.sleep(for: .milliseconds(10)) 34 | #expect(tabIndex == Coordinator.SRTabItem.setting.intValue) 35 | } 36 | 37 | @Test 38 | func testTrigger() async throws { 39 | var transition: SRTransition? 40 | let sut = SRRootView(context: context, coordinator: coordinator) { 41 | TestScreen(router: router, tests: .none).onChange(of: router.transition) { oldValue, newValue in 42 | transition = newValue 43 | } 44 | } 45 | ViewHosting.host(view: sut) 46 | router.trigger(to: .emptyScreen, with: .push) 47 | try await Task.sleep(for: .milliseconds(10)) 48 | let tran = try #require(transition) 49 | #expect(tran.type == .push) 50 | #expect(tran.route != nil) 51 | } 52 | 53 | @Test 54 | func testShowError() async throws { 55 | var transition: SRTransition? 56 | let sut = SRRootView(context: context, coordinator: coordinator) { 57 | TestScreen(router: router, tests: .none).onChange(of: router.transition) { oldValue, newValue in 58 | transition = newValue 59 | } 60 | } 61 | ViewHosting.host(view: sut) 62 | router.show(alert: .timeOut) 63 | try await Task.sleep(for: .milliseconds(10)) 64 | #expect(transition?.type == .alert) 65 | #expect(transition?.alert != nil) 66 | } 67 | 68 | @Test 69 | func testShowAlert() async throws { 70 | var transition: SRTransition? 71 | let sut = SRRootView(context: context, coordinator: coordinator) { 72 | TestScreen(router: router, tests: .none).onChange(of: router.transition) { oldValue, newValue in 73 | transition = newValue 74 | } 75 | } 76 | ViewHosting.host(view: sut) 77 | router.show(alert: .timeOut) 78 | try await Task.sleep(for: .milliseconds(10)) 79 | #expect(transition?.type == .alert) 80 | #expect(transition?.alert != nil) 81 | } 82 | 83 | @Test 84 | func testDismiss() async throws { 85 | var transition: SRTransition? 86 | let sut = SRRootView(context: context, coordinator: coordinator) { 87 | TestScreen(router: router, tests: .none).onChange(of: router.transition) { oldValue, newValue in 88 | transition = newValue 89 | } 90 | } 91 | ViewHosting.host(view: sut) 92 | router.dismiss() 93 | try await Task.sleep(for: .milliseconds(10)) 94 | #expect(transition?.type == .dismiss) 95 | } 96 | 97 | @Test 98 | func testDismissAll() async throws { 99 | var transition: SRTransition? 100 | let sut = SRRootView(context: context, coordinator: coordinator) { 101 | TestScreen(router: router, tests: .none).onChange(of: router.transition) { oldValue, newValue in 102 | transition = newValue 103 | } 104 | } 105 | ViewHosting.host(view: sut) 106 | router.dismissAll() 107 | try await Task.sleep(for: .milliseconds(10)) 108 | #expect(transition?.type == .dismissAll) 109 | } 110 | 111 | @Test 112 | func testPop() async throws { 113 | var transition: SRTransition? 114 | let sut = SRRootView(context: context, coordinator: coordinator) { 115 | TestScreen(router: router, tests: .none).onChange(of: router.transition) { oldValue, newValue in 116 | transition = newValue 117 | } 118 | } 119 | ViewHosting.host(view: sut) 120 | router.pop() 121 | try await Task.sleep(for: .milliseconds(10)) 122 | #expect(transition?.type == .pop) 123 | } 124 | 125 | @Test 126 | func testPopToRoot() async throws { 127 | var transition: SRTransition? 128 | let sut = SRRootView(context: context, coordinator: coordinator) { 129 | TestScreen(router: router, tests: .none).onChange(of: router.transition) { oldValue, newValue in 130 | transition = newValue 131 | } 132 | } 133 | ViewHosting.host(view: sut) 134 | router.popToRoot() 135 | try await Task.sleep(for: .milliseconds(10)) 136 | #expect(transition?.type == .popToRoot) 137 | } 138 | 139 | @Test 140 | func testPopToRoute() async throws { 141 | var transition: SRTransition? 142 | let sut = SRRootView(context: context, coordinator: coordinator) { 143 | TestScreen(router: router, tests: .none).onChange(of: router.transition) { oldValue, newValue in 144 | transition = newValue 145 | } 146 | } 147 | ViewHosting.host(view: sut) 148 | router.pop(to: TestRoute.Paths.emptyScreen) 149 | try await Task.sleep(for: .milliseconds(10)) 150 | #expect(transition?.type == .popToRoute) 151 | #expect(transition?.popToPath != nil) 152 | } 153 | 154 | @Test 155 | func testOpenWindowId() async throws { 156 | var transition: SRWindowTransition? 157 | let sut = SRRootView(context: context, coordinator: coordinator) { 158 | TestScreen(router: router, tests: .init(didOpenWindow: { tran in 159 | transition = tran 160 | })) 161 | } 162 | ViewHosting.host(view: sut) 163 | router.openWindow(windowTrans: .init(windowId: "window_id")) 164 | try await Task.sleep(for: .milliseconds(10)) 165 | #expect(transition?.windowId == "window_id") 166 | #expect(transition?.windowValue == nil) 167 | } 168 | 169 | @Test 170 | func testOpenWindowValue() async throws { 171 | var transition: SRWindowTransition? 172 | let sut = SRRootView(context: context, coordinator: coordinator) { 173 | TestScreen(router: router, tests: .init(didOpenWindow: { tran in 174 | transition = tran 175 | })) 176 | } 177 | ViewHosting.host(view: sut) 178 | router.openWindow(windowTrans: .init(value: 123)) 179 | try await Task.sleep(for: .milliseconds(10)) 180 | #expect(transition?.windowValue?.hashValue == 123.hashValue) 181 | #expect(transition?.windowId == nil) 182 | } 183 | 184 | @Test 185 | func testOpenWindowIdAndValue() async throws { 186 | var transition: SRWindowTransition? 187 | let sut = SRRootView(context: context, coordinator: coordinator) { 188 | TestScreen(router: router, tests: .init(didOpenWindow: { tran in 189 | transition = tran 190 | })) 191 | } 192 | ViewHosting.host(view: sut) 193 | router.openWindow(windowTrans: .init(windowId: "window_id", value: 123)) 194 | try await Task.sleep(for: .milliseconds(10)) 195 | #expect(transition?.windowValue?.hashValue == 123.hashValue) 196 | #expect(transition?.windowId == "window_id") 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /Tests/sRoutingTests/Testcases/SwitcherTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwitcherTests.swift 3 | // sRouting 4 | // 5 | // Created by Thang Kieu on 27/4/25. 6 | // 7 | 8 | import Testing 9 | @testable import sRouting 10 | 11 | @Suite("Test SRSwitcher functionality") 12 | struct SRSwitcherTests { 13 | 14 | @Test 15 | func testSRSwitcherInitialization() { 16 | let initialRoute = TestRoute.home 17 | let switcher = SRSwitcher(route: initialRoute) 18 | #expect(switcher.route == initialRoute) 19 | } 20 | 21 | @Test 22 | func testSRSwitcherSwitchToValidRoute() { 23 | let initialRoute = TestRoute.home 24 | let newRoute = TestRoute.setting 25 | let switcher = SRSwitcher(route: initialRoute) 26 | 27 | switcher.switchTo(route: newRoute) 28 | #expect(switcher.route == newRoute, "Switcher should switch to the new route.") 29 | } 30 | 31 | @Test 32 | func testSwitcherBoxSwitchToRoute() { 33 | let initialRoute = TestRoute.home 34 | let newRoute = TestRoute.setting 35 | let switcher = SRSwitcher(route: initialRoute) 36 | let switcherBox = SwitcherBox(switcher: switcher) 37 | 38 | switcherBox.switchTo(route: newRoute) 39 | 40 | #expect(switcher.route == newRoute) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Tests/sRoutingTests/Testcases/TestContext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestContext.swift 3 | // sRouting 4 | // 5 | // Created by Thang Kieu on 6/4/25. 6 | // 7 | 8 | @testable import sRouting 9 | import Foundation 10 | import Testing 11 | import ViewInspector 12 | 13 | @Suite("SRContext Tests") 14 | @MainActor 15 | struct TestContext { 16 | 17 | let context = SRContext() 18 | let coordinator = Coordinator() 19 | let router = SRRouter(TestRoute.self) 20 | 21 | @Test 22 | func testRouting() async throws { 23 | let sut = SRRootView(context: context, coordinator: coordinator) { 24 | NavigationRootView(path: coordinator.testStackPath) { 25 | TestScreen(router: router, tests: .none) 26 | } 27 | } 28 | 29 | ViewHosting.host(view: sut) 30 | 31 | await context.routing(.push(route: TestRoute.emptyScreen), 32 | .dismissAll, 33 | .popToRoot, .push(route: TestRoute.home), 34 | .push(route: TestRoute.setting)) 35 | #expect(coordinator.testStackPath.navPath.count == 2) 36 | let lastPath = try #require(coordinator.testStackPath.stack.last) 37 | #expect(lastPath.contains(TestRoute.setting.path)) 38 | } 39 | 40 | @Test 41 | func testDismissAll() { 42 | #expect(context.dismissAllSignal == false) 43 | context.dismissAll() 44 | #expect(context.dismissAllSignal == true) 45 | } 46 | 47 | @Test 48 | func testOpenCoordinator() { 49 | context.openCoordinator(.init(route: TestRoute.home, triggerKind: .sheet)) 50 | #expect(context.coordinatorRoute?.route.path == TestRoute.home.path) 51 | #expect(context.coordinatorRoute?.triggerKind == .sheet) 52 | } 53 | 54 | @Test 55 | func testRegisterCoordinator() { 56 | context.registerActiveCoordinator(coordinator) 57 | #expect(context.coordinatorCount == 1) 58 | } 59 | 60 | @Test 61 | func testTopCoordinator() async throws { 62 | context.registerActiveCoordinator(coordinator) 63 | try await Task.sleep(for: .milliseconds(100)) 64 | let other = Coordinator() 65 | context.registerActiveCoordinator(other) 66 | #expect(context.coordinatorCount == 2) 67 | #expect(context.topCoordinator === other) 68 | } 69 | 70 | @Test 71 | func resignActiveCoordinator() async throws { 72 | context.registerActiveCoordinator(coordinator) 73 | try await Task.sleep(for: .milliseconds(100)) 74 | let other = Coordinator() 75 | context.registerActiveCoordinator(other) 76 | #expect(context.coordinatorCount == 2) 77 | context.resignActiveCoordinator(identifier: other.identifier) 78 | #expect(context.coordinatorCount == 1) 79 | #expect(context.topCoordinator === coordinator) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Tests/sRoutingTests/Testcases/TestInitializers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestInitializers.swift 3 | // 4 | // 5 | // Created by ThangKieu on 7/1/21. 6 | // 7 | 8 | import Testing 9 | import SwiftUI 10 | import ViewInspector 11 | 12 | @testable import sRouting 13 | 14 | 15 | @Suite("Test SRTransition initializers") 16 | @MainActor 17 | struct TestInitializers { 18 | 19 | let coordinator = Coordinator() 20 | 21 | @Test 22 | func testInitialTransitionWithSelectTab() { 23 | let sut = SRTransition(selectTab: Coordinator.SRTabItem.setting) 24 | #expect(sut.alert == nil) 25 | #expect(sut.route == nil) 26 | #expect(sut.tabIndex?.intValue == Coordinator.SRTabItem.setting.intValue) 27 | #expect(sut.type == .selectTab) 28 | } 29 | 30 | @Test 31 | func testInitalTrasitionWithType() { 32 | let sut = SRTransition(with: .dismissAll) 33 | #expect(sut.alert == nil) 34 | #expect(sut.route == nil) 35 | #expect(sut.tabIndex == nil) 36 | #expect(sut.type == .dismissAll) 37 | } 38 | 39 | @Test 40 | func testInitTransitionWithAlert() { 41 | let sut = SRTransition.init(with: .timeOut) 42 | #expect(sut.alert != nil) 43 | #expect(sut.route == nil) 44 | #expect(sut.tabIndex == nil) 45 | #expect(sut.type == .alert) 46 | } 47 | 48 | @Test 49 | func testInitTransitionWithRoute() { 50 | let sut = SRTransition(with: .emptyScreen, and: .sheet) 51 | #expect(sut.route != nil) 52 | #expect(sut.alert == nil) 53 | #expect(sut.tabIndex == nil) 54 | #expect(sut.type == .sheet) 55 | } 56 | 57 | @Test 58 | func testInitTransitionNoneType() throws { 59 | let sut = SRTransition.none 60 | #expect(sut.alert == nil) 61 | #expect(sut.route == nil) 62 | #expect(sut.tabIndex == nil) 63 | #expect(sut.type == .none) 64 | #expect(sut == SRTransition.none) 65 | } 66 | 67 | @Test 68 | func testInitTransitionType() { 69 | SRTriggerType.allCases.forEach { triggerType in 70 | let transitionType = SRTransitionKind(with: triggerType) 71 | #expect(transitionType.rawValue == triggerType.rawValue) 72 | } 73 | } 74 | 75 | @Test 76 | func testTransitionType() { 77 | SRTransitionKind.allCases.forEach { type in 78 | #expect(type.description == "TransitionType - \(type)") 79 | } 80 | } 81 | 82 | @Test 83 | func testTriggerType() { 84 | SRTriggerType.allCases.forEach { type in 85 | #expect(type.description == "TriggerType - \(type)") 86 | } 87 | } 88 | 89 | @Test 90 | func testInitialNavigaitonStack() async { 91 | let sut = NavigationStack(path: coordinator.testStackPath) { 92 | Text("screen") 93 | .routeObserver(RouteObserver.self) 94 | } 95 | ViewHosting.host(view: sut) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Tests/sRoutingTests/Testcases/TestModifiers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestModifiers.swift 3 | // 4 | // 5 | // Created by Thang Kieu on 29/03/2024. 6 | // 7 | 8 | import Testing 9 | import ViewInspector 10 | import SwiftUI 11 | 12 | @testable import sRouting 13 | 14 | @Suite("Test ViewModifiers actions") 15 | @MainActor 16 | struct TestModifiers { 17 | 18 | let router = SRRouter(TestRoute.self) 19 | let coordinator = Coordinator() 20 | let context = SRContext() 21 | 22 | @Test 23 | func testOnDismissAll() async throws { 24 | var isEnter = false 25 | let sut = SRRootView(context: context, coordinator: coordinator) { 26 | TestScreen(router: router, tests: .none).onDismissAllChange { 27 | isEnter.toggle() 28 | } 29 | } 30 | ViewHosting.host(view: sut) 31 | router.dismissAll() 32 | try await Task.sleep(for: .milliseconds(50)) 33 | #expect(isEnter) 34 | } 35 | 36 | @Test 37 | func testOnNavigationStackChange() async throws { 38 | var pathCount = 0 39 | let sut = SRRootView(context: context, coordinator: coordinator) { 40 | NavigationStack(path: coordinator.testStackPath) { 41 | TestScreen(router: router, tests: .none).onNaviStackChange { oldPaths, newPaths in 42 | pathCount = newPaths.count 43 | } 44 | } 45 | } 46 | ViewHosting.host(view: sut) 47 | router.trigger(to: .emptyScreen, with: .push) 48 | try await Task.sleep(for: .milliseconds(50)) 49 | #expect(pathCount == 1) 50 | } 51 | 52 | @Test 53 | func testOnTabSelectionChange() async throws { 54 | var tabIndex = 0 55 | let sut = SRRootView(context: context, coordinator: coordinator) { 56 | @Bindable var emitter = coordinator.emitter 57 | TabView(selection: $emitter.tabSelection) { 58 | TestScreen(router: router, tests: .none) 59 | .tabItem { 60 | Label("Home", systemImage: "house") 61 | }.tag(0) 62 | TestScreen(router: router, tests: .none).tabItem { 63 | Label("Setting", systemImage: "gear") 64 | }.tag(1) 65 | } 66 | .onTabSelectionChange { value in 67 | tabIndex = value 68 | } 69 | } 70 | ViewHosting.host(view: sut) 71 | router.selectTabbar(at: Coordinator.SRTabItem.setting) 72 | try await Task.sleep(for: .milliseconds(50)) 73 | #expect(tabIndex == Coordinator.SRTabItem.setting.intValue) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Tests/sRoutingTests/Testcases/TestSwitchView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestSwitchView.swift 3 | // sRouting 4 | // 5 | // Created by Thang Kieu on 27/4/25. 6 | // 7 | 8 | import Testing 9 | import ViewInspector 10 | @testable import sRouting 11 | 12 | @Suite("Test SRSwitchView") @MainActor 13 | struct SwitchViewTests { 14 | 15 | let router = SRRouter(AppRoute.self) 16 | let coordinator = Coordinator() 17 | let context = SRContext() 18 | 19 | @Test 20 | func testSwitchView() { 21 | let sut = SRRootView(context: context, coordinator: coordinator) { 22 | SRSwitchView(startingWith: AppRoute.main(router)) 23 | } 24 | ViewHosting.host(view: sut) 25 | router.switchTo(route: AppRoute.login) 26 | } 27 | 28 | @Test 29 | func testSwitchRouteView() async throws { 30 | let waiter = Waiter() 31 | let sut = SRRootView(context: context, coordinator: coordinator) { 32 | SRSwitchRouteView(startingWith: AppRoute.main(router)) { route in 33 | if route == AppRoute.login { 34 | waiter.fulfill() 35 | } 36 | return route.screen 37 | } 38 | } 39 | ViewHosting.host(view: sut) 40 | try await Task.sleep(for: .microseconds(100)) 41 | router.switchTo(route: AppRoute.login) 42 | try await waiter.waiting(timeout: .seconds(1)) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Tests/sRoutingTests/Testcases/TypeTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TypeTests.swift 3 | // 4 | // 5 | // Created by Thang Kieu on 20/8/24. 6 | // 7 | 8 | import Testing 9 | import SwiftUI 10 | 11 | @testable import sRouting 12 | 13 | @Suite("Test AnyRoute and SRRoutingError") 14 | @MainActor 15 | struct TypeTests { 16 | 17 | @Test 18 | func testAnyRoute() { 19 | let route = AnyRoute(route: TestRoute.home) 20 | #expect(route.path.contains(TestRoute.home.path)) 21 | } 22 | 23 | @Test 24 | func testSRRoutingError() { 25 | let error = SRRoutingError.unsupportedDecodable 26 | #expect(error.errorCode < .zero) 27 | #expect(error.localizedDescription == error.description) 28 | #expect(SRRoutingError.errorDomain == "com.srouting") 29 | } 30 | 31 | @Test 32 | func testSRAlertRoute() { 33 | let route = TestErrorRoute.timeOut 34 | #expect(route.titleKey == "Error") 35 | } 36 | 37 | @Test 38 | func testDefaultAlertRoute() { 39 | let route = AlertEmptyRoute() 40 | #expect(route.titleKey == "Alert") 41 | } 42 | 43 | @Test 44 | func testDialog() async throws { 45 | let route = TestDialog.confirmOK 46 | #expect(route.titleKey == "Confirm") 47 | #expect(route.titleVisibility == .visible) 48 | } 49 | 50 | @Test 51 | func testDefaultDialog() async throws { 52 | let route = ConfirmationDialogEmptyRoute() 53 | #expect(route.titleKey == "") 54 | #expect(route.titleVisibility == .hidden) 55 | } 56 | } 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /Tests/sRoutingTests/Views/TestScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestScreen.swift 3 | // 4 | // 5 | // Created by ThangKieu on 7/8/21. 6 | // 7 | 8 | import SwiftUI 9 | import ViewInspector 10 | import Testing 11 | 12 | @testable import sRouting 13 | 14 | struct TestScreen: View where R: SRRoute { 15 | 16 | let router: SRRouter 17 | let tests: UnitTestActions>? 18 | 19 | var body: some View { 20 | Text("TestScreen.Screen.Text") 21 | .onRouting(of: router, tests: tests) 22 | } 23 | } 24 | 25 | 26 | struct DialogScreen: View { 27 | 28 | let router: SRRouter 29 | let tests: UnitTestActions>? 30 | 31 | var body: some View { 32 | Text("DialogScreen.Text") 33 | .onDialogRouting(of: router, for: .confirmOK, tests: tests) 34 | } 35 | } 36 | 37 | 38 | struct PopoverScreen: View { 39 | 40 | let router: SRRouter 41 | let tests: UnitTestActions>? 42 | 43 | var body: some View { 44 | Text("DialogScreen.Text") 45 | .onPopoverRouting(of: router, for: .testPopover, tests: tests) 46 | } 47 | } 48 | 49 | struct TestCoordinatorView: View { 50 | 51 | let context: SRContext 52 | let coordinator: Coordinator 53 | let router: SRRouter 54 | 55 | var body: some View { 56 | SRRootView(context: context, coordinator: coordinator) { 57 | TestScreen(router: router, tests: nil) 58 | } 59 | .onRoutingCoordinator(TestRoute.self, context: context) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Tests/sRoutingTests/Waiter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Waiter.swift 3 | // sRouting 4 | // 5 | // Created by Thang Kieu on 21/1/25. 6 | // 7 | 8 | import Foundation 9 | 10 | struct TimeoutError: CustomNSError, CustomStringConvertible { 11 | 12 | static let errorDomain: String = "com.unittesting" 13 | let errorCode: Int = -408 14 | let description: String = "Timeout!" 15 | 16 | var errorUserInfo: [String : Any] { 17 | [NSLocalizedDescriptionKey: description] 18 | } 19 | } 20 | 21 | actor Waiter { 22 | 23 | private var continuation: AsyncThrowingStream.Continuation? 24 | 25 | private lazy var stream: AsyncThrowingStream = .init {[weak self] continuation in 26 | self?.continuation = continuation 27 | } 28 | 29 | private var isFinished: Bool = false 30 | 31 | var task: Task? 32 | 33 | func waiting(timeout: Duration = .milliseconds(500)) async throws { 34 | 35 | guard !isFinished else { return } 36 | 37 | task = Task {[weak self] in 38 | try await Task.sleep(for: timeout) 39 | try Task.checkCancellation() 40 | await self?.timeout() 41 | } 42 | 43 | for try await _ in stream { } 44 | } 45 | 46 | nonisolated func fulfill() { 47 | Task(priority: .high) { 48 | await finish() 49 | } 50 | } 51 | 52 | private func timeout() { 53 | continuation?.finish(throwing: TimeoutError()) 54 | continuation = nil 55 | isFinished = true 56 | task = nil 57 | } 58 | 59 | private func finish() { 60 | continuation?.finish() 61 | task?.cancel() 62 | isFinished = true 63 | continuation = nil 64 | task = nil 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Tests/sRoutingTests/sRoutingMacrosTests/CoordinatorMacroTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoordinatorMacroTest.swift 3 | // 4 | // 5 | // Created by Thang Kieu on 20/8/24. 6 | // 7 | 8 | 9 | import SwiftSyntax 10 | import SwiftSyntaxBuilder 11 | import SwiftSyntaxMacros 12 | import SwiftSyntaxMacrosTestSupport 13 | import XCTest 14 | import Observation 15 | @testable import sRouting 16 | 17 | #if canImport(sRoutingMacros) && os(macOS) 18 | import sRoutingMacros 19 | 20 | final class CoordinatorMacroTest: XCTestCase { 21 | 22 | func testCoordinatorMacroImp() async throws { 23 | assertMacroExpansion(""" 24 | @sRouteCoordinator(tabs: ["homeItem", "settingItem"], stacks: "home", "setting") 25 | class Coordinator { 26 | 27 | } 28 | """, expandedSource: """ 29 | class Coordinator { 30 | 31 | let identifier: String 32 | 33 | @MainActor let rootRouter = SRRouter(AnyRoute.self) 34 | 35 | @MainActor let emitter = SRCoordinatorEmitter() 36 | 37 | @MainActor private lazy var navStacks = [SRNavStack.home: SRNavigationPath(coordinator: self), SRNavStack.setting: SRNavigationPath(coordinator: self)] 38 | 39 | @MainActor 40 | var homePath: SRNavigationPath { 41 | navStacks[SRNavStack.home]! 42 | } 43 | 44 | @MainActor 45 | var settingPath: SRNavigationPath { 46 | navStacks[SRNavStack.setting]! 47 | } 48 | 49 | @MainActor var navigationStacks: [SRNavigationPath] { 50 | navStacks.map(\\.value) 51 | } 52 | 53 | @MainActor private(set) var activeNavigation: SRNavigationPath? 54 | 55 | @MainActor init() { 56 | self.identifier = "Coordinator" + "_" + TimeIdentifier.now.id 57 | } 58 | 59 | @MainActor 60 | func registerActiveNavigation(_ navigationPath: SRNavigationPath) { 61 | activeNavigation = navigationPath 62 | } 63 | 64 | } 65 | 66 | extension Coordinator: sRouting.SRRouteCoordinatorType { 67 | 68 | enum SRTabItem: Int, IntRawRepresentable { 69 | case homeItem 70 | case settingItem 71 | } 72 | 73 | enum SRNavStack: String, Sendable { 74 | case home 75 | case setting 76 | } 77 | } 78 | """, macros:testMacros) 79 | } 80 | 81 | func testNoneStructOrClassImp() async throws { 82 | 83 | let dianosSpec = DiagnosticSpec(message: SRMacroError.onlyClass.description, line: 1, column: 1,severity: .error) 84 | 85 | assertMacroExpansion(""" 86 | @sRouteCoordinator(tabs: ["homeItem", "settingItem"], stacks: "home", "setting") 87 | enum Coordinator { 88 | } 89 | """, expandedSource:""" 90 | enum Coordinator { 91 | } 92 | """, 93 | diagnostics: [dianosSpec, dianosSpec], 94 | macros: testMacros) 95 | } 96 | 97 | func testMissingArgsImp() async throws { 98 | 99 | let dianosSpec = DiagnosticSpec(message: SRMacroError.missingArguments.description, line: 1, column: 1,severity: .error) 100 | 101 | assertMacroExpansion(""" 102 | @sRouteCoordinator(tabs: [], stacks: "") 103 | class Coordinator { 104 | } 105 | """, expandedSource:""" 106 | class Coordinator { 107 | } 108 | """, 109 | diagnostics: [dianosSpec, dianosSpec], 110 | macros: testMacros) 111 | } 112 | 113 | func testTabItemDuplicationArgsImp() async throws { 114 | 115 | let dianosSpec = DiagnosticSpec(message: SRMacroError.duplication.description, line: 1, column: 1,severity: .error) 116 | 117 | assertMacroExpansion(""" 118 | @sRouteCoordinator(tabs: ["home","home"], stacks: "home") 119 | class Coordinator { 120 | } 121 | """, expandedSource:""" 122 | class Coordinator { 123 | } 124 | """, 125 | diagnostics: [dianosSpec, dianosSpec], 126 | macros: testMacros) 127 | } 128 | 129 | func testStackDuplicationArgsImp() async throws { 130 | 131 | let dianosSpec = DiagnosticSpec(message: SRMacroError.duplication.description, line: 1, column: 1,severity: .error) 132 | 133 | assertMacroExpansion(""" 134 | @sRouteCoordinator(tabs: ["home", "setting"], stacks: "home", "setting", "home") 135 | class Coordinator { 136 | } 137 | """, expandedSource:""" 138 | class Coordinator { 139 | } 140 | """, 141 | diagnostics: [dianosSpec, dianosSpec], 142 | macros: testMacros) 143 | } 144 | } 145 | #endif 146 | -------------------------------------------------------------------------------- /Tests/sRoutingTests/sRoutingMacrosTests/RouteMacroTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RouteMacroTest.swift 3 | // sRouting 4 | // 5 | // Created by Thang Kieu on 15/4/25. 6 | // 7 | 8 | import SwiftSyntax 9 | import SwiftSyntaxBuilder 10 | import SwiftSyntaxMacros 11 | import SwiftSyntaxMacrosTestSupport 12 | import XCTest 13 | import Observation 14 | @testable import sRouting 15 | 16 | #if canImport(sRoutingMacros) && os(macOS) 17 | import sRoutingMacros 18 | 19 | final class RouteMacroTest: XCTestCase { 20 | 21 | func testRouteMacroImp() async throws { 22 | assertMacroExpansion(""" 23 | @sRoute 24 | enum HomeRoute { 25 | case message 26 | case home 27 | case accountManagement(String) 28 | case eventSetting 29 | } 30 | """, expandedSource: """ 31 | enum HomeRoute { 32 | case message 33 | case home 34 | case accountManagement(String) 35 | case eventSetting 36 | } 37 | 38 | extension HomeRoute: sRouting.SRRoute { 39 | 40 | enum Paths: String, StringRawRepresentable { 41 | case message = "hr_message" 42 | case home = "hr_home" 43 | case accountManagement = "hr_accountmanagement" 44 | case eventSetting = "hr_eventsetting" 45 | } 46 | 47 | nonisolated var path: String { 48 | switch self { 49 | case .message: 50 | return Paths.message.rawValue 51 | case .home: 52 | return Paths.home.rawValue 53 | case .accountManagement: 54 | return Paths.accountManagement.rawValue 55 | case .eventSetting: 56 | return Paths.eventSetting.rawValue 57 | } 58 | } 59 | } 60 | """, macros:testMacros) 61 | } 62 | 63 | func testRouteMacroHasSubRouteImp() async throws { 64 | assertMacroExpansion(""" 65 | @sRoute 66 | enum HomeRoute { 67 | case message 68 | case home 69 | case accountManagement(String) 70 | @sSubRoute 71 | case eventSetting(EventRoute) 72 | } 73 | """, expandedSource: """ 74 | enum HomeRoute { 75 | case message 76 | case home 77 | case accountManagement(String) 78 | case eventSetting(EventRoute) 79 | } 80 | 81 | extension HomeRoute: sRouting.SRRoute { 82 | 83 | enum Paths: String, StringRawRepresentable { 84 | case message = "hr_message" 85 | case home = "hr_home" 86 | case accountManagement = "hr_accountmanagement" 87 | } 88 | 89 | nonisolated var path: String { 90 | switch self { 91 | case .message: 92 | return Paths.message.rawValue 93 | case .home: 94 | return Paths.home.rawValue 95 | case .accountManagement: 96 | return Paths.accountManagement.rawValue 97 | case .eventSetting(let route): 98 | return route.path 99 | } 100 | } 101 | } 102 | """, macros:testMacros) 103 | } 104 | 105 | func testNoneCaseEnumImp() async throws { 106 | 107 | let dianosSpec = DiagnosticSpec(message: SRMacroError.onlyCaseinAnEnum.description, line: 1, column: 1,severity: .error) 108 | 109 | assertMacroExpansion(""" 110 | @sSubRoute 111 | struct HomeRoute { 112 | } 113 | """, expandedSource:""" 114 | struct HomeRoute { 115 | } 116 | """, 117 | diagnostics: [dianosSpec], 118 | macros: testMacros) 119 | } 120 | 121 | func testNoneCaseEnumWithSubRouteImp() async throws { 122 | 123 | let dianosSpec = DiagnosticSpec(message: SRMacroError.subRouteNotFound.description, line: 3, column: 5,severity: .error) 124 | 125 | assertMacroExpansion(""" 126 | @sRoute 127 | enum HomeRoute { 128 | @sSubRoute 129 | case home 130 | } 131 | """, expandedSource:""" 132 | enum HomeRoute { 133 | case home 134 | } 135 | 136 | extension HomeRoute: sRouting.SRRoute { 137 | 138 | nonisolated var path: String { 139 | switch self { 140 | case .home(let route): 141 | return route.path 142 | } 143 | } 144 | } 145 | """, 146 | diagnostics: [dianosSpec], 147 | macros: testMacros) 148 | } 149 | 150 | func testNoneCaseEnumWithInvalidParamsImp() async throws { 151 | 152 | let dianosSpec = DiagnosticSpec(message: SRMacroError.declareSubRouteMustBeOnlyOne.description, line: 3, column: 5,severity: .error) 153 | 154 | assertMacroExpansion(""" 155 | @sRoute 156 | enum HomeRoute { 157 | @sSubRoute 158 | case home(Int, String) 159 | } 160 | """, expandedSource:""" 161 | enum HomeRoute { 162 | case home(Int, String) 163 | } 164 | 165 | extension HomeRoute: sRouting.SRRoute { 166 | 167 | nonisolated var path: String { 168 | switch self { 169 | case .home(let route): 170 | return route.path 171 | } 172 | } 173 | } 174 | """, 175 | diagnostics: [dianosSpec], 176 | macros: testMacros) 177 | } 178 | 179 | func testNoneEnumImp() async throws { 180 | 181 | let dianosSpec = DiagnosticSpec(message: SRMacroError.onlyEnum.description, line: 1, column: 1,severity: .error) 182 | 183 | assertMacroExpansion(""" 184 | @sRoute 185 | struct HomeRoute { 186 | } 187 | """, expandedSource:""" 188 | struct HomeRoute { 189 | } 190 | """, 191 | diagnostics: [dianosSpec], 192 | macros: testMacros) 193 | } 194 | 195 | func testCasesDuplicationArgsImp() async throws { 196 | 197 | let dianosSpec = DiagnosticSpec(message: SRMacroError.duplication.description, line: 1, column: 1,severity: .error) 198 | 199 | assertMacroExpansion(""" 200 | @sRoute 201 | enum HomeRoute { 202 | case home 203 | case home(String) 204 | } 205 | """, expandedSource:""" 206 | enum HomeRoute { 207 | case home 208 | case home(String) 209 | } 210 | """, 211 | diagnostics: [dianosSpec], 212 | macros: testMacros) 213 | } 214 | 215 | func testRedudantConformanceImp() async throws { 216 | 217 | let dianosSpec = DiagnosticSpec(message: SRMacroError.redundantConformance.description, line: 1, column: 1,severity: .error) 218 | 219 | assertMacroExpansion(""" 220 | @sRoute 221 | enum HomeRoute: SRRoute { 222 | case home 223 | case home(String) 224 | } 225 | """, expandedSource:""" 226 | enum HomeRoute: SRRoute { 227 | case home 228 | case home(String) 229 | } 230 | """, 231 | diagnostics: [dianosSpec], 232 | macros: testMacros) 233 | } 234 | 235 | func testEmptyEnumImp() async throws { 236 | let dianosSpec = DiagnosticSpec(message: SRMacroError.noneRoutes.description, line: 1, column: 1,severity: .error) 237 | 238 | assertMacroExpansion(""" 239 | @sRoute 240 | enum HomeRoute { 241 | 242 | } 243 | """, expandedSource:""" 244 | enum HomeRoute { 245 | 246 | } 247 | """, 248 | diagnostics: [dianosSpec], 249 | macros: testMacros) 250 | } 251 | } 252 | #endif 253 | -------------------------------------------------------------------------------- /Tests/sRoutingTests/sRoutingMacrosTests/RouteObserverMacroTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RouteObserverMacroTest.swift 3 | // 4 | // 5 | // Created by Thang Kieu on 20/8/24. 6 | // 7 | 8 | import SwiftSyntax 9 | import SwiftSyntaxBuilder 10 | import SwiftSyntaxMacros 11 | import SwiftSyntaxMacrosTestSupport 12 | import XCTest 13 | @testable import sRouting 14 | 15 | #if canImport(sRoutingMacros) && os(macOS) 16 | import sRoutingMacros 17 | 18 | final class RouteObserverMacroTest: XCTestCase { 19 | 20 | func testRouteObserverMacroImp() async throws { 21 | assertMacroExpansion(""" 22 | @sRouteObserver(HomeRoute.self, SettingRoute.self) 23 | struct RouteObserver { 24 | 25 | } 26 | """, expandedSource:""" 27 | struct RouteObserver { 28 | 29 | @Environment(SRNavigationPath.self) 30 | private var path 31 | 32 | init() { } 33 | 34 | @MainActor 35 | func body(content: Content) -> some View { 36 | content 37 | .navigationDestination(for: HomeRoute.self) { route in route.screen.environment(path) } 38 | .navigationDestination(for: SettingRoute.self) { route in route.screen.environment(path) } 39 | 40 | } 41 | 42 | } 43 | 44 | extension RouteObserver: sRouting.SRRouteObserverType { 45 | } 46 | """, 47 | macros: testMacros) 48 | } 49 | 50 | func testNoneStructImp() async throws { 51 | assertMacroExpansion(""" 52 | @sRouteObserver(HomeRoute.self, SettingRoute.self) 53 | class RouteObserver { 54 | } 55 | """, expandedSource:""" 56 | class RouteObserver { 57 | } 58 | 59 | extension RouteObserver: sRouting.SRRouteObserverType { 60 | } 61 | """, 62 | diagnostics: [.init(message: SRMacroError.onlyStruct.description, line: 1, column: 1,severity: .error)], 63 | macros: testMacros) 64 | } 65 | 66 | func testRouteDuplication() async throws { 67 | assertMacroExpansion(""" 68 | @sRouteObserver(HomeRoute.self, SettingRoute.self, HomeRoute.self) 69 | struct RouteObserver { 70 | } 71 | """, expandedSource:""" 72 | struct RouteObserver { 73 | } 74 | 75 | extension RouteObserver: sRouting.SRRouteObserverType { 76 | } 77 | """, 78 | diagnostics: [.init(message: SRMacroError.duplication.description, line: 1, column: 1,severity: .error)], 79 | macros: testMacros) 80 | } 81 | } 82 | #endif 83 | -------------------------------------------------------------------------------- /Tests/sRoutingTests/sRoutingMacrosTests/TestingMacro.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestingMacro.swift 3 | // 4 | // 5 | // Created by Thang Kieu on 20/8/24. 6 | // 7 | 8 | import SwiftSyntax 9 | import SwiftSyntaxBuilder 10 | import SwiftSyntaxMacros 11 | import SwiftSyntaxMacrosTestSupport 12 | import XCTest 13 | import sRouting 14 | 15 | 16 | 17 | #if canImport(sRoutingMacros) && os(macOS) 18 | 19 | import sRoutingMacros 20 | 21 | let testMacros: [String: Macro.Type] = [ 22 | "sRouteCoordinator": RouteCoordinatorMacro.self, 23 | "sRouteObserver": RouteObserverMacro.self, 24 | "sRoute": RouteMacro.self, 25 | "sSubRoute": SubRouteMacro.self 26 | ] 27 | 28 | #endif 29 | --------------------------------------------------------------------------------