├── .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 | |  | [@ohitsdaniel](https://github.com/ohitsdaniel) | Daniel Peter | |
8 | |  | [@mltbnz](https://github.com/mltbnz) | Malte Bünz | |
9 | |  | [@oliverlist](https://github.com/oliverlist) | Oliver List | |
10 | |  | [@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
--------------------------------------------------------------------------------