├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── idea.md │ └── question.md ├── pull_request_template.md └── workflows │ ├── documentation.yml │ └── test.yml ├── .gitignore ├── .ruby-version ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── contents.xcworkspacedata │ └── xcshareddata │ └── xcschemes │ ├── ComposableDeeplinking.xcscheme │ ├── ComposableNavigator.xcscheme │ ├── ComposableNavigatorTCA.xcscheme │ └── swift-composable-navigator-Package.xcscheme ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dangerfile ├── Documentation ├── Deeplinking.md ├── appTarget.png ├── exampleapp.gif ├── infoTab.png ├── logo.png ├── project.png ├── readmeExample.mmd ├── readmeExample.svg ├── urlscheme.png └── xc.png ├── Example ├── Example.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcshareddata │ │ └── xcschemes │ │ └── Example.xcscheme ├── Example.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── Example │ ├── Accessibility │ │ └── AccessibilityIdentifier.swift │ ├── AppCore.swift │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── Deeplinking │ │ ├── AppDeeplinkParser.swift │ │ ├── DetailsDeeplink.swift │ │ ├── DetailsSettingsDeeplink.swift │ │ └── HomeSettingsDeeplink.swift │ ├── Detail Screen │ │ └── DetailView.swift │ ├── ExampleApp.swift │ ├── Home Screen │ │ └── HomeView.swift │ ├── Info.plist │ ├── Navigation Shortcuts │ │ ├── NavigationShortcuts.swift │ │ └── NavigationShortcutsView.swift │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ └── Settings Screen │ │ └── SettingsView.swift ├── ExampleUITests │ ├── ExampleUITests.swift │ ├── Info.plist │ └── Screens │ │ ├── Base.swift │ │ ├── Detail.swift │ │ ├── Home.swift │ │ ├── NavigationShortcuts.swift │ │ ├── Settings.swift │ │ ├── XCUIApplication+BackButton.swift │ │ ├── XCUIElement+ExistsAfterTimeout.swift │ │ └── XCUIElementQuery+AccessibiltyIdentifier.swift ├── FullTests.xctestplan ├── README.md ├── UITests.xctestplan ├── UnitTests.xctestplan └── Vanilla SwiftUI Example │ ├── Vanilla SwiftUI Example.xcodeproj │ └── project.pbxproj │ └── Vanilla SwiftUI Example │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── CapacityView.swift │ ├── DetailView.swift │ ├── HomeView.swift │ ├── Info.plist │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ ├── Train.swift │ └── Vanilla_ExampleApp.swift ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── MAINTAINERS.md ├── Makefile ├── Package.swift ├── README.md ├── Sources ├── ComposableDeeplinking │ ├── Deeplink.swift │ ├── DeeplinkComponent.swift │ ├── DeeplinkHandler.swift │ ├── DeeplinkParser.swift │ └── Parsers │ │ ├── DeeplinkParser+AnyOf.swift │ │ ├── DeeplinkParser+Empty.swift │ │ └── DeeplinkParser+Prepending.swift ├── ComposableNavigator │ ├── Helpers │ │ └── UIKitOnAppear.swift │ ├── NavigationTree │ │ ├── Generated.NavigationTreeBuilder+AnyOf.swift │ │ ├── NavigationTree Convenience Extensions │ │ │ ├── NavigationTree+AnyOf.swift │ │ │ ├── NavigationTree+Conditional.swift │ │ │ ├── NavigationTree+Empty.swift │ │ │ ├── NavigationTree+Screen.swift │ │ │ └── NavigationTree+Wildcard.swift │ │ ├── NavigationTree+ControlFlow.swift │ │ ├── NavigationTree.swift │ │ ├── NavigationTreeBuilder+AnyOf.swift.gyb │ │ └── NavigationTreeBuilder.swift │ ├── Navigator │ │ ├── Navigator+Debug.swift │ │ ├── Navigator+Testing.swift │ │ ├── Navigator.swift │ │ ├── NavigatorDataSource+Navigator.swift │ │ ├── NavigatorDatasource.swift │ │ ├── NavigatorKeys.swift │ │ └── Path │ │ │ ├── NavigationPath.swift │ │ │ ├── NavigationPathElementUpdate.swift │ │ │ ├── NavigationPathUpdate.swift │ │ │ └── PathElement │ │ │ └── NavigationPathElement.swift │ ├── PathBuilder │ │ ├── NavigationNode.swift │ │ ├── PathBuilders │ │ │ ├── Generated.PathBuilder+AnyOf.swift │ │ │ ├── PathBuilder+AnyOf.swift.gyb │ │ │ ├── PathBuilder+BeforeBuild.swift │ │ │ ├── PathBuilder+Conditional.swift │ │ │ ├── PathBuilder+Empty.swift │ │ │ ├── PathBuilder+EraseCircularPath.swift │ │ │ ├── PathBuilder+OnDismiss.swift │ │ │ ├── PathBuilder+Screen.swift │ │ │ └── PathBuilder+Wildcard.swift │ │ └── _PathBuilder.swift │ ├── Root.swift │ └── Screen │ │ ├── AnyScreen.swift │ │ ├── IdentifiedScreen.swift │ │ ├── Screen.swift │ │ ├── ScreenID.swift │ │ ├── ScreenKeys.swift │ │ └── ScreenPresentationStyle.swift └── ComposableNavigatorTCA │ ├── NavigationTree │ └── NavigationTree+IfLetStore.swift │ └── PathBuilder │ ├── PathBuilder+IfLetStore.swift │ └── PathBuilder+OnDismiss+TCA.swift ├── Tests ├── ComposableDeeplinkingTests │ ├── DeeplinkComponentTests.swift │ ├── DeeplinkHandlerTests.swift │ ├── DeeplinkTests.swift │ ├── Helpers │ │ ├── Deeplink+Stub.swift │ │ └── TestScreen.swift │ └── Parsers │ │ ├── DeeplinkParser+AnyOfTests.swift │ │ ├── DeeplinkParser+EmptyTests.swift │ │ └── DeeplinkParser+PrependingTests.swift ├── ComposableNavigatorTCATests │ ├── NavigationPathElementHelpers.swift │ ├── PathBuilder+IfLetStoreTests.swift │ ├── PathBuilder+OnDismiss+TCATests.swift │ ├── TestScreen.swift │ └── __Snapshots__ │ │ └── PathBuilder+OnDismiss+TCATests │ │ ├── test_closure_based_on_dismiss_of_matching_screen_sends_action_into_store.1.png │ │ ├── test_on_dismiss_of_anyScreen_sends_action_into_store.1.png │ │ └── test_type_based_on_dismiss_of_matching_screen_sends_action_into_store.1.png └── ComposableNavigatorTests │ ├── Helpers │ ├── EmptyNavigationTree.swift │ ├── NavigationPathElementHelpers.swift │ ├── TestScreen.swift │ └── TestView.swift │ ├── NavigationTree │ ├── Generated.NavigationTreeBuilder+AnyOf.swift │ ├── NavigationTree+ConditionalTests.swift │ ├── NavigationTree+EmptyTests.swift │ ├── NavigationTree+Screen.swift │ ├── NavigationTree+WildcardTests.swift │ ├── NavigationTreeBuilder+AnyOf.swift.gyb │ └── __Snapshots__ │ │ └── Generated.NavigationTreeBuilder+AnyOf │ │ ├── test_10_buildsPath.1.png │ │ ├── test_10_buildsPath.10.png │ │ ├── test_10_buildsPath.2.png │ │ ├── test_10_buildsPath.3.png │ │ ├── test_10_buildsPath.4.png │ │ ├── test_10_buildsPath.5.png │ │ ├── test_10_buildsPath.6.png │ │ ├── test_10_buildsPath.7.png │ │ ├── test_10_buildsPath.8.png │ │ ├── test_10_buildsPath.9.png │ │ ├── test_2_buildsPath.1.png │ │ ├── test_2_buildsPath.2.png │ │ ├── test_3_buildsPath.1.png │ │ ├── test_3_buildsPath.2.png │ │ ├── test_3_buildsPath.3.png │ │ ├── test_4_buildsPath.1.png │ │ ├── test_4_buildsPath.2.png │ │ ├── test_4_buildsPath.3.png │ │ ├── test_4_buildsPath.4.png │ │ ├── test_5_buildsPath.1.png │ │ ├── test_5_buildsPath.2.png │ │ ├── test_5_buildsPath.3.png │ │ ├── test_5_buildsPath.4.png │ │ ├── test_5_buildsPath.5.png │ │ ├── test_6_buildsPath.1.png │ │ ├── test_6_buildsPath.2.png │ │ ├── test_6_buildsPath.3.png │ │ ├── test_6_buildsPath.4.png │ │ ├── test_6_buildsPath.5.png │ │ ├── test_6_buildsPath.6.png │ │ ├── test_7_buildsPath.1.png │ │ ├── test_7_buildsPath.2.png │ │ ├── test_7_buildsPath.3.png │ │ ├── test_7_buildsPath.4.png │ │ ├── test_7_buildsPath.5.png │ │ ├── test_7_buildsPath.6.png │ │ ├── test_7_buildsPath.7.png │ │ ├── test_8_buildsPath.1.png │ │ ├── test_8_buildsPath.2.png │ │ ├── test_8_buildsPath.3.png │ │ ├── test_8_buildsPath.4.png │ │ ├── test_8_buildsPath.5.png │ │ ├── test_8_buildsPath.6.png │ │ ├── test_8_buildsPath.7.png │ │ ├── test_8_buildsPath.8.png │ │ ├── test_9_buildsPath.1.png │ │ ├── test_9_buildsPath.2.png │ │ ├── test_9_buildsPath.3.png │ │ ├── test_9_buildsPath.4.png │ │ ├── test_9_buildsPath.5.png │ │ ├── test_9_buildsPath.6.png │ │ ├── test_9_buildsPath.7.png │ │ ├── test_9_buildsPath.8.png │ │ └── test_9_buildsPath.9.png │ ├── Navigator │ ├── Navigator+DebugTests.swift │ ├── NavigatorDatasourceTests.swift │ ├── NavigatorKeysTests.swift │ └── Path │ │ └── PathElement │ │ └── NavigationPathElementTests.swift │ ├── PathBuilder │ ├── NavigationNodeTests.swift │ ├── PathBuilder+BeforeBuildTests.swift │ ├── PathBuilder+ConditionalTests.swift │ ├── PathBuilder+EmptyTests.swift │ ├── PathBuilder+EraseCircularPathTests.swift │ ├── PathBuilder+OnDismissTests.swift │ └── __Snapshots__ │ │ ├── NavigationNodeTests │ │ ├── test_calls_closure_on_consequent_appear.1.png │ │ └── test_calls_closure_on_initial_appear.1.png │ │ └── PathBuilder+OnDismissTests │ │ ├── test_onDismiss_calls_perform_with_any_screen_when_path_changes.1.png │ │ ├── test_onDismiss_calls_perform_with_screen_when_path_changes.1.png │ │ └── test_onDismiss_of_calls_perform_when_path_changes.1.png │ └── Screen │ ├── RootTests.swift │ ├── ScreenKeysTests.swift │ └── __Snapshots__ │ └── RootTests │ └── test_root_wraps_content_in_navigation_view.1.png └── generateGybs.sh /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | # Bug description 11 | [//]: # (Give a clear and concise description of what the bug is.) 12 | 13 | # Steps to reproduce 14 | [//]: # (Zip up a project that reproduces the behavior and attach it by dragging it here.) 15 | 16 | ```swift 17 | // And/or enter code that reproduces the behavior here. 18 | 19 | ``` 20 | 21 | # Expected behavior 22 | [//]: # (Give a clear and concise description of what you expected to happen.) 23 | 24 | # Screenshots 25 | [//]: # (If applicable, add screenshots to help explain your problem.) 26 | 27 | # Environment 28 | - Xcode [e.g. 11.4.1] 29 | - Swift [e.g. 5.2.2] 30 | - OS (if applicable): [e.g. iOS 13] 31 | 32 | # Additional context 33 | [//]: # (Add any more context about the problem here.) 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/idea.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Idea 3 | about: Have an idea on how to improve the Composable Navigator? 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | # Idea 10 | [//]: # (Add a brief description of your idea here) 11 | 12 | # Problem description 13 | [//]: # (Describe what is unclear to you.) 14 | 15 | # Considered solutions 16 | [//]: # (Outline considered solutions and describe how you would solve your problem. Zip up a project that underline why it would be beneficial to implement the selected approach and attach it by dragging it here.) 17 | 18 | ```swift 19 | // And/or enter code that can be helpful to answer the question here. 20 | 21 | ``` 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Have a question about the Composable Navigator? 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | # Question 11 | [//]: # (Add your question here) 12 | 13 | # Problem description 14 | [//]: # (Describe what is unclear to you. Zip up a project that helps to answer the question and attach it by dragging it here.) 15 | 16 | ```swift 17 | // And/or enter code that can be helpful to answer the question here. 18 | 19 | ``` -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | [//]: # (Link the resolved Github issue.) 2 | Resolves # . 3 | 4 | ## Problem 5 | [//]: # (Explain why this pull request is necessary.) 6 | ... 7 | 8 | ## Solution 9 | [//]: # (Explain how you solved the problem.) 10 | ... -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/documentation.yml 2 | name: Documentation 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Generate Documentation 16 | uses: SwiftDocOrg/swift-doc@master 17 | with: 18 | inputs: "Sources" 19 | module-name: ComposableNavigator 20 | output: "Documentation" 21 | - name: Upload Documentation to Wiki 22 | uses: SwiftDocOrg/github-wiki-publish-action@v1 23 | with: 24 | path: "Documentation" 25 | env: 26 | GH_PERSONAL_ACCESS_TOKEN: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "*" 7 | 8 | jobs: 9 | tests: 10 | runs-on: macos-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | with: 14 | fetch-depth: 0 15 | 16 | - uses: ruby/setup-ruby@v1 17 | with: 18 | bundler-cache: true 19 | 20 | - name: ruby versions 21 | run: | 22 | ruby --version 23 | gem --version 24 | bundler --version 25 | 26 | - name: Run tests 27 | run: make test 28 | 29 | # - name: Danger 30 | # run: bundle exec danger 31 | # env: 32 | # DANGER_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | 34 | - uses: actions/upload-artifact@v2 35 | if: ${{ always() }} 36 | with: 37 | name: ResultBundle 38 | path: ./coverage/*.xcresult 39 | if-no-files-found: warn 40 | retention-days: 5 41 | 42 | - name: Clean up 43 | if: ${{ always() }} 44 | run: make cleanup 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | .DS_Store 92 | Package.resolved 93 | Build/ 94 | swift-composable-navigator.xcodeproj 95 | xcov_report 96 | fastlane/README.md 97 | coverage/ -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.7.2 -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/ComposableDeeplinking.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 34 | 40 | 41 | 42 | 43 | 44 | 54 | 55 | 61 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/ComposableNavigator.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 38 | 39 | 40 | 41 | 43 | 49 | 50 | 51 | 52 | 53 | 63 | 64 | 70 | 71 | 77 | 78 | 79 | 80 | 82 | 83 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/ComposableNavigatorTCA.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 33 | 34 | 44 | 45 | 51 | 52 | 58 | 59 | 60 | 61 | 63 | 64 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # These owners will be the default owners for everything in 2 | # the repo. Unless a later match takes precedence, 3 | # @ohitsdaniel will be requested for 4 | # review when someone opens a pull request. 5 | * @ohitsdaniel -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at `bahnx-ios@deutschebahn.com`. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | **ComposableNavigator** is actively maintained by its [current maintainers](./MAINTAINERS.md). 2 | 3 | Check our [code of conduct](./Documentation/CODE_OF_CONDUCT.md) and feel free to [open issues](https://github.com/Bahn-X/swift-composable-navigator/issues) for ideas, questions and improvements. 4 | 5 | ## Contribution process 6 | 1. Open an issue to discuess your idea. Gather early feedback on your approach and if it aligns with the maintainers' vision for the library. 7 | 2. Fork the repository and implement your idea. 8 | 3. Make sure to document changes and update both documentation comments in code and markdown files, if necessary. 9 | 4. Open a pull request from the feature branch in your fork to the origin's `main` branch. 10 | 5. Get feedback. Merging a pull request requires approval from at least one maintainer. 11 | 6. After your pull request was merged, celebrate your contribution. 🎉 -------------------------------------------------------------------------------- /Dangerfile: -------------------------------------------------------------------------------- 1 | xcov.report( 2 | workspace: 'Example/Example.xcworkspace', 3 | scheme: 'Example', 4 | html_report: false, 5 | include_targets: 'ComposableNavigator, ComposableNavigatorTCA, ComposableDeeplinking', 6 | xccov_file_direct_path: 'coverage/test_result.xcresult' 7 | ) -------------------------------------------------------------------------------- /Documentation/appTarget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Documentation/appTarget.png -------------------------------------------------------------------------------- /Documentation/exampleapp.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Documentation/exampleapp.gif -------------------------------------------------------------------------------- /Documentation/infoTab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Documentation/infoTab.png -------------------------------------------------------------------------------- /Documentation/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Documentation/logo.png -------------------------------------------------------------------------------- /Documentation/project.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Documentation/project.png -------------------------------------------------------------------------------- /Documentation/readmeExample.mmd: -------------------------------------------------------------------------------- 1 | stateDiagram-v2 2 | Home --> Detail 3 | Home --> Settings -------------------------------------------------------------------------------- /Documentation/urlscheme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Documentation/urlscheme.png -------------------------------------------------------------------------------- /Documentation/xc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Documentation/xc.png -------------------------------------------------------------------------------- /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 | "object": { 3 | "pins": [ 4 | { 5 | "package": "combine-schedulers", 6 | "repositoryURL": "https://github.com/pointfreeco/combine-schedulers", 7 | "state": { 8 | "branch": null, 9 | "revision": "8eab6ad841ccc420f29f3e77e17e7b260841b50a", 10 | "version": "0.2.0" 11 | } 12 | }, 13 | { 14 | "package": "Logger", 15 | "repositoryURL": "https://github.com/shibapm/Logger", 16 | "state": { 17 | "branch": null, 18 | "revision": "53c3ecca5abe8cf46697e33901ee774236d94cce", 19 | "version": "0.2.3" 20 | } 21 | }, 22 | { 23 | "package": "PackageConfig", 24 | "repositoryURL": "https://github.com/shibapm/PackageConfig.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "bf90dc69fa0792894b08a0b74cf34029694ae486", 28 | "version": "0.13.0" 29 | } 30 | }, 31 | { 32 | "package": "Rocket", 33 | "repositoryURL": "https://github.com/shibapm/Rocket", 34 | "state": { 35 | "branch": null, 36 | "revision": "25613f7ffd16105c74417c1b4eb4c93fd04e2cdf", 37 | "version": "1.1.0" 38 | } 39 | }, 40 | { 41 | "package": "swift-case-paths", 42 | "repositoryURL": "https://github.com/pointfreeco/swift-case-paths", 43 | "state": { 44 | "branch": null, 45 | "revision": "1aa1bf7c4069d9ba2f7edd36dbfc96ff1c58cbff", 46 | "version": "0.1.3" 47 | } 48 | }, 49 | { 50 | "package": "swift-composable-architecture", 51 | "repositoryURL": "https://github.com/pointfreeco/swift-composable-architecture", 52 | "state": { 53 | "branch": null, 54 | "revision": "0fb6c9bfc306e39dc750786edf28a4a77e67749c", 55 | "version": "0.14.0" 56 | } 57 | }, 58 | { 59 | "package": "SnapshotTesting", 60 | "repositoryURL": "https://github.com/pointfreeco/swift-snapshot-testing.git", 61 | "state": { 62 | "branch": null, 63 | "revision": "c466812aa2e22898f27557e2e780d3aad7a27203", 64 | "version": "1.8.2" 65 | } 66 | }, 67 | { 68 | "package": "SwiftShell", 69 | "repositoryURL": "https://github.com/kareman/SwiftShell", 70 | "state": { 71 | "branch": null, 72 | "revision": "a6014fe94c3dbff0ad500e8da4f251a5d336530b", 73 | "version": "5.1.0-beta.1" 74 | } 75 | }, 76 | { 77 | "package": "Yams", 78 | "repositoryURL": "https://github.com/jpsim/Yams", 79 | "state": { 80 | "branch": null, 81 | "revision": "c947a306d2e80ecb2c0859047b35c73b8e1ca27f", 82 | "version": "2.0.0" 83 | } 84 | } 85 | ] 86 | }, 87 | "version": 1 88 | } 89 | -------------------------------------------------------------------------------- /Example/Example.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Example/Example.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/Example/Accessibility/AccessibilityIdentifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct AccessibilityIdentifier { 4 | let value: String 5 | } 6 | 7 | extension View { 8 | func accessibility(identifier: AccessibilityIdentifier) -> some View { 9 | accessibility(identifier: identifier.value) 10 | } 11 | } 12 | 13 | extension AccessibilityIdentifier { 14 | enum HomeScreen { 15 | static let settingsNavigationBarItem = AccessibilityIdentifier(value: "home.settings.open") 16 | 17 | static func detail(for id: String) -> AccessibilityIdentifier { 18 | AccessibilityIdentifier(value: "detail.\(id)") 19 | } 20 | 21 | static func detailSettings(for id: String) -> AccessibilityIdentifier { 22 | AccessibilityIdentifier(value: "detail.\(id).settings") 23 | } 24 | } 25 | 26 | struct SettingsScreen { 27 | let prefix: String 28 | 29 | var shortcutsSheet: AccessibilityIdentifier { 30 | AccessibilityIdentifier(value: "\(prefix).settings.shortcuts.sheet") 31 | } 32 | 33 | var shortcutsPush: AccessibilityIdentifier { 34 | AccessibilityIdentifier(value: "\(prefix).settings.shortcuts.push") 35 | } 36 | } 37 | 38 | struct DetailScreen { 39 | let id: String 40 | 41 | var shortcuts: AccessibilityIdentifier { 42 | AccessibilityIdentifier(value: "detail.\(id).shortcuts") 43 | } 44 | 45 | var settings: AccessibilityIdentifier { 46 | AccessibilityIdentifier(value: "detail.\(id).settings") 47 | } 48 | } 49 | 50 | struct NavigationShortcuts { 51 | let prefix: String 52 | 53 | var detailShortcuts: AccessibilityIdentifier { 54 | AccessibilityIdentifier(value: "\(prefix).detailShortcuts") 55 | } 56 | 57 | var detailSettings: AccessibilityIdentifier { 58 | AccessibilityIdentifier(value: "\(prefix).detailSettings") 59 | } 60 | 61 | var detailSettingsShortcutsPush: AccessibilityIdentifier { 62 | AccessibilityIdentifier(value: "\(prefix).detailSettingsShortcutsPush") 63 | } 64 | 65 | var detailSettingsShortcutsSheet: AccessibilityIdentifier { 66 | AccessibilityIdentifier(value: "\(prefix).detailSettingsShortcutsSheet") 67 | } 68 | 69 | var homeSettings: AccessibilityIdentifier { 70 | AccessibilityIdentifier(value: "\(prefix).homeSettings") 71 | } 72 | 73 | var home: AccessibilityIdentifier { 74 | AccessibilityIdentifier(value: "\(prefix).home") 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Example/Example/AppCore.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ComposableArchitecture 3 | import ComposableNavigator 4 | import ComposableNavigatorTCA 5 | import SwiftUI 6 | 7 | struct AppState: Equatable { 8 | var home: HomeState 9 | var settings: SettingsState 10 | 11 | init(elements: [String]) { 12 | home = HomeState(elements: elements, selectedDetail: nil) 13 | settings = SettingsState() 14 | } 15 | } 16 | 17 | enum AppAction: Equatable { 18 | case home(HomeAction) 19 | case detail(DetailAction) 20 | case settings(SettingsAction) 21 | 22 | case binding(BindingAction) 23 | } 24 | 25 | struct AppEnvironment { 26 | let navigator: Navigator 27 | 28 | var home: HomeEnvironment { 29 | HomeEnvironment(navigator: navigator) 30 | } 31 | 32 | var detail: DetailEnvironment { 33 | DetailEnvironment(navigator: navigator) 34 | } 35 | 36 | var settings: SettingsEnvironment { 37 | SettingsEnvironment(navigator: navigator) 38 | } 39 | } 40 | 41 | let appReducer = Reducer< 42 | AppState, 43 | AppAction, 44 | AppEnvironment 45 | >.combine( 46 | homeReducer.pullback( 47 | state: \.home, 48 | action: /AppAction.home, 49 | environment: \.home 50 | ), 51 | settingsReducer.pullback( 52 | state: \.settings, 53 | action: /AppAction.settings, 54 | environment: \.settings 55 | ) 56 | ) 57 | -------------------------------------------------------------------------------- /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" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/Example/Deeplinking/AppDeeplinkParser.swift: -------------------------------------------------------------------------------- 1 | import ComposableDeeplinking 2 | 3 | extension DeeplinkParser { 4 | /// Parses all supported deeplinks in the example app 5 | /// 6 | /// Supported deeplinks: 7 | /// * example://home/settings 8 | /// * example://detail?id={id} 9 | /// * example://detail?id={id}/settings 10 | static let exampleApp: DeeplinkParser = .anyOf( 11 | .homeSettings, 12 | .details, 13 | .detailSettings 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /Example/Example/Deeplinking/DetailsDeeplink.swift: -------------------------------------------------------------------------------- 1 | import ComposableDeeplinking 2 | 3 | extension DeeplinkParser { 4 | /// example://detail?id={id} 5 | static let details = DeeplinkParser( 6 | parse: { deeplink in 7 | guard deeplink.components.count == 1, 8 | deeplink.components[0].name == "detail", 9 | case let .value(id) = deeplink.components[0].arguments?["id"] 10 | else { 11 | return nil 12 | } 13 | 14 | return [ 15 | HomeScreen().eraseToAnyScreen(), 16 | DetailScreen(detailID: id).eraseToAnyScreen() 17 | ] 18 | } 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /Example/Example/Deeplinking/DetailsSettingsDeeplink.swift: -------------------------------------------------------------------------------- 1 | import ComposableDeeplinking 2 | 3 | extension DeeplinkParser { 4 | /// example://detail?id={id}/settings 5 | static let detailSettings = DeeplinkParser( 6 | parse: { deeplink in 7 | guard deeplink.components.count == 2, 8 | deeplink.components[0].name == "detail", 9 | case let .value(id) = deeplink.components[0].arguments?["id"], 10 | deeplink.components[1].name == "settings" 11 | else { 12 | return nil 13 | } 14 | 15 | return [ 16 | HomeScreen().eraseToAnyScreen(), 17 | DetailScreen(detailID: id).eraseToAnyScreen(), 18 | SettingsScreen().eraseToAnyScreen() 19 | ] 20 | } 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /Example/Example/Deeplinking/HomeSettingsDeeplink.swift: -------------------------------------------------------------------------------- 1 | import ComposableDeeplinking 2 | 3 | extension DeeplinkParser { 4 | /// example://home/settings 5 | static let homeSettings = DeeplinkParser( 6 | parse: { deeplink in 7 | guard deeplink.components.count == 2, 8 | deeplink.components[0].name == "home", 9 | deeplink.components[1].name == "settings" 10 | else { 11 | return nil 12 | } 13 | 14 | return [ 15 | HomeScreen().eraseToAnyScreen(), 16 | SettingsScreen().eraseToAnyScreen() 17 | ] 18 | } 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /Example/Example/Detail Screen/DetailView.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import ComposableNavigator 3 | import ComposableNavigatorTCA 4 | import SwiftUI 5 | 6 | struct DetailState: Equatable { 7 | let id: String 8 | 9 | var navigationShortcuts = NavigationShortcutsState() 10 | } 11 | 12 | enum DetailAction: Equatable { 13 | case viewAppeared 14 | case settingsButtonTapped(ScreenID) 15 | case navigationShortcuts(NavigationShortcutsAction) 16 | } 17 | 18 | struct DetailEnvironment { 19 | let navigator: Navigator 20 | 21 | var navigationShortcuts: NavigationShortcutsEnvironment { 22 | NavigationShortcutsEnvironment( 23 | navigator: navigator 24 | ) 25 | } 26 | } 27 | 28 | struct DetailScreen: Screen { 29 | let presentationStyle: ScreenPresentationStyle = .push 30 | let detailID: String 31 | 32 | struct Builder: NavigationTree { 33 | let store: Store 34 | let settingsStore: Store 35 | 36 | var builder: some PathBuilder { 37 | If { (screen: DetailScreen) in 38 | Screen( 39 | DetailScreen.self, 40 | content: { 41 | DetailView( 42 | store: store 43 | ) 44 | }, 45 | nesting: { 46 | SettingsScreen.Builder( 47 | store: settingsStore, 48 | entrypoint: "detail.\(screen.detailID)" 49 | ) 50 | .onDismiss(of: SettingsScreen.self) { 51 | print("Detail settings dismissed") 52 | } 53 | 54 | NavigationShortcutsScreen.Builder( 55 | store: store.scope( 56 | state: \.navigationShortcuts, 57 | action: DetailAction.navigationShortcuts 58 | ) 59 | ) 60 | } 61 | ) 62 | } 63 | } 64 | } 65 | } 66 | 67 | struct DetailView: View { 68 | @Environment(\.navigator) var navigator 69 | @Environment(\.currentScreenID) var currentID 70 | let store: Store 71 | 72 | var body: some View { 73 | WithViewStore(store) { viewStore in 74 | HStack { 75 | VStack(alignment: .leading, spacing: 16) { 76 | Button( 77 | action: { 78 | navigator.go( 79 | to: NavigationShortcutsScreen( 80 | presentationStyle: .push 81 | ), 82 | on: currentID 83 | ) 84 | }, 85 | label: { Text("Go to [home/detail?id=\(viewStore.id)/shortcuts]") } 86 | ) 87 | .accessibility( 88 | identifier: AccessibilityIdentifier.DetailScreen(id: viewStore.id).shortcuts 89 | ) 90 | 91 | NavigationShortcuts( 92 | accessibilityIdentifiers: AccessibilityIdentifier.NavigationShortcuts(prefix: "detail.\(viewStore.id).shortcuts") 93 | ) 94 | Spacer() 95 | } 96 | Spacer() 97 | } 98 | .padding(16) 99 | .navigationBarItems( 100 | trailing: Button( 101 | action: { viewStore.send(.settingsButtonTapped(currentID)) }, 102 | label: { Image(systemName: "gear") } 103 | ) 104 | .accessibility( 105 | identifier: AccessibilityIdentifier.DetailScreen(id: viewStore.id).settings 106 | ) 107 | ) 108 | .navigationBarTitle("Detail for \(viewStore.id)") 109 | } 110 | } 111 | } 112 | 113 | let detailReducer = Reducer< 114 | DetailState, 115 | DetailAction, 116 | DetailEnvironment 117 | >.combine( 118 | Reducer { _, action, environment in 119 | switch action { 120 | case .viewAppeared: 121 | return .none 122 | 123 | case let .settingsButtonTapped(id): 124 | return .fireAndForget { 125 | environment 126 | .navigator 127 | .go(to: SettingsScreen(), on: id) 128 | } 129 | 130 | case .navigationShortcuts: 131 | return .none 132 | } 133 | }, 134 | navigationShortcutsReducer.pullback( 135 | state: \.navigationShortcuts, 136 | action: /DetailAction.navigationShortcuts, 137 | environment: \.navigationShortcuts 138 | ) 139 | ) 140 | 141 | struct DetailView_Previews: PreviewProvider { 142 | static var previews: some View { 143 | DetailView( 144 | store: Store( 145 | initialState: DetailState(id: "123"), 146 | reducer: .empty, 147 | environment: () 148 | ) 149 | ) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /Example/Example/ExampleApp.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import ComposableDeeplinking 3 | import ComposableNavigator 4 | import SwiftUI 5 | 6 | @main 7 | struct ExampleApp: App { 8 | let appStore: Store 9 | 10 | let navigator: Navigator 11 | let dataSource: Navigator.Datasource 12 | 13 | var deeplinkHandler: DeeplinkHandler { 14 | DeeplinkHandler( 15 | navigator: navigator, 16 | parser: DeeplinkParser.exampleApp 17 | ) 18 | } 19 | 20 | init() { 21 | dataSource = Navigator.Datasource( 22 | root: HomeScreen() 23 | ) 24 | 25 | navigator = Navigator(dataSource: dataSource).debug() 26 | 27 | appStore = Store( 28 | initialState: AppState( 29 | elements: (0..<10).map(String.init) 30 | ), 31 | reducer: appReducer, 32 | environment: AppEnvironment(navigator: navigator) 33 | ) 34 | } 35 | 36 | var body: some Scene { 37 | WindowGroup { 38 | Root( 39 | dataSource: dataSource, 40 | navigator: navigator, 41 | pathBuilder: HomeScreen.Builder(appStore: appStore) 42 | ) 43 | .onOpenURL( 44 | perform: { url in 45 | guard let deeplink = Deeplink( 46 | url: url, 47 | matching: "example" 48 | ) 49 | else { return } 50 | 51 | deeplinkHandler.handle(deeplink: deeplink) 52 | } 53 | ) 54 | .environment(\.treatSheetDismissAsAppearInPresenter, true) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Example/Example/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleURLTypes 20 | 21 | 22 | CFBundleTypeRole 23 | Editor 24 | CFBundleURLSchemes 25 | 26 | example 27 | 28 | 29 | 30 | CFBundleVersion 31 | 1 32 | LSRequiresIPhoneOS 33 | 34 | UIApplicationSceneManifest 35 | 36 | UIApplicationSupportsMultipleScenes 37 | 38 | 39 | UIApplicationSupportsIndirectInputEvents 40 | 41 | UILaunchScreen 42 | 43 | UIRequiredDeviceCapabilities 44 | 45 | armv7 46 | 47 | UISupportedInterfaceOrientations 48 | 49 | UIInterfaceOrientationPortrait 50 | UIInterfaceOrientationLandscapeLeft 51 | UIInterfaceOrientationLandscapeRight 52 | 53 | UISupportedInterfaceOrientations~ipad 54 | 55 | UIInterfaceOrientationPortrait 56 | UIInterfaceOrientationPortraitUpsideDown 57 | UIInterfaceOrientationLandscapeLeft 58 | UIInterfaceOrientationLandscapeRight 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /Example/Example/Navigation Shortcuts/NavigationShortcuts.swift: -------------------------------------------------------------------------------- 1 | import ComposableNavigator 2 | import SwiftUI 3 | 4 | struct NavigationShortcuts: View { 5 | @Environment(\.navigator) var navigator 6 | @Environment(\.currentScreenID) var id 7 | 8 | let accessibilityIdentifiers: AccessibilityIdentifier.NavigationShortcuts 9 | 10 | var body: some View { 11 | Divider() 12 | Button( 13 | action: { 14 | navigator.replace( 15 | path: [ 16 | HomeScreen().eraseToAnyScreen(), 17 | DetailScreen(detailID: "0").eraseToAnyScreen(), 18 | NavigationShortcutsScreen(presentationStyle: .push).eraseToAnyScreen() 19 | ] 20 | ) 21 | }, 22 | label: { 23 | Text("Go to [home/detail?id=0/shortcuts]") 24 | } 25 | ) 26 | .accessibility(identifier: accessibilityIdentifiers.detailShortcuts) 27 | 28 | Divider() 29 | Button( 30 | action: { 31 | navigator.replace( 32 | path: [ 33 | HomeScreen().eraseToAnyScreen(), 34 | DetailScreen(detailID: "0").eraseToAnyScreen(), 35 | SettingsScreen().eraseToAnyScreen() 36 | ] 37 | ) 38 | }, 39 | label: { Text("Go to [home/detail?id=0/settings]") } 40 | ) 41 | .accessibility(identifier: accessibilityIdentifiers.detailSettings) 42 | 43 | Button( 44 | action: { 45 | navigator.replace( 46 | path: [ 47 | HomeScreen().eraseToAnyScreen(), 48 | DetailScreen(detailID: "0").eraseToAnyScreen(), 49 | SettingsScreen().eraseToAnyScreen(), 50 | NavigationShortcutsScreen(presentationStyle: .push).eraseToAnyScreen() 51 | ] 52 | ) 53 | }, 54 | label: { Text("Go to [home/detail?id=0/settings/shortcuts?style=push]") } 55 | ) 56 | .accessibility(identifier: accessibilityIdentifiers.detailSettingsShortcutsPush) 57 | 58 | Button( 59 | action: { 60 | navigator.replace( 61 | path: [ 62 | HomeScreen().eraseToAnyScreen(), 63 | DetailScreen(detailID: "0").eraseToAnyScreen(), 64 | SettingsScreen().eraseToAnyScreen(), 65 | NavigationShortcutsScreen( 66 | presentationStyle: .sheet(allowsPush: true) 67 | ).eraseToAnyScreen() 68 | ] 69 | ) 70 | }, 71 | label: { Text("Go to [home/detail?id=0/settings/shortcuts?style=sheet]") } 72 | ) 73 | .accessibility(identifier: accessibilityIdentifiers.detailSettingsShortcutsSheet) 74 | 75 | Divider() 76 | Button( 77 | action: { 78 | navigator.replace( 79 | path: [ 80 | HomeScreen().eraseToAnyScreen(), 81 | SettingsScreen().eraseToAnyScreen() 82 | ] 83 | ) 84 | }, 85 | label: { Text("Go to [home/settings]") } 86 | ) 87 | .accessibility(identifier: accessibilityIdentifiers.homeSettings) 88 | 89 | Button( 90 | action: { navigator.goBack(to: HomeScreen()) }, 91 | label: { Text("Go back to [home]") } 92 | ) 93 | .accessibility(identifier: accessibilityIdentifiers.home) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Example/Example/Navigation Shortcuts/NavigationShortcutsView.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import ComposableNavigator 3 | import SwiftUI 4 | 5 | struct NavigationShortcutsState: Equatable {} 6 | 7 | enum NavigationShortcutsAction: Equatable { 8 | case viewAppeared 9 | } 10 | 11 | struct NavigationShortcutsEnvironment { 12 | let navigator: Navigator 13 | } 14 | 15 | struct NavigationShortcutsScreen: Screen { 16 | let presentationStyle: ScreenPresentationStyle 17 | 18 | struct Builder: NavigationTree { 19 | let store: Store 20 | 21 | var builder: some PathBuilder { 22 | Screen( 23 | NavigationShortcutsScreen.self, 24 | content: { 25 | NavigationShortcutsView(store: store) 26 | } 27 | ) 28 | } 29 | } 30 | } 31 | 32 | let navigationShortcutsReducer = Reducer< 33 | NavigationShortcutsState, 34 | NavigationShortcutsAction, 35 | NavigationShortcutsEnvironment 36 | >.empty 37 | 38 | struct NavigationShortcutsView: View { 39 | @Environment(\.navigator) var navigator 40 | let store: Store 41 | 42 | var body: some View { 43 | HStack { 44 | VStack(alignment: .leading, spacing: 16) { 45 | NavigationShortcuts( 46 | accessibilityIdentifiers: AccessibilityIdentifier.NavigationShortcuts(prefix: "shortcuts") 47 | ) 48 | Spacer() 49 | } 50 | Spacer() 51 | } 52 | .padding(16) 53 | .navigationTitle(Text("Navigation Shortcuts")) 54 | } 55 | } 56 | 57 | struct NavigationShortcutsView_Previews: PreviewProvider { 58 | static var previews: some View { 59 | NavigationShortcutsView( 60 | store: Store( 61 | initialState: NavigationShortcutsState(), 62 | reducer: .empty, 63 | environment: () 64 | ) 65 | ) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Example/Example/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/Example/Settings Screen/SettingsView.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import ComposableNavigator 3 | import SwiftUI 4 | 5 | struct SettingsState: Equatable { 6 | var navigationShortcuts = NavigationShortcutsState() 7 | } 8 | 9 | enum SettingsAction: Equatable { 10 | case viewAppeared 11 | 12 | case navigationShortcuts(NavigationShortcutsAction) 13 | } 14 | 15 | struct SettingsEnvironment { 16 | let navigator: Navigator 17 | 18 | var navigationShortcuts: NavigationShortcutsEnvironment { 19 | NavigationShortcutsEnvironment(navigator: navigator) 20 | } 21 | } 22 | 23 | struct SettingsScreen: Screen { 24 | let presentationStyle: ScreenPresentationStyle = .sheet(allowsPush: true) 25 | 26 | struct Builder: NavigationTree { 27 | let store: Store 28 | let entrypoint: String 29 | 30 | var builder: some PathBuilder { 31 | Screen( // settings 32 | SettingsScreen.self, 33 | content: { 34 | SettingsView( 35 | store: store, 36 | accessibilityIdentifiers: AccessibilityIdentifier.SettingsScreen(prefix: entrypoint) 37 | ) 38 | }, 39 | nesting: { 40 | NavigationShortcutsScreen.Builder( 41 | store: store.scope( 42 | state: \.navigationShortcuts, 43 | action: SettingsAction.navigationShortcuts 44 | ) 45 | ) 46 | } 47 | ) 48 | } 49 | } 50 | } 51 | 52 | let settingsReducer = Reducer< 53 | SettingsState, 54 | SettingsAction, 55 | SettingsEnvironment 56 | >.combine( 57 | .empty, 58 | navigationShortcutsReducer.pullback( 59 | state: \.navigationShortcuts, 60 | action: /SettingsAction.navigationShortcuts, 61 | environment: \.navigationShortcuts 62 | ) 63 | ) 64 | 65 | struct SettingsView: View { 66 | @Environment(\.navigator) var navigator 67 | @Environment(\.currentScreenID) var id 68 | let store: Store 69 | let accessibilityIdentifiers: AccessibilityIdentifier.SettingsScreen 70 | 71 | var body: some View { 72 | HStack { 73 | VStack(alignment: .leading, spacing: 16) { 74 | Button( 75 | action: { 76 | navigator.go( 77 | to: NavigationShortcutsScreen( 78 | presentationStyle: .push 79 | ), 80 | on: id 81 | ) 82 | }, 83 | label: { Text("Go to [./shortcuts?style=push]") } 84 | ) 85 | .accessibility(identifier: accessibilityIdentifiers.shortcutsPush) 86 | 87 | Button( 88 | action: { 89 | navigator.go( 90 | to: NavigationShortcutsScreen( 91 | presentationStyle: .sheet(allowsPush: true) 92 | ), 93 | on: id 94 | ) 95 | }, 96 | label: { Text("Go to [./shortcuts?style=sheet]") } 97 | ) 98 | .accessibility(identifier: accessibilityIdentifiers.shortcutsSheet) 99 | 100 | NavigationShortcuts( 101 | accessibilityIdentifiers: AccessibilityIdentifier.NavigationShortcuts(prefix: "settings") 102 | ) 103 | 104 | Spacer() 105 | } 106 | Spacer() 107 | } 108 | .padding(16) 109 | .navigationTitle(Text("Settings")) 110 | } 111 | } 112 | 113 | struct SettingsView_Previews: PreviewProvider { 114 | static var previews: some View { 115 | SettingsView( 116 | store: Store( 117 | initialState: SettingsState(), 118 | reducer: .empty, 119 | environment: () 120 | ), 121 | accessibilityIdentifiers: AccessibilityIdentifier.SettingsScreen(prefix: "settings") 122 | ) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Example/ExampleUITests/ExampleUITests.swift: -------------------------------------------------------------------------------- 1 | import Example 2 | import XCTest 3 | 4 | final class ComposableNavigatorSheetUITests: XCTestCase { 5 | var app: XCUIApplication! 6 | 7 | override func setUp() { 8 | app = XCUIApplication() 9 | app.launch() 10 | continueAfterFailure = false 11 | } 12 | 13 | func test_sheet() { 14 | Home(app: app) 15 | .goToSettings() 16 | .assertVisible() 17 | .dismissSheet() 18 | .assertVisible() 19 | } 20 | 21 | func test_sheet_sheet() { 22 | Home(app: app) 23 | .goToSettings() 24 | .assertVisible() 25 | .goToShortcutsSheet() 26 | .assertVisible() 27 | .goToBackToHome() 28 | .assertVisible() 29 | } 30 | 31 | func test_sheet_push() { 32 | Home(app: app) 33 | .goToSettings() 34 | .assertVisible() 35 | .goToShortcutsPush() 36 | .assertVisible() 37 | .goToBackToHome() 38 | .assertVisible() 39 | } 40 | } 41 | 42 | final class ComposableNavigatorPushTests: XCTestCase { 43 | var app: XCUIApplication! 44 | 45 | override func setUp() { 46 | app = XCUIApplication() 47 | app.launch() 48 | continueAfterFailure = false 49 | } 50 | 51 | func test_push() { 52 | Home(app: app) 53 | .goToDetail(for: "0") 54 | .assertVisible() 55 | .goToBackToHome() 56 | .assertVisible() 57 | } 58 | 59 | func test_push_push() { 60 | Home(app: app) 61 | .goToDetail(for: "0") 62 | .assertVisible() 63 | .goToShortcuts() 64 | .assertVisible() 65 | .goToBackToHome() 66 | .assertVisible() 67 | } 68 | 69 | func test_push_sheet() { 70 | Home(app: app) 71 | .goToDetail(for: "0") 72 | .assertVisible() 73 | .goToSettings() 74 | .assertVisible() 75 | .goBackToHome() 76 | .assertVisible() 77 | } 78 | } 79 | 80 | final class ComposableNavigatorPathTransitionTests: XCTestCase { 81 | var app: XCUIApplication! 82 | 83 | override func setUp() { 84 | app = XCUIApplication() 85 | app.launch() 86 | continueAfterFailure = false 87 | } 88 | 89 | func test_sheet_push_to_push_sheet_sheet() { 90 | Home(app: app) 91 | .goToSettings() 92 | .assertVisible() 93 | .goToShortcutsPush() 94 | .assertVisible() 95 | .goToDetailSettingsShortcutsSheet() 96 | .assertVisible() 97 | } 98 | 99 | func test_detail_one_settings_to_detail_zero_settings() { 100 | Home(app: app) 101 | .goToDetail(for: "1") 102 | .assertVisible() 103 | .goToSettings() 104 | .shortcuts 105 | .goToDetailZeroSettings() 106 | .assertVisible() 107 | .dismissSheet() 108 | .assertVisible() 109 | .goToBackToHome() 110 | .assertVisible() 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Example/ExampleUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Example/ExampleUITests/Screens/Base.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | class Base { 4 | let app: XCUIApplication 5 | 6 | init(app: XCUIApplication) { 7 | self.app = app 8 | } 9 | 10 | func pop(to predecessor: Predecessor) -> Predecessor { 11 | app.backButton.tap() 12 | return predecessor 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Example/ExampleUITests/Screens/Detail.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | class Detail: Base { 4 | let id: String 5 | lazy var accessibilityIdentifiers = AccessibilityIdentifier.DetailScreen(id: id) 6 | 7 | var shortcutsButton: XCUIElement { 8 | app.buttons[accessibilityIdentifiers.shortcuts].await() 9 | } 10 | 11 | var settingsButton: XCUIElement { 12 | app.buttons[accessibilityIdentifiers.settings].await() 13 | } 14 | 15 | var shortcuts: NavigationShortcuts { 16 | NavigationShortcuts(accessibilityPrefix: "detail.\(id)", app: app) 17 | } 18 | 19 | init(app: XCUIApplication, id: String) { 20 | self.id = id 21 | super.init(app: app) 22 | } 23 | 24 | @discardableResult 25 | func assertVisible() -> Self { 26 | XCTAssertTrue(shortcutsButton.exists) 27 | return self 28 | } 29 | 30 | @discardableResult 31 | func goToShortcuts() -> NavigationShortcuts { 32 | shortcutsButton.tap() 33 | 34 | return NavigationShortcuts( 35 | accessibilityPrefix: "shortcuts", 36 | app: app 37 | ) 38 | } 39 | 40 | @discardableResult 41 | func goToSettings() -> Settings { 42 | settingsButton.tap() 43 | 44 | return Settings( 45 | predecessor: self, 46 | prefix: "detail.\(id)", 47 | app: app 48 | ) 49 | } 50 | 51 | @discardableResult 52 | func goToBackToHome() -> Home { 53 | return pop(to: Home(app: app)) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Example/ExampleUITests/Screens/Home.swift: -------------------------------------------------------------------------------- 1 | @testable import Example 2 | import XCTest 3 | 4 | class Home: Base { 5 | var settingsButton: XCUIElement { 6 | app.buttons[AccessibilityIdentifier.HomeScreen.settingsNavigationBarItem].await() 7 | } 8 | 9 | func detail(for id: String) -> XCUIElement { 10 | app.buttons[AccessibilityIdentifier.HomeScreen.detail(for: id)].await() 11 | } 12 | 13 | func detailSettings(for id: String) -> XCUIElement { 14 | app.buttons[AccessibilityIdentifier.HomeScreen.detailSettings(for: id)].await() 15 | } 16 | 17 | @discardableResult 18 | func assertVisible() -> Self { 19 | XCTAssertTrue(settingsButton.exists) 20 | return self 21 | } 22 | 23 | @discardableResult 24 | func goToSettings() -> Settings { 25 | settingsButton.tap() 26 | return Settings(predecessor: self, prefix: "home", app: app) 27 | } 28 | 29 | @discardableResult 30 | func goToDetail(for id: String) -> Detail { 31 | detail(for: id).tap() 32 | return Detail(app: app, id: id) 33 | } 34 | 35 | @discardableResult 36 | func goToDetailSettings(for id: String) -> Detail { 37 | detail(for: id).tap() 38 | return Detail(app: app, id: id) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Example/ExampleUITests/Screens/NavigationShortcuts.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | class NavigationShortcuts: Base { 4 | let accessibilityPrefix: String 5 | 6 | var accessibilityIdentifiers: AccessibilityIdentifier.NavigationShortcuts { 7 | AccessibilityIdentifier.NavigationShortcuts(prefix: accessibilityPrefix) 8 | } 9 | 10 | lazy var detailZeroSettingsButton = app 11 | .buttons[accessibilityIdentifiers.detailSettings] 12 | .await() 13 | 14 | lazy var detailSettingsShortcutsPush = app 15 | .buttons[accessibilityIdentifiers.detailSettingsShortcutsPush] 16 | .await() 17 | 18 | lazy var detailSettingsShortcutsSheet = app 19 | .buttons[accessibilityIdentifiers.detailSettingsShortcutsSheet] 20 | .await() 21 | 22 | lazy var backToHome = app 23 | .buttons[accessibilityIdentifiers.home] 24 | .await() 25 | 26 | init(accessibilityPrefix: String, app: XCUIApplication) { 27 | self.accessibilityPrefix = accessibilityPrefix 28 | super.init(app: app) 29 | } 30 | 31 | @discardableResult 32 | func assertVisible() -> Self { 33 | XCTAssertTrue(backToHome.exists) 34 | return self 35 | } 36 | 37 | @discardableResult 38 | func goToDetailZeroSettings() -> Settings { 39 | detailZeroSettingsButton.tap() 40 | return Settings( 41 | predecessor: Detail(app: app, id: "0"), 42 | prefix: "detail.0", 43 | app: app 44 | ) 45 | } 46 | 47 | @discardableResult 48 | func goToDetailSettingsShortcutsPush() -> NavigationShortcuts { 49 | detailSettingsShortcutsPush.tap() 50 | return NavigationShortcuts(accessibilityPrefix: "shortcuts", app: app) 51 | } 52 | 53 | @discardableResult 54 | func goToDetailSettingsShortcutsSheet() -> NavigationShortcuts { 55 | detailSettingsShortcutsSheet.tap() 56 | return NavigationShortcuts(accessibilityPrefix: "shortcuts", app: app) 57 | } 58 | 59 | func goToBackToHome() -> Home { 60 | backToHome.tap() 61 | return Home(app: app) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Example/ExampleUITests/Screens/Settings.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | class Settings: Base { 4 | let predecessor: Predecessor 5 | let prefix: String 6 | 7 | var shortcuts: NavigationShortcuts { 8 | NavigationShortcuts(accessibilityPrefix: "settings", app: app) 9 | } 10 | 11 | var accessibilityIdentifiers: AccessibilityIdentifier.SettingsScreen { 12 | AccessibilityIdentifier.SettingsScreen(prefix: prefix) 13 | } 14 | 15 | var shortcutsSheetButton: XCUIElement { 16 | app.buttons[accessibilityIdentifiers.shortcutsSheet].await() 17 | } 18 | 19 | var shortcutsPushButton: XCUIElement { 20 | app.buttons[accessibilityIdentifiers.shortcutsPush].await() 21 | } 22 | 23 | init(predecessor: Predecessor, prefix: String, app: XCUIApplication) { 24 | self.predecessor = predecessor 25 | self.prefix = prefix 26 | super.init(app: app) 27 | } 28 | 29 | @discardableResult 30 | func assertVisible() -> Self { 31 | XCTAssertTrue(shortcutsSheetButton.exists) 32 | return self 33 | } 34 | 35 | @discardableResult 36 | func goToShortcutsPush() -> NavigationShortcuts { 37 | shortcutsPushButton.tap() 38 | return NavigationShortcuts(accessibilityPrefix: "shortcuts", app: app) 39 | } 40 | 41 | @discardableResult 42 | func goToShortcutsSheet() -> NavigationShortcuts { 43 | shortcutsSheetButton.tap() 44 | return NavigationShortcuts(accessibilityPrefix: "shortcuts", app: app) 45 | } 46 | 47 | @discardableResult 48 | func dismissSheet() -> Predecessor { 49 | app.swipeDown(velocity: .fast) 50 | return predecessor 51 | } 52 | 53 | @discardableResult 54 | func goBackToHome() -> Home { 55 | shortcuts.goToBackToHome() 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Example/ExampleUITests/Screens/XCUIApplication+BackButton.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | extension XCUIApplication { 4 | var backButton: XCUIElement { 5 | navigationBars.buttons.element(boundBy: 0).await() 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Example/ExampleUITests/Screens/XCUIElement+ExistsAfterTimeout.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | extension XCUIElement { 4 | func await(_ timeout: TimeInterval = 6.0) -> XCUIElement { 5 | let exists = self.exists(after: timeout, pollInterval: 0.2) 6 | return exists ? self: self 7 | } 8 | 9 | func exists(after timeout: TimeInterval, pollInterval: TimeInterval) -> Bool { 10 | var elapsed: TimeInterval = 0 11 | while elapsed < timeout { 12 | if waitForExistence(timeout: pollInterval) { 13 | return true 14 | } 15 | 16 | elapsed += pollInterval 17 | } 18 | 19 | return false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Example/ExampleUITests/Screens/XCUIElementQuery+AccessibiltyIdentifier.swift: -------------------------------------------------------------------------------- 1 | @testable import Example 2 | import XCTest 3 | 4 | extension XCUIElementQuery { 5 | subscript(identifier: AccessibilityIdentifier) -> XCUIElement { 6 | return self[identifier.value] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Example/FullTests.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "D9600B5A-8B2E-4493-A051-F002D5B4D3C7", 5 | "name" : "Configuration 1", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | 13 | }, 14 | "testTargets" : [ 15 | { 16 | "parallelizable" : true, 17 | "target" : { 18 | "containerPath" : "container:Example.xcodeproj", 19 | "identifier" : "23C9A49625E3A140005942F8", 20 | "name" : "ExampleUITests" 21 | } 22 | }, 23 | { 24 | "parallelizable" : true, 25 | "target" : { 26 | "containerPath" : "container:..", 27 | "identifier" : "ComposableDeeplinkingTests", 28 | "name" : "ComposableDeeplinkingTests" 29 | } 30 | }, 31 | { 32 | "parallelizable" : true, 33 | "target" : { 34 | "containerPath" : "container:..", 35 | "identifier" : "ComposableNavigatorTCATests", 36 | "name" : "ComposableNavigatorTCATests" 37 | } 38 | }, 39 | { 40 | "parallelizable" : true, 41 | "target" : { 42 | "containerPath" : "container:..", 43 | "identifier" : "ComposableNavigatorTests", 44 | "name" : "ComposableNavigatorTests" 45 | } 46 | } 47 | ], 48 | "version" : 1 49 | } 50 | -------------------------------------------------------------------------------- /Example/README.md: -------------------------------------------------------------------------------- 1 | # Example Application and Test Suite 2 | 3 |

4 | 5 | ## Requirements 6 | - Ruby 7 | - Bundler 8 | - Danger 9 | 10 | ## Example applications 11 | 12 | ```shell 13 | open ./Example.xcworkspace 14 | ``` 15 | 16 | This repository contains two example applications. 17 | 18 | The main example application is a TCA based application that covers complex navigation patterns. This application contains a UI test suite to ensure that future updates of SwiftUI do not break ComposableNavigator. 19 | 20 | The vanilla example application is a simplified example to demonstrate the usage of ComposableNavigator in a Vanialla SwiftUI application. 21 | 22 | Both applications can be accessed through the Example.xcworkspace. Change the scheme in Xcode to run either application. 23 | 24 | ## Tests 25 | 26 | Run `make test` in the root folder. 27 | 28 | ## Cleanup 29 | 30 | Run `make cleanup` to delete all test artifacts. -------------------------------------------------------------------------------- /Example/UITests.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "08875A8E-F0D8-4FB1-9FD9-5A4055061C1D", 5 | "name" : "Configuration 1", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | 13 | }, 14 | "testTargets" : [ 15 | { 16 | "target" : { 17 | "containerPath" : "container:Example.xcodeproj", 18 | "identifier" : "23C9A49625E3A140005942F8", 19 | "name" : "ExampleUITests" 20 | } 21 | } 22 | ], 23 | "version" : 1 24 | } 25 | -------------------------------------------------------------------------------- /Example/UnitTests.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "D51A2EF0-51DA-4376-BB14-7B3B72809E3E", 5 | "name" : "Configuration 1", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "targetForVariableExpansion" : { 13 | "containerPath" : "container:Example.xcodeproj", 14 | "identifier" : "236E657B2559498C00C19B46", 15 | "name" : "Example" 16 | } 17 | }, 18 | "testTargets" : [ 19 | { 20 | "parallelizable" : true, 21 | "target" : { 22 | "containerPath" : "container:..", 23 | "identifier" : "ComposableDeeplinkingTests", 24 | "name" : "ComposableDeeplinkingTests" 25 | } 26 | }, 27 | { 28 | "parallelizable" : true, 29 | "target" : { 30 | "containerPath" : "container:..", 31 | "identifier" : "ComposableNavigatorTCATests", 32 | "name" : "ComposableNavigatorTCATests" 33 | } 34 | }, 35 | { 36 | "parallelizable" : true, 37 | "target" : { 38 | "containerPath" : "container:..", 39 | "identifier" : "ComposableNavigatorTests", 40 | "name" : "ComposableNavigatorTests" 41 | } 42 | } 43 | ], 44 | "version" : 1 45 | } 46 | -------------------------------------------------------------------------------- /Example/Vanilla SwiftUI Example/Vanilla SwiftUI 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/Vanilla SwiftUI Example/Vanilla SwiftUI Example/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Example/Vanilla SwiftUI Example/Vanilla SwiftUI Example/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/Vanilla SwiftUI Example/Vanilla SwiftUI Example/CapacityView.swift: -------------------------------------------------------------------------------- 1 | import ComposableNavigator 2 | import SwiftUI 3 | 4 | struct CapacityScreen: Screen { 5 | let capacity: Int 6 | var presentationStyle: ScreenPresentationStyle = .sheet(allowsPush: true) 7 | 8 | struct Builder: NavigationTree { 9 | var builder: some PathBuilder { 10 | Screen { (screen: CapacityScreen) in 11 | CapacityView(capacity: screen.capacity) 12 | } 13 | } 14 | } 15 | } 16 | 17 | struct CapacityView: View { 18 | @Environment(\.navigator) private var navigator 19 | @Environment(\.currentScreen) private var currentScreen 20 | 21 | let capacity: Int 22 | 23 | var body: some View { 24 | VStack { 25 | Image(systemName: "person.3.fill") 26 | .imageScale(.medium) 27 | .padding(.bottom) 28 | Text("\(capacity)") 29 | .font(.largeTitle) 30 | .bold() 31 | } 32 | .navigationBarItems( 33 | trailing: Button( 34 | action: { navigator.dismiss(screen: currentScreen) }, 35 | label: { Image(systemName: "xmark") }) 36 | ) 37 | .navigationTitle(Text("Capacity")) 38 | } 39 | } 40 | 41 | struct CapacityView_Previews: PreviewProvider { 42 | static var previews: some View { 43 | CapacityView(capacity: 101) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Example/Vanilla SwiftUI Example/Vanilla SwiftUI Example/DetailView.swift: -------------------------------------------------------------------------------- 1 | import ComposableNavigator 2 | import SwiftUI 3 | 4 | struct DetailScreen: Screen { 5 | let train: Train 6 | let presentationStyle: ScreenPresentationStyle = .push 7 | 8 | struct Builder: NavigationTree { 9 | var builder: some PathBuilder { 10 | Screen( 11 | content: { (screen: DetailScreen) in 12 | DetailView(train: screen.train) 13 | }, 14 | nesting: { 15 | CapacityScreen.Builder() 16 | } 17 | ) 18 | } 19 | } 20 | } 21 | 22 | struct DetailView: View { 23 | @Environment(\.navigator) private var navigator 24 | @Environment(\.currentScreen) private var currentScreen 25 | 26 | let train: Train 27 | 28 | var body: some View { 29 | VStack { 30 | Text(train.name) 31 | .padding() 32 | Button( 33 | action: { 34 | navigator.go( 35 | to: CapacityScreen(capacity: train.capacity), 36 | on: currentScreen 37 | ) 38 | }, 39 | label: { Text("Show capacity").foregroundColor(.red) } 40 | ) 41 | } 42 | .navigationTitle(train.name) 43 | } 44 | } 45 | 46 | struct DetailView_Previews: PreviewProvider { 47 | static var previews: some View { 48 | DetailView(train: Train(name: "ICE", capacity: 63)) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Example/Vanilla SwiftUI Example/Vanilla SwiftUI Example/HomeView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import ComposableNavigator 3 | 4 | struct HomeScreen: Screen { 5 | let presentationStyle: ScreenPresentationStyle = .push 6 | 7 | struct Builder: NavigationTree { 8 | var builder: some PathBuilder { 9 | Screen( 10 | HomeScreen.self, 11 | content: { HomeView() }, 12 | nesting: { 13 | DetailScreen.Builder() 14 | } 15 | ) 16 | } 17 | } 18 | } 19 | 20 | struct HomeView: View { 21 | @Environment(\.navigator) private var navigator: Navigator 22 | @Environment(\.currentScreen) private var currentScreen 23 | 24 | var body: some View { 25 | VStack { 26 | List(trains, id: \.name) { train in 27 | HStack { 28 | Button( 29 | action: { 30 | navigator.go( 31 | to: DetailScreen(train: train), 32 | on: currentScreen 33 | ) 34 | }, 35 | label: { Text(train.name) } 36 | ) 37 | Spacer() 38 | } 39 | } 40 | Spacer() 41 | } 42 | .navigationTitle(Text("Trains")) 43 | } 44 | } 45 | 46 | struct ContentView_Previews: PreviewProvider { 47 | static var previews: some View { 48 | HomeView() 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Example/Vanilla SwiftUI Example/Vanilla SwiftUI Example/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | 28 | UIApplicationSupportsIndirectInputEvents 29 | 30 | UILaunchScreen 31 | 32 | UIRequiredDeviceCapabilities 33 | 34 | armv7 35 | 36 | UISupportedInterfaceOrientations 37 | 38 | UIInterfaceOrientationPortrait 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | UISupportedInterfaceOrientations~ipad 43 | 44 | UIInterfaceOrientationPortrait 45 | UIInterfaceOrientationPortraitUpsideDown 46 | UIInterfaceOrientationLandscapeLeft 47 | UIInterfaceOrientationLandscapeRight 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /Example/Vanilla SwiftUI Example/Vanilla SwiftUI Example/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/Vanilla SwiftUI Example/Vanilla SwiftUI Example/Train.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Train: Hashable { 4 | let name: String 5 | let capacity: Int 6 | } 7 | -------------------------------------------------------------------------------- /Example/Vanilla SwiftUI Example/Vanilla SwiftUI Example/Vanilla_ExampleApp.swift: -------------------------------------------------------------------------------- 1 | import ComposableNavigator 2 | import SwiftUI 3 | 4 | let trains: [Train] = [ 5 | Train(name: "ICE", capacity: 403), 6 | Train(name: "Regio", capacity: 380), 7 | Train(name: "SBahn", capacity: 184), 8 | Train(name: "IC", capacity: 468) 9 | ] 10 | 11 | @main 12 | struct Vanilla_SwiftUI_ExampleApp: App { 13 | let dataSource: Navigator.Datasource = Navigator.Datasource(root: HomeScreen()) 14 | 15 | var body: some Scene { 16 | WindowGroup { 17 | Root(dataSource: dataSource, pathBuilder: HomeScreen.Builder()) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'danger', '~>8.2' 4 | gem 'danger-xcov', '~>0.5.0' -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Deutsche Bahn AG 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 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | ### Current ComposableNavigator project maintainers 2 | 3 | ----- 4 | 5 | | User Image | Github ID | Name | Email | 6 | | -------------------------------------------------------------------------------------------- | ---------------------------------------------- | --------------- | -------------------------------------- | 7 | | ![Daniel Peter profile picture](https://avatars.githubusercontent.com/u/9058860?s=30&v=4) | [@ohitsdaniel](https://github.com/ohitsdaniel) | Daniel Peter | | 8 | | ![Malte Bünz profile picture](https://avatars.githubusercontent.com/u/14075359?s=30&v=4) | [@mltbnz](https://github.com/mltbnz) | Malte Bünz | | 9 | | ![Oliver List profile picture](https://avatars.githubusercontent.com/u/1352189?s=30&v=4) | [@oliverlist](https://github.com/oliverlist) | Oliver List | | 10 | | ![Oliver Michalak profile picture](https://avatars.githubusercontent.com/u/1526074?s=30&v=4) | [@omichde](https://github.com/omichde) | Oliver Michalak | | 11 | 12 | ---- 13 | 14 | ### Prior project maintainers 15 | 16 | ----- 17 | 18 | | User Image | Github ID | Name | Email | 19 | | ---------- | --------- | ---- | ----- | 20 | | - | - | - | - | 21 | 22 | ---- -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PLATFORM_IOS = iOS Simulator,name=iPhone 8 2 | 3 | test: 4 | xcodebuild -resolvePackageDependencies \ 5 | -workspace Example/Example.xcworkspace \ 6 | -scheme Example 7 | 8 | xcodebuild clean test \ 9 | -workspace Example/Example.xcworkspace \ 10 | -scheme Example \ 11 | -testPlan FullTests \ 12 | -destination platform="$(PLATFORM_IOS)" \ 13 | -enableCodeCoverage YES \ 14 | -parallel-testing-enabled YES \ 15 | -parallel-testing-worker-count 4 \ 16 | -quiet \ 17 | -resultBundlePath coverage/test_result.xcresult 18 | 19 | cleanup: 20 | rm -rf ./Build 21 | rm -rf ./xcov_report 22 | rm -rf ./coverage 23 | 24 | release: 25 | swift run rocket ${version} 26 | 27 | .PHONY: test cleanup release -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let snapshotFolders = [ 7 | "PathBuilder/__Snapshots__", 8 | "NavigationTree/__Snapshots__", 9 | "Screen/__Snapshots__", 10 | ] 11 | 12 | let tcaSnapshotFolders = [ 13 | "__Snapshots__" 14 | ] 15 | 16 | let testGybFiles = [ 17 | "NavigationTree/NavigationTreeBuilder+AnyOf.swift.gyb" 18 | ] 19 | 20 | let package = Package( 21 | name: "swift-composable-navigator", 22 | platforms: [ 23 | .iOS(.v13), 24 | .macOS(.v10_15), 25 | .tvOS(.v13), 26 | .watchOS(.v6), 27 | ], 28 | products: [ 29 | .library( 30 | name: "ComposableNavigator", 31 | targets: ["ComposableNavigator"] 32 | ), 33 | .library( 34 | name: "ComposableDeeplinking", 35 | targets: ["ComposableDeeplinking"] 36 | ), 37 | .library( 38 | name: "ComposableNavigatorTCA", 39 | targets: ["ComposableNavigatorTCA"] 40 | ), 41 | ], 42 | dependencies: [ 43 | .package( 44 | name: "swift-composable-architecture", 45 | url: "https://github.com/pointfreeco/swift-composable-architecture", 46 | from: "0.7.0" 47 | ), 48 | .package(url: "https://github.com/shibapm/Rocket", from: "1.1.0"), // dev 49 | .package(name: "SnapshotTesting", url: "https://github.com/pointfreeco/swift-snapshot-testing.git", from: "1.8.2"), // dev 50 | ], 51 | targets: [ 52 | .target( 53 | name: "ComposableNavigator", 54 | dependencies: [], 55 | exclude: [ 56 | "NavigationTree/NavigationTreeBuilder+AnyOf.swift.gyb", 57 | "PathBuilder/PathBuilders/PathBuilder+AnyOf.swift.gyb", 58 | ] 59 | ), 60 | .target( 61 | name: "ComposableNavigatorTCA", 62 | dependencies: [ 63 | .target(name: "ComposableNavigator"), 64 | .product( 65 | name: "ComposableArchitecture", 66 | package: "swift-composable-architecture" 67 | ), 68 | ] 69 | ), 70 | .target( 71 | name: "ComposableDeeplinking", 72 | dependencies: [ 73 | .target(name: "ComposableNavigator"), 74 | ] 75 | ), 76 | .testTarget(name: "ComposableNavigatorTests", dependencies: ["ComposableNavigator", "SnapshotTesting"], exclude: testGybFiles + snapshotFolders), // dev 77 | .testTarget(name: "ComposableDeeplinkingTests", dependencies: ["ComposableDeeplinking"]), // dev 78 | .testTarget(name: "ComposableNavigatorTCATests", dependencies: ["ComposableNavigatorTCA", "SnapshotTesting"], exclude: tcaSnapshotFolders), // dev 79 | ] 80 | ) 81 | 82 | #if canImport(PackageConfig) 83 | import PackageConfig 84 | 85 | let config = PackageConfiguration( 86 | [ 87 | "rocket": [ 88 | "pre_release_checks": [ 89 | "clean_git" 90 | ], 91 | "before": [ 92 | // "make test", 93 | // "make cleanup", 94 | ] 95 | ] 96 | ] 97 | ) 98 | .write() 99 | #endif 100 | -------------------------------------------------------------------------------- /Sources/ComposableDeeplinking/Deeplink.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// First class representation of a deeplink based on URLs 4 | public struct Deeplink: Hashable { 5 | public let components: [DeeplinkComponent] 6 | } 7 | 8 | extension Deeplink { 9 | /// Initialise a deeplink from a URL, matching the passed Scheme 10 | /// 11 | /// Typically used to parse deeplinks from URL scheme triggers 12 | /// 13 | /// # Example 14 | /// ```swift 15 | /// // scheme://name?flag&value=123 16 | /// let url = URL(string: "scheme://name?flag&value=123")! 17 | /// 18 | /// let deeplink = Deeplink( 19 | /// url: url, 20 | /// matching: "scheme" 21 | /// ) 22 | /// 23 | /// deeplink.components == [ 24 | /// DeeplinkComponent( 25 | /// name: "name", 26 | /// arguments: [ 27 | /// "flag": .flag, 28 | /// "value": "123" 29 | /// ] 30 | /// ) 31 | /// ] // True 32 | /// ``` 33 | public init?(url: URL, matching scheme: String) { 34 | guard url.scheme == scheme else { 35 | return nil 36 | } 37 | 38 | components = [DeeplinkComponent](url: url) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/ComposableDeeplinking/DeeplinkComponent.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// First class representation of a URL path component 4 | /// 5 | /// # Example 6 | /// ```swift 7 | /// // scheme://name?flag&value=123 8 | /// DeeplinkComponent( 9 | /// name: "name", 10 | /// arguments: [ 11 | /// "flag": .flag, 12 | /// "value": "123" 13 | /// ] 14 | /// ) 15 | /// ``` 16 | public struct DeeplinkComponent: Hashable { 17 | public enum Argument: Hashable { 18 | case flag 19 | case value(String) 20 | } 21 | 22 | public let name: String 23 | public let arguments: [String: Argument]? 24 | 25 | /// Initialise a deeplink component from a URL 26 | /// 27 | /// # Example 28 | /// ```swift 29 | /// // scheme://name?flag&value=123 30 | /// DeeplinkComponent( 31 | /// name: "name", 32 | /// arguments: [ 33 | /// "flag": .flag, 34 | /// "value": "123" 35 | /// ] 36 | /// ) 37 | /// ``` 38 | public init?(url: URL) { 39 | guard let host = url.host else { 40 | return nil 41 | } 42 | 43 | self.name = host 44 | self.arguments = URLComponents( 45 | url: url, 46 | resolvingAgainstBaseURL: false 47 | )? 48 | .queryItems? 49 | .reduce( 50 | into: [:], 51 | { items, item in 52 | items[item.name] = item 53 | .value? 54 | .removingPercentEncoding 55 | .flatMap(Argument.value) 56 | ?? .flag 57 | } 58 | ) 59 | } 60 | 61 | public init(name: String, arguments: [String: Argument]? = nil) { 62 | self.name = name 63 | self.arguments = arguments 64 | } 65 | } 66 | 67 | extension Array where Element == DeeplinkComponent { 68 | init(url: URL) { 69 | self = url.absoluteString 70 | .dropFirst(url.scheme?.count.advanced(by: 3) ?? 0) 71 | .split(separator: "/") 72 | .map { "scheme://\($0)" } 73 | .compactMap(URL.init(string:)) 74 | .compactMap(DeeplinkComponent.init(url:)) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Sources/ComposableDeeplinking/DeeplinkHandler.swift: -------------------------------------------------------------------------------- 1 | import ComposableNavigator 2 | 3 | /// Handles deeplinks by building the navigation path based on a deeplink and replacing the current navigation path 4 | public struct DeeplinkHandler { 5 | private let navigator: Navigator 6 | private let parser: DeeplinkParser 7 | 8 | public init(navigator: Navigator, parser: DeeplinkParser) { 9 | self.navigator = navigator 10 | self.parser = parser 11 | } 12 | 13 | public func handle(deeplink: Deeplink) { 14 | if let path = parser.parse(deeplink) { 15 | navigator.replace(path: path) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/ComposableDeeplinking/DeeplinkParser.swift: -------------------------------------------------------------------------------- 1 | import ComposableNavigator 2 | 3 | /// `DeeplinkParser`s parse navigation paths from `Deeplink`s. 4 | /// 5 | /// `DeeplinkParser`s are wrapper structs around a pure `(Deeplink) -> [AnyScreen]?` function and support composition. 6 | /// 7 | /// # Returns 8 | /// If a deeplink parser handles the input `Deeplink`, it returns a `navigation path` in the form of an `AnyScreen` array. 9 | /// If the deeplink parser is not responsible for parsing the deeplink, it returns nil. 10 | public struct DeeplinkParser { 11 | private let _parse: (Deeplink) -> [AnyScreen]? 12 | 13 | public init(parse: @escaping (Deeplink) -> [AnyScreen]?) { 14 | _parse = parse 15 | } 16 | 17 | /// Parses a Deeplink to a navigation path 18 | /// 19 | /// - Returns: If the DeepLinkParser is responsible for the passed deeplink, it returns the built navigation path. Else nil. 20 | public func parse(_ deeplink: Deeplink) -> [AnyScreen]? { 21 | _parse(deeplink) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/ComposableDeeplinking/Parsers/DeeplinkParser+AnyOf.swift: -------------------------------------------------------------------------------- 1 | public extension DeeplinkParser { 2 | /// Any of the listed deeplink parsers might take care of parsing the deeplink 3 | static func anyOf( 4 | _ parsers: [DeeplinkParser] 5 | ) -> DeeplinkParser { 6 | DeeplinkParser( 7 | parse: { deeplink in 8 | for parser in parsers { 9 | if let path = parser.parse(deeplink) { 10 | return path 11 | } 12 | } 13 | return nil 14 | } 15 | ) 16 | } 17 | 18 | /// Any of the listed deeplink parsers might take care of parsing the deeplink 19 | static func anyOf( 20 | _ parsers: DeeplinkParser... 21 | ) -> DeeplinkParser { 22 | Self.anyOf(parsers) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/ComposableDeeplinking/Parsers/DeeplinkParser+Empty.swift: -------------------------------------------------------------------------------- 1 | public extension DeeplinkParser { 2 | /// Empty deeplink parses, not parsing any deeplink 3 | /// 4 | /// Can be used as a stub value 5 | static let empty = DeeplinkParser( 6 | parse: { _ in nil } 7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /Sources/ComposableDeeplinking/Parsers/DeeplinkParser+Prepending.swift: -------------------------------------------------------------------------------- 1 | import ComposableNavigator 2 | 3 | public extension DeeplinkParser { 4 | /// Allows you to prepend a navigation path to the feature's entrypoint, given the underlying parses succeeds in parsing the deeplink 5 | /// 6 | /// In bigger, modularly designed applications, features often have entrypoints. This Deeplink Parses allows you to navigate to the feature's entrypoint before the performing the navigation defined in the deeplink. 7 | static func prepending( 8 | path pathToEntrypoint: [AnyScreen], 9 | to parser: DeeplinkParser 10 | ) -> DeeplinkParser { 11 | DeeplinkParser( 12 | parse: { deeplink in 13 | parser.parse(deeplink).flatMap { parsedPath in 14 | pathToEntrypoint + parsedPath 15 | } 16 | } 17 | ) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/Helpers/UIKitOnAppear.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SwiftUI 3 | 4 | struct UIKitAppear: UIViewControllerRepresentable { 5 | final class UIAppearViewController: UIViewController { 6 | var action: () -> Void = {} 7 | 8 | override func viewDidAppear(_ animated: Bool) { 9 | action() 10 | } 11 | } 12 | 13 | let action: () -> Void 14 | 15 | func makeUIViewController(context: Context) -> UIAppearViewController { 16 | let vc = UIAppearViewController() 17 | vc.action = action 18 | return vc 19 | } 20 | 21 | func updateUIViewController(_ controller: UIAppearViewController, context: Context) { } 22 | } 23 | 24 | extension View { 25 | /// Unfortunately, onAppear is broken in SwiftUI iOS >14. 26 | /// Therefore, we fallback to UIKit's viewDidAppear method in `Routed` to determine when a screen is shown. 27 | /// [Apple Developer Forums Discussion](https://developer.apple.com/forums/thread/655338) 28 | func uiKitOnAppear(_ perform: @escaping () -> Void) -> some View { 29 | self.background(UIKitAppear(action: perform)) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/NavigationTree/Generated.NavigationTreeBuilder+AnyOf.swift: -------------------------------------------------------------------------------- 1 | // AUTO-GENERATED: Do not edit 2 | extension NavigationTreeBuilder { 3 | public static func buildBlock( 4 | _ a: ABuilder, _ b: BBuilder 5 | ) -> _PathBuilder> { 6 | PathBuilders.anyOf(a, b) 7 | } 8 | 9 | public static func buildBlock( 10 | _ a: ABuilder, _ b: BBuilder, _ c: CBuilder 11 | ) -> _PathBuilder> { 12 | PathBuilders.anyOf(a, b, c) 13 | } 14 | 15 | public static func buildBlock( 16 | _ a: ABuilder, _ b: BBuilder, _ c: CBuilder, _ d: DBuilder 17 | ) -> _PathBuilder> { 18 | PathBuilders.anyOf(a, b, c, d) 19 | } 20 | 21 | public static func buildBlock( 22 | _ a: ABuilder, _ b: BBuilder, _ c: CBuilder, _ d: DBuilder, _ e: EBuilder 23 | ) -> _PathBuilder> { 24 | PathBuilders.anyOf(a, b, c, d, e) 25 | } 26 | 27 | public static func buildBlock( 28 | _ a: ABuilder, _ b: BBuilder, _ c: CBuilder, _ d: DBuilder, _ e: EBuilder, _ f: FBuilder 29 | ) -> _PathBuilder> { 30 | PathBuilders.anyOf(a, b, c, d, e, f) 31 | } 32 | 33 | public static func buildBlock( 34 | _ a: ABuilder, _ b: BBuilder, _ c: CBuilder, _ d: DBuilder, _ e: EBuilder, _ f: FBuilder, _ g: GBuilder 35 | ) -> _PathBuilder> { 36 | PathBuilders.anyOf(a, b, c, d, e, f, g) 37 | } 38 | 39 | public static func buildBlock( 40 | _ a: ABuilder, _ b: BBuilder, _ c: CBuilder, _ d: DBuilder, _ e: EBuilder, _ f: FBuilder, _ g: GBuilder, _ h: HBuilder 41 | ) -> _PathBuilder> { 42 | PathBuilders.anyOf(a, b, c, d, e, f, g, h) 43 | } 44 | 45 | public static func buildBlock( 46 | _ a: ABuilder, _ b: BBuilder, _ c: CBuilder, _ d: DBuilder, _ e: EBuilder, _ f: FBuilder, _ g: GBuilder, _ h: HBuilder, _ i: IBuilder 47 | ) -> _PathBuilder> { 48 | PathBuilders.anyOf(a, b, c, d, e, f, g, h, i) 49 | } 50 | 51 | public static func buildBlock( 52 | _ a: ABuilder, _ b: BBuilder, _ c: CBuilder, _ d: DBuilder, _ e: EBuilder, _ f: FBuilder, _ g: GBuilder, _ h: HBuilder, _ i: IBuilder, _ j: JBuilder 53 | ) -> _PathBuilder> { 54 | PathBuilders.anyOf(a, b, c, d, e, f, g, h, i, j) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/NavigationTree/NavigationTree Convenience Extensions/NavigationTree+AnyOf.swift: -------------------------------------------------------------------------------- 1 | extension NavigationTree { 2 | public func AnyOf( 3 | @NavigationTreeBuilder _ builder: () -> P 4 | ) -> P { 5 | builder() 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/NavigationTree/NavigationTree Convenience Extensions/NavigationTree+Conditional.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension NavigationTree { 4 | public func If( 5 | @NavigationTreeBuilder screen pathBuilder: @escaping (S) -> IfBuilder, 6 | @NavigationTreeBuilder else: () -> Else 7 | ) -> _PathBuilder> { 8 | PathBuilders.if(screen: pathBuilder, else: `else`()) 9 | } 10 | 11 | public func If( 12 | @NavigationTreeBuilder screen pathBuilder: @escaping (S) -> IfBuilder 13 | ) -> _PathBuilder> { 14 | If(screen: pathBuilder, else: { PathBuilders.empty }) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/NavigationTree/NavigationTree Convenience Extensions/NavigationTree+Empty.swift: -------------------------------------------------------------------------------- 1 | extension NavigationTree { 2 | public func Empty() -> PathBuilders.EmptyBuilder { PathBuilders.empty } 3 | } 4 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/NavigationTree/NavigationTree Convenience Extensions/NavigationTree+Screen.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension NavigationTree { 4 | public func Screen< 5 | S: Screen, 6 | Content: View, 7 | Successor: PathBuilder 8 | >( 9 | onAppear: @escaping (Bool) -> Void = { _ in }, 10 | @ViewBuilder content build: @escaping (S) -> Content, 11 | @NavigationTreeBuilder nesting: () -> Successor 12 | ) -> _PathBuilder>{ 13 | PathBuilders.screen( 14 | onAppear: onAppear, 15 | content: build, 16 | nesting: nesting() 17 | ) 18 | } 19 | 20 | public func Screen( 21 | onAppear: @escaping (Bool) -> Void = { _ in }, 22 | @ViewBuilder content build: @escaping (S) -> Content 23 | ) -> _PathBuilder> { 24 | Screen( 25 | onAppear: onAppear, 26 | content: build, 27 | nesting: { PathBuilders.empty } 28 | ) 29 | } 30 | 31 | public func Screen< 32 | S: Screen, 33 | Content: View, 34 | Successor: PathBuilder 35 | >( 36 | _ type: S.Type, 37 | onAppear: @escaping (Bool) -> Void = { _ in }, 38 | @ViewBuilder content build: @escaping () -> Content, 39 | @NavigationTreeBuilder nesting: () -> Successor 40 | ) -> _PathBuilder> { 41 | PathBuilders.screen( 42 | type, 43 | onAppear: onAppear, 44 | content: build, 45 | nesting: nesting() 46 | ) 47 | } 48 | 49 | public func Screen( 50 | _ type: S.Type, 51 | onAppear: @escaping (Bool) -> Void = { _ in }, 52 | @ViewBuilder content build: @escaping () -> Content 53 | ) -> _PathBuilder> { 54 | Screen( 55 | type, 56 | onAppear: onAppear, 57 | content: build, 58 | nesting: { PathBuilders.empty } 59 | ) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/NavigationTree/NavigationTree Convenience Extensions/NavigationTree+Wildcard.swift: -------------------------------------------------------------------------------- 1 | public extension NavigationTree { 2 | /// Convenience wrapper around PathBuilders.wildcard. Replaces any screen with a predefined one. 3 | /// 4 | /// Based on the example for the conditional `PathBuilder`, you might run into a situation in which your deeplink parser parses a navigation path that can only be handled by the homeScreenBuilder. This would lead to an empty application, which is unfortunate. 5 | /// 6 | /// To mitigate this problem, you can combine a conditional `PathBuilder` with a wildcard `PathBuilder`: 7 | /// 8 | /// ```swift 9 | /// .conditional( 10 | /// either: .wildcard( 11 | /// screen: HomeScreen(), 12 | /// pathBuilder: HomeScreen.Builder(store: homeStore) 13 | /// ), 14 | /// or: wildcard( 15 | /// screen: LoginScreen(), 16 | /// loginScreen(store: loginStore) 17 | /// ), 18 | /// basedOn: { user.isLoggedIn } 19 | /// ) 20 | /// ``` 21 | /// 22 | /// This is example basically states: Whatever path I get, the first element should be a defined screen. 23 | /// 24 | /// # ⚠️ Warning ⚠️ 25 | /// If you use a wildcard `PathBuilder` in as part of an anyOf `PathBuilder`, make sure it is the last one in the list. If it isn't, it will swallow all screens and the `PathBuilder`s listed after the wildcard will be ignored. 26 | /// 27 | /// - Parameters: 28 | /// - screen: 29 | /// The screen that replaces the current path element. 30 | /// - pathBuilder: 31 | /// The `PathBuilder` used to build the altered path. 32 | func Wildcard< 33 | S: Screen, 34 | ContentBuilder: PathBuilder 35 | >( 36 | screen: S, 37 | pathBuilder: ContentBuilder 38 | ) -> _PathBuilder> { 39 | PathBuilders.wildcard(screen: screen, pathBuilder: pathBuilder) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/NavigationTree/NavigationTree+ControlFlow.swift: -------------------------------------------------------------------------------- 1 | extension NavigationTreeBuilder { 2 | public static func buildEither(first component: P) -> P { 3 | component 4 | } 5 | 6 | public static func buildEither(second component: P) -> P { 7 | component 8 | } 9 | 10 | public static func buildOptional(_ component: P?) -> some PathBuilder { 11 | PathBuilders.if(let: { component }, then: { component in component }) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/NavigationTree/NavigationTreeBuilder+AnyOf.swift.gyb: -------------------------------------------------------------------------------- 1 | // AUTO-GENERATED: Do not edit 2 | % import string 3 | %{ 4 | letters = string.ascii_uppercase 5 | combineCount = 10 6 | }% 7 | extension NavigationTreeBuilder { 8 | % for i in range(2, combineCount+1): 9 | %{ 10 | # ABCD... 11 | genericCharacters = letters[0:i] 12 | genericCharactersLower = map(string.lower, genericCharacters) 13 | 14 | # ABuilder: PathBuilder, BBuilder: PathBuilder, ... 15 | builderRequirements = ", ".join(map(lambda x: "{0}Builder: PathBuilder".format(x), genericCharacters)) 16 | 17 | # Combined 18 | contents = ", ".join(map(lambda x: "{0}Builder.Content".format(x), genericCharacters)) 19 | enumName = "Either{0}<{1}>".format(genericCharacters, contents) 20 | 21 | # _ a: PathBuilder,\n ... 22 | parameters = ", ".join(map(lambda x: "_ {0}: {1}Builder".format(x.lower(), x), genericCharacters)) 23 | 24 | # .anyOf(a, b, c...) 25 | anyOfParameters = ", ".join(genericCharactersLower) 26 | 27 | # PathBuilder> 28 | combinedPathBuilderType = "_PathBuilder<{0}>".format(enumName) 29 | }% 30 | public static func buildBlock<${builderRequirements}>( 31 | ${parameters} 32 | ) -> ${combinedPathBuilderType} { 33 | PathBuilders.anyOf(${anyOfParameters}) 34 | } 35 | % if i != combineCount: 36 | 37 | % else: 38 | % end 39 | % end 40 | } 41 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/NavigationTree/NavigationTreeBuilder.swift: -------------------------------------------------------------------------------- 1 | #if swift(>=5.4) 2 | @resultBuilder 3 | public enum NavigationTreeBuilder { 4 | public static func buildBlock(_ builder: P) -> P { 5 | builder 6 | } 7 | } 8 | #else 9 | @_functionBuilder 10 | public enum NavigationTreeBuilder { 11 | public static func buildBlock(_ builder: P) -> P { 12 | builder 13 | } 14 | } 15 | #endif 16 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/Navigator/Navigator+Debug.swift: -------------------------------------------------------------------------------- 1 | // MARK: - Debug 2 | public extension Navigator { 3 | /// Enable logging received function calls and path changes. 4 | func debug( 5 | log: @escaping (String) -> Void = { print($0) }, 6 | dumpPath: @escaping (NavigationPathUpdate) -> Void = { dump($0) } 7 | ) -> Navigator { 8 | Navigator( 9 | path: path, 10 | go: { (screen, id) in 11 | go(to: screen, on: id) 12 | log("Sent go(to: \(screen), on: \(id)).\nNew path:") 13 | dumpPath(path()) 14 | }, 15 | goToOnScreen: { screen, parent in 16 | go(to: screen, on: parent) 17 | log("Sent go(to: \(screen), on: \(parent)).\nNew path:") 18 | dumpPath(path()) 19 | }, 20 | goToPath: { newPath, id in 21 | go(to: newPath, on: id) 22 | log("Sent go(to path: \(newPath), on: \(id)).\nNew path:") 23 | dumpPath(path()) 24 | }, 25 | goToPathOnScreen: { newPath, parent in 26 | go(to: newPath, on: parent) 27 | log("Sent go(to path: \(newPath), on: \(parent)).\nNew path:") 28 | dumpPath(path()) 29 | }, 30 | goBack: { (predecessor) in 31 | goBack(to: predecessor) 32 | log("Sent goBack(to: \(predecessor)).\nNew path:") 33 | dumpPath(path()) 34 | }, 35 | goBackToID: { id in 36 | goBack(to: id) 37 | log("Sent goBack(to: \(id)).\nNew path:") 38 | dumpPath(path()) 39 | }, 40 | replace: { (newPath) in 41 | replace(path: newPath) 42 | log("Sent replace(path: \(newPath)).\nNew path:") 43 | dumpPath(path()) 44 | }, 45 | dismiss: { (id) in 46 | dismiss(id: id) 47 | log("Sent dismiss(id: \(id)).\nNew path:") 48 | dumpPath(path()) 49 | }, 50 | dismissScreen: { screen in 51 | dismiss(screen: screen) 52 | log("Sent dismiss(screen: \(screen)).\nNew path:") 53 | dumpPath(path()) 54 | }, 55 | dismissSuccessor: { (id) in 56 | dismissSuccessor(of: id) 57 | log("Sent dismissSuccessor(of: \(id)).\nNew path:") 58 | dumpPath(path()) 59 | }, 60 | dismissSuccessorOfScreen: { screen in 61 | dismissSuccessor(of: screen) 62 | log("Sent dismissSuccessor(of: \(screen)).\nNew path:") 63 | dumpPath(path()) 64 | }, 65 | replaceContent: { id, screen in 66 | replaceContent(of: id, with: screen) 67 | log("Sent replaceContent(of: \(id), with: \(screen)).\nNew path:") 68 | dumpPath(path()) 69 | }, 70 | replaceScreen: { oldContent, newContent in 71 | replace(screen: oldContent, with: newContent) 72 | log("Sent replace(screen: \(oldContent), with: \(newContent)).\nNew path:") 73 | dumpPath(path()) 74 | }, 75 | didAppear: { (id) in 76 | didAppear(id: id) 77 | log("Sent didAppear(id: \(id)).\nNew path:") 78 | dumpPath(path()) 79 | } 80 | ) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/Navigator/NavigatorDataSource+Navigator.swift: -------------------------------------------------------------------------------- 1 | public extension Navigator { 2 | /// Initialises a Navigator wrapping a Datasource object 3 | /// - Parameters: 4 | /// - dataSource: The wrapped data source 5 | init(dataSource: Navigator.Datasource) { 6 | self.init( 7 | path: { dataSource.path }, 8 | go: { screen, id in 9 | dataSource.go(to: screen, on: id) 10 | }, 11 | goToOnScreen: { screen, parent in 12 | dataSource.go(to: screen, on: parent) 13 | }, 14 | goToPath: { path, id in 15 | dataSource.go(to: path, on: id) 16 | }, 17 | goToPathOnScreen: { path, parent in 18 | dataSource.go(to: path, on: parent) 19 | }, 20 | goBack: { predecessor in 21 | dataSource.goBack(to: predecessor) 22 | }, 23 | goBackToID: { id in 24 | dataSource.goBack(to: id) 25 | }, 26 | replace: { path in 27 | dataSource.replace(path: path) 28 | }, 29 | dismiss: { id in 30 | dataSource.dismiss(id: id) 31 | }, 32 | dismissScreen: { screen in 33 | dataSource.dismiss(screen: screen) 34 | }, 35 | dismissSuccessor: { id in 36 | dataSource.dismissSuccessor(of: id) 37 | }, 38 | dismissSuccessorOfScreen: { screen in 39 | dataSource.dismissSuccessor(of: screen) 40 | }, 41 | replaceContent: { id, newContent in 42 | dataSource.replaceContent(of: id, with: newContent) 43 | }, 44 | replaceScreen: { oldContent, newContent in 45 | dataSource.replace(screen: oldContent, with: newContent) 46 | }, 47 | didAppear: { id in 48 | dataSource.didAppear(id: id) 49 | } 50 | ) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/Navigator/NavigatorKeys.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// EnvironmentKey identifying the `Navigator` allowing navigation path mutations 4 | public enum NavigatorKey: EnvironmentKey { 5 | public static let defaultValue: Navigator = .stub 6 | } 7 | 8 | /// EnvironmentKey used to pass down treatSheetDismissAsAppearInPresenter down the view hierarchy 9 | public enum TreatSheetDismissAsAppearInPresenterKey: EnvironmentKey { 10 | public static let defaultValue: Bool = false 11 | } 12 | 13 | public extension EnvironmentValues { 14 | /// The `Navigator` allowing navigation path mutations 15 | /// 16 | /// Can be used to directly navigate from a Vanilla SwiftUI. 17 | /// 18 | /// ```swift 19 | /// struct RootView: View { 20 | /// @Environment(\.navigator) var navigator: Navigator 21 | /// @Environment(\.currentScreenID) var screenID: ScreenID 22 | /// 23 | /// var body: some View { 24 | /// Button( 25 | /// action: { navigator.go(to: DetailScreen(), on: screenID) }, 26 | /// label: Text("Go to DetailScreen") 27 | /// } 28 | /// } 29 | /// ``` 30 | var navigator: Navigator { 31 | get { self[NavigatorKey.self] } 32 | set { self[NavigatorKey.self] = newValue } 33 | } 34 | 35 | /// `viewAppeared(animated:)` is not called in SwiftUI and UIKit when a ViewController dismisses a sheet. This environment value allows you to override this behaviour. 36 | /// 37 | /// Use `.environment(\.treatSheetDismissAsAppearInPresenter, true)` on your Root view to get onAppear events for sheet dismissals. 38 | /// 39 | /// **Example** 40 | /// ```swift 41 | /// Root( 42 | /// dataSource: dataSource, 43 | /// pathBuilder: pathBuilder 44 | /// ) 45 | /// .environment(\.treatSheetDismissAsAppearInPresenter, true) 46 | /// ``` 47 | var treatSheetDismissAsAppearInPresenter: Bool { 48 | get { self[TreatSheetDismissAsAppearInPresenterKey.self] } 49 | set { self[TreatSheetDismissAsAppearInPresenterKey.self] = newValue } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/Navigator/Path/NavigationPath.swift: -------------------------------------------------------------------------------- 1 | public typealias NavigationPath = [NavigationPathElement] 2 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/Navigator/Path/NavigationPathElementUpdate.swift: -------------------------------------------------------------------------------- 1 | public struct NavigationPathElementUpdate: Hashable { 2 | public let previous: NavigationPathElement? 3 | public let current: NavigationPathElement? 4 | 5 | public init(previous: NavigationPathElement?, current: NavigationPathElement?) { 6 | self.previous = previous 7 | self.current = current 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/Navigator/Path/NavigationPathUpdate.swift: -------------------------------------------------------------------------------- 1 | public struct NavigationPathUpdate: Equatable { 2 | public let previous: NavigationPath 3 | public let current: NavigationPath 4 | 5 | public func component(for id: ScreenID) -> NavigationPathElementUpdate { 6 | NavigationPathElementUpdate( 7 | previous: extractPathComponent(for: id, from: previous), 8 | current: extractPathComponent(for: id, from: current) 9 | ) 10 | } 11 | 12 | public func successor(of id: ScreenID) -> NavigationPathElementUpdate { 13 | NavigationPathElementUpdate( 14 | previous: extractSuccessor(of: id, from: previous), 15 | current: extractSuccessor(of: id, from: current) 16 | ) 17 | } 18 | 19 | private func extractPathComponent( 20 | for id: ScreenID, 21 | from path: NavigationPath 22 | ) -> NavigationPathElement? { 23 | return path.first(where: { $0.id == id }) 24 | } 25 | 26 | private func extractSuccessor( 27 | of id: ScreenID, 28 | from path: NavigationPath 29 | ) -> NavigationPathElement? { 30 | guard let successorIndex = path.firstIndex(where: { $0.id == id })?.advanced(by: 1), 31 | path.indices.contains(successorIndex) 32 | else { 33 | return nil 34 | } 35 | 36 | return path[successorIndex] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/Navigator/Path/PathElement/NavigationPathElement.swift: -------------------------------------------------------------------------------- 1 | public enum NavigationPathElement: Hashable { 2 | case screen(IdentifiedScreen) 3 | 4 | public var id: ScreenID { 5 | switch self { 6 | case .screen(let screen): 7 | return screen.id 8 | } 9 | } 10 | 11 | public func ids() -> Set { 12 | switch self { 13 | case .screen(let screen): 14 | return [screen.id] 15 | } 16 | } 17 | 18 | public var content: AnyScreen { 19 | switch self { 20 | case .screen(let screen): 21 | return screen.content 22 | } 23 | } 24 | 25 | public var hasAppeared: Bool { 26 | switch self { 27 | case .screen(let screen): 28 | return screen.hasAppeared 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/PathBuilder/PathBuilders/PathBuilder+AnyOf.swift.gyb: -------------------------------------------------------------------------------- 1 | // AUTO-GENERATED: Do not edit 2 | % import string 3 | %{ 4 | letters = string.ascii_uppercase 5 | combineCount = 10 6 | }% 7 | import SwiftUI 8 | 9 | % for i in range(2, combineCount+1): 10 | %{ 11 | genericCharacters = letters[0:i] 12 | requirements = ", ".join(map(lambda x: "{0}: View".format(x), genericCharacters)) 13 | enumName = "Either{0}<{1}>".format(genericCharacters, requirements) 14 | }% 15 | /// An either type, representing up to ${i} different view types 16 | public enum ${enumName}: View { 17 | % for c in genericCharacters: 18 | case ${c.lower()}(${c}) 19 | % end 20 | 21 | public var body: some View { 22 | switch self { 23 | % for c in genericCharacters: 24 | case let .${c.lower()}(${c}): 25 | ${c} 26 | % end 27 | } 28 | } 29 | } 30 | 31 | % end 32 | public extension PathBuilders { 33 | % for i in range(2, combineCount+1): 34 | %{ 35 | # ABCD... 36 | genericCharacters = letters[0:i] 37 | 38 | # BCD... 39 | allButFirst = genericCharacters[1:] 40 | allButFirstLower = map(string.lower, allButFirst) 41 | 42 | # ABuilder: PathBuilder, BBuilder: PathBuilder, ... 43 | builderRequirements = ",\n".join(map(lambda x: " {0}Builder: PathBuilder".format(x), genericCharacters)) 44 | 45 | # _ a: PathBuilder,\n ... 46 | parameters = ",\n".join(map(lambda x: " _ {0}: {1}Builder".format(x.lower(), x), genericCharacters)) 47 | 48 | eitherContents = ",\n ".join(map(lambda x: "{0}Builder.Content".format(x), genericCharacters)) 49 | 50 | # Either 51 | enumName = "\n Either{0}<\n {1}\n >".format(genericCharacters, eitherContents) 52 | 53 | # PathBuilder> 54 | combinedPathBuilderType = "_PathBuilder<{0}\n >".format(enumName) 55 | }% 56 | /// ```swift 57 | /// .screen( 58 | /// // ... 59 | /// nesting: PathBuilders.anyOf( 60 | /// SettingsScreen.Builder(store: settingsStore), 61 | /// DetailScreen.Builder(store: detailStore) 62 | /// ) 63 | /// ) 64 | /// ... 65 | /// ``` 66 | /// 67 | /// If a screen can have more than one possible successor, the AnyOf `PathBuilder` allows to branch out. In the example, the Home Screen can either route to the Settings or the Detail screen. We express these two possible navigation paths by passing an anyOf `PathBuilder` as a `nesting` argument. 68 | /// 69 | /// Read AnyOf `PathBuilder`s as "any of the listed `PathBuilder` builds the path". Given our example, the settings and the detail screen can follow after the home screen. AnyOf allows us to branch out in this case. The resulting app navigation tree would be: 70 | /// 71 | /// ``` 72 | /// -- Settings 73 | /// Home --- 74 | /// -- Detail 75 | /// ``` 76 | /// 77 | /// Keep in mind, that the order of the listed `PathBuilder`s matters. The first `PathBuilder`s that can handle the path will build it. 78 | /// 79 | /// ⚠️ The anyOf PathBuilder is limited to 10 elements, stack .anyOf `PathBuilder`s if a screen can be followed up by more than ten screens ⚠️ 80 | /// ```swift 81 | /// .screen( 82 | /// // ... 83 | /// nesting: .anyOf( 84 | /// .anyOf( 85 | /// // ... up to 10 `PathBuilder`s here 86 | /// ), 87 | /// .anyOf( 88 | /// // ... the other `PathBuilder`s 89 | /// ) 90 | /// ) 91 | /// ) 92 | /// ... 93 | /// ``` 94 | static func anyOf< 95 | ${builderRequirements} 96 | >( 97 | ${parameters} 98 | ) -> ${combinedPathBuilderType} 99 | { 100 | _PathBuilder { pathElement in 101 | if let aContent = a.build(pathElement: pathElement) { 102 | return .a(aContent) 103 | % for c in allButFirstLower: 104 | } else if let ${c}Content = ${c}.build(pathElement: pathElement) { 105 | return .${c}(${c}Content) 106 | % end 107 | } 108 | 109 | return nil 110 | } 111 | } 112 | % end 113 | } 114 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/PathBuilder/PathBuilders/PathBuilder+BeforeBuild.swift: -------------------------------------------------------------------------------- 1 | public extension PathBuilder { 2 | /// Performs an action before this `PathBuilder` is built. 3 | func beforeBuild(perform action: @escaping () -> Void) -> _PathBuilder { 4 | _PathBuilder { pathElement in 5 | action() 6 | return build(pathElement: pathElement) 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/PathBuilder/PathBuilders/PathBuilder+Empty.swift: -------------------------------------------------------------------------------- 1 | public extension PathBuilders { 2 | struct EmptyBuilder: PathBuilder { 3 | public func build(pathElement: NavigationPathElement) -> Never? { nil } 4 | } 5 | 6 | /// The empty `PathBuilder` does not build any screen and just returns nil for all screens. 7 | /// 8 | /// The .empty PathBuilder can be used as a stub value. 9 | static var empty: EmptyBuilder { 10 | EmptyBuilder() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/PathBuilder/PathBuilders/PathBuilder+EraseCircularPath.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// An erased path builder that erases the underlying Content to AnyView 4 | public struct AnyPathBuilder: PathBuilder { 5 | private let buildPathElement: (NavigationPathElement) -> AnyView? 6 | 7 | /// Erases the passed PathBuilder's Content to AnyView, if it builds the passed PathElement 8 | public init(erasing: Erased) { 9 | buildPathElement = { pathElement in 10 | erasing 11 | .build(pathElement: pathElement) 12 | .flatMap(AnyView.init) 13 | } 14 | } 15 | 16 | public func build(pathElement: NavigationPathElement) -> AnyView? { 17 | buildPathElement(pathElement) 18 | } 19 | } 20 | 21 | extension PathBuilder { 22 | /// Erases circular navigation paths 23 | /// 24 | /// NavigationTrees define a `PathBuilder` via their builder computed variable. 25 | /// The `Screen` PathBuilder adds `NavigationNode`s to the NavigationTree. 26 | /// 27 | /// One the one hand, this makes sure that all valid navigation paths are known at build time. 28 | /// On the other hand, this leads to problems if the NavigationTree contains a circular path. 29 | /// 30 | /// The following NavigationTree leads to a circular path: 31 | /// 32 | /// ```swift 33 | /// struct HomeScreen { 34 | /// let presentationStyle = ScreenPresentationStyle.push 35 | /// 36 | /// struct Builder: NavigationTree { 37 | /// var builder: _PathBuilder< 38 | /// NavigationNode // <- Circular Type 40 | /// > 41 | /// > { 42 | /// Screen( 43 | /// HomeScreen.self, 44 | /// content: HomeView.init, 45 | /// nesting: { 46 | /// HomeScreen.Builder() 47 | /// } 48 | /// ) 49 | /// } 50 | /// } 51 | /// } 52 | /// ``` 53 | /// 54 | /// Whenever a `Screen` Builder is contained multiple times in a navigation tree, 55 | /// the `Screen` and its successors are contained recursively in the NavigationTrees content type. 56 | /// 57 | /// Unfortunately, the compiler is not able to resolve recursive, generic types. 58 | /// To solve this, we can erase PathBuilders that lead to circular paths. 59 | /// 60 | /// ```swift 61 | /// struct HomeScreen { 62 | /// let presentationStyle = ScreenPresentationStyle.push 63 | /// 64 | /// struct Builder: NavigationTree { 65 | /// var builder: _PathBuilder< 66 | /// NavigationNode // <- No longer circular 67 | /// > { 68 | /// Screen( 69 | /// HomeScreen.self, 70 | /// content: HomeView.init, 71 | /// nesting: { 72 | /// HomeScreen 73 | /// .Builder() 74 | /// .eraseCircularNavigationPath() 75 | /// } 76 | /// ) 77 | /// } 78 | /// } 79 | /// } 80 | /// ``` 81 | public func eraseCircularNavigationPath() -> AnyPathBuilder { 82 | AnyPathBuilder(erasing: self) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/PathBuilder/PathBuilders/PathBuilder+Wildcard.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension PathBuilders { 4 | /// Wildcard View replaces the current path element with the passed wildcard, when it appears. 5 | /// 6 | /// - SeeAlso: `PathBuilders.wildcard(screen:, pathBuilder:)` 7 | struct WildcardView: View { 8 | @Environment(\.navigator) var navigator 9 | @Environment(\.currentScreenID) var id 10 | let wildcard: Wildcard 11 | let content: Content 12 | 13 | public var body: some View { 14 | content 15 | .uiKitOnAppear { 16 | navigator.replaceContent(of: id, with: wildcard) 17 | } 18 | } 19 | } 20 | 21 | /// Wildcard `PathBuilder`s replace any screen with a predefined one. 22 | /// 23 | /// Based on the example for the conditional `PathBuilder`, you might run into a situation in which your deeplink parser parses a navigation path that can only be handled by the homeScreenBuilder. This would lead to an empty application, which is unfortunate. 24 | /// 25 | /// To mitigate this problem, you can combine a conditional `PathBuilder` with a wildcard `PathBuilder`: 26 | /// 27 | /// ```swift 28 | /// .conditional( 29 | /// either: .wildcard( 30 | /// screen: HomeScreen(), 31 | /// pathBuilder: HomeScreen.Builder(store: homeStore) 32 | /// ), 33 | /// or: wildcard( 34 | /// screen: LoginScreen(), 35 | /// loginScreen(store: loginStore) 36 | /// ), 37 | /// basedOn: { user.isLoggedIn } 38 | /// ) 39 | /// ``` 40 | /// 41 | /// This is example basically states: Whatever path I get, the first element should be a defined screen. 42 | /// 43 | /// # ⚠️ Warning ⚠️ 44 | /// If you use a wildcard `PathBuilder` in as part of an anyOf `PathBuilder`, make sure it is the last one in the list. If it isn't, it will swallow all screens and the `PathBuilder`s listed after the wildcard will be unreachable. 45 | /// 46 | /// - Parameters: 47 | /// - screen: 48 | /// The screen that replaces the current path element. 49 | /// - pathBuilder: 50 | /// The `PathBuilder` used to build the altered path. 51 | static func wildcard< 52 | S: Screen, 53 | ContentBuilder: PathBuilder, 54 | Content 55 | >( 56 | screen: S, 57 | pathBuilder: ContentBuilder 58 | ) -> _PathBuilder> where ContentBuilder.Content == Content { 59 | _PathBuilder { pathElement in 60 | pathBuilder.build( 61 | pathElement: pathElement.wildcard( 62 | screen: screen 63 | ) 64 | ) 65 | .flatMap { content in 66 | WildcardView( 67 | wildcard: screen, 68 | content: content 69 | ) 70 | } 71 | } 72 | } 73 | } 74 | 75 | extension NavigationPathElement { 76 | func wildcard(screen: S) -> NavigationPathElement { 77 | switch self { 78 | case .screen: 79 | return .screen( 80 | IdentifiedScreen( 81 | id: id, 82 | content: screen, 83 | hasAppeared: hasAppeared 84 | ) 85 | ) 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/PathBuilder/_PathBuilder.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// PathBuilders define how a navigation path is built into a view hierarchy 4 | public protocol PathBuilder { 5 | associatedtype Content: View 6 | func build(pathElement: NavigationPathElement) -> Content? 7 | } 8 | 9 | /// Convenience type to define PathBuilders based on a build closure 10 | public struct _PathBuilder: PathBuilder { 11 | private let _buildPathElement: (NavigationPathElement) -> Content? 12 | 13 | public init(_ buildPathElement: @escaping (NavigationPathElement) -> Content?) { 14 | self._buildPathElement = buildPathElement 15 | } 16 | 17 | public func build(pathElement: NavigationPathElement) -> Content? { 18 | return _buildPathElement(pathElement) 19 | } 20 | } 21 | 22 | /// Namespace enum for all available PathBuilders 23 | public enum PathBuilders {} 24 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/Root.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Root View of any ComposableNavigator driven application 4 | /// 5 | /// Embeds the content in a `NavigationView` and builds the navigation path, whenever it changes. 6 | /// ## Usage 7 | /// ```swift 8 | /// import ComposableNavigator 9 | /// import SwiftUI 10 | /// 11 | /// struct AppNavigationTree: NavigationTree { 12 | /// let homeViewModel: HomeViewModel 13 | /// let detailViewModel: DetailViewModel 14 | /// let settingsViewModel: SettingsViewModel 15 | /// 16 | /// var builder: some PathBuilder { 17 | /// Screen( 18 | /// HomeScreen.self, 19 | /// content: { 20 | /// HomeView(viewModel: homeViewModel) 21 | /// }, 22 | /// nesting: { 23 | /// DetailScreen.Builder(viewModel: detailViewModel), 24 | /// SettingsScreen.Builder(viewModel: settingsViewModel) 25 | /// } 26 | /// ) 27 | /// } 28 | /// } 29 | /// 30 | /// @main 31 | /// struct ExampleApp: App { 32 | /// let dataSource = Navigator.Datasource(root: HomeScreen()) 33 | /// 34 | /// var body: some Scene { 35 | /// WindowGroup { 36 | /// Root( 37 | /// dataSource: dataSource, 38 | /// pathBuilder: AppNavigationTree(...) 39 | /// ) 40 | /// } 41 | /// } 42 | /// } 43 | /// ``` 44 | public struct Root: View { 45 | @ObservedObject private var dataSource: Navigator.Datasource 46 | private let navigator: Navigator 47 | private let pathBuilder: Builder 48 | 49 | public init( 50 | dataSource: Navigator.Datasource, 51 | navigator: Navigator, 52 | pathBuilder: Builder 53 | ) { 54 | self.dataSource = dataSource 55 | self.navigator = navigator 56 | self.pathBuilder = pathBuilder 57 | } 58 | 59 | public var body: some View { 60 | if let rootPathComponent = dataSource.path.current.first { 61 | NavigationView { 62 | pathBuilder.build( 63 | pathElement: rootPathComponent 64 | ) 65 | } 66 | .environment( 67 | \.currentScreenID, 68 | rootPathComponent.id 69 | ) 70 | .environment( 71 | \.currentScreen, 72 | rootPathComponent.content 73 | ) 74 | .environment( 75 | \.navigator, 76 | navigator 77 | ) 78 | .environmentObject(dataSource) 79 | .navigationViewStyle(StackNavigationViewStyle()) 80 | } 81 | } 82 | } 83 | 84 | public extension Root { 85 | init( 86 | dataSource: Navigator.Datasource, 87 | pathBuilder: Builder 88 | ) { 89 | self.init( 90 | dataSource: dataSource, 91 | navigator: Navigator(dataSource: dataSource), 92 | pathBuilder: pathBuilder 93 | ) 94 | } 95 | 96 | /// Enable logging function calls to the Navigator object and path changes. 97 | func debug() -> Root { 98 | Root( 99 | dataSource: dataSource, 100 | navigator: navigator.debug(), 101 | pathBuilder: pathBuilder 102 | ) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/Screen/AnyScreen.swift: -------------------------------------------------------------------------------- 1 | /// Type-erased representation of `Screen` objects 2 | public struct AnyScreen: Hashable, Screen { 3 | let screen: AnyHashable 4 | public let presentationStyle: ScreenPresentationStyle 5 | 6 | public init(_ route: S) { 7 | self.screen = route 8 | self.presentationStyle = route.presentationStyle 9 | } 10 | 11 | public func unwrap() -> S? { 12 | screen as? S 13 | } 14 | 15 | public func `is`(_ screenType: S.Type) -> Bool { 16 | (screen as? S) != nil 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/Screen/IdentifiedScreen.swift: -------------------------------------------------------------------------------- 1 | /// An identified representation of a `Screen` in a navigation path 2 | public struct IdentifiedScreen: Hashable, Identifiable { 3 | public let id: ScreenID 4 | public let content: AnyScreen 5 | public var hasAppeared: Bool 6 | 7 | public init(id: ScreenID, content: AnyScreen, hasAppeared: Bool) { 8 | self.id = id 9 | self.content = content 10 | self.hasAppeared = hasAppeared 11 | } 12 | 13 | public init(id: ScreenID, content: S, hasAppeared: Bool) { 14 | self.id = id 15 | self.content = content.eraseToAnyScreen() 16 | self.hasAppeared = hasAppeared 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/Screen/Screen.swift: -------------------------------------------------------------------------------- 1 | /// The Screen protocol is the underlying protocol for navigation path elements. Each navigation path element defines how it is presented. 2 | public protocol Screen: Hashable { 3 | var presentationStyle: ScreenPresentationStyle { get } 4 | } 5 | 6 | public extension Screen { 7 | /// Erase an instance of a concrete Screen type to AnyScreen 8 | func eraseToAnyScreen() -> AnyScreen { 9 | // If the screen was already type-erased, return the type-erased instance instead of wrapping it 10 | if let anyScreen = self as? AnyScreen { 11 | return anyScreen 12 | } else { 13 | return AnyScreen(self) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/Screen/ScreenID.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A unique identifier for Screens 4 | public typealias ScreenID = UUID 5 | 6 | public extension ScreenID { 7 | /// The ID of the root of any navigation path 8 | static let root = UUID(uuidString: "00000000-0000-0000-0000-000000000000")! 9 | } 10 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/Screen/ScreenKeys.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// EnvironmentKey identifying the `ScreenID` of the screen preceding the screen the view is embedded in 4 | enum ParentScreenIDKey: EnvironmentKey { 5 | /// The `ScreenID` of the screen preceding the screen the view is embedded in 6 | /// 7 | /// ComposableNavigator makes sure that this value is always filled with the correct value, as long as you embed your content in a `Root` view. 8 | /// 9 | /// - SeeAlso: `Root.swift` 10 | static let defaultValue: ScreenID? = nil 11 | } 12 | 13 | /// EnvironmentKey identifying the `ScreenID` of the screen the view is embedded in 14 | enum CurrentScreenIDKey: EnvironmentKey { 15 | /// The `ScreenID` of the screen the view is embedded in 16 | /// 17 | /// ComposableNavigator makes sure that this value is always filled with the correct value, as long as you embed your content in a `Root` view. 18 | /// 19 | /// - SeeAlso: `Root.swift` 20 | static let defaultValue: ScreenID = ScreenID() 21 | } 22 | 23 | /// EnvironmentKey identifying the `Screen` preceding the screen the view is embedded in 24 | enum ParentScreenKey: EnvironmentKey { 25 | /// The screen preceding the screen the view is embedded in 26 | /// 27 | /// ComposableNavigator makes sure that this value is always filled with the correct value, as long as you embed your content in a `Root` view. 28 | /// 29 | /// - SeeAlso: `Root.swift` 30 | static let defaultValue: AnyScreen? = nil 31 | } 32 | 33 | /// EnvironmentKey identifying the `Screen` the view is embedded in 34 | enum CurrentScreenKey: EnvironmentKey { 35 | /// The screen the view is embedded in 36 | /// 37 | /// ComposableNavigator makes sure that this value is always filled with the correct value, as long as you embed your content in a `Root` view. 38 | /// 39 | /// - SeeAlso: `Root.swift` 40 | static var defaultValue: AnyScreen { 41 | struct UnbuildableScreen: Screen { 42 | let presentationStyle: ScreenPresentationStyle = .push 43 | } 44 | return UnbuildableScreen().eraseToAnyScreen() 45 | } 46 | } 47 | 48 | public extension EnvironmentValues { 49 | /// The `ScreenID` of the screen preceding the screen the view is embedded in 50 | var parentScreenID: ScreenID? { 51 | get { self[ParentScreenIDKey.self] } 52 | set { self[ParentScreenIDKey.self] = newValue } 53 | } 54 | 55 | /// The `ScreenID` of the screen the view is embedded in 56 | var currentScreenID: ScreenID { 57 | get { self[CurrentScreenIDKey.self] } 58 | set { self[CurrentScreenIDKey.self] = newValue } 59 | } 60 | 61 | /// The `Screen` preceding the screen the view is embedded in 62 | var parentScreen: AnyScreen? { 63 | get { self[ParentScreenKey.self] } 64 | set { self[ParentScreenKey.self] = newValue } 65 | } 66 | 67 | /// The `Screen` the view is embedded in 68 | var currentScreen: AnyScreen { 69 | get { self[CurrentScreenKey.self] } 70 | set { self[CurrentScreenKey.self] = newValue } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/ComposableNavigator/Screen/ScreenPresentationStyle.swift: -------------------------------------------------------------------------------- 1 | /// Defines how a screen is presented 2 | public enum ScreenPresentationStyle: Hashable { 3 | /// The screen is presented as a push, analogous to `UINavigationController.pushViewController(_ vc:)` 4 | case push 5 | 6 | /// The screen is presented as a sheet, analogous to `UIViewController.present(vc:, animated:, completion:)` 7 | /// 8 | /// If `allowsPush:` is set to false, the sheet content is not embedded in a `NavigationView` and therefore pushes do not work. 9 | case sheet(allowsPush: Bool = true) 10 | } 11 | -------------------------------------------------------------------------------- /Sources/ComposableNavigatorTCA/NavigationTree/NavigationTree+IfLetStore.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import ComposableNavigator 3 | 4 | extension NavigationTree { 5 | /// Convenience wrapper around PathBuilders.ifLetStore 6 | public func IfLetStore< 7 | State: Equatable, 8 | Action, 9 | If, 10 | Else, 11 | IfBuilder: PathBuilder, 12 | ElseBuilder: PathBuilder 13 | >( 14 | store: Store, 15 | @NavigationTreeBuilder then: @escaping (Store) -> IfBuilder, 16 | @NavigationTreeBuilder else: () -> ElseBuilder 17 | ) -> _PathBuilder> where IfBuilder.Content == If, ElseBuilder.Content == Else { 18 | PathBuilders.ifLetStore(store: store, then: then, else: `else`()) 19 | } 20 | 21 | /// Convenience wrapper around PathBuilders.ifLetStore 22 | public func IfLetStore< 23 | State: Equatable, 24 | Action, 25 | If, 26 | IfBuilder: PathBuilder 27 | >( 28 | store: Store, 29 | @NavigationTreeBuilder then: @escaping (Store) -> IfBuilder 30 | ) -> _PathBuilder> where IfBuilder.Content == If { 31 | IfLetStore(store: store, then: then, else: { PathBuilders.empty }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/ComposableNavigatorTCA/PathBuilder/PathBuilder+IfLetStore.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import ComposableNavigator 3 | import SwiftUI 4 | 5 | public extension PathBuilders { 6 | /// A `PathBuilder` that safely unwraps a store of optional state in order to show one of two views. 7 | /// 8 | /// When the underlying state is non-`nil`, the `then` closure will be performed with a `Store` that 9 | /// holds onto non-optional state to build the navigation path, and otherwise the `else` PathBuilder will be used. 10 | /// ```swift 11 | /// PathBuilders.ifLetStore( 12 | /// store: store.scope(state: \SearchState.results, action: SearchAction.results), 13 | /// then: { store in DetailScreen.Builder(store: store) }, 14 | /// else: NotFoundScreen.Builder() 15 | /// ) 16 | /// ``` 17 | /// 18 | /// - Parameters: 19 | /// - store: The store scoping to the optional state 20 | /// - then: Closure defining how to build the path building given a non-optional store 21 | /// - else: The PathBuilder used, if the scoped state is `nil` 22 | static func ifLetStore< 23 | State: Equatable, 24 | Action, 25 | If, 26 | Else, 27 | IfBuilder: PathBuilder, 28 | ElseBuilder: PathBuilder 29 | >( 30 | store: Store, 31 | then: @escaping (Store) -> IfBuilder, 32 | else: ElseBuilder 33 | ) -> _PathBuilder> where IfBuilder.Content == If, ElseBuilder.Content == Else { 34 | _PathBuilder { pathElement in 35 | if let state = ViewStore(store).state { 36 | return then(store.scope(state: { $0 ?? state })) 37 | .build(pathElement: pathElement) 38 | .flatMap(EitherAB.a) 39 | } else { 40 | return `else`.build(pathElement: pathElement).flatMap(EitherAB.b) 41 | } 42 | } 43 | } 44 | 45 | /// A `PathBuilder` that safely unwraps a store of optional state in order to show one of two views. 46 | /// 47 | /// When the underlying state is non-`nil`, the `then` closure will be performed with a `Store` that 48 | /// holds onto non-optional state to build the navigation path. 49 | /// ```swift 50 | /// PathBuilders.ifLetStore( 51 | /// store: store.scope(state: \SearchState.results, action: SearchAction.results), 52 | /// then: { store in DetailScreen.Builder(store: store) } 53 | /// ) 54 | /// ``` 55 | /// 56 | /// - Parameters: 57 | /// - store: The store scoping to the optional state 58 | /// - then: Closure defining how to build the path building given a non-optional store 59 | static func ifLetStore< 60 | State: Equatable, 61 | Action, 62 | If, 63 | IfBuilder: PathBuilder 64 | >( 65 | store: Store, 66 | then: @escaping (Store) -> IfBuilder 67 | ) -> _PathBuilder> where IfBuilder.Content == If { 68 | ifLetStore(store: store, then: then, else: PathBuilders.empty) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Tests/ComposableDeeplinkingTests/DeeplinkComponentTests.swift: -------------------------------------------------------------------------------- 1 | @testable import ComposableDeeplinking 2 | import XCTest 3 | 4 | final class DeeplinkComponentTests: XCTestCase { 5 | func test_init_fails_if_URL_has_no_host() { 6 | var components = URLComponents() 7 | components.scheme = "app" 8 | components.path = "somePathWithoutHost" 9 | 10 | let url = components.url! 11 | 12 | XCTAssertNil(DeeplinkComponent(url: url)) 13 | } 14 | 15 | func test_array_extension_init_from_url() { 16 | let expectedComponents = [ 17 | DeeplinkComponent( 18 | name: "first", 19 | arguments: nil 20 | ), 21 | DeeplinkComponent( 22 | name: "second", 23 | arguments: nil 24 | ) 25 | ] 26 | 27 | var components = URLComponents() 28 | components.host = "first" 29 | components.path = "/second" 30 | 31 | let url = components.url! 32 | 33 | XCTAssertEqual(expectedComponents, [DeeplinkComponent](url: url)) 34 | } 35 | 36 | func test_init_DeeplinkComponent_fromURL() { 37 | let expectedPathElement = DeeplinkComponent( 38 | name: "test", 39 | arguments: [ 40 | "id": .value("1"), 41 | "message": .value("Hello World"), 42 | "flag": .flag 43 | ] 44 | ) 45 | 46 | let url = URL(string: "app://test?id=1&message=Hello%20World&flag")! 47 | 48 | XCTAssertEqual(DeeplinkComponent(url: url), expectedPathElement) 49 | } 50 | 51 | func test_DeeplinkComponents_from_url() { 52 | let url = URL(string: "app://first?id=0&flag/second?flag&id=1")! 53 | let expectedPathElements = [ 54 | DeeplinkComponent( 55 | name: "first", 56 | arguments: [ 57 | "id": .value("0"), 58 | "flag": .flag 59 | ] 60 | ), 61 | DeeplinkComponent( 62 | name: "second", 63 | arguments: [ 64 | "id": .value("1"), 65 | "flag": .flag 66 | ] 67 | ) 68 | ] 69 | 70 | XCTAssertEqual([DeeplinkComponent](url: url), expectedPathElements) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Tests/ComposableDeeplinkingTests/DeeplinkHandlerTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableDeeplinking 2 | @testable import ComposableNavigator 3 | import XCTest 4 | 5 | final class DeeplinkHandlerTests: XCTestCase { 6 | func test_handle_deeplink_replaces_path_if_deeplink_resolves() { 7 | let resolvedPath = [ 8 | TestScreen(identifier: "first", presentationStyle: .push).eraseToAnyScreen() 9 | ] 10 | 11 | var replacePathInvocations = [Navigator.ReplacePathInvocation]() 12 | let expectedInvocations = [ 13 | Navigator.ReplacePathInvocation(path: resolvedPath) 14 | ] 15 | 16 | let sut = DeeplinkHandler( 17 | navigator: Navigator.mock( 18 | replacePathInvoked: { invocation in 19 | replacePathInvocations.append(invocation) 20 | } 21 | ), 22 | parser: DeeplinkParser(parse: { _ in resolvedPath }) 23 | ) 24 | 25 | sut.handle(deeplink: Deeplink.Stub.deeplink) 26 | 27 | XCTAssertEqual(expectedInvocations, replacePathInvocations) 28 | } 29 | 30 | func test_handle_deeplink_does_not_replace_path_if_deeplink_does_not_resolves() { 31 | var replacePathInvocations = [Navigator.ReplacePathInvocation]() 32 | let expectedInvocations = [Navigator.ReplacePathInvocation]() 33 | 34 | let sut = DeeplinkHandler( 35 | navigator: Navigator.mock( 36 | replacePathInvoked: { invocation in 37 | replacePathInvocations.append(invocation) 38 | } 39 | ), 40 | parser: DeeplinkParser(parse: { _ in nil }) 41 | ) 42 | 43 | sut.handle(deeplink: Deeplink.Stub.deeplink) 44 | 45 | XCTAssertEqual(expectedInvocations, replacePathInvocations) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Tests/ComposableDeeplinkingTests/DeeplinkTests.swift: -------------------------------------------------------------------------------- 1 | @testable import ComposableDeeplinking 2 | import XCTest 3 | 4 | final class DeeplinkTests: XCTestCase { 5 | func test_init_Deeplink_fromURL_matching_scheme() { 6 | let expectedDeeplink = Deeplink( 7 | components: [ 8 | DeeplinkComponent( 9 | name: "test", 10 | arguments: [ 11 | "id": .value("1"), 12 | "message": .value("Hello World"), 13 | "flag": .flag 14 | ] 15 | ) 16 | ] 17 | ) 18 | 19 | let url = URL(string: "app://test?id=1&message=Hello%20World&flag")! 20 | 21 | XCTAssertEqual(Deeplink(url: url, matching: "app"), expectedDeeplink) 22 | } 23 | 24 | func test_init_Deeplink_fromURL_non_matching_scheme() { 25 | let url = URL(string: "app://test?id=1&message=Hello%20World&flag")! 26 | 27 | XCTAssertNil(Deeplink(url: url, matching: "otherScheme")) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/ComposableDeeplinkingTests/Helpers/Deeplink+Stub.swift: -------------------------------------------------------------------------------- 1 | @testable import ComposableDeeplinking 2 | 3 | extension Deeplink { 4 | enum Stub { 5 | static let deeplink = Deeplink( 6 | components: [ 7 | DeeplinkComponent( 8 | name: "test", 9 | arguments: [ 10 | "id": .value("1"), 11 | "message": .value("Hello World"), 12 | "flag": .flag 13 | ] 14 | ) 15 | ] 16 | ) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Tests/ComposableDeeplinkingTests/Helpers/TestScreen.swift: -------------------------------------------------------------------------------- 1 | import ComposableNavigator 2 | 3 | struct TestScreen: Screen { 4 | let identifier: String 5 | let presentationStyle: ScreenPresentationStyle 6 | 7 | init( 8 | identifier: String, 9 | presentationStyle: ScreenPresentationStyle 10 | ) { 11 | self.identifier = identifier 12 | self.presentationStyle = presentationStyle 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Tests/ComposableDeeplinkingTests/Parsers/DeeplinkParser+AnyOfTests.swift: -------------------------------------------------------------------------------- 1 | @testable import ComposableDeeplinking 2 | import XCTest 3 | 4 | final class DeeplinkParser_AnyOfTests: XCTestCase { 5 | func test_anyOf_deeplink_parser_returns_first_built_path() { 6 | let firstPath = [ 7 | TestScreen(identifier: "first", presentationStyle: .push).eraseToAnyScreen() 8 | ] 9 | 10 | let secondPath = [ 11 | TestScreen(identifier: "second", presentationStyle: .push).eraseToAnyScreen() 12 | ] 13 | 14 | let sut: DeeplinkParser = .anyOf( 15 | .init(parse: { _ in firstPath }), 16 | .init(parse: { _ in secondPath }) 17 | ) 18 | 19 | XCTAssertEqual(firstPath, sut.parse(Deeplink.Stub.deeplink)) 20 | } 21 | 22 | func test_anyOf_deeplink_parser_returns_second_built_path_when_first_does_not_match() { 23 | let secondPath = [ 24 | TestScreen(identifier: "second", presentationStyle: .push).eraseToAnyScreen() 25 | ] 26 | 27 | let sut: DeeplinkParser = .anyOf( 28 | .init(parse: { _ in nil }), 29 | .init(parse: { _ in secondPath }) 30 | ) 31 | 32 | XCTAssertEqual(secondPath, sut.parse(Deeplink.Stub.deeplink)) 33 | } 34 | 35 | func test_anyOf_deeplink_parser_returns_nil_when_none_matches() { 36 | let sut: DeeplinkParser = .anyOf( 37 | .init(parse: { _ in nil }), 38 | .init(parse: { _ in nil }) 39 | ) 40 | 41 | XCTAssertNil(sut.parse(Deeplink.Stub.deeplink)) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Tests/ComposableDeeplinkingTests/Parsers/DeeplinkParser+EmptyTests.swift: -------------------------------------------------------------------------------- 1 | @testable import ComposableDeeplinking 2 | import XCTest 3 | 4 | final class DeeplinkParser_EmptyTests: XCTestCase { 5 | func test_empty_deeplink_parser_always_returns_nil() { 6 | let sut = DeeplinkParser.empty 7 | XCTAssertNil(sut.parse(Deeplink.Stub.deeplink)) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Tests/ComposableDeeplinkingTests/Parsers/DeeplinkParser+PrependingTests.swift: -------------------------------------------------------------------------------- 1 | @testable import ComposableDeeplinking 2 | import XCTest 3 | 4 | final class DeeplinkParser_PrependingTests: XCTestCase { 5 | private let prepended = TestScreen( 6 | identifier: "prepended", 7 | presentationStyle: .push 8 | ) 9 | 10 | private let parsingSuccess = TestScreen( 11 | identifier: "parsingSuccess", 12 | presentationStyle: .push 13 | ) 14 | 15 | func test_prepends_path_if_parsing_succeeds() { 16 | let succeedingParser = DeeplinkParser( 17 | parse: { _ in 18 | [ self.parsingSuccess.eraseToAnyScreen() ] 19 | } 20 | ) 21 | 22 | let expectedPath = [ 23 | prepended.eraseToAnyScreen(), 24 | parsingSuccess.eraseToAnyScreen() 25 | ] 26 | 27 | let sut = DeeplinkParser.prepending( 28 | path: [ 29 | prepended.eraseToAnyScreen() 30 | ], 31 | to: succeedingParser 32 | ) 33 | 34 | let parsedPath = sut.parse( 35 | Deeplink(components: []) 36 | ) 37 | 38 | XCTAssertEqual(parsedPath, expectedPath) 39 | } 40 | 41 | func test_returns_nil_if_parser_does_not_resolve() { 42 | let succeedingParser = DeeplinkParser( 43 | parse: { _ in nil } 44 | ) 45 | 46 | let sut = DeeplinkParser.prepending( 47 | path: [ 48 | prepended.eraseToAnyScreen() 49 | ], 50 | to: succeedingParser 51 | ) 52 | 53 | let parsedPath = sut.parse( 54 | Deeplink(components: []) 55 | ) 56 | 57 | XCTAssertNil(parsedPath) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTCATests/NavigationPathElementHelpers.swift: -------------------------------------------------------------------------------- 1 | @testable import ComposableNavigator 2 | 3 | extension Screen { 4 | func asPathElement( 5 | with id: ScreenID = .root, 6 | hasAppeared: Bool = false 7 | ) -> NavigationPathElement { 8 | .screen( 9 | IdentifiedScreen( 10 | id: id, 11 | content: self, 12 | hasAppeared: hasAppeared 13 | ) 14 | ) 15 | } 16 | } 17 | 18 | extension IdentifiedScreen { 19 | func asPathElement() -> NavigationPathElement { 20 | .screen(self) 21 | } 22 | } 23 | 24 | extension NavigationPathUpdate { 25 | init(previous: [IdentifiedScreen], current: [IdentifiedScreen]) { 26 | self.init( 27 | previous: previous.map { $0.asPathElement() }, 28 | current: current.map { $0.asPathElement() } 29 | ) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTCATests/PathBuilder+IfLetStoreTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import ComposableNavigator 3 | import ComposableNavigatorTCA 4 | import SwiftUI 5 | import XCTest 6 | 7 | final class PathBuilder_IfLetStoreTests: XCTestCase { 8 | // MARK: - ifLet 9 | func test_ifLet_builds_then_builder_with_unwrapped_store_if_state_initialised() { 10 | var innerBuildCalled = false 11 | 12 | let store = Store, Void>( 13 | initialState: 0, 14 | reducer: .empty, 15 | environment: () 16 | ) 17 | 18 | let expectedPath = TestScreen().asPathElement() 19 | 20 | let sut = PathBuilders.ifLetStore( 21 | store: store, 22 | then: { store in 23 | _PathBuilder { pathElement -> EmptyView? in 24 | XCTAssertEqual(expectedPath, pathElement) 25 | XCTAssertEqual(ViewStore(store).state, 0) 26 | innerBuildCalled = true 27 | return EmptyView() 28 | } 29 | } 30 | ) 31 | 32 | XCTAssertNotNil(sut.build(pathElement: expectedPath)) 33 | XCTAssertTrue(innerBuildCalled) 34 | } 35 | 36 | func test_ifLet_builds_nil_if_state_nil() { 37 | var innerBuildCalled = false 38 | 39 | let store = Store, Void>( 40 | initialState: nil, 41 | reducer: .empty, 42 | environment: () 43 | ) 44 | 45 | let expectedPath = TestScreen().asPathElement() 46 | 47 | let sut = PathBuilders.ifLetStore( 48 | store: store, 49 | then: { store in 50 | _PathBuilder { pathElement -> EmptyView? in 51 | innerBuildCalled = true 52 | return EmptyView() 53 | } 54 | } 55 | ) 56 | 57 | XCTAssertNil(sut.build(pathElement: expectedPath)) 58 | XCTAssertFalse(innerBuildCalled) 59 | } 60 | 61 | // MARK: - ifLet else 62 | func test_ifLetElse_builds_then_builder_with_unwrapped_store_if_state_initialised() { 63 | let store = Store, Void>( 64 | initialState: 0, 65 | reducer: .empty, 66 | environment: () 67 | ) 68 | 69 | var thenBuilderInvocations = [NavigationPathElement]() 70 | var elseBuilderInvocations = [NavigationPathElement]() 71 | 72 | let expectedPath = TestScreen().asPathElement() 73 | 74 | let expectedThenBuilderInvocations = [ 75 | expectedPath 76 | ] 77 | 78 | let expectedElseBuilderInvocations = [NavigationPathElement]() 79 | 80 | let sut = PathBuilders.ifLetStore( 81 | store: store, 82 | then: { store in 83 | _PathBuilder { pathElement -> EmptyView? in 84 | XCTAssertEqual(ViewStore(store).state, 0) 85 | 86 | thenBuilderInvocations.append(pathElement) 87 | 88 | return EmptyView() 89 | } 90 | }, 91 | else: _PathBuilder { pathElement -> EmptyView? in 92 | elseBuilderInvocations.append(pathElement) 93 | return EmptyView() 94 | } 95 | ) 96 | 97 | XCTAssertNotNil(sut.build(pathElement: expectedPath)) 98 | XCTAssertEqual(expectedThenBuilderInvocations, thenBuilderInvocations) 99 | XCTAssertEqual(expectedElseBuilderInvocations, elseBuilderInvocations) 100 | } 101 | 102 | func test_ifLetElse_builds_else_builder_if_state_nil() { 103 | let store = Store, Void>( 104 | initialState: nil, 105 | reducer: .empty, 106 | environment: () 107 | ) 108 | 109 | var thenBuilderInvocations = [NavigationPathElement]() 110 | var elseBuilderInvocations = [NavigationPathElement]() 111 | 112 | let expectedPath = TestScreen().asPathElement() 113 | 114 | let expectedThenBuilderInvocations = [NavigationPathElement]() 115 | 116 | let expectedElseBuilderInvocations = [ 117 | expectedPath 118 | ] 119 | 120 | let sut = PathBuilders.ifLetStore( 121 | store: store, 122 | then: { store in 123 | _PathBuilder { pathElement -> EmptyView? in 124 | thenBuilderInvocations.append(pathElement) 125 | return EmptyView() 126 | } 127 | }, 128 | else: _PathBuilder { pathElement -> EmptyView? in 129 | elseBuilderInvocations.append(pathElement) 130 | return EmptyView() 131 | } 132 | ) 133 | 134 | XCTAssertNotNil(sut.build(pathElement: expectedPath)) 135 | XCTAssertEqual(expectedThenBuilderInvocations, thenBuilderInvocations) 136 | XCTAssertEqual(expectedElseBuilderInvocations, elseBuilderInvocations) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTCATests/PathBuilder+OnDismiss+TCATests.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | @testable import ComposableNavigator 3 | import ComposableNavigatorTCA 4 | import SnapshotTesting 5 | import SwiftUI 6 | import XCTest 7 | 8 | final class PathBuilder_OnDismiss_TCATests: XCTestCase { 9 | typealias State = Int 10 | 11 | let nextID = ScreenID() 12 | 13 | enum Action: Equatable { 14 | case anyScreen(AnyScreen) 15 | case screen(S) 16 | case action 17 | } 18 | 19 | struct NonMatching: Screen { 20 | let presentationStyle: ScreenPresentationStyle = .push 21 | } 22 | 23 | func dataSource() -> Navigator.Datasource { 24 | Navigator.Datasource( 25 | path: [ 26 | IdentifiedScreen(id: .root, content: TestScreen(), hasAppeared: false), 27 | IdentifiedScreen(id: nextID, content: TestScreen(), hasAppeared: false) 28 | ] 29 | ) 30 | } 31 | 32 | func test_on_dismiss_of_anyScreen_sends_action_into_store() { 33 | let dataSource = self.dataSource() 34 | 35 | var receivedActions = [Action]() 36 | let expectedActions = [ 37 | Action.anyScreen(TestScreen().eraseToAnyScreen()) 38 | ] 39 | 40 | let reducer = Reducer, Void> { _, action, _ in 41 | receivedActions.append(action) 42 | return .none 43 | } 44 | 45 | let store = Store( 46 | initialState: 0, 47 | reducer: reducer, 48 | environment: () 49 | ) 50 | 51 | let sut = _PathBuilder { path -> Color? in .red } 52 | .onDismiss( 53 | send: { (screen: AnyScreen) in .anyScreen(screen)}, 54 | into: store 55 | ) 56 | 57 | let content = sut 58 | .build(pathElement: dataSource.path.component(for: nextID).current!)? 59 | .environment(\.parentScreenID, .root) 60 | .environmentObject(dataSource) 61 | .frame(width: 20, height: 20) 62 | 63 | // Assert snapshot to force view building 64 | assertSnapshot(matching: content, as: .image) 65 | 66 | dataSource.dismiss(id: nextID) 67 | 68 | XCTAssertEqual(expectedActions, receivedActions) 69 | } 70 | 71 | func test_type_based_on_dismiss_of_matching_screen_sends_action_into_store() { 72 | let dataSource = self.dataSource() 73 | 74 | var receivedActions = [Action]() 75 | let expectedActions = [ 76 | Action.action 77 | ] 78 | 79 | let reducer = Reducer, Void> { _, action, _ in 80 | receivedActions.append(action) 81 | return .none 82 | } 83 | 84 | let store = Store( 85 | initialState: 0, 86 | reducer: reducer, 87 | environment: () 88 | ) 89 | 90 | let sut = _PathBuilder { path -> Color? in .red } 91 | .onDismiss( 92 | of: TestScreen.self, 93 | send: .action, 94 | into: store 95 | ) 96 | 97 | let content = sut 98 | .build(pathElement: dataSource.path.component(for: nextID).current!)? 99 | .environment(\.parentScreenID, .root) 100 | .environmentObject(dataSource) 101 | .frame(width: 20, height: 20) 102 | 103 | // Assert snapshot to force view building 104 | assertSnapshot(matching: content, as: .image) 105 | 106 | dataSource.dismiss(id: nextID) 107 | 108 | XCTAssertEqual(expectedActions, receivedActions) 109 | } 110 | 111 | func test_closure_based_on_dismiss_of_matching_screen_sends_action_into_store() { 112 | let dataSource = self.dataSource() 113 | 114 | var receivedActions = [Action]() 115 | let expectedActions = [ 116 | Action.screen(TestScreen()) 117 | ] 118 | 119 | let reducer = Reducer, Void> { _, action, _ in 120 | receivedActions.append(action) 121 | return .none 122 | } 123 | 124 | let store = Store( 125 | initialState: 0, 126 | reducer: reducer, 127 | environment: () 128 | ) 129 | 130 | let sut = _PathBuilder { path -> Color? in .red } 131 | .onDismiss( 132 | send: { (screen: TestScreen) in Action.screen(screen) }, 133 | into: store 134 | ) 135 | 136 | let content = sut 137 | .build(pathElement: dataSource.path.component(for: nextID).current!)? 138 | .environment(\.parentScreenID, .root) 139 | .environmentObject(dataSource) 140 | .frame(width: 20, height: 20) 141 | 142 | // Assert snapshot to force view building 143 | assertSnapshot(matching: content, as: .image) 144 | 145 | dataSource.dismiss(id: nextID) 146 | 147 | XCTAssertEqual(expectedActions, receivedActions) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTCATests/TestScreen.swift: -------------------------------------------------------------------------------- 1 | import ComposableNavigator 2 | 3 | struct TestScreen: Screen { 4 | let presentationStyle: ScreenPresentationStyle = .push 5 | } 6 | -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTCATests/__Snapshots__/PathBuilder+OnDismiss+TCATests/test_closure_based_on_dismiss_of_matching_screen_sends_action_into_store.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTCATests/__Snapshots__/PathBuilder+OnDismiss+TCATests/test_closure_based_on_dismiss_of_matching_screen_sends_action_into_store.1.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTCATests/__Snapshots__/PathBuilder+OnDismiss+TCATests/test_on_dismiss_of_anyScreen_sends_action_into_store.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTCATests/__Snapshots__/PathBuilder+OnDismiss+TCATests/test_on_dismiss_of_anyScreen_sends_action_into_store.1.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTCATests/__Snapshots__/PathBuilder+OnDismiss+TCATests/test_type_based_on_dismiss_of_matching_screen_sends_action_into_store.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTCATests/__Snapshots__/PathBuilder+OnDismiss+TCATests/test_type_based_on_dismiss_of_matching_screen_sends_action_into_store.1.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/Helpers/EmptyNavigationTree.swift: -------------------------------------------------------------------------------- 1 | import ComposableNavigator 2 | 3 | struct EmptyNavigationTree: NavigationTree { 4 | var builder: some PathBuilder { Empty() } 5 | } 6 | -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/Helpers/NavigationPathElementHelpers.swift: -------------------------------------------------------------------------------- 1 | @testable import ComposableNavigator 2 | 3 | extension Screen { 4 | func asPathElement( 5 | with id: ScreenID = .root, 6 | hasAppeared: Bool = false 7 | ) -> NavigationPathElement { 8 | .screen( 9 | IdentifiedScreen( 10 | id: id, 11 | content: self, 12 | hasAppeared: hasAppeared 13 | ) 14 | ) 15 | } 16 | } 17 | 18 | extension IdentifiedScreen { 19 | func asPathElement() -> NavigationPathElement { 20 | .screen(self) 21 | } 22 | } 23 | 24 | extension NavigationPathUpdate { 25 | init(previous: [IdentifiedScreen], current: [IdentifiedScreen]) { 26 | self.init( 27 | previous: previous.map { $0.asPathElement() }, 28 | current: current.map { $0.asPathElement() } 29 | ) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/Helpers/TestScreen.swift: -------------------------------------------------------------------------------- 1 | import ComposableNavigator 2 | 3 | struct TestScreen: Screen { 4 | let identifier: String 5 | let presentationStyle: ScreenPresentationStyle 6 | 7 | init( 8 | identifier: String, 9 | presentationStyle: ScreenPresentationStyle 10 | ) { 11 | self.identifier = identifier 12 | self.presentationStyle = presentationStyle 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/Helpers/TestView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct TestView: View, Equatable { 4 | let id: Int 5 | 6 | var body: some View { 7 | Color.red 8 | } 9 | 10 | static func ==(lhs: Self, rhs: Self) -> Bool { 11 | lhs.id == rhs.id 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/NavigationTree+ConditionalTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableNavigator 2 | import SwiftUI 3 | import XCTest 4 | 5 | final class NavigationTree_ConditionalTests: XCTestCase { 6 | struct NonMatching: Screen { 7 | let presentationStyle: ScreenPresentationStyle = .push 8 | } 9 | 10 | let pathElement = TestScreen(identifier: "", presentationStyle: .push).asPathElement() 11 | 12 | // MARK: - ifScreen 13 | func test_ifScreen_builds_path_if_screen_matches() { 14 | var builtScreens = [AnyScreen]() 15 | var builtPaths = [NavigationPathElement]() 16 | 17 | let expectedScreens = [ 18 | pathElement.content 19 | ] 20 | 21 | let expectedPaths = [ 22 | pathElement 23 | ] 24 | 25 | let sut = EmptyNavigationTree() 26 | .If { (screen: TestScreen) in 27 | _PathBuilder { pathElement -> EmptyView? in 28 | builtScreens.append(screen.eraseToAnyScreen()) 29 | builtPaths.append(pathElement) 30 | return EmptyView() 31 | } 32 | } 33 | 34 | XCTAssertNotNil(sut.build(pathElement: pathElement)) 35 | XCTAssertEqual(expectedPaths, builtPaths) 36 | XCTAssertEqual(expectedScreens, builtScreens) 37 | } 38 | 39 | func test_ifScreen_does_not_build_path_if_screen_does_not_match() { 40 | struct NonMatching: Screen { 41 | let presentationStyle: ScreenPresentationStyle = .push 42 | } 43 | 44 | var builtScreens = [AnyScreen]() 45 | var builtPaths = [NavigationPathElement]() 46 | 47 | let expectedScreens = [AnyScreen]() 48 | let expectedPaths = [NavigationPathElement]() 49 | 50 | let sut = EmptyNavigationTree() 51 | .If { (screen: TestScreen) in 52 | _PathBuilder { pathElement -> EmptyView? in 53 | builtScreens.append(screen.eraseToAnyScreen()) 54 | builtPaths.append(pathElement) 55 | return EmptyView() 56 | } 57 | } 58 | 59 | XCTAssertNil(sut.build(pathElement: NonMatching().asPathElement())) 60 | XCTAssertEqual(expectedPaths, builtPaths) 61 | XCTAssertEqual(expectedScreens, builtScreens) 62 | } 63 | 64 | func test_ifScreen_builds_else_builder_if_screen_does_not_match() { 65 | var builtScreens = [AnyScreen]() 66 | var builtPaths = [NavigationPathElement]() 67 | var builtElsePaths = [NavigationPathElement]() 68 | 69 | let expectedScreens = [AnyScreen]() 70 | let expectedPaths = [NavigationPathElement]() 71 | let expectedElsePaths = [NonMatching().asPathElement()] 72 | 73 | let sut = EmptyNavigationTree() 74 | .If { (screen: TestScreen) in 75 | return _PathBuilder { pathElement -> EmptyView? in 76 | builtScreens.append(screen.eraseToAnyScreen()) 77 | builtPaths.append(pathElement) 78 | return nil 79 | } 80 | } 81 | else: { 82 | _PathBuilder { pathElement -> EmptyView? in 83 | builtElsePaths.append(pathElement) 84 | return EmptyView() 85 | } 86 | } 87 | 88 | XCTAssertNotNil(sut.build(pathElement: NonMatching().asPathElement())) 89 | XCTAssertEqual(expectedPaths, builtPaths) 90 | XCTAssertEqual(expectedScreens, builtScreens) 91 | XCTAssertEqual(expectedElsePaths, builtElsePaths) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/NavigationTree+EmptyTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableNavigator 2 | import XCTest 3 | 4 | final class NavigationTree_EmptyTests: XCTestCase { 5 | func test_build_returns_nil() { 6 | let pathElement = TestScreen(identifier: "0", presentationStyle: .push) 7 | 8 | struct TestNavigationTree: NavigationTree { 9 | var builder: some PathBuilder { 10 | Empty() 11 | } 12 | } 13 | 14 | let sut = TestNavigationTree() 15 | 16 | XCTAssertNil(sut.build(pathElement: pathElement.asPathElement())) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/NavigationTree+WildcardTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableNavigator 2 | import SwiftUI 3 | import XCTest 4 | 5 | final class NavigationTree_WildcardTest: XCTestCase { 6 | let testScreen = TestScreen(identifier: "0", presentationStyle: .push) 7 | 8 | func test_buildsWildcardView_for_non_matching_screen() { 9 | struct NonMatching: Screen { 10 | let presentationStyle: ScreenPresentationStyle = .push 11 | } 12 | 13 | let pathElement = NonMatching().asPathElement() 14 | 15 | let sut = EmptyNavigationTree().Wildcard( 16 | screen: testScreen, 17 | pathBuilder: _PathBuilder { path -> EmptyView? in 18 | let expected = self.testScreen.asPathElement() 19 | 20 | XCTAssertEqual(expected, path) 21 | return EmptyView() 22 | } 23 | ) 24 | 25 | let builtScreen = sut.build(pathElement: pathElement) 26 | 27 | XCTAssertNotNil(builtScreen) 28 | } 29 | 30 | func test_buildsWildcardView_for_matching_screen() { 31 | let sut = EmptyNavigationTree().Wildcard( 32 | screen: testScreen, 33 | pathBuilder: _PathBuilder { pathElement -> EmptyView? in 34 | XCTAssertEqual(self.testScreen.asPathElement(), pathElement) 35 | return EmptyView() 36 | } 37 | ) 38 | 39 | let builtScreen = sut.build( 40 | pathElement: testScreen.asPathElement() 41 | ) 42 | 43 | XCTAssertNotNil(builtScreen) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/NavigationTreeBuilder+AnyOf.swift.gyb: -------------------------------------------------------------------------------- 1 | // AUTO-GENERATED: Do not edit 2 | % import string 3 | %{ 4 | letters = string.ascii_uppercase 5 | combineCount = 10 6 | }% 7 | import ComposableNavigator 8 | import SnapshotTesting 9 | import SwiftUI 10 | import XCTest 11 | 12 | final class PathBuilder_AnyOfTests: XCTestCase { 13 | struct NonMatching: Screen { 14 | let presentationStyle: ScreenPresentationStyle = .push 15 | } 16 | 17 | %{ 18 | genericCharacters = letters[0:combineCount] 19 | }% 20 | % for character in genericCharacters: 21 | struct ${character}Screen: Screen { 22 | let presentationStyle: ScreenPresentationStyle = .push 23 | } 24 | 25 | % end 26 | % for i in range(2, combineCount+1): 27 | %{ 28 | # ABCD... 29 | genericCharacters = letters[0:i] 30 | genericCharactersLower = map(string.lower, genericCharacters) 31 | }% 32 | func test_${i}_buildsPath() { 33 | let sut = EmptyNavigationTree().AnyOf { 34 | % for inner in range(0, i): 35 | %{ 36 | innerChar = genericCharacters[inner] 37 | }% 38 | PathBuilders.screen( 39 | ${innerChar}Screen.self, 40 | content: { Text("${innerChar}") } 41 | ) 42 | % end 43 | } 44 | 45 | % for inner in range(0, i): 46 | %{ 47 | innerChar = genericCharacters[inner] 48 | innerCharLower = genericCharactersLower[inner] 49 | }% 50 | // MARK: ${innerCharLower}Screen 51 | let ${innerCharLower}BuiltView = sut.build(pathElement: ${innerChar}Screen().asPathElement()) 52 | 53 | guard case .${innerCharLower} = ${innerCharLower}BuiltView else { 54 | XCTFail("Expected \(${innerChar}Screen.self) to build Either.${innerCharLower}. Got \(${innerCharLower}BuiltView.debugDescription).") 55 | return 56 | } 57 | 58 | assertSnapshot( 59 | matching: ${innerCharLower}BuiltView 60 | .frame(width: 20, height: 20) 61 | .environmentObject(Navigator.Datasource(path: [])), 62 | as: .image 63 | ) 64 | 65 | % end 66 | let nonMatchingBuilt = sut.build(pathElement: NonMatching().asPathElement()) 67 | 68 | XCTAssertNil(nonMatchingBuilt) 69 | } 70 | % if i != combineCount: 71 | 72 | % else: 73 | % end 74 | % end 75 | } 76 | -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_10_buildsPath.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_10_buildsPath.1.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_10_buildsPath.10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_10_buildsPath.10.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_10_buildsPath.2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_10_buildsPath.2.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_10_buildsPath.3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_10_buildsPath.3.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_10_buildsPath.4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_10_buildsPath.4.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_10_buildsPath.5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_10_buildsPath.5.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_10_buildsPath.6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_10_buildsPath.6.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_10_buildsPath.7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_10_buildsPath.7.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_10_buildsPath.8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_10_buildsPath.8.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_10_buildsPath.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_10_buildsPath.9.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_2_buildsPath.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_2_buildsPath.1.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_2_buildsPath.2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_2_buildsPath.2.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_3_buildsPath.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_3_buildsPath.1.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_3_buildsPath.2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_3_buildsPath.2.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_3_buildsPath.3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_3_buildsPath.3.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_4_buildsPath.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_4_buildsPath.1.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_4_buildsPath.2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_4_buildsPath.2.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_4_buildsPath.3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_4_buildsPath.3.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_4_buildsPath.4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_4_buildsPath.4.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_5_buildsPath.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_5_buildsPath.1.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_5_buildsPath.2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_5_buildsPath.2.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_5_buildsPath.3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_5_buildsPath.3.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_5_buildsPath.4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_5_buildsPath.4.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_5_buildsPath.5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_5_buildsPath.5.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_6_buildsPath.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_6_buildsPath.1.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_6_buildsPath.2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_6_buildsPath.2.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_6_buildsPath.3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_6_buildsPath.3.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_6_buildsPath.4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_6_buildsPath.4.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_6_buildsPath.5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_6_buildsPath.5.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_6_buildsPath.6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_6_buildsPath.6.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_7_buildsPath.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_7_buildsPath.1.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_7_buildsPath.2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_7_buildsPath.2.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_7_buildsPath.3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_7_buildsPath.3.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_7_buildsPath.4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_7_buildsPath.4.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_7_buildsPath.5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_7_buildsPath.5.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_7_buildsPath.6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_7_buildsPath.6.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_7_buildsPath.7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_7_buildsPath.7.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_8_buildsPath.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_8_buildsPath.1.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_8_buildsPath.2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_8_buildsPath.2.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_8_buildsPath.3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_8_buildsPath.3.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_8_buildsPath.4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_8_buildsPath.4.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_8_buildsPath.5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_8_buildsPath.5.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_8_buildsPath.6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_8_buildsPath.6.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_8_buildsPath.7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_8_buildsPath.7.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_8_buildsPath.8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_8_buildsPath.8.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_9_buildsPath.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_9_buildsPath.1.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_9_buildsPath.2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_9_buildsPath.2.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_9_buildsPath.3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_9_buildsPath.3.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_9_buildsPath.4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_9_buildsPath.4.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_9_buildsPath.5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_9_buildsPath.5.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_9_buildsPath.6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_9_buildsPath.6.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_9_buildsPath.7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_9_buildsPath.7.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_9_buildsPath.8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_9_buildsPath.8.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_9_buildsPath.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/NavigationTree/__Snapshots__/Generated.NavigationTreeBuilder+AnyOf/test_9_buildsPath.9.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/Navigator/NavigatorKeysTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableNavigator 2 | import SwiftUI 3 | import XCTest 4 | 5 | final class NavigatorKeysTests: XCTestCase { 6 | func test_navigator_get_set_from_environment() { 7 | var newNavigatorCalled = false 8 | let newNavigator = Navigator.mock( 9 | dismiss: { _ in newNavigatorCalled = true } 10 | ) 11 | 12 | var environment = EnvironmentValues() 13 | XCTAssertNoThrow(environment.navigator) 14 | environment.navigator = newNavigator 15 | let sut = environment.navigator 16 | sut.dismiss(id: ScreenID()) 17 | XCTAssertTrue(newNavigatorCalled) 18 | } 19 | 20 | func test_treatSheetDismissAsAppearInPresenter_get_set_from_environment() { 21 | var environment = EnvironmentValues() 22 | XCTAssertFalse(environment.treatSheetDismissAsAppearInPresenter) 23 | environment.treatSheetDismissAsAppearInPresenter = true 24 | XCTAssertTrue(environment.treatSheetDismissAsAppearInPresenter) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/Navigator/Path/PathElement/NavigationPathElementTests.swift: -------------------------------------------------------------------------------- 1 | @testable import ComposableNavigator 2 | import XCTest 3 | 4 | final class NavigationPathElement_Tests: XCTestCase { 5 | let screenID = ScreenID() 6 | let content = TestScreen( 7 | identifier: "", 8 | presentationStyle: .push 9 | ) 10 | 11 | // MARK: - Screen path element 12 | func test_screen_id_equals_wrapped_screen_id() { 13 | let sut = NavigationPathElement.screen( 14 | IdentifiedScreen( 15 | id: screenID, 16 | content: content, 17 | hasAppeared: false 18 | ) 19 | ) 20 | 21 | XCTAssertEqual(screenID, sut.id) 22 | } 23 | 24 | func test_screen_ids_contains_only_screen_id() { 25 | let expectedIDs = Set([screenID]) 26 | 27 | let sut = NavigationPathElement.screen( 28 | IdentifiedScreen( 29 | id: screenID, 30 | content: content, 31 | hasAppeared: false 32 | ) 33 | ) 34 | 35 | XCTAssertEqual(expectedIDs, sut.ids()) 36 | } 37 | 38 | func test_screen_content_equals_wrapped_content() { 39 | let expectedContent = content.eraseToAnyScreen() 40 | 41 | let sut = NavigationPathElement.screen( 42 | IdentifiedScreen( 43 | id: screenID, 44 | content: content, 45 | hasAppeared: false 46 | ) 47 | ) 48 | 49 | XCTAssertEqual(expectedContent, sut.content) 50 | } 51 | 52 | func test_screen_hasAppeared_equals_wrapped_screen() { 53 | var sut = NavigationPathElement.screen( 54 | IdentifiedScreen( 55 | id: screenID, 56 | content: content, 57 | hasAppeared: false 58 | ) 59 | ) 60 | 61 | XCTAssertFalse(sut.hasAppeared) 62 | 63 | sut = NavigationPathElement.screen( 64 | IdentifiedScreen( 65 | id: screenID, 66 | content: content, 67 | hasAppeared: true 68 | ) 69 | ) 70 | 71 | XCTAssertTrue(sut.hasAppeared) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/PathBuilder/NavigationNodeTests.swift: -------------------------------------------------------------------------------- 1 | @testable import ComposableNavigator 2 | import SnapshotTesting 3 | import SwiftUI 4 | import XCTest 5 | 6 | final class NavigationNodeTests: XCTestCase { 7 | func test_calls_closure_on_initial_appear() { 8 | var onAppearClosureInvocations = [Bool]() 9 | let expectedInvocations = [true] 10 | 11 | let dataSource = Navigator.Datasource( 12 | path: [ 13 | IdentifiedScreen( 14 | id: .root, 15 | content: TestScreen(identifier: "0", presentationStyle: .push), 16 | hasAppeared: false 17 | ) 18 | ] 19 | ) 20 | 21 | let routed = NavigationNode( 22 | content: Text("A"), 23 | onAppear: { initialAppear in onAppearClosureInvocations.append(initialAppear) }, 24 | buildSuccessor: { _ -> EmptyView? in nil } 25 | ) 26 | .frame(width: 20, height: 20) 27 | .environmentObject(dataSource) 28 | .environment(\.currentScreenID, .root) 29 | .environment(\.navigator, Navigator(dataSource: dataSource).debug()) 30 | 31 | assertSnapshot(matching: routed, as: .image) 32 | 33 | XCTAssertEqual(expectedInvocations, onAppearClosureInvocations) 34 | } 35 | 36 | func test_calls_closure_on_consequent_appear() { 37 | var onAppearClosureInvocations = [Bool]() 38 | let expectedInvocations = [false] 39 | 40 | let dataSource = Navigator.Datasource( 41 | path: [ 42 | IdentifiedScreen( 43 | id: .root, 44 | content: TestScreen(identifier: "0", presentationStyle: .push), 45 | hasAppeared: true 46 | ) 47 | ] 48 | ) 49 | 50 | let routed = NavigationNode( 51 | content: Text("A"), 52 | onAppear: { initialAppear in onAppearClosureInvocations.append(initialAppear) }, 53 | buildSuccessor: { _ -> EmptyView? in nil } 54 | ) 55 | .frame(width: 20, height: 20) 56 | .environmentObject(dataSource) 57 | .environment(\.currentScreenID, .root) 58 | .environment(\.navigator, Navigator(dataSource: dataSource).debug()) 59 | 60 | assertSnapshot(matching: routed, as: .image) 61 | 62 | XCTAssertEqual(expectedInvocations, onAppearClosureInvocations) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/PathBuilder/PathBuilder+BeforeBuildTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableNavigator 2 | import SwiftUI 3 | import XCTest 4 | 5 | final class PathBuilder_BeforeBuildTests: XCTestCase { 6 | func test_executes_perform_closure_before_build() { 7 | let pathElement = TestScreen( 8 | identifier: "", 9 | presentationStyle: .push 10 | ).asPathElement() 11 | 12 | let pathBuilder = PathBuilders.empty 13 | var performClosureCalled = false 14 | 15 | let sut = pathBuilder.beforeBuild { 16 | performClosureCalled = true 17 | } 18 | 19 | XCTAssertNil(sut.build(pathElement: pathElement)) 20 | XCTAssertTrue(performClosureCalled) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/PathBuilder/PathBuilder+EmptyTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableNavigator 2 | import XCTest 3 | 4 | final class PathBuilder_EmptyTests: XCTestCase { 5 | func test_build_returns_nil() { 6 | let pathElement = TestScreen(identifier: "0", presentationStyle: .push) 7 | 8 | let sut = PathBuilders.empty 9 | 10 | XCTAssertNil(sut.build(pathElement: pathElement.asPathElement())) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/PathBuilder/PathBuilder+EraseCircularPathTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableNavigator 2 | import SwiftUI 3 | import XCTest 4 | 5 | final class PathBuilder_EraseCircularPathTests: XCTestCase { 6 | struct CircularNavigationTree: NavigationTree { 7 | var builder: some PathBuilder { 8 | Screen( 9 | TestScreen.self, 10 | content: { EmptyView() }, 11 | nesting: { CircularNavigationTree().eraseCircularNavigationPath() } 12 | ) 13 | } 14 | } 15 | 16 | func test_if_wrapped_builds_path_erases_to_AnyView() { 17 | let sut = CircularNavigationTree().eraseCircularNavigationPath() 18 | 19 | let built = sut.build( 20 | pathElement: TestScreen( 21 | identifier: "", 22 | presentationStyle: .push 23 | ).asPathElement() 24 | ) 25 | 26 | XCTAssertNotNil(built) 27 | } 28 | 29 | func test_if_wrapped_does_not_build_path_returns_nil() { 30 | let sut = _PathBuilder { _ in nil } 31 | .eraseCircularNavigationPath() 32 | 33 | let built = sut.build( 34 | pathElement: TestScreen( 35 | identifier: "", 36 | presentationStyle: .push 37 | ).asPathElement() 38 | ) 39 | 40 | XCTAssertNil(built) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/PathBuilder/PathBuilder+OnDismissTests.swift: -------------------------------------------------------------------------------- 1 | @testable import ComposableNavigator 2 | import SnapshotTesting 3 | import SwiftUI 4 | import XCTest 5 | 6 | final class PathBuilder_OnDismissTest: XCTestCase { 7 | let testScreenID = ScreenID() 8 | 9 | struct RootScreen: Screen { 10 | let presentationStyle: ScreenPresentationStyle = .push 11 | } 12 | 13 | let testScreen = TestScreen(identifier: "0", presentationStyle: .push) 14 | 15 | lazy var identifiedTestScreen = IdentifiedScreen( 16 | id: testScreenID, 17 | content: testScreen, 18 | hasAppeared: false 19 | ) 20 | 21 | lazy var pathElement = testScreen.asPathElement() 22 | 23 | func dataSource() -> Navigator.Datasource { 24 | Navigator.Datasource( 25 | path: [ 26 | IdentifiedScreen(id: .root, content: RootScreen(), hasAppeared: true), 27 | identifiedTestScreen 28 | ] 29 | ) 30 | } 31 | 32 | let expectedView = TestView(id: 0) 33 | 34 | let testBuilder = _PathBuilder { _ in 35 | TestView(id: 0) 36 | } 37 | 38 | func test_onDismiss_calls_perform_with_any_screen_when_path_changes() { 39 | let dataSource = self.dataSource() 40 | var dismissCalled = false 41 | 42 | let sut = testBuilder 43 | .onDismiss( 44 | perform: { (screen) in 45 | dismissCalled = true 46 | XCTAssertEqual( 47 | screen, 48 | self.identifiedTestScreen.content 49 | ) 50 | } 51 | ) 52 | 53 | let content = sut.build(pathElement: pathElement)? 54 | .environment(\.parentScreenID, .root) 55 | .environmentObject(dataSource) 56 | .frame(width: 20, height: 20) 57 | 58 | // Force view building by asserting snapshots 59 | assertSnapshot( 60 | matching: content, 61 | as: .image 62 | ) 63 | 64 | dataSource.dismiss(id: testScreenID) 65 | 66 | XCTAssertEqual(expectedView, sut.build(pathElement: pathElement)?.content) 67 | XCTAssertTrue(dismissCalled) 68 | } 69 | 70 | func test_onDismiss_calls_perform_with_screen_when_path_changes() { 71 | let dataSource = self.dataSource() 72 | var dismissCalled = false 73 | 74 | let sut = testBuilder 75 | .onDismiss( 76 | perform: { (screen: TestScreen) in 77 | dismissCalled = true 78 | XCTAssertEqual( 79 | screen, 80 | self.identifiedTestScreen.content.unwrap() 81 | ) 82 | } 83 | ) 84 | 85 | let content = sut.build(pathElement: pathElement)? 86 | .environment(\.parentScreenID, .root) 87 | .environmentObject(dataSource) 88 | .frame(width: 20, height: 20) 89 | 90 | // Force view building by asserting snapshots 91 | assertSnapshot( 92 | matching: content, 93 | as: .image 94 | ) 95 | 96 | dataSource.dismiss(id: testScreenID) 97 | 98 | XCTAssertEqual(expectedView, sut.build(pathElement: pathElement)?.content) 99 | XCTAssertTrue(dismissCalled) 100 | } 101 | 102 | func test_onDismiss_of_calls_perform_when_path_changes() { 103 | let dataSource = self.dataSource() 104 | var dismissCalled = false 105 | 106 | let sut = testBuilder 107 | .onDismiss( 108 | of: TestScreen.self, 109 | perform: { 110 | dismissCalled = true 111 | } 112 | ) 113 | 114 | let content = sut.build(pathElement: pathElement)? 115 | .environment(\.parentScreenID, .root) 116 | .environmentObject(dataSource) 117 | .frame(width: 20, height: 20) 118 | 119 | // Force view building by asserting snapshots 120 | assertSnapshot( 121 | matching: content, 122 | as: .image 123 | ) 124 | 125 | dataSource.dismiss(id: testScreenID) 126 | 127 | XCTAssertEqual(expectedView, sut.build(pathElement: pathElement)?.content) 128 | XCTAssertTrue(dismissCalled) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/PathBuilder/__Snapshots__/NavigationNodeTests/test_calls_closure_on_consequent_appear.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/PathBuilder/__Snapshots__/NavigationNodeTests/test_calls_closure_on_consequent_appear.1.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/PathBuilder/__Snapshots__/NavigationNodeTests/test_calls_closure_on_initial_appear.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/PathBuilder/__Snapshots__/NavigationNodeTests/test_calls_closure_on_initial_appear.1.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/PathBuilder/__Snapshots__/PathBuilder+OnDismissTests/test_onDismiss_calls_perform_with_any_screen_when_path_changes.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/PathBuilder/__Snapshots__/PathBuilder+OnDismissTests/test_onDismiss_calls_perform_with_any_screen_when_path_changes.1.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/PathBuilder/__Snapshots__/PathBuilder+OnDismissTests/test_onDismiss_calls_perform_with_screen_when_path_changes.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/PathBuilder/__Snapshots__/PathBuilder+OnDismissTests/test_onDismiss_calls_perform_with_screen_when_path_changes.1.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/PathBuilder/__Snapshots__/PathBuilder+OnDismissTests/test_onDismiss_of_calls_perform_when_path_changes.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/PathBuilder/__Snapshots__/PathBuilder+OnDismissTests/test_onDismiss_of_calls_perform_when_path_changes.1.png -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/Screen/RootTests.swift: -------------------------------------------------------------------------------- 1 | @testable import ComposableNavigator 2 | import SnapshotTesting 3 | import SwiftUI 4 | import XCTest 5 | 6 | final class RootTests: XCTestCase { 7 | func test_root_wraps_content_in_navigation_view() { 8 | let rootScreen = TestScreen(identifier: "0", presentationStyle: .push) 9 | 10 | let expectedPath = rootScreen.asPathElement() 11 | var onAppearCalled = false 12 | 13 | let root = Root( 14 | dataSource: Navigator.Datasource( 15 | root: rootScreen 16 | ), 17 | pathBuilder: _PathBuilder { pathElement in 18 | Text("A") 19 | .navigationBarTitle("Navbar title") 20 | .onAppear { 21 | XCTAssertEqual(expectedPath, pathElement) 22 | onAppearCalled = true 23 | } 24 | } 25 | ) 26 | 27 | assertSnapshot(matching: root, as: .image(layout: .device(config: .iPhone8))) 28 | XCTAssertTrue(onAppearCalled) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/Screen/ScreenKeysTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableNavigator 2 | import SwiftUI 3 | import XCTest 4 | 5 | final class ScreenKeysTests: XCTestCase { 6 | func test_parentScreenID_get_set() { 7 | let expectedScreenID = ScreenID() 8 | var environment = EnvironmentValues() 9 | 10 | XCTAssertNil(environment.parentScreenID) 11 | environment.parentScreenID = expectedScreenID 12 | XCTAssertEqual(environment.parentScreenID, expectedScreenID) 13 | } 14 | 15 | func test_currentScreenID_get_set() { 16 | let expectedScreenID = ScreenID() 17 | var environment = EnvironmentValues() 18 | 19 | XCTAssertNotEqual(environment.currentScreenID, .root) 20 | environment.currentScreenID = expectedScreenID 21 | XCTAssertEqual(environment.currentScreenID, expectedScreenID) 22 | } 23 | 24 | func test_parentScreen_get_set() { 25 | let expectedScreen = TestScreen(identifier: "0", presentationStyle: .push).eraseToAnyScreen() 26 | var environment = EnvironmentValues() 27 | 28 | XCTAssertNil(environment.parentScreen) 29 | environment.parentScreen = expectedScreen 30 | XCTAssertEqual(environment.parentScreen, expectedScreen) 31 | } 32 | 33 | func test_currentScreen_get_set() { 34 | let expectedScreen = TestScreen(identifier: "0", presentationStyle: .push).eraseToAnyScreen() 35 | var environment = EnvironmentValues() 36 | 37 | XCTAssertNotEqual(environment.currentScreen, expectedScreen) 38 | environment.currentScreen = expectedScreen 39 | XCTAssertEqual(environment.currentScreen, expectedScreen) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Tests/ComposableNavigatorTests/Screen/__Snapshots__/RootTests/test_root_wraps_content_in_navigation_view.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bahn-X/swift-composable-navigator/69dbc2ab36dffa1e48d1b7f87a03db4767162538/Tests/ComposableNavigatorTests/Screen/__Snapshots__/RootTests/test_root_wraps_content_in_navigation_view.1.png -------------------------------------------------------------------------------- /generateGybs.sh: -------------------------------------------------------------------------------- 1 | find . -name '*.gyb' | \ 2 | while read file; do \ 3 | gyb \ 4 | --line-directive '' \ 5 | -o "`dirname ${file%.gyb}`/Generated.`basename ${file%.gyb}`" \ 6 | "$file"; \ 7 | done --------------------------------------------------------------------------------