├── .github └── workflows │ └── swift.yml ├── .gitignore ├── Example ├── Example.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcshareddata │ │ └── xcschemes │ │ └── Example.xcscheme └── Example │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── ContentView.swift │ ├── ExampleApp.swift │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ ├── Route1View.swift │ └── Route2View.swift ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── Voyager │ ├── BaseVoyagerView.swift │ ├── DeeplinkHandler.swift │ ├── NavVoyagerView.swift │ ├── PresentationOption.swift │ ├── Route.swift │ ├── Router.swift │ ├── TabRouter.swift │ └── TabVoyagerView.swift └── Tests └── VoyagerTests ├── RouterTests.swift ├── TabRouterTests.swift ├── TestDeeplinkHandler.swift └── TestRoute.swift /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Swift project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: Swift 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: macos-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Build 20 | run: swift build -v 21 | - name: Run tests 22 | run: swift test -v 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # MacOS 2 | .DS_Store 3 | 4 | /Packages 5 | xcuserdata/ 6 | DerivedData/ 7 | 8 | # SPM 9 | .build/ 10 | .swiftpm/ 11 | .netrc 12 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | D516E04B2B8D177C00715BF7 /* ExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D516E04A2B8D177C00715BF7 /* ExampleApp.swift */; }; 11 | D516E04D2B8D177C00715BF7 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D516E04C2B8D177C00715BF7 /* ContentView.swift */; }; 12 | D516E0762B8D17AF00715BF7 /* Voyager in Frameworks */ = {isa = PBXBuildFile; productRef = D516E0752B8D17AF00715BF7 /* Voyager */; }; 13 | D516E0782B8D17DF00715BF7 /* Route1View.swift in Sources */ = {isa = PBXBuildFile; fileRef = D516E0772B8D17DF00715BF7 /* Route1View.swift */; }; 14 | D516E07A2B8D17E500715BF7 /* Route2View.swift in Sources */ = {isa = PBXBuildFile; fileRef = D516E0792B8D17E500715BF7 /* Route2View.swift */; }; 15 | D516E07C2B8D18EA00715BF7 /* Preview Content in Resources */ = {isa = PBXBuildFile; fileRef = D516E07B2B8D18EA00715BF7 /* Preview Content */; }; 16 | D516E07E2B8D190100715BF7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D516E07D2B8D190100715BF7 /* Assets.xcassets */; }; 17 | /* End PBXBuildFile section */ 18 | 19 | /* Begin PBXContainerItemProxy section */ 20 | D516E0582B8D177D00715BF7 /* PBXContainerItemProxy */ = { 21 | isa = PBXContainerItemProxy; 22 | containerPortal = D516E03F2B8D177C00715BF7 /* Project object */; 23 | proxyType = 1; 24 | remoteGlobalIDString = D516E0462B8D177C00715BF7; 25 | remoteInfo = Example; 26 | }; 27 | D516E0622B8D177D00715BF7 /* PBXContainerItemProxy */ = { 28 | isa = PBXContainerItemProxy; 29 | containerPortal = D516E03F2B8D177C00715BF7 /* Project object */; 30 | proxyType = 1; 31 | remoteGlobalIDString = D516E0462B8D177C00715BF7; 32 | remoteInfo = Example; 33 | }; 34 | /* End PBXContainerItemProxy section */ 35 | 36 | /* Begin PBXFileReference section */ 37 | D516E0472B8D177C00715BF7 /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; 38 | D516E04A2B8D177C00715BF7 /* ExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleApp.swift; sourceTree = ""; }; 39 | D516E04C2B8D177C00715BF7 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 40 | D516E0572B8D177D00715BF7 /* ExampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ExampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 41 | D516E0612B8D177D00715BF7 /* ExampleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ExampleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 42 | D516E0772B8D17DF00715BF7 /* Route1View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Route1View.swift; sourceTree = ""; }; 43 | D516E0792B8D17E500715BF7 /* Route2View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Route2View.swift; sourceTree = ""; }; 44 | D516E07B2B8D18EA00715BF7 /* Preview Content */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "Preview Content"; sourceTree = ""; }; 45 | D516E07D2B8D190100715BF7 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 46 | /* End PBXFileReference section */ 47 | 48 | /* Begin PBXFrameworksBuildPhase section */ 49 | D516E0442B8D177C00715BF7 /* Frameworks */ = { 50 | isa = PBXFrameworksBuildPhase; 51 | buildActionMask = 2147483647; 52 | files = ( 53 | D516E0762B8D17AF00715BF7 /* Voyager in Frameworks */, 54 | ); 55 | runOnlyForDeploymentPostprocessing = 0; 56 | }; 57 | D516E0542B8D177D00715BF7 /* Frameworks */ = { 58 | isa = PBXFrameworksBuildPhase; 59 | buildActionMask = 2147483647; 60 | files = ( 61 | ); 62 | runOnlyForDeploymentPostprocessing = 0; 63 | }; 64 | D516E05E2B8D177D00715BF7 /* Frameworks */ = { 65 | isa = PBXFrameworksBuildPhase; 66 | buildActionMask = 2147483647; 67 | files = ( 68 | ); 69 | runOnlyForDeploymentPostprocessing = 0; 70 | }; 71 | /* End PBXFrameworksBuildPhase section */ 72 | 73 | /* Begin PBXGroup section */ 74 | D516E03E2B8D177C00715BF7 = { 75 | isa = PBXGroup; 76 | children = ( 77 | D516E0492B8D177C00715BF7 /* Example */, 78 | D516E0482B8D177C00715BF7 /* Products */, 79 | ); 80 | sourceTree = ""; 81 | }; 82 | D516E0482B8D177C00715BF7 /* Products */ = { 83 | isa = PBXGroup; 84 | children = ( 85 | D516E0472B8D177C00715BF7 /* Example.app */, 86 | D516E0572B8D177D00715BF7 /* ExampleTests.xctest */, 87 | D516E0612B8D177D00715BF7 /* ExampleUITests.xctest */, 88 | ); 89 | name = Products; 90 | sourceTree = ""; 91 | }; 92 | D516E0492B8D177C00715BF7 /* Example */ = { 93 | isa = PBXGroup; 94 | children = ( 95 | D516E04A2B8D177C00715BF7 /* ExampleApp.swift */, 96 | D516E04C2B8D177C00715BF7 /* ContentView.swift */, 97 | D516E0772B8D17DF00715BF7 /* Route1View.swift */, 98 | D516E0792B8D17E500715BF7 /* Route2View.swift */, 99 | D516E07B2B8D18EA00715BF7 /* Preview Content */, 100 | D516E07D2B8D190100715BF7 /* Assets.xcassets */, 101 | ); 102 | path = Example; 103 | sourceTree = ""; 104 | }; 105 | /* End PBXGroup section */ 106 | 107 | /* Begin PBXNativeTarget section */ 108 | D516E0462B8D177C00715BF7 /* Example */ = { 109 | isa = PBXNativeTarget; 110 | buildConfigurationList = D516E06B2B8D177D00715BF7 /* Build configuration list for PBXNativeTarget "Example" */; 111 | buildPhases = ( 112 | D516E0432B8D177C00715BF7 /* Sources */, 113 | D516E0442B8D177C00715BF7 /* Frameworks */, 114 | D516E0452B8D177C00715BF7 /* Resources */, 115 | ); 116 | buildRules = ( 117 | ); 118 | dependencies = ( 119 | ); 120 | name = Example; 121 | packageProductDependencies = ( 122 | D516E0752B8D17AF00715BF7 /* Voyager */, 123 | ); 124 | productName = Example; 125 | productReference = D516E0472B8D177C00715BF7 /* Example.app */; 126 | productType = "com.apple.product-type.application"; 127 | }; 128 | D516E0562B8D177D00715BF7 /* ExampleTests */ = { 129 | isa = PBXNativeTarget; 130 | buildConfigurationList = D516E06E2B8D177D00715BF7 /* Build configuration list for PBXNativeTarget "ExampleTests" */; 131 | buildPhases = ( 132 | D516E0532B8D177D00715BF7 /* Sources */, 133 | D516E0542B8D177D00715BF7 /* Frameworks */, 134 | D516E0552B8D177D00715BF7 /* Resources */, 135 | ); 136 | buildRules = ( 137 | ); 138 | dependencies = ( 139 | D516E0592B8D177D00715BF7 /* PBXTargetDependency */, 140 | ); 141 | name = ExampleTests; 142 | productName = ExampleTests; 143 | productReference = D516E0572B8D177D00715BF7 /* ExampleTests.xctest */; 144 | productType = "com.apple.product-type.bundle.unit-test"; 145 | }; 146 | D516E0602B8D177D00715BF7 /* ExampleUITests */ = { 147 | isa = PBXNativeTarget; 148 | buildConfigurationList = D516E0712B8D177D00715BF7 /* Build configuration list for PBXNativeTarget "ExampleUITests" */; 149 | buildPhases = ( 150 | D516E05D2B8D177D00715BF7 /* Sources */, 151 | D516E05E2B8D177D00715BF7 /* Frameworks */, 152 | D516E05F2B8D177D00715BF7 /* Resources */, 153 | ); 154 | buildRules = ( 155 | ); 156 | dependencies = ( 157 | D516E0632B8D177D00715BF7 /* PBXTargetDependency */, 158 | ); 159 | name = ExampleUITests; 160 | productName = ExampleUITests; 161 | productReference = D516E0612B8D177D00715BF7 /* ExampleUITests.xctest */; 162 | productType = "com.apple.product-type.bundle.ui-testing"; 163 | }; 164 | /* End PBXNativeTarget section */ 165 | 166 | /* Begin PBXProject section */ 167 | D516E03F2B8D177C00715BF7 /* Project object */ = { 168 | isa = PBXProject; 169 | attributes = { 170 | BuildIndependentTargetsInParallel = 1; 171 | LastSwiftUpdateCheck = 1500; 172 | LastUpgradeCheck = 1500; 173 | TargetAttributes = { 174 | D516E0462B8D177C00715BF7 = { 175 | CreatedOnToolsVersion = 15.0; 176 | }; 177 | D516E0562B8D177D00715BF7 = { 178 | CreatedOnToolsVersion = 15.0; 179 | TestTargetID = D516E0462B8D177C00715BF7; 180 | }; 181 | D516E0602B8D177D00715BF7 = { 182 | CreatedOnToolsVersion = 15.0; 183 | TestTargetID = D516E0462B8D177C00715BF7; 184 | }; 185 | }; 186 | }; 187 | buildConfigurationList = D516E0422B8D177C00715BF7 /* Build configuration list for PBXProject "Example" */; 188 | compatibilityVersion = "Xcode 14.0"; 189 | developmentRegion = en; 190 | hasScannedForEncodings = 0; 191 | knownRegions = ( 192 | en, 193 | Base, 194 | ); 195 | mainGroup = D516E03E2B8D177C00715BF7; 196 | packageReferences = ( 197 | D516E0742B8D17AF00715BF7 /* XCRemoteSwiftPackageReference "Voyager" */, 198 | ); 199 | productRefGroup = D516E0482B8D177C00715BF7 /* Products */; 200 | projectDirPath = ""; 201 | projectRoot = ""; 202 | targets = ( 203 | D516E0462B8D177C00715BF7 /* Example */, 204 | D516E0562B8D177D00715BF7 /* ExampleTests */, 205 | D516E0602B8D177D00715BF7 /* ExampleUITests */, 206 | ); 207 | }; 208 | /* End PBXProject section */ 209 | 210 | /* Begin PBXResourcesBuildPhase section */ 211 | D516E0452B8D177C00715BF7 /* Resources */ = { 212 | isa = PBXResourcesBuildPhase; 213 | buildActionMask = 2147483647; 214 | files = ( 215 | D516E07E2B8D190100715BF7 /* Assets.xcassets in Resources */, 216 | D516E07C2B8D18EA00715BF7 /* Preview Content in Resources */, 217 | ); 218 | runOnlyForDeploymentPostprocessing = 0; 219 | }; 220 | D516E0552B8D177D00715BF7 /* Resources */ = { 221 | isa = PBXResourcesBuildPhase; 222 | buildActionMask = 2147483647; 223 | files = ( 224 | ); 225 | runOnlyForDeploymentPostprocessing = 0; 226 | }; 227 | D516E05F2B8D177D00715BF7 /* Resources */ = { 228 | isa = PBXResourcesBuildPhase; 229 | buildActionMask = 2147483647; 230 | files = ( 231 | ); 232 | runOnlyForDeploymentPostprocessing = 0; 233 | }; 234 | /* End PBXResourcesBuildPhase section */ 235 | 236 | /* Begin PBXSourcesBuildPhase section */ 237 | D516E0432B8D177C00715BF7 /* Sources */ = { 238 | isa = PBXSourcesBuildPhase; 239 | buildActionMask = 2147483647; 240 | files = ( 241 | D516E0782B8D17DF00715BF7 /* Route1View.swift in Sources */, 242 | D516E04D2B8D177C00715BF7 /* ContentView.swift in Sources */, 243 | D516E04B2B8D177C00715BF7 /* ExampleApp.swift in Sources */, 244 | D516E07A2B8D17E500715BF7 /* Route2View.swift in Sources */, 245 | ); 246 | runOnlyForDeploymentPostprocessing = 0; 247 | }; 248 | D516E0532B8D177D00715BF7 /* Sources */ = { 249 | isa = PBXSourcesBuildPhase; 250 | buildActionMask = 2147483647; 251 | files = ( 252 | ); 253 | runOnlyForDeploymentPostprocessing = 0; 254 | }; 255 | D516E05D2B8D177D00715BF7 /* Sources */ = { 256 | isa = PBXSourcesBuildPhase; 257 | buildActionMask = 2147483647; 258 | files = ( 259 | ); 260 | runOnlyForDeploymentPostprocessing = 0; 261 | }; 262 | /* End PBXSourcesBuildPhase section */ 263 | 264 | /* Begin PBXTargetDependency section */ 265 | D516E0592B8D177D00715BF7 /* PBXTargetDependency */ = { 266 | isa = PBXTargetDependency; 267 | target = D516E0462B8D177C00715BF7 /* Example */; 268 | targetProxy = D516E0582B8D177D00715BF7 /* PBXContainerItemProxy */; 269 | }; 270 | D516E0632B8D177D00715BF7 /* PBXTargetDependency */ = { 271 | isa = PBXTargetDependency; 272 | target = D516E0462B8D177C00715BF7 /* Example */; 273 | targetProxy = D516E0622B8D177D00715BF7 /* PBXContainerItemProxy */; 274 | }; 275 | /* End PBXTargetDependency section */ 276 | 277 | /* Begin XCBuildConfiguration section */ 278 | D516E0692B8D177D00715BF7 /* Debug */ = { 279 | isa = XCBuildConfiguration; 280 | buildSettings = { 281 | ALWAYS_SEARCH_USER_PATHS = NO; 282 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 283 | CLANG_ANALYZER_NONNULL = YES; 284 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 285 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 286 | CLANG_ENABLE_MODULES = YES; 287 | CLANG_ENABLE_OBJC_ARC = YES; 288 | CLANG_ENABLE_OBJC_WEAK = YES; 289 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 290 | CLANG_WARN_BOOL_CONVERSION = YES; 291 | CLANG_WARN_COMMA = YES; 292 | CLANG_WARN_CONSTANT_CONVERSION = YES; 293 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 294 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 295 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 296 | CLANG_WARN_EMPTY_BODY = YES; 297 | CLANG_WARN_ENUM_CONVERSION = YES; 298 | CLANG_WARN_INFINITE_RECURSION = YES; 299 | CLANG_WARN_INT_CONVERSION = YES; 300 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 301 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 302 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 303 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 304 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 305 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 306 | CLANG_WARN_STRICT_PROTOTYPES = YES; 307 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 308 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 309 | CLANG_WARN_UNREACHABLE_CODE = YES; 310 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 311 | COPY_PHASE_STRIP = NO; 312 | DEBUG_INFORMATION_FORMAT = dwarf; 313 | ENABLE_STRICT_OBJC_MSGSEND = YES; 314 | ENABLE_TESTABILITY = YES; 315 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 316 | GCC_C_LANGUAGE_STANDARD = gnu17; 317 | GCC_DYNAMIC_NO_PIC = NO; 318 | GCC_NO_COMMON_BLOCKS = YES; 319 | GCC_OPTIMIZATION_LEVEL = 0; 320 | GCC_PREPROCESSOR_DEFINITIONS = ( 321 | "DEBUG=1", 322 | "$(inherited)", 323 | ); 324 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 325 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 326 | GCC_WARN_UNDECLARED_SELECTOR = YES; 327 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 328 | GCC_WARN_UNUSED_FUNCTION = YES; 329 | GCC_WARN_UNUSED_VARIABLE = YES; 330 | IPHONEOS_DEPLOYMENT_TARGET = 17.0; 331 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 332 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 333 | MTL_FAST_MATH = YES; 334 | ONLY_ACTIVE_ARCH = YES; 335 | SDKROOT = iphoneos; 336 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 337 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 338 | }; 339 | name = Debug; 340 | }; 341 | D516E06A2B8D177D00715BF7 /* Release */ = { 342 | isa = XCBuildConfiguration; 343 | buildSettings = { 344 | ALWAYS_SEARCH_USER_PATHS = NO; 345 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 346 | CLANG_ANALYZER_NONNULL = YES; 347 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 348 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 349 | CLANG_ENABLE_MODULES = YES; 350 | CLANG_ENABLE_OBJC_ARC = YES; 351 | CLANG_ENABLE_OBJC_WEAK = YES; 352 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 353 | CLANG_WARN_BOOL_CONVERSION = YES; 354 | CLANG_WARN_COMMA = YES; 355 | CLANG_WARN_CONSTANT_CONVERSION = YES; 356 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 357 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 358 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 359 | CLANG_WARN_EMPTY_BODY = YES; 360 | CLANG_WARN_ENUM_CONVERSION = YES; 361 | CLANG_WARN_INFINITE_RECURSION = YES; 362 | CLANG_WARN_INT_CONVERSION = YES; 363 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 364 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 365 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 366 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 367 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 368 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 369 | CLANG_WARN_STRICT_PROTOTYPES = YES; 370 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 371 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 372 | CLANG_WARN_UNREACHABLE_CODE = YES; 373 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 374 | COPY_PHASE_STRIP = NO; 375 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 376 | ENABLE_NS_ASSERTIONS = NO; 377 | ENABLE_STRICT_OBJC_MSGSEND = YES; 378 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 379 | GCC_C_LANGUAGE_STANDARD = gnu17; 380 | GCC_NO_COMMON_BLOCKS = YES; 381 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 382 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 383 | GCC_WARN_UNDECLARED_SELECTOR = YES; 384 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 385 | GCC_WARN_UNUSED_FUNCTION = YES; 386 | GCC_WARN_UNUSED_VARIABLE = YES; 387 | IPHONEOS_DEPLOYMENT_TARGET = 17.0; 388 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 389 | MTL_ENABLE_DEBUG_INFO = NO; 390 | MTL_FAST_MATH = YES; 391 | SDKROOT = iphoneos; 392 | SWIFT_COMPILATION_MODE = wholemodule; 393 | VALIDATE_PRODUCT = YES; 394 | }; 395 | name = Release; 396 | }; 397 | D516E06C2B8D177D00715BF7 /* Debug */ = { 398 | isa = XCBuildConfiguration; 399 | buildSettings = { 400 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 401 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 402 | CODE_SIGN_STYLE = Automatic; 403 | CURRENT_PROJECT_VERSION = 1; 404 | DEVELOPMENT_ASSET_PATHS = "\"Example/Preview Content\""; 405 | ENABLE_PREVIEWS = YES; 406 | GENERATE_INFOPLIST_FILE = YES; 407 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 408 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 409 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 410 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 411 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 412 | LD_RUNPATH_SEARCH_PATHS = ( 413 | "$(inherited)", 414 | "@executable_path/Frameworks", 415 | ); 416 | MARKETING_VERSION = 1.0; 417 | PRODUCT_BUNDLE_IDENTIFIER = com.voyager.Example; 418 | PRODUCT_NAME = "$(TARGET_NAME)"; 419 | SWIFT_EMIT_LOC_STRINGS = YES; 420 | SWIFT_VERSION = 5.0; 421 | TARGETED_DEVICE_FAMILY = "1,2"; 422 | }; 423 | name = Debug; 424 | }; 425 | D516E06D2B8D177D00715BF7 /* Release */ = { 426 | isa = XCBuildConfiguration; 427 | buildSettings = { 428 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 429 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 430 | CODE_SIGN_STYLE = Automatic; 431 | CURRENT_PROJECT_VERSION = 1; 432 | DEVELOPMENT_ASSET_PATHS = "\"Example/Preview Content\""; 433 | ENABLE_PREVIEWS = YES; 434 | GENERATE_INFOPLIST_FILE = YES; 435 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 436 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 437 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 438 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 439 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 440 | LD_RUNPATH_SEARCH_PATHS = ( 441 | "$(inherited)", 442 | "@executable_path/Frameworks", 443 | ); 444 | MARKETING_VERSION = 1.0; 445 | PRODUCT_BUNDLE_IDENTIFIER = com.voyager.Example; 446 | PRODUCT_NAME = "$(TARGET_NAME)"; 447 | SWIFT_EMIT_LOC_STRINGS = YES; 448 | SWIFT_VERSION = 5.0; 449 | TARGETED_DEVICE_FAMILY = "1,2"; 450 | }; 451 | name = Release; 452 | }; 453 | D516E06F2B8D177D00715BF7 /* Debug */ = { 454 | isa = XCBuildConfiguration; 455 | buildSettings = { 456 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 457 | BUNDLE_LOADER = "$(TEST_HOST)"; 458 | CODE_SIGN_STYLE = Automatic; 459 | CURRENT_PROJECT_VERSION = 1; 460 | GENERATE_INFOPLIST_FILE = YES; 461 | IPHONEOS_DEPLOYMENT_TARGET = 17.0; 462 | MARKETING_VERSION = 1.0; 463 | PRODUCT_BUNDLE_IDENTIFIER = com.voyager.ExampleTests; 464 | PRODUCT_NAME = "$(TARGET_NAME)"; 465 | SWIFT_EMIT_LOC_STRINGS = NO; 466 | SWIFT_VERSION = 5.0; 467 | TARGETED_DEVICE_FAMILY = "1,2"; 468 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Example"; 469 | }; 470 | name = Debug; 471 | }; 472 | D516E0702B8D177D00715BF7 /* Release */ = { 473 | isa = XCBuildConfiguration; 474 | buildSettings = { 475 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 476 | BUNDLE_LOADER = "$(TEST_HOST)"; 477 | CODE_SIGN_STYLE = Automatic; 478 | CURRENT_PROJECT_VERSION = 1; 479 | GENERATE_INFOPLIST_FILE = YES; 480 | IPHONEOS_DEPLOYMENT_TARGET = 17.0; 481 | MARKETING_VERSION = 1.0; 482 | PRODUCT_BUNDLE_IDENTIFIER = com.voyager.ExampleTests; 483 | PRODUCT_NAME = "$(TARGET_NAME)"; 484 | SWIFT_EMIT_LOC_STRINGS = NO; 485 | SWIFT_VERSION = 5.0; 486 | TARGETED_DEVICE_FAMILY = "1,2"; 487 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Example"; 488 | }; 489 | name = Release; 490 | }; 491 | D516E0722B8D177D00715BF7 /* Debug */ = { 492 | isa = XCBuildConfiguration; 493 | buildSettings = { 494 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 495 | CODE_SIGN_STYLE = Automatic; 496 | CURRENT_PROJECT_VERSION = 1; 497 | GENERATE_INFOPLIST_FILE = YES; 498 | MARKETING_VERSION = 1.0; 499 | PRODUCT_BUNDLE_IDENTIFIER = com.voyager.ExampleUITests; 500 | PRODUCT_NAME = "$(TARGET_NAME)"; 501 | SWIFT_EMIT_LOC_STRINGS = NO; 502 | SWIFT_VERSION = 5.0; 503 | TARGETED_DEVICE_FAMILY = "1,2"; 504 | TEST_TARGET_NAME = Example; 505 | }; 506 | name = Debug; 507 | }; 508 | D516E0732B8D177D00715BF7 /* Release */ = { 509 | isa = XCBuildConfiguration; 510 | buildSettings = { 511 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 512 | CODE_SIGN_STYLE = Automatic; 513 | CURRENT_PROJECT_VERSION = 1; 514 | GENERATE_INFOPLIST_FILE = YES; 515 | MARKETING_VERSION = 1.0; 516 | PRODUCT_BUNDLE_IDENTIFIER = com.voyager.ExampleUITests; 517 | PRODUCT_NAME = "$(TARGET_NAME)"; 518 | SWIFT_EMIT_LOC_STRINGS = NO; 519 | SWIFT_VERSION = 5.0; 520 | TARGETED_DEVICE_FAMILY = "1,2"; 521 | TEST_TARGET_NAME = Example; 522 | }; 523 | name = Release; 524 | }; 525 | /* End XCBuildConfiguration section */ 526 | 527 | /* Begin XCConfigurationList section */ 528 | D516E0422B8D177C00715BF7 /* Build configuration list for PBXProject "Example" */ = { 529 | isa = XCConfigurationList; 530 | buildConfigurations = ( 531 | D516E0692B8D177D00715BF7 /* Debug */, 532 | D516E06A2B8D177D00715BF7 /* Release */, 533 | ); 534 | defaultConfigurationIsVisible = 0; 535 | defaultConfigurationName = Release; 536 | }; 537 | D516E06B2B8D177D00715BF7 /* Build configuration list for PBXNativeTarget "Example" */ = { 538 | isa = XCConfigurationList; 539 | buildConfigurations = ( 540 | D516E06C2B8D177D00715BF7 /* Debug */, 541 | D516E06D2B8D177D00715BF7 /* Release */, 542 | ); 543 | defaultConfigurationIsVisible = 0; 544 | defaultConfigurationName = Release; 545 | }; 546 | D516E06E2B8D177D00715BF7 /* Build configuration list for PBXNativeTarget "ExampleTests" */ = { 547 | isa = XCConfigurationList; 548 | buildConfigurations = ( 549 | D516E06F2B8D177D00715BF7 /* Debug */, 550 | D516E0702B8D177D00715BF7 /* Release */, 551 | ); 552 | defaultConfigurationIsVisible = 0; 553 | defaultConfigurationName = Release; 554 | }; 555 | D516E0712B8D177D00715BF7 /* Build configuration list for PBXNativeTarget "ExampleUITests" */ = { 556 | isa = XCConfigurationList; 557 | buildConfigurations = ( 558 | D516E0722B8D177D00715BF7 /* Debug */, 559 | D516E0732B8D177D00715BF7 /* Release */, 560 | ); 561 | defaultConfigurationIsVisible = 0; 562 | defaultConfigurationName = Release; 563 | }; 564 | /* End XCConfigurationList section */ 565 | 566 | /* Begin XCRemoteSwiftPackageReference section */ 567 | D516E0742B8D17AF00715BF7 /* XCRemoteSwiftPackageReference "Voyager" */ = { 568 | isa = XCRemoteSwiftPackageReference; 569 | repositoryURL = "https://github.com/bryan-vh/Voyager"; 570 | requirement = { 571 | kind = upToNextMajorVersion; 572 | minimumVersion = 1.2.0; 573 | }; 574 | }; 575 | /* End XCRemoteSwiftPackageReference section */ 576 | 577 | /* Begin XCSwiftPackageProductDependency section */ 578 | D516E0752B8D17AF00715BF7 /* Voyager */ = { 579 | isa = XCSwiftPackageProductDependency; 580 | package = D516E0742B8D17AF00715BF7 /* XCRemoteSwiftPackageReference "Voyager" */; 581 | productName = Voyager; 582 | }; 583 | /* End XCSwiftPackageProductDependency section */ 584 | }; 585 | rootObject = D516E03F2B8D177C00715BF7 /* Project object */; 586 | } 587 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "voyager", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/bryan-vh/Voyager", 7 | "state" : { 8 | "revision" : "a3e33156a448d070e32100c1bcbd45f79201e325", 9 | "version" : "1.2.0" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 35 | 41 | 42 | 43 | 46 | 52 | 53 | 54 | 55 | 56 | 66 | 68 | 74 | 75 | 76 | 77 | 83 | 85 | 91 | 92 | 93 | 94 | 96 | 97 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/Example/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // Example 4 | // 5 | // Created by Bryan Van Horn on 2/26/24. 6 | // 7 | 8 | import Voyager 9 | import SwiftUI 10 | 11 | enum ExampleRoute: Route { 12 | case route1 13 | case route2 14 | } 15 | 16 | struct ContentView: View { 17 | 18 | @StateObject var router = Router(root: .route1) 19 | 20 | var body: some View { 21 | NavVoyagerView(router: router) { (route: ExampleRoute) in 22 | switch route { 23 | case .route1: 24 | Route1View() 25 | case .route2: 26 | Route2View() 27 | } 28 | } 29 | } 30 | } 31 | 32 | #Preview { 33 | ContentView() 34 | } 35 | -------------------------------------------------------------------------------- /Example/Example/ExampleApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExampleApp.swift 3 | // Example 4 | // 5 | // Created by Bryan Van Horn on 2/26/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct ExampleApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | ContentView() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Example/Example/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/Example/Route1View.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Route1View.swift 3 | // 4 | 5 | import Voyager 6 | import SwiftUI 7 | 8 | struct Route1View: View { 9 | 10 | @EnvironmentObject var router: Router 11 | 12 | var body: some View { 13 | Text("Route 1") 14 | NavigationLink(value: ExampleRoute.route2) { 15 | Text("Go to route 2") 16 | } 17 | Button("Present route 2 (sheet)") { 18 | router.present(.route2, option: .sheet) 19 | } 20 | } 21 | } 22 | 23 | #Preview { 24 | Route1View() 25 | } 26 | -------------------------------------------------------------------------------- /Example/Example/Route2View.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Route2View.swift 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct Route2View: View { 8 | var body: some View { 9 | Text("Route 2") 10 | } 11 | } 12 | 13 | #Preview { 14 | Route2View() 15 | } 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Bryan Van Horn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Voyager", 8 | platforms: [.iOS(.v16)], 9 | products: [ 10 | // Products define the executables and libraries a package produces, making them visible to other packages. 11 | .library( 12 | name: "Voyager", 13 | targets: ["Voyager"]), 14 | ], 15 | targets: [ 16 | // Targets are the basic building blocks of a package, defining a module or a test suite. 17 | // Targets can depend on other targets in this package and products from dependencies. 18 | .target( 19 | name: "Voyager"), 20 | .testTarget( 21 | name: "VoyagerTests", 22 | dependencies: ["Voyager"]), 23 | ] 24 | ) 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fbryan-vh%2FVoyager%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/bryan-vh/Voyager) 2 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fbryan-vh%2FVoyager%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/bryan-vh/Voyager) 3 | 4 | Voyager 5 | 6 | > Lightweight framework for navigation & routing in SwiftUI 7 | 8 | # Voyager 9 | 10 | **Voyager** empowers developers to power their SwiftUI-based apps with routing that not only applies to navigation, but also tabs. 11 | 12 | ## Installation 13 | 14 | In Xcode add the dependency to your project via *File > Add Packages > Search or Enter Package URL* and use the following url: 15 | ``` 16 | https://github.com/bryan-vh/Voyager.git 17 | ``` 18 | 19 | Once added, import the package in your code: 20 | ```swift 21 | import Voyager 22 | ``` 23 | 24 | ## Usage 25 | 26 | ### Route 27 | ```swift 28 | enum ExampleRoute: Route { 29 | case route1 30 | case route2 31 | case route3(String) 32 | case route4(Int) 33 | } 34 | ``` 35 | 36 | To start off, create an enum that will represent your set of routes. These can be parameterized by using Swift's enum with associated values. 37 | 38 | ### BaseVoyagerView 39 | ```swift 40 | @StateObject var router = Router(root: .route1) 41 | 42 | BaseVoyagerView(router: router) { route in 43 | switch route { 44 | case route1: Route1View() 45 | case route2: Route2View() 46 | // ... 47 | } 48 | } 49 | ``` 50 | 51 | The simplest of all Voyager views. Use when you don't need navigation or tabs. If you do need navigation or tabs, use the corresponding Voyager view below. 52 | 53 | ### NavVoyagerView 54 | ```swift 55 | @StateObject var router = Router(root: .route1) 56 | 57 | NavVoyagerView(router: router) { route in 58 | switch route { 59 | case route1: Route1View() 60 | case route2: Route2View() 61 | // ... 62 | } 63 | } 64 | ``` 65 | 66 | NavVoyagerView uses NavigationStack under the hood so you are able to use NavigationLink views as needed in child views. 67 | 68 | ### TabVoyagerView 69 | ```swift 70 | @StateObject var router = TabRouter(tabs: [.route1, .route2], selected: .route1) 71 | 72 | TabVoyagerView(router: router) { route in 73 | switch route { 74 | case route1: Route1View() 75 | case route2: Route2View() 76 | // ... 77 | } 78 | } tabItem: { route in 79 | // Design a label for your tab item 80 | } 81 | ``` 82 | 83 | TabVoyagerView uses a TabView with an array of NavVoyagerViews under the hood, so navigation works for each tab separately. 84 | 85 | ### Router 86 | ```swift 87 | struct Route1View: View { 88 | 89 | @EnvironmentObject var router: Router 90 | 91 | // You can then use the router to push, pop, present modals, or dismiss as needed. 92 | } 93 | ``` 94 | 95 | You can access a router in any child view of the parent Voyager view. 96 | 97 | ### DeeplinkHandler 98 | ```swift 99 | final class ExampleDeeplinkHandler: DeeplinkHandler { 100 | 101 | override func handleDeeplink(url: URL) -> (ExampleRoute, PresentationOption)? { 102 | // Transform the deeplink into a route with a given presentation option. 103 | } 104 | } 105 | ``` 106 | 107 | By injecting a route-specific **DeeplinkHandler** into a **Router**, you will be able to 108 | handle any deeplinks that would present some route from that router. 109 | 110 | ## License 111 | [MIT License](LICENSE) 112 | -------------------------------------------------------------------------------- /Sources/Voyager/BaseVoyagerView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct BaseVoyagerView: View { 4 | 5 | @ObservedObject private var router: Router 6 | private let content: (T) -> Content 7 | 8 | public init( 9 | router: Router, 10 | @ViewBuilder content: @escaping (T) -> Content 11 | ) { 12 | self.router = router 13 | self.content = content 14 | } 15 | 16 | public var body: some View { 17 | content(router.root) 18 | .sheet(item: $router.sheet) { 19 | router.onDismiss?() 20 | } content: { route in 21 | content(route) 22 | } 23 | .fullScreenCover(item: $router.fullscreenCover) { 24 | router.onDismiss?() 25 | } content: { route in 26 | content(route) 27 | } 28 | .popover(item: $router.popover) { route in 29 | content(route) 30 | } 31 | .environmentObject(router) 32 | .onOpenURL { url in 33 | router.handleDeeplink(url: url) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/Voyager/DeeplinkHandler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | open class DeeplinkHandler { 4 | 5 | public init() {} 6 | 7 | open func handleDeeplink(url: URL) -> (T, PresentationOption)? { 8 | fatalError("Handle the deeplink in your own subclass that implements this method.") 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/Voyager/NavVoyagerView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct NavVoyagerView: View { 4 | 5 | @ObservedObject private var router: Router 6 | private let content: (T) -> Content 7 | 8 | public init( 9 | router: Router, 10 | @ViewBuilder content: @escaping (T) -> Content 11 | ) { 12 | self.router = router 13 | self.content = content 14 | } 15 | 16 | public var body: some View { 17 | NavigationStack(path: $router.routes) { 18 | content(router.root) 19 | .navigationDestination(for: T.self) { route in 20 | content(route) 21 | } 22 | } 23 | .sheet(item: $router.sheet) { 24 | router.onDismiss?() 25 | } content: { route in 26 | content(route) 27 | } 28 | .fullScreenCover(item: $router.fullscreenCover) { 29 | router.onDismiss?() 30 | } content: { route in 31 | content(route) 32 | } 33 | .popover(item: $router.popover) { route in 34 | content(route) 35 | } 36 | .environmentObject(router) 37 | .onOpenURL { url in 38 | router.handleDeeplink(url: url) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/Voyager/PresentationOption.swift: -------------------------------------------------------------------------------- 1 | public enum PresentationOption { 2 | case navigation 3 | case popover 4 | case fullscreenCover 5 | case sheet 6 | } 7 | -------------------------------------------------------------------------------- /Sources/Voyager/Route.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol Route: Equatable, Hashable, Identifiable {} 4 | 5 | public extension Route { 6 | var id: Self { self } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/Voyager/Router.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public class Router: ObservableObject { 4 | 5 | // MARK: - 6 | 7 | @Published var root: T 8 | @Published var routes: [T] 9 | @Published var sheet: T? 10 | @Published var fullscreenCover: T? 11 | @Published var popover: T? 12 | 13 | var onDismiss: (() -> Void)? 14 | var deeplinkHandler: DeeplinkHandler? 15 | 16 | // MARK: - Initializer 17 | 18 | public init(root: T, deeplinkHandler: DeeplinkHandler? = nil) { 19 | self.root = root 20 | self.routes = [] 21 | self.deeplinkHandler = deeplinkHandler 22 | } 23 | 24 | // MARK: - 25 | 26 | public func updateRoot(_ route: T) { 27 | root = route 28 | routes.removeAll() 29 | } 30 | 31 | public func present(_ route: T, option: PresentationOption = .navigation, onDismiss: (() -> Void)? = nil) { 32 | switch option { 33 | case .fullscreenCover: 34 | presentFullscreenCover(route, onDismiss: onDismiss) 35 | case .popover: 36 | presentPopover(route) 37 | case .navigation: 38 | push(route) 39 | case .sheet: 40 | presentSheet(route, onDismiss: onDismiss) 41 | } 42 | } 43 | 44 | public func dismiss(_ option: PresentationOption? = nil) { 45 | switch option { 46 | case .fullscreenCover: 47 | fullscreenCover = nil 48 | case .navigation: 49 | pop() 50 | case .popover: 51 | popover = nil 52 | case .sheet: 53 | sheet = nil 54 | case .none: 55 | if sheet != nil { 56 | sheet = nil 57 | } else if fullscreenCover != nil { 58 | fullscreenCover = nil 59 | } else if popover != nil { 60 | popover = nil 61 | } else { 62 | pop() 63 | } 64 | } 65 | } 66 | 67 | public func handleDeeplink(url: URL) { 68 | if let (route, option) = deeplinkHandler?.handleDeeplink(url: url) { 69 | present(route, option: option) 70 | } 71 | } 72 | 73 | // MARK: - Private 74 | 75 | private func push(_ route: T) { 76 | routes.append(route) 77 | } 78 | 79 | private func pop() { 80 | routes.removeLast() 81 | } 82 | 83 | private func presentSheet(_ route: T, onDismiss: (() -> Void)? = nil) { 84 | sheet = route 85 | self.onDismiss = onDismiss 86 | } 87 | 88 | private func presentFullscreenCover(_ route: T, onDismiss: (() -> Void)? = nil) { 89 | fullscreenCover = route 90 | self.onDismiss = onDismiss 91 | } 92 | 93 | private func presentPopover(_ route: T) { 94 | popover = route 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Sources/Voyager/TabRouter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public class TabRouter: ObservableObject { 4 | 5 | @Published var tabs: [T] 6 | @Published var selected: T 7 | @Published var routers: [Router] 8 | 9 | public init(tabs: [T], selected: T, deeplinkHandler: DeeplinkHandler? = nil) { 10 | self.tabs = tabs 11 | self.selected = selected 12 | self.routers = tabs.map { Router(root: $0, deeplinkHandler: deeplinkHandler) } 13 | } 14 | 15 | public func updateSelectedTab(_ to: T) { 16 | selected = to 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/Voyager/TabVoyagerView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct TabVoyagerView: View { 4 | 5 | @ObservedObject private var router: TabRouter 6 | private let content: (T) -> Content 7 | private let tabItem: (T) -> TabItem 8 | 9 | public init( 10 | router: TabRouter, 11 | @ViewBuilder content: @escaping (T) -> Content, 12 | @ViewBuilder tabItem: @escaping (T) -> TabItem 13 | ) { 14 | self.router = router 15 | self.content = content 16 | self.tabItem = tabItem 17 | } 18 | 19 | public var body: some View { 20 | TabView(selection: $router.selected) { 21 | ForEach(router.tabs) { tab in 22 | NavVoyagerView(router: getRouter(for: tab)) { route in 23 | content(route) 24 | } 25 | .tabItem { 26 | tabItem(tab) 27 | } 28 | } 29 | } 30 | .environmentObject(router) 31 | } 32 | 33 | private func getRouter(for tab: T) -> Router { 34 | guard let index = router.tabs.firstIndex(of: tab) else { 35 | fatalError("Tab found no longer exists") 36 | } 37 | return router.routers[index] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tests/VoyagerTests/RouterTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Voyager 2 | import XCTest 3 | 4 | final class RouterTests: XCTestCase { 5 | 6 | private var router: Router! 7 | private let deeplinkHandler = TestDeeplinkHandler() 8 | private let testUrl = URL(string: "https://apple.com")! 9 | 10 | override func setUp() { 11 | super.setUp() 12 | 13 | router = Router(root: .route1, deeplinkHandler: deeplinkHandler) 14 | } 15 | 16 | func test_router_init() { 17 | XCTAssertEqual(router.root, .route1) 18 | XCTAssertEqual(router.routes, []) 19 | XCTAssertNotNil(router.deeplinkHandler) 20 | } 21 | 22 | func test_router_updateRoot() { 23 | XCTAssertEqual(router.root, .route1) 24 | XCTAssertEqual(router.routes, []) 25 | router.present(.route1) 26 | router.updateRoot(.route2) 27 | XCTAssertEqual(router.root, .route2) 28 | XCTAssertEqual(router.routes, []) 29 | } 30 | 31 | func test_router_presentAndDismiss_navigation() { 32 | XCTAssertEqual(router.routes, []) 33 | router.present(.route2, option: .navigation) 34 | XCTAssertEqual(router.routes, [.route2]) 35 | router.present(.route3, option: .navigation) 36 | XCTAssertEqual(router.routes, [.route2, .route3]) 37 | router.dismiss() 38 | XCTAssertEqual(router.routes, [.route2]) 39 | router.dismiss() 40 | XCTAssertEqual(router.routes, []) 41 | } 42 | 43 | func test_router_presentAndDismiss_sheet() { 44 | XCTAssertNil(router.sheet) 45 | router.present(.route1, option: .sheet) 46 | XCTAssertEqual(router.sheet, .route1) 47 | router.dismiss() 48 | XCTAssertNil(router.sheet) 49 | } 50 | 51 | func test_router_presentAndDismiss_fullscreenCover() { 52 | XCTAssertNil(router.fullscreenCover) 53 | router.present(.route1, option: .fullscreenCover) 54 | XCTAssertEqual(router.fullscreenCover, .route1) 55 | router.dismiss() 56 | XCTAssertNil(router.fullscreenCover) 57 | } 58 | 59 | func test_router_presentAndDismiss_popover() { 60 | XCTAssertNil(router.popover) 61 | router.present(.route1, option: .popover) 62 | XCTAssertEqual(router.popover, .route1) 63 | router.dismiss() 64 | XCTAssertNil(router.popover) 65 | } 66 | 67 | func test_router_withDeeplinkHandler_handleDeeplink() { 68 | XCTAssertNil(router.sheet) 69 | router.handleDeeplink(url: testUrl) 70 | XCTAssertEqual(router.sheet, .route1) 71 | } 72 | 73 | func test_router_withoutDeeplinkHandler_doesNotHandleDeeplink() { 74 | let router = Router(root: .route1) 75 | XCTAssertNil(router.sheet) 76 | router.handleDeeplink(url: testUrl) 77 | XCTAssertNil(router.sheet) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Tests/VoyagerTests/TabRouterTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Voyager 2 | import XCTest 3 | 4 | final class TabRouterTests: XCTestCase { 5 | 6 | private var router: TabRouter! 7 | private let deeplinkHandler = TestDeeplinkHandler() 8 | 9 | override func setUp() { 10 | super.setUp() 11 | 12 | router = TabRouter(tabs: [.route1, .route2, .route3], 13 | selected: .route1, 14 | deeplinkHandler: deeplinkHandler) 15 | } 16 | 17 | func test_router_init() { 18 | XCTAssertEqual(router.tabs, [.route1, .route2, .route3]) 19 | XCTAssertEqual(router.selected, .route1) 20 | 21 | for router in router.routers { 22 | XCTAssertNotNil(router.deeplinkHandler) 23 | } 24 | } 25 | 26 | func test_router_updateCurrentTab() { 27 | XCTAssertEqual(router.selected, .route1) 28 | router.updateSelectedTab(.route2) 29 | XCTAssertEqual(router.selected, .route2) 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /Tests/VoyagerTests/TestDeeplinkHandler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Voyager 3 | 4 | final class TestDeeplinkHandler: DeeplinkHandler { 5 | 6 | override func handleDeeplink(url: URL) -> (TestRoute, PresentationOption)? { 7 | return (.route1, .sheet) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Tests/VoyagerTests/TestRoute.swift: -------------------------------------------------------------------------------- 1 | import Voyager 2 | 3 | enum TestRoute: Route { 4 | case route1 5 | case route2 6 | case route3 7 | } 8 | --------------------------------------------------------------------------------