├── .editorconfig ├── .github ├── CODE_OF_CONDUCT.md └── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── config.yml ├── .gitignore ├── .spi.yml ├── Examples ├── CaseStudies │ ├── 01-Alerts.swift │ ├── 02-ConfirmationDialogs.swift │ ├── 03-Sheets.swift │ ├── 04-Popovers.swift │ ├── 05-FullScreenCovers.swift │ ├── 06-NavigationDestinations.swift │ ├── 07-NavigationLinks.swift │ ├── 08-Routing.swift │ ├── 09-CustomComponents.swift │ ├── 10-SynchronizedBindings.swift │ ├── 11-IfLet.swift │ ├── 12-IfCaseLet.swift │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── CaseStudiesApp.swift │ ├── FactClient.swift │ ├── Info.plist │ └── RootView.swift ├── Examples.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcshareddata │ │ └── xcschemes │ │ └── Inventory.xcscheme ├── Inventory │ ├── App.swift │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── Inventory.swift │ ├── Item.swift │ └── ItemRow.swift └── Package.swift ├── LICENSE ├── Makefile ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── SwiftUINavigation │ ├── Alert.swift │ ├── Binding.swift │ ├── ConfirmationDialog.swift │ ├── Documentation.docc │ │ ├── Articles │ │ │ ├── AlertsDialogs.md │ │ │ ├── Bindings.md │ │ │ ├── Navigation.md │ │ │ ├── SheetsPopoversCovers.md │ │ │ └── WhatIsNavigation.md │ │ ├── Extensions │ │ │ ├── Deprecations.md │ │ │ └── Switch.md │ │ └── SwiftUINavigation.md │ ├── FullScreenCover.swift │ ├── HashableObject.swift │ ├── Internal │ │ ├── Binding+Internal.swift │ │ ├── Deprecations.swift │ │ ├── Exports.swift │ │ ├── Identified.swift │ │ └── LockIsolated.swift │ ├── NavigationDestination.swift │ ├── NavigationLink.swift │ ├── Popover.swift │ ├── Sheet.swift │ └── WithState.swift └── SwiftUINavigationCore │ ├── Alert.swift │ ├── AlertState.swift │ ├── Bind.swift │ ├── Binding.swift │ ├── ButtonState.swift │ ├── ButtonStateBuilder.swift │ ├── ConfirmationDialog.swift │ ├── ConfirmationDialogState.swift │ ├── Documentation.docc │ ├── Extensions │ │ ├── AlertState.md │ │ ├── AlertStateDeprecations.md │ │ ├── ButtonState.md │ │ ├── ButtonStateDeprecations.md │ │ ├── ConfirmationDialogState.md │ │ ├── ConfirmationDialogStateDeprecations.md │ │ ├── Deprecations.md │ │ └── TextState.md │ └── SwiftUINavigationCore.md │ ├── Internal │ └── Deprecations.swift │ ├── NavigationDestination.swift │ └── TextState.swift ├── SwiftUINavigation.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ ├── swiftpm │ └── Package.resolved │ └── xcschemes │ └── SwiftUINavigation.xcscheme └── Tests └── SwiftUINavigationTests ├── AlertTests.swift ├── BindingTests.swift ├── ButtonStateTests.swift ├── SwiftUINavigationTests.swift └── TextStateTests.swift /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Something isn't working as expected 3 | labels: [bug] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thank you for contributing to the SwiftUI Navigation! 9 | 10 | Before you submit your issue, please complete each text area below with the relevant details for your bug, and complete the steps in the checklist. 11 | - type: textarea 12 | attributes: 13 | label: Description 14 | description: | 15 | A short description of the incorrect behavior. 16 | 17 | If you think this issue has been recently introduced and did not occur in an earlier version, please note that. If possible, include the last version that the behavior was correct in addition to your current version. 18 | validations: 19 | required: true 20 | - type: checkboxes 21 | attributes: 22 | label: Checklist 23 | options: 24 | - label: I have determined whether this bug is also reproducible in a vanilla SwiftUI project. 25 | required: false 26 | - label: If possible, I've reproduced the issue using the `main` branch of this package. 27 | required: false 28 | - label: This issue hasn't been addressed in an [existing GitHub issue](https://github.com/pointfreeco/swiftui-navigation/issues) or [discussion](https://github.com/pointfreeco/swiftui-navigation/discussions). 29 | required: true 30 | - type: textarea 31 | attributes: 32 | label: Expected behavior 33 | description: Describe what you expected to happen. 34 | validations: 35 | required: false 36 | - type: textarea 37 | attributes: 38 | label: Actual behavior 39 | description: Describe or copy/paste the behavior you observe. 40 | validations: 41 | required: false 42 | - type: textarea 43 | attributes: 44 | label: Steps to reproduce 45 | description: | 46 | Explanation of how to reproduce the incorrect behavior. 47 | 48 | This could include an attached project or link to code that is exhibiting the issue, and/or a screen recording. 49 | placeholder: | 50 | 1. ... 51 | validations: 52 | required: false 53 | - type: input 54 | attributes: 55 | label: SwiftUI Navigation version information 56 | description: The version of SwiftUI Navigation used to reproduce this issue. 57 | placeholder: "'0.7.0' for example, or a commit hash" 58 | - type: input 59 | attributes: 60 | label: Destination operating system 61 | description: The OS running your application. 62 | placeholder: "'iOS 16' for example" 63 | - type: input 64 | attributes: 65 | label: Xcode version information 66 | description: The version of Xcode used to reproduce this issue. 67 | placeholder: "The version displayed from 'Xcode 〉About Xcode'" 68 | - type: textarea 69 | attributes: 70 | label: Swift Compiler version information 71 | description: The version of Swift used to reproduce this issue. 72 | placeholder: Output from 'xcrun swiftc --version' 73 | render: shell 74 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | 3 | contact_links: 4 | - name: Project Discussion 5 | url: https://github.com/pointfreeco/swiftui-navigation/discussions 6 | about: SwiftUI Navigation Q&A, ideas, and more 7 | - name: Documentation 8 | url: https://pointfreeco.github.io/swiftui-navigation/main/documentation/swiftuinavigation/ 9 | about: Read SwiftUI Navigation's documentation 10 | - name: Videos 11 | url: https://www.pointfree.co/collections/swiftui-navigation 12 | about: Watch videos to get a behind-the-scenes look at how SwiftUI Navigation was motivated and built 13 | - name: Slack 14 | url: https://www.pointfree.co/slack-invite 15 | about: Community chat 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /.swiftpm 4 | /Packages 5 | /*.xcodeproj 6 | xcuserdata/ 7 | DerivedData/ 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [SwiftUINavigation, SwiftUINavigationCore] 5 | -------------------------------------------------------------------------------- /Examples/CaseStudies/01-Alerts.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftUINavigation 3 | 4 | @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) 5 | struct OptionalAlerts: View { 6 | @State private var model = FeatureModel() 7 | 8 | var body: some View { 9 | List { 10 | Stepper("Number: \(model.count)", value: $model.count) 11 | Button { 12 | Task { await model.numberFactButtonTapped() } 13 | } label: { 14 | HStack { 15 | Text("Get number fact") 16 | if model.isLoading { 17 | Spacer() 18 | ProgressView() 19 | } 20 | } 21 | } 22 | .disabled(model.isLoading) 23 | } 24 | .alert(item: $model.fact) { 25 | Text("Fact about \($0.number)") 26 | } actions: { 27 | Button("Get another fact about \($0.number)") { 28 | Task { await model.numberFactButtonTapped() } 29 | } 30 | Button("Close", role: .cancel) { 31 | model.fact = nil 32 | } 33 | } message: { 34 | Text($0.description) 35 | } 36 | .navigationTitle("Alerts") 37 | } 38 | } 39 | 40 | @Observable 41 | private class FeatureModel { 42 | var count = 0 43 | var isLoading = false 44 | var fact: Fact? 45 | 46 | @MainActor 47 | func numberFactButtonTapped() async { 48 | isLoading = true 49 | defer { isLoading = false } 50 | fact = await getNumberFact(count) 51 | } 52 | } 53 | 54 | #Preview { 55 | OptionalAlerts() 56 | } 57 | -------------------------------------------------------------------------------- /Examples/CaseStudies/02-ConfirmationDialogs.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftUINavigation 3 | 4 | @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) 5 | struct OptionalConfirmationDialogs: View { 6 | @State private var model = FeatureModel() 7 | 8 | var body: some View { 9 | List { 10 | Stepper("Number: \(model.count)", value: $model.count) 11 | Button { 12 | Task { await model.numberFactButtonTapped() } 13 | } label: { 14 | HStack { 15 | Text("Get number fact") 16 | if model.isLoading { 17 | Spacer() 18 | ProgressView() 19 | } 20 | } 21 | } 22 | .disabled(model.isLoading) 23 | .confirmationDialog(item: $model.fact, titleVisibility: .visible) { 24 | Text("Fact about \($0.number)") 25 | } actions: { 26 | Button("Get another fact about \($0.number)") { 27 | Task { await model.numberFactButtonTapped() } 28 | } 29 | } message: { 30 | Text($0.description) 31 | } 32 | } 33 | .navigationTitle("Dialogs") 34 | } 35 | } 36 | 37 | @Observable 38 | private class FeatureModel { 39 | var count = 0 40 | var isLoading = false 41 | var fact: Fact? 42 | 43 | @MainActor 44 | func numberFactButtonTapped() async { 45 | isLoading = true 46 | defer { isLoading = false } 47 | fact = await getNumberFact(count) 48 | } 49 | } 50 | 51 | #Preview { 52 | OptionalConfirmationDialogs() 53 | } 54 | -------------------------------------------------------------------------------- /Examples/CaseStudies/03-Sheets.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftUINavigation 3 | 4 | struct OptionalSheets: View { 5 | @State private var model = FeatureModel() 6 | 7 | var body: some View { 8 | List { 9 | Section { 10 | Stepper("Number: \(model.count)", value: $model.count) 11 | 12 | HStack { 13 | Button("Get number fact") { 14 | Task { await model.numberFactButtonTapped() } 15 | } 16 | 17 | if model.isLoading { 18 | Spacer() 19 | ProgressView() 20 | } 21 | } 22 | } header: { 23 | Text("Fact Finder") 24 | } 25 | 26 | Section { 27 | ForEach(model.savedFacts) { fact in 28 | Text(fact.description) 29 | } 30 | .onDelete { model.removeSavedFacts(atOffsets: $0) } 31 | } header: { 32 | Text("Saved Facts") 33 | } 34 | } 35 | .sheet(item: $model.fact) { $fact in 36 | NavigationStack { 37 | FactEditor(fact: $fact.description) 38 | .disabled(model.isLoading) 39 | .foregroundColor(model.isLoading ? .gray : nil) 40 | .toolbar { 41 | ToolbarItem(placement: .cancellationAction) { 42 | Button("Cancel") { 43 | model.cancelButtonTapped() 44 | } 45 | } 46 | ToolbarItem(placement: .confirmationAction) { 47 | Button("Save") { 48 | model.saveButtonTapped(fact: fact) 49 | } 50 | } 51 | } 52 | } 53 | } 54 | .navigationTitle("Sheets") 55 | } 56 | } 57 | 58 | private struct FactEditor: View { 59 | @Binding var fact: String 60 | 61 | var body: some View { 62 | VStack { 63 | TextEditor(text: $fact) 64 | } 65 | .padding() 66 | .navigationTitle("Fact editor") 67 | } 68 | } 69 | 70 | @Observable 71 | private class FeatureModel { 72 | var count = 0 73 | var fact: Fact? 74 | var isLoading = false 75 | var savedFacts: [Fact] = [] 76 | private var task: Task? 77 | 78 | deinit { 79 | task?.cancel() 80 | } 81 | 82 | @MainActor 83 | func numberFactButtonTapped() async { 84 | isLoading = true 85 | fact = Fact(description: "\(count) is still loading...", number: count) 86 | task = Task { 87 | let fact = await getNumberFact(self.count) 88 | isLoading = false 89 | guard !Task.isCancelled 90 | else { return } 91 | self.fact = fact 92 | } 93 | await task?.value 94 | } 95 | 96 | @MainActor 97 | func cancelButtonTapped() { 98 | task?.cancel() 99 | task = nil 100 | fact = nil 101 | } 102 | 103 | @MainActor 104 | func saveButtonTapped(fact: Fact) { 105 | task?.cancel() 106 | task = nil 107 | savedFacts.append(fact) 108 | self.fact = nil 109 | } 110 | 111 | @MainActor 112 | func removeSavedFacts(atOffsets offsets: IndexSet) { 113 | savedFacts.remove(atOffsets: offsets) 114 | } 115 | } 116 | 117 | #Preview { 118 | OptionalSheets() 119 | } 120 | -------------------------------------------------------------------------------- /Examples/CaseStudies/04-Popovers.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftUINavigation 3 | 4 | struct OptionalPopovers: View { 5 | @State private var model = FeatureModel() 6 | 7 | var body: some View { 8 | List { 9 | Section { 10 | Stepper("Number: \(model.count)", value: $model.count) 11 | 12 | HStack { 13 | Button("Get number fact") { 14 | Task { await model.numberFactButtonTapped() } 15 | } 16 | .popover(item: $model.fact, arrowEdge: .bottom) { $fact in 17 | NavigationStack { 18 | FactEditor(fact: $fact.description) 19 | .disabled(model.isLoading) 20 | .foregroundColor(model.isLoading ? .gray : nil) 21 | .navigationBarItems( 22 | leading: Button("Cancel") { 23 | model.cancelButtonTapped() 24 | }, 25 | trailing: Button("Save") { 26 | model.saveButtonTapped(fact: fact) 27 | } 28 | ) 29 | } 30 | } 31 | 32 | if model.isLoading { 33 | Spacer() 34 | ProgressView() 35 | } 36 | } 37 | } header: { 38 | Text("Fact Finder") 39 | } 40 | 41 | Section { 42 | ForEach(model.savedFacts) { fact in 43 | Text(fact.description) 44 | } 45 | .onDelete { model.removeSavedFacts(atOffsets: $0) } 46 | } header: { 47 | Text("Saved Facts") 48 | } 49 | } 50 | .navigationTitle("Popovers") 51 | } 52 | } 53 | 54 | private struct FactEditor: View { 55 | @Binding var fact: String 56 | 57 | var body: some View { 58 | VStack { 59 | TextEditor(text: $fact) 60 | } 61 | .padding() 62 | .navigationTitle("Fact editor") 63 | } 64 | } 65 | 66 | @Observable 67 | private class FeatureModel { 68 | var count = 0 69 | var fact: Fact? 70 | var isLoading = false 71 | var savedFacts: [Fact] = [] 72 | private var task: Task? 73 | 74 | deinit { 75 | self.task?.cancel() 76 | } 77 | 78 | @MainActor 79 | func numberFactButtonTapped() async { 80 | isLoading = true 81 | fact = Fact(description: "\(count) is still loading...", number: count) 82 | task = Task { 83 | let fact = await getNumberFact(self.count) 84 | isLoading = false 85 | guard !Task.isCancelled 86 | else { return } 87 | self.fact = fact 88 | } 89 | await task?.value 90 | } 91 | 92 | @MainActor 93 | func cancelButtonTapped() { 94 | task?.cancel() 95 | task = nil 96 | fact = nil 97 | } 98 | 99 | @MainActor 100 | func saveButtonTapped(fact: Fact) { 101 | task?.cancel() 102 | task = nil 103 | savedFacts.append(fact) 104 | self.fact = nil 105 | } 106 | 107 | @MainActor 108 | func removeSavedFacts(atOffsets offsets: IndexSet) { 109 | savedFacts.remove(atOffsets: offsets) 110 | } 111 | } 112 | 113 | #Preview { 114 | OptionalPopovers() 115 | } 116 | -------------------------------------------------------------------------------- /Examples/CaseStudies/05-FullScreenCovers.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftUINavigation 3 | 4 | struct OptionalFullScreenCovers: View { 5 | @State private var model = FeatureModel() 6 | 7 | var body: some View { 8 | List { 9 | Section { 10 | Stepper("Number: \(model.count)", value: $model.count) 11 | 12 | HStack { 13 | Button("Get number fact") { 14 | Task { await model.numberFactButtonTapped() } 15 | } 16 | 17 | if model.isLoading { 18 | Spacer() 19 | ProgressView() 20 | } 21 | } 22 | } header: { 23 | Text("Fact Finder") 24 | } 25 | 26 | Section { 27 | ForEach(model.savedFacts) { fact in 28 | Text(fact.description) 29 | } 30 | .onDelete { model.removeSavedFacts(atOffsets: $0) } 31 | } header: { 32 | Text("Saved Facts") 33 | } 34 | } 35 | .fullScreenCover(item: $model.fact) { $fact in 36 | NavigationStack { 37 | FactEditor(fact: $fact.description) 38 | .disabled(model.isLoading) 39 | .foregroundColor(model.isLoading ? .gray : nil) 40 | .toolbar { 41 | ToolbarItem(placement: .cancellationAction) { 42 | Button("Cancel") { 43 | model.cancelButtonTapped() 44 | } 45 | } 46 | ToolbarItem(placement: .confirmationAction) { 47 | Button("Save") { 48 | model.saveButtonTapped(fact: fact) 49 | } 50 | } 51 | } 52 | } 53 | } 54 | .navigationTitle("Full-screen covers") 55 | } 56 | } 57 | 58 | private struct FactEditor: View { 59 | @Binding var fact: String 60 | 61 | var body: some View { 62 | VStack { 63 | TextEditor(text: $fact) 64 | } 65 | .padding() 66 | .navigationTitle("Fact editor") 67 | } 68 | } 69 | 70 | @Observable 71 | private class FeatureModel { 72 | var count = 0 73 | var fact: Fact? 74 | var isLoading = false 75 | var savedFacts: [Fact] = [] 76 | private var task: Task? 77 | 78 | @MainActor 79 | func numberFactButtonTapped() async { 80 | isLoading = true 81 | fact = Fact(description: "\(count) is still loading...", number: count) 82 | task = Task { 83 | let fact = await getNumberFact(count) 84 | isLoading = false 85 | guard !Task.isCancelled 86 | else { return } 87 | self.fact = fact 88 | } 89 | await task?.value 90 | } 91 | 92 | @MainActor 93 | func cancelButtonTapped() { 94 | task?.cancel() 95 | task = nil 96 | fact = nil 97 | } 98 | 99 | @MainActor 100 | func saveButtonTapped(fact: Fact) { 101 | task?.cancel() 102 | task = nil 103 | savedFacts.append(fact) 104 | self.fact = nil 105 | } 106 | 107 | @MainActor 108 | func removeSavedFacts(atOffsets offsets: IndexSet) { 109 | savedFacts.remove(atOffsets: offsets) 110 | } 111 | } 112 | 113 | #Preview { 114 | OptionalFullScreenCovers() 115 | } 116 | -------------------------------------------------------------------------------- /Examples/CaseStudies/06-NavigationDestinations.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftUINavigation 3 | 4 | @available(iOS 16, *) 5 | struct NavigationDestinations: View { 6 | @State private var model = FeatureModel() 7 | 8 | var body: some View { 9 | List { 10 | Section { 11 | Stepper("Number: \(model.count)", value: $model.count) 12 | 13 | HStack { 14 | Button("Get number fact") { 15 | Task { await model.numberFactButtonTapped() } 16 | } 17 | 18 | if model.isLoading { 19 | Spacer() 20 | ProgressView() 21 | } 22 | } 23 | } header: { 24 | Text("Fact Finder") 25 | } 26 | 27 | Section { 28 | ForEach(model.savedFacts) { fact in 29 | Text(fact.description) 30 | } 31 | .onDelete { model.removeSavedFacts(atOffsets: $0) } 32 | } header: { 33 | Text("Saved Facts") 34 | } 35 | } 36 | .navigationTitle("Destinations") 37 | .navigationDestination(item: $model.fact) { $fact in 38 | FactEditor(fact: $fact.description) 39 | .disabled(model.isLoading) 40 | .foregroundColor(model.isLoading ? .gray : nil) 41 | .navigationBarBackButtonHidden(true) 42 | .toolbar { 43 | ToolbarItem(placement: .cancellationAction) { 44 | Button("Cancel") { 45 | Task { await model.cancelButtonTapped() } 46 | } 47 | } 48 | ToolbarItem(placement: .confirmationAction) { 49 | Button("Save") { 50 | Task { await model.saveButtonTapped(fact: fact) } 51 | } 52 | } 53 | } 54 | } 55 | } 56 | } 57 | 58 | private struct FactEditor: View { 59 | @Binding var fact: String 60 | 61 | var body: some View { 62 | VStack { 63 | if #available(iOS 14, *) { 64 | TextEditor(text: $fact) 65 | } else { 66 | TextField("Untitled", text: $fact) 67 | } 68 | } 69 | .padding() 70 | .navigationBarTitle("Fact Editor") 71 | } 72 | } 73 | 74 | @Observable 75 | private class FeatureModel { 76 | var count = 0 77 | var fact: Fact? 78 | var isLoading = false 79 | var savedFacts: [Fact] = [] 80 | private var task: Task? 81 | 82 | deinit { 83 | task?.cancel() 84 | } 85 | 86 | @MainActor 87 | func setFactNavigation(isActive: Bool) async { 88 | if isActive { 89 | isLoading = true 90 | fact = Fact(description: "\(count) is still loading...", number: count) 91 | task = Task { 92 | let fact = await getNumberFact(self.count) 93 | isLoading = false 94 | guard !Task.isCancelled 95 | else { return } 96 | self.fact = fact 97 | } 98 | await task?.value 99 | } else { 100 | task?.cancel() 101 | task = nil 102 | fact = nil 103 | } 104 | } 105 | 106 | @MainActor 107 | func numberFactButtonTapped() async { 108 | await setFactNavigation(isActive: true) 109 | } 110 | 111 | @MainActor 112 | func cancelButtonTapped() async { 113 | await setFactNavigation(isActive: false) 114 | } 115 | 116 | @MainActor 117 | func saveButtonTapped(fact: Fact) async { 118 | savedFacts.append(fact) 119 | await setFactNavigation(isActive: false) 120 | } 121 | 122 | @MainActor 123 | func removeSavedFacts(atOffsets offsets: IndexSet) { 124 | savedFacts.remove(atOffsets: offsets) 125 | } 126 | } 127 | 128 | #Preview { 129 | NavigationStack { 130 | NavigationDestinations() 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /Examples/CaseStudies/07-NavigationLinks.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftUINavigation 3 | 4 | struct OptionalNavigationLinks: View { 5 | @State private var model = FeatureModel() 6 | 7 | var body: some View { 8 | List { 9 | Section { 10 | Stepper("Number: \(model.count)", value: $model.count) 11 | 12 | HStack { 13 | Button("Get number fact") { 14 | Task { await model.setFactNavigation(isActive: true) } 15 | } 16 | 17 | if self.model.isLoading { 18 | Spacer() 19 | ProgressView() 20 | } 21 | } 22 | } header: { 23 | Text("Fact Finder") 24 | } 25 | 26 | Section { 27 | ForEach(model.savedFacts) { fact in 28 | Text(fact.description) 29 | } 30 | .onDelete { model.removeSavedFacts(atOffsets: $0) } 31 | } header: { 32 | Text("Saved Facts") 33 | } 34 | } 35 | .navigationDestination(item: $model.fact) { $fact in 36 | FactEditor(fact: $fact.description) 37 | .disabled(model.isLoading) 38 | .foregroundColor(model.isLoading ? .gray : nil) 39 | .navigationBarBackButtonHidden(true) 40 | .toolbar { 41 | ToolbarItem(placement: .cancellationAction) { 42 | Button("Cancel") { 43 | Task { await model.cancelButtonTapped() } 44 | } 45 | } 46 | ToolbarItem(placement: .confirmationAction) { 47 | Button("Save") { 48 | Task { await model.saveButtonTapped(fact: fact) } 49 | } 50 | } 51 | } 52 | } 53 | .navigationTitle("Links") 54 | } 55 | } 56 | 57 | private struct FactEditor: View { 58 | @Binding var fact: String 59 | 60 | var body: some View { 61 | VStack { 62 | TextEditor(text: $fact) 63 | } 64 | .padding() 65 | .navigationTitle("Fact editor") 66 | } 67 | } 68 | 69 | @Observable 70 | private class FeatureModel { 71 | var count = 0 72 | var fact: Fact? 73 | var isLoading = false 74 | var savedFacts: [Fact] = [] 75 | private var task: Task? 76 | 77 | deinit { 78 | task?.cancel() 79 | } 80 | 81 | @MainActor 82 | func setFactNavigation(isActive: Bool) async { 83 | if isActive { 84 | isLoading = true 85 | fact = Fact(description: "\(count) is still loading...", number: count) 86 | task = Task { 87 | let fact = await getNumberFact(self.count) 88 | isLoading = false 89 | guard !Task.isCancelled 90 | else { return } 91 | self.fact = fact 92 | } 93 | await task?.value 94 | } else { 95 | task?.cancel() 96 | task = nil 97 | fact = nil 98 | } 99 | } 100 | 101 | @MainActor 102 | func cancelButtonTapped() async { 103 | await setFactNavigation(isActive: false) 104 | } 105 | 106 | @MainActor 107 | func saveButtonTapped(fact: Fact) async { 108 | savedFacts.append(fact) 109 | await setFactNavigation(isActive: false) 110 | } 111 | 112 | @MainActor 113 | func removeSavedFacts(atOffsets offsets: IndexSet) { 114 | savedFacts.remove(atOffsets: offsets) 115 | } 116 | } 117 | 118 | #Preview { 119 | NavigationStack { 120 | OptionalNavigationLinks() 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Examples/CaseStudies/08-Routing.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftUINavigation 3 | 4 | private let readMe = """ 5 | This case study demonstrates how to power multiple forms of navigation from a single destination \ 6 | enum that describes all of the possible destinations one can travel to from this screen. 7 | 8 | The screen has four navigation destinations: an alert, a confirmation dialog, a navigation link \ 9 | to a count stepper, and a modal sheet to a count stepper. The state for each of these \ 10 | destinations is held as associated data of an enum, and bindings to the cases of that enum are \ 11 | derived using the tools in this library. 12 | """ 13 | 14 | @CasePathable 15 | enum Destination { 16 | case alert(AlertState) 17 | case confirmationDialog(ConfirmationDialogState) 18 | case link(Int) 19 | case sheet(Int) 20 | 21 | enum AlertAction { 22 | case randomize 23 | case reset 24 | } 25 | enum DialogAction { 26 | case decrement 27 | case increment 28 | } 29 | } 30 | 31 | struct Routing: View { 32 | @State var count = 0 33 | @State var destination: Destination? 34 | 35 | var body: some View { 36 | Form { 37 | Section { 38 | Text(readMe) 39 | } 40 | 41 | Section { 42 | Text("Count: \(count)") 43 | } 44 | 45 | Button("Alert") { 46 | destination = .alert( 47 | AlertState { 48 | TextState("Update count?") 49 | } actions: { 50 | ButtonState(action: .send(.randomize)) { 51 | TextState("Randomize") 52 | } 53 | ButtonState(role: .destructive, action: .send(.reset)) { 54 | TextState("Reset") 55 | } 56 | } 57 | ) 58 | } 59 | 60 | Button("Confirmation dialog") { 61 | destination = .confirmationDialog( 62 | ConfirmationDialogState(titleVisibility: .visible) { 63 | TextState("Update count?") 64 | } actions: { 65 | ButtonState(action: .send(.increment)) { 66 | TextState("Increment") 67 | } 68 | ButtonState(action: .send(.decrement)) { 69 | TextState("Decrement") 70 | } 71 | } 72 | ) 73 | } 74 | 75 | Button("Link") { 76 | destination = .link(count) 77 | } 78 | 79 | Button("Sheet") { 80 | destination = .sheet(count) 81 | } 82 | } 83 | .navigationTitle("Routing") 84 | .alert($destination.alert) { action in 85 | switch action { 86 | case .randomize?: 87 | count = .random(in: 0...1_000) 88 | case .reset?: 89 | count = 0 90 | case nil: 91 | break 92 | } 93 | } 94 | .confirmationDialog($destination.confirmationDialog) { action in 95 | switch action { 96 | case .decrement?: 97 | count -= 1 98 | case .increment?: 99 | count += 1 100 | case nil: 101 | break 102 | } 103 | } 104 | .navigationDestination(item: $destination.link) { $count in 105 | Form { 106 | Stepper("Count: \(count)", value: $count) 107 | } 108 | .navigationTitle("Routing link") 109 | } 110 | .sheet(item: $destination.sheet, id: \.self) { $count in 111 | NavigationStack { 112 | Form { 113 | Stepper("Count: \(count)", value: $count) 114 | } 115 | .navigationTitle("Routing sheet") 116 | } 117 | } 118 | } 119 | } 120 | 121 | #Preview { 122 | NavigationStack { 123 | Routing() 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Examples/CaseStudies/09-CustomComponents.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftUINavigation 3 | 4 | private let readMe = """ 5 | This case study demonstrates how to enhance an existing SwiftUI component so that it can be \ 6 | driven off of optional and enum state. 7 | 8 | The BottomMenuModifier component in this is file is primarily powered by a simple boolean \ 9 | binding, which means its content cannot be dynamic based off of the source of truth that drives \ 10 | its presentation, and it cannot make mutations to the source of truth. 11 | 12 | However, by leveraging the binding transformations that come with this library we can extend the \ 13 | bottom menu component with additional APIs that allow presentation and dismissal to be powered \ 14 | by optionals and enums. 15 | """ 16 | 17 | struct CustomComponents: View { 18 | @State var count: Int? 19 | 20 | var body: some View { 21 | Form { 22 | Section { 23 | Text(readMe) 24 | } 25 | 26 | Button("Show bottom menu") { 27 | withAnimation { 28 | count = 0 29 | } 30 | } 31 | 32 | if let count = count, count > 0 { 33 | Text("Current count: \(count)") 34 | .transition(.opacity) 35 | } 36 | } 37 | .bottomMenu(item: $count) { $count in 38 | Stepper("Number: \(count)", value: $count.animation()) 39 | } 40 | .navigationTitle("Custom components") 41 | } 42 | } 43 | 44 | private struct BottomMenuModifier: ViewModifier 45 | where BottomMenuContent: View { 46 | @Binding var isActive: Bool 47 | let content: () -> BottomMenuContent 48 | 49 | func body(content: Content) -> some View { 50 | content.overlay( 51 | ZStack(alignment: .bottom) { 52 | if isActive { 53 | Rectangle() 54 | .fill(Color.black.opacity(0.4)) 55 | .frame(maxWidth: .infinity, maxHeight: .infinity) 56 | .onTapGesture { 57 | withAnimation { 58 | isActive = false 59 | } 60 | } 61 | .zIndex(1) 62 | .transition(.opacity) 63 | 64 | self.content() 65 | .padding() 66 | .background(Color.white) 67 | .cornerRadius(10) 68 | .frame(maxWidth: .infinity) 69 | .padding(24) 70 | .padding(.bottom) 71 | .zIndex(2) 72 | .transition(.move(edge: .bottom)) 73 | } 74 | } 75 | .ignoresSafeArea() 76 | ) 77 | } 78 | } 79 | 80 | extension View { 81 | fileprivate func bottomMenu( 82 | isActive: Binding, 83 | @ViewBuilder content: @escaping () -> Content 84 | ) -> some View 85 | where Content: View { 86 | modifier( 87 | BottomMenuModifier( 88 | isActive: isActive, 89 | content: content 90 | ) 91 | ) 92 | } 93 | 94 | fileprivate func bottomMenu( 95 | item: Binding, 96 | @ViewBuilder content: @escaping (Binding) -> Content 97 | ) -> some View 98 | where Content: View { 99 | modifier( 100 | BottomMenuModifier( 101 | isActive: Binding(item), 102 | content: { Binding(unwrapping: item).map(content) } 103 | ) 104 | ) 105 | } 106 | } 107 | 108 | #Preview { 109 | CustomComponents() 110 | } 111 | -------------------------------------------------------------------------------- /Examples/CaseStudies/10-SynchronizedBindings.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftUINavigation 3 | 4 | private let readMe = """ 5 | This demonstrates how to synchronize model state with view state using the "bind" view modifier. \ 6 | The model starts focused on the "Username" field, which is immediately focused when the form \ 7 | first appears. When you tap the "Sign in" button, the focus will change to the first non-empty \ 8 | field. 9 | """ 10 | 11 | struct SynchronizedBindings: View { 12 | @FocusState private var focusedField: FeatureModel.Field? 13 | @State private var model = FeatureModel() 14 | 15 | var body: some View { 16 | Form { 17 | Section { 18 | Text(readMe) 19 | } 20 | 21 | Section { 22 | TextField("Username", text: $model.username) 23 | .focused($focusedField, equals: .username) 24 | 25 | SecureField("Password", text: $model.password) 26 | .focused($focusedField, equals: .password) 27 | 28 | Button("Sign In") { 29 | model.signInButtonTapped() 30 | } 31 | .buttonStyle(.borderedProminent) 32 | } 33 | .textFieldStyle(.roundedBorder) 34 | } 35 | .bind($model.focusedField, to: $focusedField) 36 | .navigationTitle("Synchronized focus") 37 | } 38 | } 39 | 40 | @Observable 41 | private class FeatureModel { 42 | enum Field: String { 43 | case username 44 | case password 45 | } 46 | 47 | var focusedField: Field? = .username 48 | var password: String = "" 49 | var username: String = "" 50 | 51 | func signInButtonTapped() { 52 | if username.isEmpty { 53 | focusedField = .username 54 | } else if password.isEmpty { 55 | focusedField = .password 56 | } else { 57 | focusedField = nil 58 | } 59 | } 60 | } 61 | 62 | #Preview { 63 | SynchronizedBindings() 64 | } 65 | -------------------------------------------------------------------------------- /Examples/CaseStudies/11-IfLet.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftUINavigation 3 | 4 | private let readMe = """ 5 | This demonstrates how to unwrap a binding of an optional into a binding of an honest value. 6 | 7 | Tap the "Edit" button to put the form into edit mode. Then you can make changes to the message \ 8 | and either commit the changes by tapping "Save", or discard the changes by tapping "Discard". 9 | """ 10 | 11 | struct IfLetCaseStudy: View { 12 | @State var string: String = "Hello" 13 | @State var editableString: String? 14 | 15 | var body: some View { 16 | Form { 17 | Section { 18 | Text(readMe) 19 | } 20 | Binding(unwrapping: $editableString).map { $string in 21 | VStack { 22 | TextField("Edit string", text: $string) 23 | HStack { 24 | Button("Discard") { 25 | editableString = nil 26 | } 27 | Spacer() 28 | Button("Save") { 29 | string = string 30 | editableString = nil 31 | } 32 | } 33 | } 34 | } 35 | if editableString == nil { 36 | Text("\(string)") 37 | Button("Edit") { 38 | editableString = string 39 | } 40 | } 41 | } 42 | .buttonStyle(.borderless) 43 | } 44 | } 45 | 46 | #Preview { 47 | IfLetCaseStudy() 48 | } 49 | -------------------------------------------------------------------------------- /Examples/CaseStudies/12-IfCaseLet.swift: -------------------------------------------------------------------------------- 1 | import CasePaths 2 | import SwiftUI 3 | import SwiftUINavigation 4 | 5 | private let readMe = """ 6 | This demonstrates how to destructure a binding of an enum into a binding of one of its cases. 7 | 8 | Tap the "Edit" button to put the form into edit mode. Then you can make changes to the message \ 9 | and either commit the changes by tapping "Save", or discard the changes by tapping "Discard". 10 | """ 11 | 12 | struct IfCaseLetCaseStudy: View { 13 | @State var string: String = "Hello" 14 | @State var editableString: EditableString = .inactive 15 | 16 | @CasePathable 17 | enum EditableString { 18 | case active(String) 19 | case inactive 20 | } 21 | 22 | var body: some View { 23 | Form { 24 | Section { 25 | Text(readMe) 26 | } 27 | $editableString.active.map { $string in 28 | VStack { 29 | TextField("Edit string", text: $string) 30 | HStack { 31 | Button("Discard", role: .cancel) { 32 | editableString = .inactive 33 | } 34 | Spacer() 35 | Button("Save") { 36 | string = string 37 | editableString = .inactive 38 | } 39 | } 40 | } 41 | } 42 | if !editableString.is(\.active) { 43 | Text("\(string)") 44 | Button("Edit") { 45 | editableString = .active(string) 46 | } 47 | } 48 | } 49 | .buttonStyle(.borderless) 50 | } 51 | } 52 | 53 | #Preview { 54 | IfCaseLetCaseStudy() 55 | } 56 | -------------------------------------------------------------------------------- /Examples/CaseStudies/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 | -------------------------------------------------------------------------------- /Examples/CaseStudies/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 | -------------------------------------------------------------------------------- /Examples/CaseStudies/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/CaseStudies/CaseStudiesApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct CaseStudiesApp: App { 5 | var body: some Scene { 6 | WindowGroup { 7 | RootView() 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Examples/CaseStudies/FactClient.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Fact: Identifiable { 4 | var description: String 5 | let number: Int 6 | 7 | var id: AnyHashable { 8 | [description as AnyHashable, number] 9 | } 10 | } 11 | 12 | func getNumberFact(_ count: Int) async -> Fact { 13 | let fact: String 14 | do { 15 | let (data, _) = try await URLSession.shared.data( 16 | from: URL(string: "http://numbersapi.com/\(count)/trivia")! 17 | ) 18 | fact = String(decoding: data, as: UTF8.self) 19 | } catch { 20 | // Sometimes numbersapi.com can be flakey, so if it ever fails we will just 21 | // default to a mock response. 22 | fact = "\(count) is a good number Brent" 23 | } 24 | try? await Task.sleep(nanoseconds: NSEC_PER_SEC) 25 | return Fact(description: fact, number: count) 26 | } 27 | -------------------------------------------------------------------------------- /Examples/CaseStudies/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSAppTransportSecurity 6 | 7 | NSAllowsArbitraryLoads 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Examples/CaseStudies/RootView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftUINavigation 3 | 4 | struct RootView: View { 5 | var body: some View { 6 | NavigationStack { 7 | List { 8 | Section { 9 | NavigationLink("Optional-driven alerts") { 10 | OptionalAlerts() 11 | } 12 | NavigationLink("Optional confirmation dialogs") { 13 | OptionalConfirmationDialogs() 14 | } 15 | } header: { 16 | Text("Alerts and confirmation dialogs") 17 | } 18 | 19 | Section { 20 | NavigationLink("Optional sheets") { 21 | OptionalSheets() 22 | } 23 | NavigationLink("Optional popovers") { 24 | OptionalPopovers() 25 | } 26 | NavigationLink("Optional full-screen covers") { 27 | OptionalFullScreenCovers() 28 | } 29 | } header: { 30 | Text("Sheets and full-screen covers") 31 | } 32 | 33 | Section { 34 | NavigationLink("Optional destinations") { 35 | NavigationStack { 36 | NavigationDestinations() 37 | } 38 | .navigationTitle("Navigation stack") 39 | } 40 | NavigationLink("Optional navigation links") { 41 | OptionalNavigationLinks() 42 | } 43 | } header: { 44 | Text("Navigation links") 45 | } 46 | 47 | Section { 48 | NavigationLink("Routing") { 49 | Routing() 50 | } 51 | NavigationLink("Custom components") { 52 | CustomComponents() 53 | } 54 | NavigationLink("Synchronized bindings") { 55 | SynchronizedBindings() 56 | } 57 | NavigationLink("Optional bindings") { 58 | IfLetCaseStudy() 59 | } 60 | NavigationLink("Enum bindings") { 61 | IfCaseLetCaseStudy() 62 | } 63 | } header: { 64 | Text("Advanced") 65 | } 66 | } 67 | .navigationTitle("Case studies") 68 | } 69 | } 70 | } 71 | 72 | struct RootView_Previews: PreviewProvider { 73 | static var previews: some View { 74 | RootView() 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Examples/Examples.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "combine-schedulers", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/pointfreeco/combine-schedulers", 7 | "state" : { 8 | "revision" : "487a4d151e795a5e076a7e7aedcd13c2ebff6c31", 9 | "version" : "1.0.1" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-case-paths", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/pointfreeco/swift-case-paths", 16 | "state" : { 17 | "revision" : "031704ba0634b45e02fe875b8ddddc7f30a07f49", 18 | "version" : "1.5.3" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-clocks", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/pointfreeco/swift-clocks", 25 | "state" : { 26 | "revision" : "eb64eacfed55635a771e3410f9c91de46cf5c6a0", 27 | "version" : "1.0.3" 28 | } 29 | }, 30 | { 31 | "identity" : "swift-collections", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/apple/swift-collections", 34 | "state" : { 35 | "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", 36 | "version" : "1.1.2" 37 | } 38 | }, 39 | { 40 | "identity" : "swift-concurrency-extras", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/pointfreeco/swift-concurrency-extras", 43 | "state" : { 44 | "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", 45 | "version" : "1.1.0" 46 | } 47 | }, 48 | { 49 | "identity" : "swift-custom-dump", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 52 | "state" : { 53 | "revision" : "d237304f42af07f22563aa4cc2d7e2cfb25da82e", 54 | "version" : "1.3.1" 55 | } 56 | }, 57 | { 58 | "identity" : "swift-dependencies", 59 | "kind" : "remoteSourceControl", 60 | "location" : "http://github.com/pointfreeco/swift-dependencies", 61 | "state" : { 62 | "revision" : "52018827ce21e482a36e3795bea2666b3898164c", 63 | "version" : "1.3.4" 64 | } 65 | }, 66 | { 67 | "identity" : "swift-docc-plugin", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/apple/swift-docc-plugin", 70 | "state" : { 71 | "revision" : "26ac5758409154cc448d7ab82389c520fa8a8247", 72 | "version" : "1.3.0" 73 | } 74 | }, 75 | { 76 | "identity" : "swift-docc-symbolkit", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/apple/swift-docc-symbolkit", 79 | "state" : { 80 | "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", 81 | "version" : "1.0.0" 82 | } 83 | }, 84 | { 85 | "identity" : "swift-identified-collections", 86 | "kind" : "remoteSourceControl", 87 | "location" : "https://github.com/pointfreeco/swift-identified-collections.git", 88 | "state" : { 89 | "revision" : "2f5ab6e091dd032b63dacbda052405756010dc3b", 90 | "version" : "1.1.0" 91 | } 92 | }, 93 | { 94 | "identity" : "swift-issue-reporting", 95 | "kind" : "remoteSourceControl", 96 | "location" : "https://github.com/pointfreeco/swift-issue-reporting", 97 | "state" : { 98 | "branch" : "1.2.0", 99 | "revision" : "926f43898706eaa127db79ac42138e1ad7e85a3f" 100 | } 101 | }, 102 | { 103 | "identity" : "swift-syntax", 104 | "kind" : "remoteSourceControl", 105 | "location" : "https://github.com/swiftlang/swift-syntax", 106 | "state" : { 107 | "revision" : "4c6cc0a3b9e8f14b3ae2307c5ccae4de6167ac2c", 108 | "version" : "600.0.0-prerelease-2024-06-12" 109 | } 110 | }, 111 | { 112 | "identity" : "swift-tagged", 113 | "kind" : "remoteSourceControl", 114 | "location" : "https://github.com/pointfreeco/swift-tagged.git", 115 | "state" : { 116 | "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", 117 | "version" : "0.10.0" 118 | } 119 | } 120 | ], 121 | "version" : 2 122 | } 123 | -------------------------------------------------------------------------------- /Examples/Examples.xcodeproj/xcshareddata/xcschemes/Inventory.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Examples/Inventory/App.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct InventoryApp: App { 5 | let model = AppModel( 6 | inventoryModel: InventoryModel( 7 | inventory: [ 8 | ItemRowModel( 9 | item: Item(color: .red, name: "Keyboard", status: .inStock(quantity: 100)) 10 | ), 11 | ItemRowModel( 12 | item: Item(color: .blue, name: "Mouse", status: .inStock(quantity: 200)) 13 | ), 14 | ItemRowModel( 15 | item: Item(color: .green, name: "Monitor", status: .inStock(quantity: 20)) 16 | ), 17 | ItemRowModel( 18 | item: Item(color: .yellow, name: "Chair", status: .outOfStock(isOnBackOrder: true)) 19 | ), 20 | ] 21 | ) 22 | ) 23 | 24 | var body: some Scene { 25 | WindowGroup { 26 | AppView(model: self.model) 27 | } 28 | } 29 | } 30 | 31 | @Observable 32 | class AppModel { 33 | var inventoryModel: InventoryModel 34 | var selectedTab: Tab 35 | 36 | init( 37 | inventoryModel: InventoryModel, 38 | selectedTab: Tab = .first 39 | ) { 40 | self.inventoryModel = inventoryModel 41 | self.selectedTab = selectedTab 42 | } 43 | 44 | enum Tab { 45 | case first 46 | case inventory 47 | } 48 | } 49 | 50 | struct AppView: View { 51 | @State var model: AppModel 52 | 53 | var body: some View { 54 | TabView(selection: self.$model.selectedTab) { 55 | Button { 56 | self.model.selectedTab = .inventory 57 | } label: { 58 | Text("Go to inventory tab") 59 | } 60 | .tag(AppModel.Tab.first) 61 | .tabItem { 62 | Label("First", systemImage: "arrow.forward") 63 | } 64 | 65 | NavigationStack { 66 | InventoryView(model: self.model.inventoryModel) 67 | } 68 | .tag(AppModel.Tab.inventory) 69 | .tabItem { 70 | Label("Inventory", systemImage: "list.clipboard.fill") 71 | } 72 | } 73 | } 74 | } 75 | 76 | #Preview { 77 | AppView(model: AppModel(inventoryModel: InventoryModel())) 78 | } 79 | -------------------------------------------------------------------------------- /Examples/Inventory/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 | -------------------------------------------------------------------------------- /Examples/Inventory/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 | -------------------------------------------------------------------------------- /Examples/Inventory/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/Inventory/Inventory.swift: -------------------------------------------------------------------------------- 1 | import IdentifiedCollections 2 | import SwiftUI 3 | import SwiftUINavigation 4 | 5 | @Observable 6 | class InventoryModel { 7 | var inventory: IdentifiedArrayOf { 8 | didSet { bind() } 9 | } 10 | var destination: Destination? 11 | 12 | @CasePathable 13 | enum Destination: Equatable { 14 | case add(Item) 15 | case edit(Item) 16 | } 17 | 18 | init( 19 | inventory: IdentifiedArrayOf = [], 20 | destination: Destination? = nil 21 | ) { 22 | self.inventory = inventory 23 | self.destination = destination 24 | self.bind() 25 | } 26 | 27 | func delete(item: Item) { 28 | _ = inventory.remove(id: item.id) 29 | } 30 | 31 | func add(item: Item) { 32 | withAnimation { 33 | inventory.append(ItemRowModel(item: item)) 34 | destination = nil 35 | } 36 | } 37 | 38 | func addButtonTapped() { 39 | destination = .add(Item(color: nil, name: "", status: .inStock(quantity: 1))) 40 | } 41 | 42 | func cancelButtonTapped() { 43 | destination = nil 44 | } 45 | 46 | func cancelEditButtonTapped() { 47 | destination = nil 48 | } 49 | 50 | func commitEdit(item: Item) { 51 | inventory[id: item.id]?.item = item 52 | destination = nil 53 | } 54 | 55 | private func bind() { 56 | for itemRowModel in inventory { 57 | itemRowModel.onDelete = { [weak self, weak itemRowModel] in 58 | guard let self, let itemRowModel else { return } 59 | delete(item: itemRowModel.item) 60 | } 61 | itemRowModel.onDuplicate = { [weak self] item in 62 | guard let self else { return } 63 | add(item: item) 64 | } 65 | itemRowModel.onTap = { [weak self, weak itemRowModel] in 66 | guard let self, let itemRowModel else { return } 67 | destination = .edit(itemRowModel.item) 68 | } 69 | } 70 | } 71 | } 72 | 73 | struct InventoryView: View { 74 | @State var model: InventoryModel 75 | 76 | var body: some View { 77 | List { 78 | ForEach(model.inventory) { 79 | ItemRowView(model: $0) 80 | } 81 | } 82 | .toolbar { 83 | ToolbarItem(placement: .primaryAction) { 84 | Button("Add") { model.addButtonTapped() } 85 | } 86 | } 87 | .navigationTitle("Inventory") 88 | .navigationDestination(item: $model.destination.edit) { $item in 89 | ItemView(item: $item) 90 | .navigationBarTitle("Edit") 91 | .navigationBarBackButtonHidden(true) 92 | .toolbar { 93 | ToolbarItem(placement: .cancellationAction) { 94 | Button("Cancel") { 95 | model.cancelEditButtonTapped() 96 | } 97 | } 98 | ToolbarItem(placement: .primaryAction) { 99 | Button("Save") { 100 | model.commitEdit(item: item) 101 | } 102 | } 103 | } 104 | } 105 | .sheet(item: $model.destination.add) { $itemToAdd in 106 | NavigationStack { 107 | ItemView(item: $itemToAdd) 108 | .navigationTitle("Add") 109 | .toolbar { 110 | ToolbarItem(placement: .cancellationAction) { 111 | Button("Cancel") { model.cancelButtonTapped() } 112 | } 113 | ToolbarItem(placement: .primaryAction) { 114 | Button("Save") { model.add(item: itemToAdd) } 115 | } 116 | } 117 | } 118 | } 119 | } 120 | } 121 | 122 | #Preview { 123 | let keyboard = Item( 124 | color: .blue, 125 | name: "Keyboard", 126 | status: .inStock(quantity: 100) 127 | ) 128 | 129 | return NavigationStack { 130 | InventoryView( 131 | model: InventoryModel( 132 | inventory: [ 133 | ItemRowModel( 134 | item: keyboard 135 | ), 136 | ItemRowModel( 137 | item: Item(color: .yellow, name: "Charger", status: .inStock(quantity: 20)) 138 | ), 139 | ItemRowModel( 140 | item: Item(color: .green, name: "Phone", status: .outOfStock(isOnBackOrder: true)) 141 | ), 142 | ItemRowModel( 143 | item: Item( 144 | color: .green, name: "Headphones", status: .outOfStock(isOnBackOrder: false) 145 | ) 146 | ), 147 | ] 148 | ) 149 | ) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /Examples/Inventory/Item.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftUINavigation 3 | 4 | struct Item: Equatable, Identifiable { 5 | let id = UUID() 6 | var color: Color? 7 | var name: String 8 | var status: Status 9 | 10 | @CasePathable 11 | enum Status: Equatable { 12 | case inStock(quantity: Int) 13 | case outOfStock(isOnBackOrder: Bool) 14 | } 15 | 16 | struct Color: Equatable, Hashable { 17 | var name: String 18 | var red: CGFloat = 0 19 | var green: CGFloat = 0 20 | var blue: CGFloat = 0 21 | 22 | static let defaults: [Self] = [ 23 | .red, 24 | .green, 25 | .blue, 26 | .black, 27 | .yellow, 28 | .white, 29 | ] 30 | 31 | static let red = Self(name: "Red", red: 1) 32 | static let green = Self(name: "Green", green: 1) 33 | static let blue = Self(name: "Blue", blue: 1) 34 | static let black = Self(name: "Black") 35 | static let yellow = Self(name: "Yellow", red: 1, green: 1) 36 | static let white = Self(name: "White", red: 1, green: 1, blue: 1) 37 | 38 | var swiftUIColor: SwiftUI.Color { 39 | SwiftUI.Color(red: self.red, green: self.green, blue: self.blue) 40 | } 41 | } 42 | } 43 | 44 | struct ItemView: View { 45 | @Binding var item: Item 46 | 47 | var body: some View { 48 | Form { 49 | TextField("Name", text: self.$item.name) 50 | 51 | Picker(selection: self.$item.color, label: Text("Color")) { 52 | Text("None") 53 | .tag(Item.Color?.none) 54 | 55 | ForEach(Item.Color.defaults, id: \.name) { color in 56 | Text(color.name) 57 | .tag(Optional(color)) 58 | } 59 | } 60 | 61 | switch self.item.status { 62 | case .inStock: 63 | self.$item.status.inStock.map { $quantity in 64 | Section { 65 | Stepper("Quantity: \(quantity)", value: $quantity) 66 | Button("Mark as sold out") { 67 | withAnimation { 68 | self.item.status = .outOfStock(isOnBackOrder: false) 69 | } 70 | } 71 | } header: { 72 | Text("In stock") 73 | } 74 | .transition(.opacity) 75 | } 76 | case .outOfStock: 77 | self.$item.status.outOfStock.map { $isOnBackOrder in 78 | Section { 79 | Toggle("Is on back order?", isOn: $isOnBackOrder) 80 | Button("Is back in stock!") { 81 | withAnimation { 82 | self.item.status = .inStock(quantity: 1) 83 | } 84 | } 85 | } header: { 86 | Text("Out of stock") 87 | } 88 | .transition(.opacity) 89 | } 90 | } 91 | } 92 | } 93 | } 94 | 95 | #Preview { 96 | WithState(initialValue: Item(color: nil, name: "", status: .inStock(quantity: 1))) { $item in 97 | ItemView(item: $item) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Examples/Inventory/ItemRow.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftUINavigation 3 | import XCTestDynamicOverlay 4 | 5 | @Observable 6 | class ItemRowModel: Identifiable { 7 | var item: Item 8 | var destination: Destination? 9 | 10 | @CasePathable 11 | enum Destination: Equatable { 12 | case alert(AlertState) 13 | case duplicate(Item) 14 | } 15 | 16 | enum AlertAction { 17 | case deleteConfirmation 18 | } 19 | 20 | var onDelete: () -> Void = unimplemented("ItemRowModel.onDelete") 21 | var onDuplicate: (Item) -> Void = unimplemented("ItemRowModel.onDuplicate") 22 | var onTap: () -> Void = unimplemented("ItemRowModel.onTap") 23 | 24 | var id: Item.ID { item.id } 25 | 26 | init(item: Item) { 27 | self.item = item 28 | } 29 | 30 | func deleteButtonTapped() { 31 | destination = .alert( 32 | AlertState { 33 | TextState(item.name) 34 | } actions: { 35 | ButtonState(role: .destructive, action: .send(.deleteConfirmation, animation: .default)) { 36 | TextState("Delete") 37 | } 38 | } message: { 39 | TextState("Are you sure you want to delete this item?") 40 | } 41 | ) 42 | } 43 | 44 | func alertButtonTapped(_ action: AlertAction?) { 45 | switch action { 46 | case .deleteConfirmation?: 47 | onDelete() 48 | case nil: 49 | break 50 | } 51 | } 52 | 53 | func cancelButtonTapped() { 54 | destination = nil 55 | } 56 | 57 | func duplicateButtonTapped() { 58 | destination = .duplicate(item.duplicate()) 59 | } 60 | 61 | func duplicate(item: Item) { 62 | onDuplicate(item) 63 | destination = nil 64 | } 65 | 66 | func rowTapped() { 67 | onTap() 68 | } 69 | } 70 | 71 | extension Item { 72 | func duplicate() -> Self { 73 | Self(color: color, name: name, status: status) 74 | } 75 | } 76 | 77 | struct ItemRowView: View { 78 | @State var model: ItemRowModel 79 | 80 | var body: some View { 81 | Button { 82 | model.rowTapped() 83 | } label: { 84 | HStack { 85 | VStack(alignment: .leading) { 86 | Text(model.item.name) 87 | .font(.title3) 88 | 89 | switch model.item.status { 90 | case let .inStock(quantity): 91 | Text("In stock: \(quantity)") 92 | case let .outOfStock(isOnBackOrder): 93 | Text("Out of stock\(isOnBackOrder ? ": on back order" : "")") 94 | } 95 | } 96 | 97 | Spacer() 98 | 99 | if let color = model.item.color { 100 | Rectangle() 101 | .frame(width: 30, height: 30) 102 | .foregroundColor(color.swiftUIColor) 103 | .border(Color.black, width: 1) 104 | } 105 | 106 | Button(action: { model.duplicateButtonTapped() }) { 107 | Image(systemName: "square.fill.on.square.fill") 108 | } 109 | .padding(.leading) 110 | 111 | Button(action: { model.deleteButtonTapped() }) { 112 | Image(systemName: "trash.fill") 113 | } 114 | .padding(.leading) 115 | } 116 | .buttonStyle(.plain) 117 | .foregroundColor(model.item.status.is(\.inStock) ? nil : Color.gray) 118 | .alert($model.destination.alert) { 119 | model.alertButtonTapped($0) 120 | } 121 | .popover(item: $model.destination.duplicate) { $item in 122 | NavigationStack { 123 | ItemView(item: $item) 124 | .navigationBarTitle("Duplicate") 125 | .toolbar { 126 | ToolbarItem(placement: .cancellationAction) { 127 | Button("Cancel") { 128 | model.cancelButtonTapped() 129 | } 130 | } 131 | ToolbarItem(placement: .primaryAction) { 132 | Button("Add") { 133 | model.duplicate(item: item) 134 | } 135 | } 136 | } 137 | } 138 | .frame(minWidth: 300, minHeight: 500) 139 | } 140 | } 141 | } 142 | } 143 | 144 | #Preview { 145 | List { 146 | ItemRowView( 147 | model: ItemRowModel( 148 | item: Item( 149 | name: "Keyboard", 150 | status: .inStock(quantity: 42) 151 | ) 152 | ) 153 | ) 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /Examples/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "", 7 | products: [], 8 | dependencies: [], 9 | targets: [] 10 | ) 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Point-Free, Inc. 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PLATFORM_IOS = iOS Simulator,name=iPhone 13 Pro Max 2 | PLATFORM_MACOS = macOS 3 | PLATFORM_TVOS = tvOS Simulator,name=Apple TV 4 | PLATFORM_WATCHOS = watchOS Simulator,name=Apple Watch Series 7 (45mm) 5 | 6 | TEST_RUNNER_CI = $(CI) 7 | 8 | default: test 9 | 10 | test: 11 | xcodebuild test \ 12 | -workspace SwiftUINavigation.xcworkspace \ 13 | -scheme SwiftUINavigation \ 14 | -destination platform="$(PLATFORM_IOS)" 15 | xcodebuild test \ 16 | -workspace SwiftUINavigation.xcworkspace \ 17 | -scheme SwiftUINavigation \ 18 | -destination platform="$(PLATFORM_MACOS)" 19 | xcodebuild test \ 20 | -workspace SwiftUINavigation.xcworkspace \ 21 | -scheme SwiftUINavigation \ 22 | -destination platform="$(PLATFORM_TVOS)" 23 | xcodebuild test \ 24 | -workspace SwiftUINavigation.xcworkspace \ 25 | -scheme SwiftUINavigation \ 26 | -destination platform="$(PLATFORM_WATCHOS)" 27 | test-examples: 28 | xcodebuild test \ 29 | -workspace SwiftUINavigation.xcworkspace \ 30 | -scheme Standups \ 31 | -destination platform="$(PLATFORM_IOS)" 32 | 33 | DOC_WARNINGS := $(shell xcodebuild clean docbuild \ 34 | -scheme SwiftUINavigation \ 35 | -destination platform="$(PLATFORM_MACOS)" \ 36 | -quiet \ 37 | 2>&1 \ 38 | | grep "couldn't be resolved to known documentation" \ 39 | | sed 's|$(PWD)|.|g' \ 40 | | tr '\n' '\1') 41 | test-docs: 42 | @test "$(DOC_WARNINGS)" = "" \ 43 | || (echo "xcodebuild docbuild failed:\n\n$(DOC_WARNINGS)" | tr '\1' '\n' \ 44 | && exit 1) 45 | 46 | format: 47 | swift format \ 48 | --ignore-unparsable-files \ 49 | --in-place \ 50 | --parallel \ 51 | --recursive \ 52 | ./Examples ./Package.swift ./Sources ./Tests 53 | 54 | .PHONY: format test-all test-docs 55 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-case-paths", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/pointfreeco/swift-case-paths", 7 | "state" : { 8 | "revision" : "71344dd930fde41e8f3adafe260adcbb2fc2a3dc", 9 | "version" : "1.5.4" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-custom-dump", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 16 | "state" : { 17 | "revision" : "aec6a73f5c1dc1f1be4f61888094b95cf995d973", 18 | "version" : "1.3.2" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-docc-plugin", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/apple/swift-docc-plugin", 25 | "state" : { 26 | "revision" : "26ac5758409154cc448d7ab82389c520fa8a8247", 27 | "version" : "1.3.0" 28 | } 29 | }, 30 | { 31 | "identity" : "swift-docc-symbolkit", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/apple/swift-docc-symbolkit", 34 | "state" : { 35 | "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", 36 | "version" : "1.0.0" 37 | } 38 | }, 39 | { 40 | "identity" : "swift-syntax", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/swiftlang/swift-syntax", 43 | "state" : { 44 | "revision" : "4c6cc0a3b9e8f14b3ae2307c5ccae4de6167ac2c", 45 | "version" : "600.0.0-prerelease-2024-06-12" 46 | } 47 | }, 48 | { 49 | "identity" : "xctest-dynamic-overlay", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 52 | "state" : { 53 | "revision" : "357ca1e5dd31f613a1d43320870ebc219386a495", 54 | "version" : "1.2.2" 55 | } 56 | } 57 | ], 58 | "version" : 2 59 | } 60 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "swiftui-navigation", 7 | platforms: [ 8 | .iOS(.v13), 9 | .macOS(.v10_15), 10 | .tvOS(.v13), 11 | .watchOS(.v6), 12 | ], 13 | products: [ 14 | .library( 15 | name: "SwiftUINavigation", 16 | targets: ["SwiftUINavigation"] 17 | ), 18 | .library( 19 | name: "SwiftUINavigationCore", 20 | targets: ["SwiftUINavigationCore"] 21 | ), 22 | ], 23 | dependencies: [ 24 | .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), 25 | .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.5.4"), 26 | .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.2"), 27 | .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.2.2"), 28 | ], 29 | targets: [ 30 | .target( 31 | name: "SwiftUINavigation", 32 | dependencies: [ 33 | "SwiftUINavigationCore", 34 | .product(name: "CasePaths", package: "swift-case-paths"), 35 | .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), 36 | ] 37 | ), 38 | .testTarget( 39 | name: "SwiftUINavigationTests", 40 | dependencies: [ 41 | "SwiftUINavigation" 42 | ] 43 | ), 44 | .target( 45 | name: "SwiftUINavigationCore", 46 | dependencies: [ 47 | .product(name: "CustomDump", package: "swift-custom-dump"), 48 | .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), 49 | ] 50 | ), 51 | ] 52 | ) 53 | 54 | for target in package.targets { 55 | target.swiftSettings = target.swiftSettings ?? [] 56 | target.swiftSettings!.append(contentsOf: [ 57 | .enableExperimentalFeature("StrictConcurrency") 58 | ]) 59 | } 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!IMPORTANT] 2 | > This package has been deprecated in favor of [Swift Navigation](https://github.com/pointfreeco/swift-navigation). 3 | 4 | # SwiftUI Navigation 5 | 6 | Tools for making SwiftUI navigation simpler, more ergonomic and more precise. 7 | 8 | * [Overview](#overview) 9 | * [Examples](#examples) 10 | * [Learn more](#learn-more) 11 | * [Community](#community) 12 | * [Installation](#installation) 13 | * [Documentation](#documentation) 14 | * [License](#license) 15 | 16 | ## Overview 17 | 18 | SwiftUI comes with many forms of navigation (tabs, alerts, dialogs, modal sheets, popovers, 19 | navigation links, and more), and each comes with a few ways to construct them. These ways roughly 20 | fall in two categories: 21 | 22 | * "Fire-and-forget": These are initializers and methods that do not take binding arguments, which 23 | means SwiftUI fully manages navigation state internally. This makes it easy to get something on 24 | the screen quickly, but you also have no programmatic control over the navigation. Examples of 25 | this are the initializers on [`TabView`][TabView.init] and [`NavigationLink`][NavigationLink.init] 26 | that do not take a binding. 27 | 28 | * "State-driven": Most other initializers and methods do take a binding, which means you can 29 | mutate state in your domain to tell SwiftUI when it should activate or deactivate navigation. 30 | Using these APIs is more complicated than the "fire-and-forget" style, but doing so instantly 31 | gives you the ability to deep-link into any state of your application by just constructing a 32 | piece of data, handing it to a SwiftUI view, and letting SwiftUI handle the rest. 33 | 34 | Navigation that is "state-driven" is the more powerful form of navigation, albeit slightly more 35 | complicated. Unfortunately, SwiftUI does not ship with all of the tools necessary to model our 36 | domains with enums and make use of navigation APIs. This library bridges that gap by providing APIs 37 | that allow you to model your navigation destinations as an enum, and then drive navigation by a 38 | binding to that enum. 39 | 40 | Explore all of the tools this library comes with by checking out the [documentation][docs], and 41 | reading these articles: 42 | 43 | * **[What is navigation?][what-is-article]**: 44 | Learn how one can think of navigation as a domain modeling problem, and how that leads to the 45 | creation of concise and testable APIs for navigation. 46 | 47 | * **[Navigation links and destinations][nav-links-dests-article]**: 48 | Learn how to drive navigation in NavigationView and NavigationStack in a concise and testable 49 | manner. 50 | 51 | * **[Sheets, popovers, and covers][sheets-popovers-covers-article]**: 52 | Learn how to present sheets, popovers and covers in a concise and testable manner. 53 | 54 | * **[Alerts and dialogs][alerts-dialogs-article]**: 55 | Learn how to present alerts and confirmation dialogs in a concise and testable manner. 56 | 57 | * **[Bindings][bindings]**: 58 | Learn how to manage certain view state, such as `@FocusState` directly in your observable classes. 59 | 60 | ## Examples 61 | 62 | This repo comes with lots of examples to demonstrate how to solve common and complex navigation 63 | problems with the library. Check out [this](./Examples) directory to see them all, including: 64 | 65 | * [Case Studies](./Examples/CaseStudies) 66 | * Alerts & Confirmation Dialogs 67 | * Sheets & Popovers & Fullscreen Covers 68 | * Navigation Links 69 | * Routing 70 | * Custom Components 71 | * [Inventory](./Examples/Inventory): A multi-screen application with lists, sheets, popovers and 72 | alerts, all driven by state and deep-linkable. 73 | 74 | ## Learn More 75 | 76 | SwiftUI Navigation's tools were motivated and designed over the course of many episodes on 77 | [Point-Free](https://www.pointfree.co), a video series exploring functional programming and the 78 | Swift language, hosted by [Brandon Williams](https://twitter.com/mbrandonw) and 79 | [Stephen Celis](https://twitter.com/stephencelis). 80 | 81 | You can watch all of the episodes [here](https://www.pointfree.co/collections/swiftui/navigation). 82 | 83 | 84 | video poster image 85 | 86 | 87 | ## Community 88 | 89 | If you want to discuss this library or have a question about how to use it to solve 90 | a particular problem, there are a number of places you can discuss with fellow 91 | [Point-Free](http://www.pointfree.co) enthusiasts: 92 | 93 | * For long-form discussions, we recommend the 94 | [discussions](http://github.com/pointfreeco/swiftui-navigation/discussions) tab of this repo. 95 | * For casual chat, we recommend the 96 | [Point-Free Community slack](http://pointfree.co/slack-invite). 97 | 98 | ## Installation 99 | 100 | You can add SwiftUI Navigation to an Xcode project by adding it as a package dependency. 101 | 102 | > https://github.com/pointfreeco/swiftui-navigation 103 | 104 | If you want to use SwiftUI Navigation in a [SwiftPM](https://swift.org/package-manager/) project, 105 | it's as simple as adding it to a `dependencies` clause in your `Package.swift`: 106 | 107 | ``` swift 108 | dependencies: [ 109 | .package(url: "https://github.com/pointfreeco/swiftui-navigation", from: "1.0.0") 110 | ] 111 | ``` 112 | 113 | ## Documentation 114 | 115 | The latest documentation for the SwiftUI Navigation APIs is available 116 | [here](https://swiftpackageindex.com/pointfreeco/swiftui-navigation/main/documentation/swiftuinavigation). 117 | 118 | ## License 119 | 120 | This library is released under the MIT license. See [LICENSE](LICENSE) for details. 121 | 122 | [NavigationLink.init]: https://developer.apple.com/documentation/swiftui/navigationlink/init(destination:label:)-27n7s 123 | [TabView.init]: https://developer.apple.com/documentation/swiftui/tabview/init(content:) 124 | [case-paths-gh]: https://github.com/pointfreeco/swift-case-paths 125 | [what-is-article]: https://swiftpackageindex.com/pointfreeco/swiftui-navigation/main/documentation/swiftuinavigation/whatisnavigation 126 | [nav-links-dests-article]: https://swiftpackageindex.com/pointfreeco/swiftui-navigation/main/documentation/swiftuinavigation/navigation 127 | [sheets-popovers-covers-article]: https://swiftpackageindex.com/pointfreeco/swiftui-navigation/main/documentation/swiftuinavigation/sheetspopoverscovers 128 | [alerts-dialogs-article]: https://swiftpackageindex.com/pointfreeco/swiftui-navigation/main/documentation/swiftuinavigation/alertsdialogs 129 | [bindings]: https://swiftpackageindex.com/pointfreeco/swiftui-navigation/main/documentation/swiftuinavigation/bindings 130 | [docs]: https://swiftpackageindex.com/pointfreeco/swiftui-navigation/main/documentation/swiftuinavigation 131 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigation/Alert.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import SwiftUI 3 | 4 | @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) 5 | extension View { 6 | 7 | /// Presents an alert from a binding to optional alert state. 8 | /// 9 | /// See for more information on how to use this API. 10 | /// 11 | /// - Parameters: 12 | /// - state: A binding to optional alert state that determines whether an alert should be 13 | /// presented. When the binding is updated with non-`nil` value, it is unwrapped and used to 14 | /// populate the fields of an alert that the system displays to the user. When the user 15 | /// presses or taps one of the alert's actions, the system sets this value to `nil` and 16 | /// dismisses the alert, and the action is fed to the `action` closure. 17 | /// - handler: A closure that is called with an action from a particular alert button when 18 | /// tapped. 19 | public func alert( 20 | _ state: Binding?>, 21 | action handler: @escaping (Value?) -> Void = { (_: Never?) in } 22 | ) -> some View { 23 | alert(item: state) { 24 | Text($0.title) 25 | } actions: { 26 | ForEach($0.buttons) { 27 | Button($0, action: handler) 28 | } 29 | } message: { 30 | $0.message.map(Text.init) 31 | } 32 | } 33 | 34 | /// Presents an alert from a binding to optional alert state. 35 | /// 36 | /// See for more information on how to use this API. 37 | /// 38 | /// > Warning: Async closures cannot be performed with animation. If the underlying action is 39 | /// > animated, a runtime warning will be emitted. 40 | /// 41 | /// - Parameters: 42 | /// - state: A binding to optional alert state that determines whether an alert should be 43 | /// presented. When the binding is updated with non-`nil` value, it is unwrapped and used to 44 | /// populate the fields of an alert that the system displays to the user. When the user 45 | /// presses or taps one of the alert's actions, the system sets this value to `nil` and 46 | /// dismisses the alert, and the action is fed to the `action` closure. 47 | /// - handler: A closure that is called with an action from a particular alert button when 48 | /// tapped. 49 | public func alert( 50 | _ state: Binding?>, 51 | action handler: @escaping @Sendable (Value?) async -> Void = { (_: Never?) async in } 52 | ) -> some View { 53 | alert(item: state) { 54 | Text($0.title) 55 | } actions: { 56 | ForEach($0.buttons) { 57 | Button($0, action: handler) 58 | } 59 | } message: { 60 | $0.message.map(Text.init) 61 | } 62 | } 63 | } 64 | #endif // canImport(SwiftUI) 65 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigation/Binding.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import CasePaths 3 | import SwiftUI 4 | 5 | extension Binding { 6 | #if swift(>=5.9) 7 | /// Returns a binding to the associated value of a given case key path. 8 | /// 9 | /// Useful for producing bindings to values held in enum state. 10 | /// 11 | /// - Parameter keyPath: A case key path to a specific associated value. 12 | /// - Returns: A new binding. 13 | public subscript( 14 | dynamicMember keyPath: KeyPath> 15 | ) -> Binding? 16 | where Value: CasePathable { 17 | Binding(unwrapping: self[keyPath]) 18 | } 19 | 20 | /// Returns a binding to the associated value of a given case key path. 21 | /// 22 | /// Useful for driving navigation off an optional enumeration of destinations. 23 | /// 24 | /// - Parameter keyPath: A case key path to a specific associated value. 25 | /// - Returns: A new binding. 26 | public subscript( 27 | dynamicMember keyPath: KeyPath> 28 | ) -> Binding 29 | where Value == Enum? { 30 | self[keyPath] 31 | } 32 | #endif 33 | 34 | /// Creates a binding by projecting the base value to an unwrapped value. 35 | /// 36 | /// Useful for producing non-optional bindings from optional ones. 37 | /// 38 | /// See ``IfLet`` for a view builder-friendly version of this initializer. 39 | /// 40 | /// > Note: SwiftUI comes with an equivalent failable initializer, `Binding.init(_:)`, but using 41 | /// > it can lead to crashes at runtime. [Feedback][FB8367784] has been filed, but in the meantime 42 | /// > this initializer exists as a workaround. 43 | /// 44 | /// [FB8367784]: https://gist.github.com/stephencelis/3a232a1b718bab0ae1127ebd5fcf6f97 45 | /// 46 | /// - Parameter base: A value to project to an unwrapped value. 47 | /// - Returns: A new binding or `nil` when `base` is `nil`. 48 | public init?(unwrapping base: Binding) { 49 | guard let value = base.wrappedValue else { return nil } 50 | self.init(unwrapping: base, default: value) 51 | } 52 | 53 | public init(unwrapping base: Binding, default value: Value) { 54 | self = base[default: DefaultSubscript(value)] 55 | } 56 | 57 | /// Creates a binding that ignores writes to its wrapped value when equivalent to the new value. 58 | /// 59 | /// Useful to minimize writes to bindings passed to SwiftUI APIs. For example, [`NavigationLink` 60 | /// may write `nil` twice][FB9404926] when dismissing its destination via the navigation bar's 61 | /// back button. Logic attached to this dismissal will execute twice, which may not be desirable. 62 | /// 63 | /// [FB9404926]: https://gist.github.com/mbrandonw/70df235e42d505b3b1b9b7d0d006b049 64 | /// 65 | /// - Parameter isDuplicate: A closure to evaluate whether two elements are equivalent, for 66 | /// purposes of filtering writes. Return `true` from this closure to indicate that the second 67 | /// element is a duplicate of the first. 68 | public func removeDuplicates( 69 | by isDuplicate: @Sendable @escaping (Value, Value) -> Bool 70 | ) -> Self { 71 | .init( 72 | get: { self.wrappedValue }, 73 | set: { newValue, transaction in 74 | guard !isDuplicate(self.wrappedValue, newValue) else { return } 75 | self.transaction(transaction).wrappedValue = newValue 76 | } 77 | ) 78 | } 79 | } 80 | 81 | extension Binding where Value: Equatable { 82 | /// Creates a binding that ignores writes to its wrapped value when equivalent to the new value. 83 | /// 84 | /// Useful to minimize writes to bindings passed to SwiftUI APIs. For example, [`NavigationLink` 85 | /// may write `nil` twice][FB9404926] when dismissing its destination via the navigation bar's 86 | /// back button. Logic attached to this dismissal will execute twice, which may not be desirable. 87 | /// 88 | /// [FB9404926]: https://gist.github.com/mbrandonw/70df235e42d505b3b1b9b7d0d006b049 89 | public func removeDuplicates() -> Self { 90 | self.removeDuplicates(by: { $0 == $1 }) 91 | } 92 | } 93 | 94 | extension Binding { 95 | public func _printChanges(_ prefix: String = "") -> Self { 96 | Self( 97 | get: { self.wrappedValue }, 98 | set: { newValue, transaction in 99 | var oldDescription = "" 100 | debugPrint(self.wrappedValue, terminator: "", to: &oldDescription) 101 | var newDescription = "" 102 | debugPrint(newValue, terminator: "", to: &newDescription) 103 | print("\(prefix.isEmpty ? "\(Self.self)" : prefix):", oldDescription, "=", newDescription) 104 | self.transaction(transaction).wrappedValue = newValue 105 | } 106 | ) 107 | } 108 | } 109 | 110 | extension Optional { 111 | fileprivate subscript(default defaultSubscript: DefaultSubscript) -> Wrapped { 112 | get { 113 | defaultSubscript.value = self ?? defaultSubscript.value 114 | return defaultSubscript.value 115 | } 116 | set { 117 | defaultSubscript.value = newValue 118 | if self != nil { self = newValue } 119 | } 120 | } 121 | } 122 | 123 | private final class DefaultSubscript: Hashable { 124 | var value: Value 125 | init(_ value: Value) { 126 | self.value = value 127 | } 128 | static func == (lhs: DefaultSubscript, rhs: DefaultSubscript) -> Bool { 129 | lhs === rhs 130 | } 131 | func hash(into hasher: inout Hasher) { 132 | hasher.combine(ObjectIdentifier(self)) 133 | } 134 | } 135 | 136 | extension CasePathable { 137 | fileprivate subscript( 138 | keyPath: KeyPath> 139 | ) -> Member? { 140 | get { 141 | Self.allCasePaths[keyPath: keyPath].extract(from: self) 142 | } 143 | set { 144 | guard let newValue else { return } 145 | self = Self.allCasePaths[keyPath: keyPath].embed(newValue) 146 | } 147 | } 148 | } 149 | 150 | extension Optional where Wrapped: CasePathable { 151 | fileprivate subscript( 152 | keyPath: KeyPath> 153 | ) -> Member? { 154 | get { 155 | self.flatMap(Wrapped.allCasePaths[keyPath: keyPath].extract(from:)) 156 | } 157 | set { 158 | let casePath = Wrapped.allCasePaths[keyPath: keyPath] 159 | guard self.flatMap(casePath.extract(from:)) != nil 160 | else { return } 161 | self = newValue.map(casePath.embed) 162 | } 163 | } 164 | } 165 | #endif // canImport(SwiftUI) 166 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigation/ConfirmationDialog.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import SwiftUI 3 | 4 | extension View { 5 | /// Presents a confirmation dialog from a binding to optional confirmation dialog state. 6 | /// 7 | /// See for more information on how to use this API. 8 | /// 9 | /// - Parameters: 10 | /// - state: A binding to optional state that determines whether a confirmation dialog should 11 | /// be presented. When the binding is updated with non-`nil` value, it is unwrapped and used 12 | /// to populate the fields of a dialog that the system displays to the user. When the user 13 | /// presses or taps one of the dialog's actions, the system sets this value to `nil` and 14 | /// dismisses the dialog, and the action is fed to the `action` closure. 15 | /// - handler: A closure that is called with an action from a particular dialog button when 16 | /// tapped. 17 | @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) 18 | public func confirmationDialog( 19 | _ state: Binding?>, 20 | action handler: @escaping (Value?) -> Void = { (_: Never?) in } 21 | ) -> some View { 22 | confirmationDialog( 23 | item: state, 24 | titleVisibility: state.wrappedValue.map { .init($0.titleVisibility) } ?? .automatic 25 | ) { 26 | Text($0.title) 27 | } actions: { 28 | ForEach($0.buttons) { 29 | Button($0, action: handler) 30 | } 31 | } message: { 32 | $0.message.map(Text.init) 33 | } 34 | } 35 | 36 | /// Presents a confirmation dialog from a binding to optional confirmation dialog state. 37 | /// 38 | /// See for more information on how to use this API. 39 | /// 40 | /// > Warning: Async closures cannot be performed with animation. If the underlying action is 41 | /// > animated, a runtime warning will be emitted. 42 | /// 43 | /// - Parameters: 44 | /// - state: A binding to optional state that determines whether a confirmation dialog should 45 | /// be presented. When the binding is updated with non-`nil` value, it is unwrapped and used 46 | /// to populate the fields of a dialog that the system displays to the user. When the user 47 | /// presses or taps one of the dialog's actions, the system sets this value to `nil` and 48 | /// dismisses the dialog, and the action is fed to the `action` closure. 49 | /// - handler: A closure that is called with an action from a particular dialog button when 50 | /// tapped. 51 | @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) 52 | public func confirmationDialog( 53 | _ state: Binding?>, 54 | action handler: @escaping @Sendable (Value?) async -> Void = { (_: Never?) async in } 55 | ) -> some View { 56 | confirmationDialog( 57 | item: state, 58 | titleVisibility: state.wrappedValue.map { .init($0.titleVisibility) } ?? .automatic 59 | ) { 60 | Text($0.title) 61 | } actions: { 62 | ForEach($0.buttons) { 63 | Button($0, action: handler) 64 | } 65 | } message: { 66 | $0.message.map(Text.init) 67 | } 68 | } 69 | } 70 | #endif // canImport(SwiftUI) 71 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigation/Documentation.docc/Articles/AlertsDialogs.md: -------------------------------------------------------------------------------- 1 | # Alerts and dialogs 2 | 3 | Learn how to present alerts and confirmation dialogs in a concise and testable manner. 4 | 5 | ## Overview 6 | 7 | The library comes with new tools for driving alerts and confirmation dialogs from optional and enum 8 | state, and makes them more testable. 9 | 10 | ### Alerts 11 | 12 | Suppose you have a feature for deleting something in your application and you want to show an alert 13 | for the user to confirm the deletion. You can do this by holding onto an optional `AlertState` in 14 | your model, as well as an enum that describes every action that can happen in the alert: 15 | 16 | 17 | ```swift 18 | @Observable 19 | class FeatureModel { 20 | var alert: AlertState? 21 | enum AlertAction { 22 | case confirmDelete 23 | } 24 | 25 | // ... 26 | } 27 | ``` 28 | 29 | Then, when you need to show an alert you can update the alert state with a title, message and 30 | buttons: 31 | 32 | ```swift 33 | func deleteButtonTapped() { 34 | self.alert = AlertState { 35 | TextState("Are you sure?") 36 | } actions: { 37 | ButtonState(role: .destructive, action: .confirmDelete) { 38 | TextState("Delete") 39 | } 40 | ButtonState(role: .cancel) { 41 | TextState("Nevermind") 42 | } 43 | } message: { 44 | TextState("Deleting this item cannot be undone.") 45 | } 46 | } 47 | ``` 48 | 49 | The type `TextState` is closely related to `Text` from SwiftUI, but plays more nicely with 50 | equatability. This makes it possible to write tests against these values. 51 | 52 | > Tip: The `actions` closure is a result builder, which allows you to insert small bits of logic: 53 | > ```swift 54 | > } actions: { 55 | > if item.isLocked { 56 | > ButtonState(role: .destructive, action: .confirmDelete) { 57 | > TextState("Unlock and delete") 58 | > } 59 | > } else { 60 | > ButtonState(role: .destructive, action: .confirmDelete) { 61 | > TextState("Delete") 62 | > } 63 | > } 64 | > ButtonState(role: .cancel) { 65 | > TextState("Nevermind") 66 | > } 67 | > } 68 | > ``` 69 | 70 | Next you can provide an endpoint that will be called when the alert is interacted with: 71 | 72 | ```swift 73 | func alertButtonTapped(_ action: AlertAction?) { 74 | switch action { 75 | case .confirmDelete: 76 | // NB: Perform deletion logic here 77 | case nil: 78 | // NB: Perform cancel button logic here 79 | } 80 | } 81 | ``` 82 | 83 | Finally, you can use a new, overloaded `.alert` view modifier for showing the alert when this state 84 | becomes non-`nil`: 85 | 86 | ```swift 87 | struct ContentView: View { 88 | @ObservedObject var model: FeatureModel 89 | 90 | var body: some View { 91 | List { 92 | // ... 93 | } 94 | .alert($model.alert) { action in 95 | model.alertButtonTapped(action) 96 | } 97 | } 98 | } 99 | ``` 100 | 101 | By having all of the alert's state in your feature's model, you instantly unlock the ability to test 102 | it: 103 | 104 | ```swift 105 | func testDelete() { 106 | let model = FeatureModel(/* ... */) 107 | 108 | model.deleteButtonTapped() 109 | XCTAssertEqual(model.alert?.title, TextState("Are you sure?")) 110 | 111 | model.alertButtonTapped(.confirmDelete) 112 | // NB: Assert that deletion actually occurred. 113 | } 114 | ``` 115 | 116 | This works because all of the types for describing an alert are `Equatable`, including `AlertState`, 117 | `TextState`, and even the buttons. 118 | 119 | Sometimes it is not optimal to model the alert as an optional. In particular, if a feature can 120 | navigate to multiple, mutually exclusive screens, then a "case-pathable" enum is more appropriate. 121 | 122 | In such a case: 123 | 124 | ```swift 125 | @Observable 126 | class FeatureModel { 127 | var destination: Destination? 128 | 129 | @CasePathable 130 | enum Destination { 131 | case alert(AlertState) 132 | // NB: Other destinations 133 | } 134 | 135 | enum AlertAction { 136 | case confirmDelete 137 | } 138 | 139 | // ... 140 | } 141 | ``` 142 | 143 | With this kind of set up you can use an alternative `alert` view modifier that takes an additional 144 | argument for specifying which case of the enum drives the presentation of the alert: 145 | 146 | ```swift 147 | .alert($model.destination.alert) { action in 148 | model.alertButtonTapped(action) 149 | } 150 | ``` 151 | 152 | Note that the `case` argument is specified via a concept known as "case paths", which are like 153 | key paths except tuned specifically for enums and cases rather than structs and properties. See 154 | for more information. 155 | 156 | ### Confirmation dialogs 157 | 158 | The APIs for driving confirmation dialogs from optional and enum state look nearly identical to that 159 | of alerts. 160 | 161 | For example, the model for a delete confirmation could look like this: 162 | 163 | ```swift 164 | @Observable 165 | class FeatureModel { 166 | var dialog: ConfirmationDialogState? 167 | enum DialogAction { 168 | case confirmDelete 169 | } 170 | 171 | func deleteButtonTapped() { 172 | dialog = ConfirmationDialogState(titleVisibility: .visible) { 173 | TextState("Are you sure?") 174 | } actions: { 175 | ButtonState(role: .destructive, action: .confirmDelete) { 176 | TextState("Delete") 177 | } 178 | ButtonState(role: .cancel) { 179 | TextState("Nevermind") 180 | } 181 | } message: { 182 | TextState("Deleting this item cannot be undone.") 183 | } 184 | } 185 | 186 | func dialogButtonTapped(_ action: DialogAction?) { 187 | switch action { 188 | case .confirmDelete: 189 | // NB: Perform deletion logic here 190 | case nil: 191 | // NB: Perform cancel button logic here 192 | } 193 | } 194 | } 195 | ``` 196 | 197 | And then the view would look like this: 198 | 199 | ```swift 200 | struct ContentView: View { 201 | @ObservedObject var model: FeatureModel 202 | 203 | var body: some View { 204 | List { 205 | // ... 206 | } 207 | .confirmationDialog($model.dialog) { action in 208 | dialogButtonTapped(action) 209 | } 210 | } 211 | } 212 | ``` 213 | 214 | ## Topics 215 | 216 | ### Alert state and dialog state 217 | 218 | - ``SwiftUI/View/alert(_:action:)-sgyk`` 219 | - ``SwiftUI/View/alert(_:action:)-1gtsa`` 220 | - ``SwiftUI/View/confirmationDialog(_:action:)-9alh7`` 221 | - ``SwiftUI/View/confirmationDialog(_:action:)-7mxx7`` 222 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigation/Documentation.docc/Articles/Bindings.md: -------------------------------------------------------------------------------- 1 | # Bindings 2 | 3 | Learn how to manage certain view state, such as `@FocusState` directly in your observable classes. 4 | 5 | ## Overview 6 | 7 | SwiftUI comes with many property wrappers that can be used in views to drive view state, such as 8 | 9 | `@FocusState`. Unfortunately, these property wrappers _must_ be used in views. It's not possible to 10 | extract this logic to an `@Observable` class and integrate it with the rest of the model's business 11 | logic, and be in a better position to test this state. 12 | 13 | We can work around these limitations by introducing a published field to your observable object and 14 | synchronizing it to view state with the `bind` view modifier that ships with this library. 15 | 16 | For example, suppose you have a sign in flow where if the API request to sign in fails, you want 17 | to refocus the email field. The model can be implemented like so: 18 | 19 | ```swift 20 | @Observable 21 | class SignInModel { 22 | var email: String 23 | var password: String 24 | var focus: Field? 25 | enum Field { case email, password } 26 | 27 | func signInButtonTapped() async throws { 28 | do { 29 | try await self.apiClient.signIn(self.email, self.password) 30 | } catch { 31 | self.focus = .email 32 | } 33 | } 34 | } 35 | ``` 36 | 37 | Notice that we store the focus as a regular `var` property in the model rather than `@FocusState`. 38 | This is because `@FocusState` only works when installed directly in a view. It cannot be used in 39 | an observable class. 40 | 41 | You can implement the view as you would normally, except you must also use `@FocusState` for the 42 | focus _and_ use the `bind` helper to make sure that changes to the model's focus are replayed to 43 | the view, and vice versa. 44 | 45 | ```swift 46 | struct SignInView: View { 47 | @FocusState var focus: SignInModel.Field? 48 | @ObservedObject var model: SignInModel 49 | 50 | var body: some View { 51 | Form { 52 | TextField("Email", text: self.$model.email) 53 | .focused(self.$focus, equals: .email) 54 | TextField("Password", text: self.$model.password) 55 | .focused(self.$focus, equals: .password) 56 | Button("Sign in") { 57 | Task { 58 | await self.model.signInButtonTapped() 59 | } 60 | } 61 | } 62 | // ⬇️ Replays changes of `model.focus` to `focus` and vice-versa. 63 | .bind(self.$model.focus, to: self.$focus) 64 | } 65 | } 66 | ``` 67 | 68 | ## Topics 69 | 70 | ### Dynamic case lookup 71 | 72 | - ``SwiftUI/Binding/subscript(dynamicMember:)-9abgy`` 73 | - ``SwiftUI/Binding/subscript(dynamicMember:)-8vc80`` 74 | 75 | ### Unwrapping bindings 76 | 77 | - ``SwiftUI/Binding/init(unwrapping:)`` 78 | 79 | ### Binding transformations 80 | 81 | - ``SwiftUI/Binding/removeDuplicates()`` 82 | - ``SwiftUI/Binding/removeDuplicates(by:)`` 83 | 84 | ### Supporting views 85 | 86 | - ``WithState`` 87 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigation/Documentation.docc/Articles/Navigation.md: -------------------------------------------------------------------------------- 1 | # Navigation links and destinations 2 | 3 | Learn how to drive navigation in `NavigationView` and `NavigationStack` in a concise and testable 4 | manner. 5 | 6 | ## Overview 7 | 8 | The library comes with new tools for driving drill-down navigation with optional and enum values. 9 | This includes new initializers on `NavigationLink` and new overloads of the `navigationDestination` 10 | view modifier. 11 | 12 | Suppose your view or model holds a piece of optional state that represents whether or not a 13 | drill-down should occur: 14 | 15 | ```swift 16 | struct ContentView: View { 17 | @State var destination: Int? 18 | 19 | // ... 20 | } 21 | ``` 22 | 23 | Further suppose that the screen being navigated to wants a binding to the integer when it is 24 | non-`nil`. You can construct a `NavigationLink` that will activate when that state becomes 25 | non-`nil`, and will deactivate when the state becomes `nil`: 26 | 27 | ```swift 28 | NavigationLink(item: $destination) { isActive in 29 | destination = isActive ? 42 : nil 30 | } destination: { $number in 31 | CounterView(number: $number) 32 | } label: { 33 | Text("Go to counter") 34 | } 35 | ``` 36 | 37 | The first trailing closure is the "action" of the navigation link. It is invoked with `true` when 38 | the user taps on the link, and it is invoked with `false` when the user taps the back button or 39 | swipes on the left edge of the screen. It is your job to hydrate the state in the action closure. 40 | 41 | The second trailing closure, labeled `destination`, takes an argument that is the binding of the 42 | unwrapped state. This binding can be handed to the child view, and any changes made by the parent 43 | will be reflected in the child, and vice-versa. 44 | 45 | For iOS 16+ you can use the `navigationDestination` overload: 46 | 47 | ```swift 48 | Button { 49 | destination = 42 50 | } label: { 51 | Text("Go to counter") 52 | } 53 | .navigationDestination(item: $model.destination) { $item in 54 | CounterView(number: $number) 55 | } 56 | ``` 57 | 58 | Sometimes it is not optimal to model navigation destinations as optionals. In particular, if a 59 | feature can navigate to multiple, mutually exclusive screens, then an enum is more appropriate. 60 | 61 | Suppose that in addition to be able to drill down to a counter view that one can also open a 62 | sheet with some text. We can model those destinations as an enum: 63 | 64 | ```swift 65 | @CasePathable 66 | enum Destination { 67 | case counter(Int) 68 | case text(String) 69 | } 70 | ``` 71 | 72 | > Note: We have applied the `@CasePathable` macro from 73 | > [CasePaths](https://github.com/pointfreeco.swift-case-paths), which allows the navigation binding 74 | > to use "dynamic case lookup" to a particular enum case. 75 | 76 | And we can hold an optional destination in state to represent whether or not we are navigated to 77 | one of these destinations: 78 | 79 | ```swift 80 | @State var destination: Destination? 81 | ``` 82 | 83 | With this set up you can make use of the 84 | ``SwiftUI/NavigationLink/init(item:onNavigate:destination:label:)`` initializer on 85 | `NavigationLink` in order to specify a binding to the optional destination, and further specify 86 | which case of the enum you want driving navigation: 87 | 88 | ```swift 89 | NavigationLink(item: $destination.counter) { isActive in 90 | destination = isActive ? .counter(42) : nil 91 | } destination: { $number in 92 | CounterView(number: $number) 93 | } label: { 94 | Text("Go to counter") 95 | } 96 | ``` 97 | 98 | And similarly for ``SwiftUI/View/navigationDestination(item:destination:)``: 99 | 100 | ```swift 101 | Button { 102 | destination = .counter(42) 103 | } label: { 104 | Text("Go to counter") 105 | } 106 | .navigationDestination(item: $model.destination.counter) { $number in 107 | CounterView(number: $number) 108 | } 109 | ``` 110 | 111 | ## Topics 112 | 113 | ### Navigation views and modifiers 114 | 115 | - ``SwiftUI/View/navigationDestination(item:destination:)`` 116 | - ``SwiftUI/NavigationLink/init(item:onNavigate:destination:label:)`` 117 | 118 | ### Supporting types 119 | 120 | - ``HashableObject`` 121 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigation/Documentation.docc/Articles/SheetsPopoversCovers.md: -------------------------------------------------------------------------------- 1 | # Sheets, popovers, and covers 2 | 3 | Learn how to present sheets, popovers and covers in a concise and testable manner. 4 | 5 | ## Overview 6 | 7 | The library comes with new tools for driving sheets, popovers and covers from optional and enum 8 | state. 9 | 10 | * [Sheets](#Sheets) 11 | * [Popovers](#Popovers) 12 | * [Covers](#Covers) 13 | 14 | ### Sheets 15 | 16 | Suppose your view or model holds a piece of optional state that represents whether or not a modal 17 | sheet is presented: 18 | 19 | ```swift 20 | struct ContentView: View { 21 | @State var destination: Int? 22 | 23 | // ... 24 | } 25 | ``` 26 | 27 | Further suppose that the screen being presented wants a binding to the integer when it is non-`nil`. 28 | You can use the `sheet(item:)` overload that comes with the library: 29 | 30 | ```swift 31 | var body: some View { 32 | List { 33 | // ... 34 | } 35 | .sheet(item: $destination) { $number in 36 | CounterView(number: $number) 37 | } 38 | } 39 | ``` 40 | 41 | Notice that the trailing closure is handed a binding to the unwrapped state. This binding can be 42 | handed to the child view, and any changes made by the parent will be reflected in the child, and 43 | vice-versa. 44 | 45 | However, this does not compile just yet because `sheet(item:)` requires that the item being 46 | presented conform to `Identifable`, and `Int` does not conform. This library comes with an overload 47 | of `sheet`, called ``SwiftUI/View/sheet(item:id:onDismiss:content:)-1hi9l``, that allows you to 48 | specify the ID of the item being presented: 49 | 50 | ```swift 51 | var body: some View { 52 | List { 53 | // ... 54 | } 55 | .sheet(item: $destination, id: \.self) { $number in 56 | CounterView(number: $number) 57 | } 58 | } 59 | ``` 60 | 61 | Sometimes it is not optimal to model presentation destinations as optionals. In particular, if a 62 | feature can navigate to multiple, mutually exclusive screens, then an enum is more appropriate. 63 | 64 | There is an additional overload of the `sheet` for this situation. If you model your destinations 65 | as a "case-pathable" enum: 66 | 67 | ```swift 68 | @State var destination: Destination? 69 | 70 | @CasePathable 71 | enum Destination { 72 | case counter(Int) 73 | // More destinations 74 | } 75 | ``` 76 | 77 | Then you can show a sheet from the `counter` case with the following: 78 | 79 | ```swift 80 | var body: some View { 81 | List { 82 | // ... 83 | } 84 | .sheet(item: $destination.counter, id: \.self) { $number in 85 | CounterView(number: $number) 86 | } 87 | } 88 | ``` 89 | 90 | ### Popovers 91 | 92 | Popovers work similarly to sheets. If the popover's state is represented as an optional you can do 93 | the following: 94 | 95 | ```swift 96 | struct ContentView: View { 97 | @State var destination: Int? 98 | 99 | var body: some View { 100 | List { 101 | // ... 102 | } 103 | .popover(item: $destination, id: \.self) { $number in 104 | CounterView(number: $number) 105 | } 106 | } 107 | } 108 | ``` 109 | 110 | And if the popover state is represented as a "case-pathable" enum, then you can do the following: 111 | 112 | ```swift 113 | struct ContentView: View { 114 | @State var destination: Destination? 115 | 116 | @CasePathable 117 | enum Destination { 118 | case counter(Int) 119 | // More destinations 120 | } 121 | 122 | var body: some View { 123 | List { 124 | // ... 125 | } 126 | .popover(item: $destination.counter, id: \.self) { $number in 127 | CounterView(number: $number) 128 | } 129 | } 130 | } 131 | ``` 132 | 133 | ### Covers 134 | 135 | Full screen covers work similarly to sheets and popovers. If the cover's state is represented as an 136 | optional you can do the following: 137 | 138 | ```swift 139 | struct ContentView: View { 140 | @State var destination: Int? 141 | 142 | var body: some View { 143 | List { 144 | // ... 145 | } 146 | .fullscreenCover(item: $destination, id: \.self) { $number in 147 | CounterView(number: $number) 148 | } 149 | } 150 | } 151 | ``` 152 | 153 | And if the covers' state is represented as a "case-pathable" enum, then you can do the following: 154 | 155 | ```swift 156 | struct ContentView: View { 157 | @State var destination: Destination? 158 | 159 | @CasePathable 160 | enum Destination { 161 | case counter(Int) 162 | // More destinations 163 | } 164 | 165 | var body: some View { 166 | List { 167 | // ... 168 | } 169 | .fullscreenCover(item: $destination.counter, id: \.self) { $number in 170 | CounterView(number: $number) 171 | } 172 | } 173 | } 174 | ``` 175 | 176 | ## Topics 177 | 178 | ### Presentation modifiers 179 | 180 | - ``SwiftUI/View/fullScreenCover(item:id:onDismiss:content:)-9csbq`` 181 | - ``SwiftUI/View/fullScreenCover(item:onDismiss:content:)`` 182 | - ``SwiftUI/View/fullScreenCover(item:id:onDismiss:content:)-14to1`` 183 | - ``SwiftUI/View/popover(item:id:attachmentAnchor:arrowEdge:content:)-3un96`` 184 | - ``SwiftUI/View/popover(item:attachmentAnchor:arrowEdge:content:)`` 185 | - ``SwiftUI/View/popover(item:id:attachmentAnchor:arrowEdge:content:)-57svy`` 186 | - ``SwiftUI/View/sheet(item:id:onDismiss:content:)-1hi9l`` 187 | - ``SwiftUI/View/sheet(item:onDismiss:content:)`` 188 | - ``SwiftUI/View/sheet(item:id:onDismiss:content:)-6tgux`` 189 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigation/Documentation.docc/Extensions/Deprecations.md: -------------------------------------------------------------------------------- 1 | # Deprecations 2 | 3 | Review unsupported SwiftUI Navigation APIs and their replacements. 4 | 5 | ## Overview 6 | 7 | Avoid using deprecated APIs in your app. Select a method to see the replacement that you should use 8 | instead. 9 | 10 | ## Topics 11 | 12 | ### Views 13 | 14 | - ``IfLet`` 15 | - ``IfCaseLet`` 16 | - ``SwiftUI/NavigationLink/init(unwrapping:onNavigate:destination:label:)`` 17 | - ``SwiftUI/NavigationLink/init(unwrapping:case:onNavigate:destination:label:)`` 18 | - ``SwiftUI/NavigationLink/init(unwrapping:destination:onNavigate:label:)`` 19 | - ``SwiftUI/NavigationLink/init(unwrapping:case:destination:onNavigate:label:)`` 20 | - ``Switch`` 21 | 22 | ### View modifiers 23 | 24 | - ``SwiftUI/View/alert(title:unwrapping:actions:message:)`` 25 | - ``SwiftUI/View/alert(title:unwrapping:case:actions:message:)`` 26 | - ``SwiftUI/View/alert(title:unwrapping:actions:message:)`` 27 | - ``SwiftUI/View/alert(unwrapping:action:)-7da26`` 28 | - ``SwiftUI/View/alert(unwrapping:action:)-6y2fk`` 29 | - ``SwiftUI/View/alert(unwrapping:action:)-867h5`` 30 | - ``SwiftUI/View/alert(unwrapping:case:action:)-14fwn`` 31 | - ``SwiftUI/View/alert(unwrapping:case:action:)-3yw6u`` 32 | - ``SwiftUI/View/alert(unwrapping:case:action:)-4w3oq`` 33 | - ``SwiftUI/View/confirmationDialog(title:titleVisibility:unwrapping:actions:message:)`` 34 | - ``SwiftUI/View/confirmationDialog(title:titleVisibility:unwrapping:case:actions:message:)`` 35 | - ``SwiftUI/View/confirmationDialog(unwrapping:action:)-9465l`` 36 | - ``SwiftUI/View/confirmationDialog(unwrapping:action:)-4f8ze`` 37 | - ``SwiftUI/View/confirmationDialog(unwrapping:action:)-29s77`` 38 | - ``SwiftUI/View/confirmationDialog(unwrapping:case:action:)-uncl`` 39 | - ``SwiftUI/View/confirmationDialog(unwrapping:case:action:)-2ddxv`` 40 | - ``SwiftUI/View/confirmationDialog(unwrapping:case:action:)-7oi9`` 41 | - ``SwiftUI/View/fullScreenCover(unwrapping:onDismiss:content:)`` 42 | - ``SwiftUI/View/fullScreenCover(unwrapping:case:onDismiss:content:)`` 43 | - ``SwiftUI/View/navigationDestination(unwrapping:destination:)`` 44 | - ``SwiftUI/View/navigationDestination(unwrapping:case:destination:)`` 45 | - ``SwiftUI/View/popover(unwrapping:attachmentAnchor:arrowEdge:content:)`` 46 | - ``SwiftUI/View/popover(unwrapping:case:attachmentAnchor:arrowEdge:content:)`` 47 | - ``SwiftUI/View/sheet(unwrapping:onDismiss:content:)`` 48 | - ``SwiftUI/View/sheet(unwrapping:case:onDismiss:content:)`` 49 | 50 | ### Bindings 51 | 52 | - ``SwiftUI/Binding/init(unwrapping:case:)`` 53 | - ``SwiftUI/Binding/case(_:)`` 54 | - ``SwiftUI/Binding/isPresent(_:)`` 55 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigation/Documentation.docc/Extensions/Switch.md: -------------------------------------------------------------------------------- 1 | # ``Switch`` 2 | 3 | ## Topics 4 | 5 | ### Supporting views 6 | 7 | - ``CaseLet`` 8 | - ``Default`` 9 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigation/Documentation.docc/SwiftUINavigation.md: -------------------------------------------------------------------------------- 1 | # ``SwiftUINavigation`` 2 | 3 | Tools for making SwiftUI navigation simpler, more ergonomic and more precise. 4 | 5 | ## Additional Resources 6 | 7 | - [GitHub Repo](https://github.com/pointfreeco/swiftui-navigation) 8 | - [Discussions](https://github.com/pointfreeco/swiftui-navigation/discussions) 9 | - [Point-Free Videos](https://www.pointfree.co/collections/swiftui/navigation) 10 | 11 | ## Overview 12 | 13 | SwiftUI comes with many forms of navigation (tabs, alerts, dialogs, modal sheets, popovers, 14 | navigation links, and more), and each comes with a few ways to construct them. These ways roughly 15 | fall in two categories: 16 | 17 | * "Fire-and-forget": These are initializers and methods that do not take binding arguments, which 18 | means SwiftUI fully manages navigation state internally. This makes it is easy to get something 19 | on the screen quickly, but you also have no programmatic control over the navigation. Examples 20 | of this are the initializers on [`TabView`][TabView.init] and 21 | [`NavigationLink`][NavigationLink.init] that do not take a binding. 22 | 23 | * "State-driven": Most other initializers and methods do take a binding, which means you can 24 | mutate state in your domain to tell SwiftUI when it should activate or deactivate navigation. 25 | Using these APIs is more complicated than the "fire-and-forget" style, but doing so instantly 26 | gives you the ability to deep-link into any state of your application by just constructing a 27 | piece of data, handing it to a SwiftUI view, and letting SwiftUI handle the rest. 28 | 29 | Navigation that is "state-driven" is the more powerful form of navigation, albeit slightly more 30 | complicated. To wield it correctly you must be able to model your domain as concisely as possible, 31 | and this usually means using enums. 32 | 33 | Unfortunately, SwiftUI does not ship with all of the tools necessary to model our domains with 34 | enums and make use of navigation APIs. This library bridges that gap by providing APIs that allow 35 | you to model your navigation destinations as an enum, and then drive navigation by a binding 36 | to that enum. 37 | 38 | ## Topics 39 | 40 | ### Essentials 41 | 42 | - 43 | 44 | ### Tools 45 | 46 | - 47 | - 48 | - 49 | - 50 | 51 | ### Deprecated interfaces 52 | 53 | - 54 | 55 | ## See Also 56 | 57 | The collection of videos from [Point-Free](https://www.pointfree.co) that dive deep into the 58 | development of the library. 59 | 60 | * [Point-Free Videos](https://www.pointfree.co/collections/swiftui/navigation) 61 | 62 | [NavigationLink.init]: https://developer.apple.com/documentation/swiftui/navigationlink/init(destination:label:)-27n7s 63 | [TabView.init]: https://developer.apple.com/documentation/swiftui/tabview/init(content:) 64 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigation/FullScreenCover.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import SwiftUI 3 | 4 | @available(iOS 14, tvOS 14, watchOS 7, *) 5 | @available(macOS, unavailable) 6 | extension View { 7 | /// Presents a full-screen cover using a binding as a data source for the sheet's content. 8 | /// 9 | /// SwiftUI comes with a `fullScreenCover(item:)` view modifier that is powered by a binding to 10 | /// some identifiable state. When this state becomes non-`nil`, it passes an unwrapped value to 11 | /// the content closure. This value, however, is completely static, which prevents the sheet 12 | /// from modifying it. 13 | /// 14 | /// This overload differs in that it passes a _binding_ to the unwrapped value, instead. This 15 | /// gives the sheet the ability to write changes back to its source of truth. 16 | /// 17 | /// Also unlike `fullScreenCover(item:)`, the binding's value does _not_ need to be 18 | /// identifiable, and can instead specify a key path to the provided data's identifier. 19 | /// 20 | /// ```swift 21 | /// struct TimelineView: View { 22 | /// @State var draft: Post? 23 | /// 24 | /// var body: Body { 25 | /// Button("Compose") { 26 | /// self.draft = Post() 27 | /// } 28 | /// .fullScreenCover(item: $draft, id: \.id) { $draft in 29 | /// ComposeView(post: $draft, onSubmit: { ... }) 30 | /// } 31 | /// } 32 | /// } 33 | /// 34 | /// struct ComposeView: View { 35 | /// @Binding var post: Post 36 | /// var body: some View { ... } 37 | /// } 38 | /// ``` 39 | /// 40 | /// - Parameters: 41 | /// - item: A binding to an optional source of truth for the sheet. When `item` is non-`nil`, 42 | /// the system passes the item's content to the modifier's closure. You display this content 43 | /// in a sheet that you create that the system displays to the user. If `item`'s identity 44 | /// changes, the system dismisses the sheet and replaces it with a new one using the same 45 | /// process. 46 | /// - id: The key path to the provided item's identifier. 47 | /// - onDismiss: The closure to execute when dismissing the sheet. 48 | /// - content: A closure returning the content of the sheet. 49 | @_disfavoredOverload 50 | public func fullScreenCover( 51 | item: Binding, 52 | id: KeyPath, 53 | onDismiss: (() -> Void)? = nil, 54 | @ViewBuilder content: @escaping (Binding) -> Content 55 | ) -> some View { 56 | fullScreenCover(item: item[id: id], onDismiss: onDismiss) { 57 | content(Binding(unwrapping: item, default: $0.initialValue)) 58 | } 59 | } 60 | 61 | /// Presents a full-screen cover using a binding as a data source for the sheet's content. 62 | /// 63 | /// A version of ``SwiftUI/View/fullScreenCover(item:id:onDismiss:content:)-14to1`` that takes 64 | /// an identifiable item. 65 | /// 66 | /// - Parameters: 67 | /// - item: A binding to an optional source of truth for the sheet. When `item` is non-`nil`, 68 | /// the system passes the item's content to the modifier's closure. You display this content 69 | /// in a sheet that you create that the system displays to the user. If `item`'s identity 70 | /// changes, the system dismisses the sheet and replaces it with a new one using the same 71 | /// process. 72 | /// - onDismiss: The closure to execute when dismissing the sheet. 73 | /// - content: A closure returning the content of the sheet. 74 | @_disfavoredOverload 75 | public func fullScreenCover( 76 | item: Binding, 77 | onDismiss: (() -> Void)? = nil, 78 | @ViewBuilder content: @escaping (Binding) -> Content 79 | ) -> some View { 80 | fullScreenCover(item: item, id: \.id, onDismiss: onDismiss, content: content) 81 | } 82 | 83 | /// Presents a full-screen cover using a binding as a data source for the sheet's content. 84 | /// 85 | /// A version of ``SwiftUI/View/fullScreenCover(item:id:onDismiss:content:)-14to1`` that is 86 | /// passed an item and not a binding to an item. 87 | /// 88 | /// - Parameters: 89 | /// - item: A binding to an optional source of truth for the sheet. When `item` is non-`nil`, 90 | /// the system passes the item's content to the modifier's closure. You display this content 91 | /// in a sheet that you create that the system displays to the user. If `item`'s identity 92 | /// changes, the system dismisses the sheet and replaces it with a new one using the same 93 | /// process. 94 | /// - id: The key path to the provided item's identifier. 95 | /// - onDismiss: The closure to execute when dismissing the sheet. 96 | /// - content: A closure returning the content of the sheet. 97 | public func fullScreenCover( 98 | item: Binding, 99 | id: KeyPath, 100 | onDismiss: (() -> Void)? = nil, 101 | @ViewBuilder content: @escaping (Item) -> Content 102 | ) -> some View { 103 | fullScreenCover(item: item, id: id, onDismiss: onDismiss) { 104 | content($0.wrappedValue) 105 | } 106 | } 107 | } 108 | #endif // canImport(SwiftUI) 109 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigation/HashableObject.swift: -------------------------------------------------------------------------------- 1 | /// A protocol that adds a default implementation of `Hashable` to an object based off its object 2 | /// identity. 3 | /// 4 | /// SwiftUI's navigation tools requires `Identifiable` and `Hashable` conformances throughout its 5 | /// APIs, for example `sheet(item:)` requires `Identifiable`, while `navigationDestination(item:)` 6 | /// and `NavigationLink.init(value:)` require `Hashable`. While `Identifiable` conformances come for 7 | /// free on objects based on object identity, there is no such mechanism for `Hashable`. This 8 | /// protocol addresses this shortcoming by providing default implementations of `==` and 9 | /// `hash(into:)`. 10 | public protocol HashableObject: AnyObject, Hashable {} 11 | 12 | extension HashableObject { 13 | public static func == (lhs: Self, rhs: Self) -> Bool { 14 | lhs === rhs 15 | } 16 | 17 | public func hash(into hasher: inout Hasher) { 18 | hasher.combine(ObjectIdentifier(self)) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigation/Internal/Binding+Internal.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import SwiftUI 3 | 4 | extension Binding { 5 | func didSet(_ perform: @escaping @Sendable (Value) -> Void) -> Self { 6 | .init( 7 | get: { self.wrappedValue }, 8 | set: { newValue, transaction in 9 | self.transaction(transaction).wrappedValue = newValue 10 | perform(newValue) 11 | } 12 | ) 13 | } 14 | } 15 | #endif // canImport(SwiftUI) 16 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigation/Internal/Exports.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | @_exported import CasePaths 3 | @_exported import SwiftUINavigationCore 4 | #endif // canImport(SwiftUI) 5 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigation/Internal/Identified.swift: -------------------------------------------------------------------------------- 1 | struct Identified: Identifiable { 2 | let id: ID 3 | let initialValue: Value 4 | } 5 | 6 | extension Optional { 7 | subscript(id keyPath: KeyPath) -> Identified? { 8 | get { self.map { Identified(id: $0[keyPath: keyPath], initialValue: $0) } } 9 | set { if newValue == nil { self = nil } } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigation/Internal/LockIsolated.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class LockIsolated: @unchecked Sendable { 4 | private var _value: Value 5 | private let lock = NSRecursiveLock() 6 | init(_ value: @autoclosure @Sendable () throws -> Value) rethrows { 7 | self._value = try value() 8 | } 9 | func withLock( 10 | _ operation: @Sendable (inout Value) throws -> T 11 | ) rethrows -> T { 12 | lock.lock() 13 | defer { lock.unlock() } 14 | var value = _value 15 | defer { _value = value } 16 | return try operation(&value) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigation/NavigationDestination.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import SwiftUI 3 | 4 | @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) 5 | extension View { 6 | /// Pushes a view onto a `NavigationStack` using a binding as a data source for the 7 | /// destination's content. 8 | /// 9 | /// This is a version of SwiftUI's `navigationDestination(item:)` modifier that passes a 10 | /// _binding_ to the unwrapped item to the destination closure. 11 | /// 12 | /// ```swift 13 | /// struct TimelineView: View { 14 | /// @State var draft: Post? 15 | /// 16 | /// var body: Body { 17 | /// Button("Compose") { 18 | /// self.draft = Post() 19 | /// } 20 | /// .navigationDestination(item: $draft) { $draft in 21 | /// ComposeView(post: $draft, onSubmit: { ... }) 22 | /// } 23 | /// } 24 | /// } 25 | /// 26 | /// struct ComposeView: View { 27 | /// @Binding var post: Post 28 | /// var body: some View { ... } 29 | /// } 30 | /// ``` 31 | /// 32 | /// - Parameters: 33 | /// - item: A binding to an optional source of truth for the destination. When `item` is 34 | /// non-`nil`, a non-optional binding to the value is passed to the `destination` closure. 35 | /// You use this binding to produce content that the system pushes to the user in a 36 | /// navigation stack. Changes made to the destination's binding will be reflected back in 37 | /// the source of truth. Likewise, changes to `item` are instantly reflected in the 38 | /// destination. If `item` becomes `nil`, the destination is popped. 39 | /// - destination: A closure returning the content of the destination. 40 | @_disfavoredOverload 41 | public func navigationDestination( 42 | item: Binding, 43 | @ViewBuilder destination: @escaping (Binding) -> C 44 | ) -> some View { 45 | navigationDestination(item: item) { _ in 46 | Binding(unwrapping: item).map(destination) 47 | } 48 | } 49 | } 50 | #endif // canImport(SwiftUI) 51 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigation/NavigationLink.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import SwiftUI 3 | 4 | extension NavigationLink { 5 | /// Creates a navigation link that presents the destination view when a bound value is 6 | /// non-`nil`. 7 | /// 8 | /// > Note: This interface is deprecated to match the availability of the corresponding SwiftUI 9 | /// > API. If you are targeting iOS 16 or later, use 10 | /// > ``SwiftUI/View/navigationDestination(item:destination:)``, instead. 11 | /// 12 | /// This allows you to drive navigation to a destination from an optional value. When the 13 | /// optional value becomes non-`nil` a binding to an honest value is derived and passed to the 14 | /// destination. Any edits made to the binding in the destination are automatically reflected 15 | /// in the parent. 16 | /// 17 | /// ```swift 18 | /// struct ContentView: View { 19 | /// @State var postToEdit: Post? 20 | /// @State var posts: [Post] 21 | /// 22 | /// var body: some View { 23 | /// ForEach(self.posts) { post in 24 | /// NavigationLink(item: $postToEdit) { isActive in 25 | /// postToEdit = isActive ? post : nil 26 | /// } destination: { $draft in 27 | /// EditPostView(post: $draft) 28 | /// } label: { 29 | /// Text(post.title) 30 | /// } 31 | /// } 32 | /// } 33 | /// } 34 | /// 35 | /// struct EditPostView: View { 36 | /// @Binding var post: Post 37 | /// var body: some View { ... } 38 | /// } 39 | /// ``` 40 | /// 41 | /// - Parameters: 42 | /// - item: A binding to an optional source of truth for the destination. When `item` is 43 | /// non-`nil`, a non-optional binding to the value is passed to the `destination` closure. 44 | /// The destination can use this binding to produce its content and write changes back to 45 | /// the source of truth. Upstream changes to `item` will also be instantly reflected in the 46 | /// destination. If `item` becomes `nil`, the destination is dismissed. 47 | /// - onNavigate: A closure that executes when the link becomes active or inactive with a 48 | /// boolean that describes if the link was activated or not. Use this closure to populate 49 | /// the source of truth when it is passed a value of `true`. When passed `false`, the system 50 | /// will automatically write `nil` to `item`. 51 | /// - destination: A view for the navigation link to present. 52 | /// - label: A view builder to produce a label describing the `destination` to present. 53 | @available(iOS, introduced: 13, deprecated: 16) 54 | @available(macOS, introduced: 10.15, deprecated: 13) 55 | @available(tvOS, introduced: 13, deprecated: 16) 56 | @available(watchOS, introduced: 6, deprecated: 9) 57 | public init( 58 | item: Binding, 59 | onNavigate: @escaping @Sendable (_ isActive: Bool) -> Void, 60 | @ViewBuilder destination: @escaping (Binding) -> WrappedDestination, 61 | @ViewBuilder label: () -> Label 62 | ) where Destination == WrappedDestination? { 63 | self.init( 64 | destination: Binding(unwrapping: item).map(destination), 65 | isActive: Binding(item).didSet(onNavigate), 66 | label: label 67 | ) 68 | } 69 | } 70 | #endif // canImport(SwiftUI) 71 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigation/Popover.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import SwiftUI 3 | 4 | // NB: Moving `@available(tvOS, unavailable)` to the extension causes tvOS builds to fail 5 | extension View { 6 | /// Presents a popover using a binding as a data source for the popover's content. 7 | /// 8 | /// SwiftUI comes with a `popover(item:)` view modifier that is powered by a binding to some 9 | /// identifiable state. When this state becomes non-`nil`, it passes an unwrapped value to the 10 | /// content closure. This value, however, is completely static, which prevents the popover from 11 | /// modifying it. 12 | /// 13 | /// This overload differs in that it passes a _binding_ to the unwrapped value, instead. This 14 | /// gives the popover the ability to write changes back to its source of truth. 15 | /// 16 | /// Also unlike `popover(item:)`, the binding's value does _not_ need to be identifiable, and 17 | /// can instead specify a key path to the provided data's identifier. 18 | /// 19 | /// ```swift 20 | /// struct TimelineView: View { 21 | /// @State var draft: Post? 22 | /// 23 | /// var body: Body { 24 | /// Button("Compose") { 25 | /// self.draft = Post() 26 | /// } 27 | /// .popover(item: $draft) { $draft in 28 | /// ComposeView(post: $draft, onSubmit: { ... }) 29 | /// } 30 | /// } 31 | /// } 32 | /// 33 | /// struct ComposeView: View { 34 | /// @Binding var post: Post 35 | /// var body: some View { ... } 36 | /// } 37 | /// ``` 38 | /// 39 | /// - Parameters: 40 | /// - item: A binding to an optional source of truth for the popover. When `item` is 41 | /// non-`nil`, a non-optional binding to the value is passed to the `content` closure. You 42 | /// use this binding to produce content that the system presents to the user in a popover. 43 | /// Changes made to the popover's binding will be reflected back in the source of truth. 44 | /// Likewise, changes to `item` are instantly reflected in the popover. If `item` becomes 45 | /// `nil`, the popover is dismissed. 46 | /// - id: The key path to the provided item's identifier. 47 | /// - attachmentAnchor: The positioning anchor that defines the attachment point of the 48 | /// popover. 49 | /// - arrowEdge: The edge of the `attachmentAnchor` that defines the location of the popover's 50 | /// arrow. 51 | /// - content: A closure returning the content of the popover. 52 | @_disfavoredOverload 53 | @available(tvOS, unavailable) 54 | @available(watchOS, unavailable) 55 | public func popover( 56 | item: Binding, 57 | id: KeyPath, 58 | attachmentAnchor: PopoverAttachmentAnchor = .rect(.bounds), 59 | arrowEdge: Edge = .top, 60 | @ViewBuilder content: @escaping (Binding) -> Content 61 | ) -> some View { 62 | popover( 63 | item: item[id: id], 64 | attachmentAnchor: attachmentAnchor, 65 | arrowEdge: arrowEdge 66 | ) { 67 | content(Binding(unwrapping: item, default: $0.initialValue)) 68 | } 69 | } 70 | 71 | /// Presents a full-screen cover using a binding as a data source for the sheet's content. 72 | /// 73 | /// A version of ``SwiftUI/View/fullScreenCover(item:id:onDismiss:content:)-14to1`` that takes 74 | /// an identifiable item. 75 | /// 76 | /// - Parameters: 77 | /// - item: A binding to an optional source of truth for the popover. When `item` is 78 | /// non-`nil`, a non-optional binding to the value is passed to the `content` closure. You 79 | /// use this binding to produce content that the system presents to the user in a popover. 80 | /// Changes made to the popover's binding will be reflected back in the source of truth. 81 | /// Likewise, changes to `item` are instantly reflected in the popover. If `item` becomes 82 | /// `nil`, the popover is dismissed. 83 | /// - attachmentAnchor: The positioning anchor that defines the attachment point of the 84 | /// popover. 85 | /// - arrowEdge: The edge of the `attachmentAnchor` that defines the location of the popover's 86 | /// arrow. 87 | /// - content: A closure returning the content of the popover. 88 | @_disfavoredOverload 89 | @available(tvOS, unavailable) 90 | @available(watchOS, unavailable) 91 | public func popover( 92 | item: Binding, 93 | attachmentAnchor: PopoverAttachmentAnchor = .rect(.bounds), 94 | arrowEdge: Edge = .top, 95 | @ViewBuilder content: @escaping (Binding) -> Content 96 | ) -> some View { 97 | popover( 98 | item: item, 99 | id: \.id, 100 | attachmentAnchor: attachmentAnchor, 101 | arrowEdge: arrowEdge, 102 | content: content 103 | ) 104 | } 105 | 106 | /// Presents a popover using a binding as a data source for the sheet's content based on the 107 | /// identity of the underlying item. 108 | /// 109 | /// A version of ``SwiftUI/View/popover(item:id:attachmentAnchor:arrowEdge:content:)-3un96`` 110 | /// that is passed an item and not a binding to an item. 111 | /// 112 | /// - Parameters: 113 | /// - item: A binding to an optional source of truth for the popover. When `item` is 114 | /// non-`nil`, the system passes the item's content to the modifier's closure. You display 115 | /// this content in a popover that you create that the system displays to the user. If `item` 116 | /// changes, the system dismisses the popover and replaces it with a new one using the same 117 | /// process. 118 | /// - id: The key path to the provided item's identifier. 119 | /// - attachmentAnchor: The positioning anchor that defines the attachment point of the 120 | /// popover. 121 | /// - arrowEdge: The edge of the `attachmentAnchor` that defines the location of the popover's 122 | /// arrow. 123 | /// - content: A closure returning the content of the popover. 124 | @available(tvOS, unavailable) 125 | @available(watchOS, unavailable) 126 | public func popover( 127 | item: Binding, 128 | id: KeyPath, 129 | attachmentAnchor: PopoverAttachmentAnchor = .rect(.bounds), 130 | arrowEdge: Edge = .top, 131 | @ViewBuilder content: @escaping (Item) -> Content 132 | ) -> some View { 133 | popover(item: item, id: id, attachmentAnchor: attachmentAnchor, arrowEdge: arrowEdge) { 134 | content($0.wrappedValue) 135 | } 136 | } 137 | } 138 | #endif // canImport(SwiftUI) 139 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigation/Sheet.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import SwiftUI 3 | 4 | #if canImport(UIKit) 5 | import UIKit 6 | #elseif canImport(AppKit) 7 | import AppKit 8 | #endif 9 | 10 | extension View { 11 | /// Presents a sheet using a binding as a data source for the sheet's content. 12 | /// 13 | /// SwiftUI comes with a `sheet(item:)` view modifier that is powered by a binding to some 14 | /// identifiable state. When this state becomes non-`nil`, it passes an unwrapped value to the 15 | /// content closure. This value, however, is completely static, which prevents the sheet from 16 | /// modifying it. 17 | /// 18 | /// This overload differs in that it passes a _binding_ to the unwrapped value, instead. This 19 | /// gives the sheet the ability to write changes back to its source of truth. 20 | /// 21 | /// Also unlike `sheet(item:)`, the binding's value does _not_ need to be identifiable, and can 22 | /// instead specify a key path to the provided data's identifier. 23 | /// 24 | /// ```swift 25 | /// struct TimelineView: View { 26 | /// @State var draft: Post? 27 | /// 28 | /// var body: Body { 29 | /// Button("Compose") { 30 | /// self.draft = Post() 31 | /// } 32 | /// .sheet(item: $draft, id: \.id) { $draft in 33 | /// ComposeView(post: $draft, onSubmit: { ... }) 34 | /// } 35 | /// } 36 | /// } 37 | /// 38 | /// struct ComposeView: View { 39 | /// @Binding var post: Post 40 | /// var body: some View { ... } 41 | /// } 42 | /// ``` 43 | /// 44 | /// - Parameters: 45 | /// - item: A binding to an optional source of truth for the sheet. When `item` is non-`nil`, 46 | /// the system passes the item's content to the modifier's closure. You display this content 47 | /// in a sheet that you create that the system displays to the user. If `item`'s identity 48 | /// changes, the system dismisses the sheet and replaces it with a new one using the same 49 | /// process. 50 | /// - id: The key path to the provided item's identifier. 51 | /// - onDismiss: The closure to execute when dismissing the sheet. 52 | /// - content: A closure returning the content of the sheet. 53 | @_disfavoredOverload 54 | public func sheet( 55 | item: Binding, 56 | id: KeyPath, 57 | onDismiss: (() -> Void)? = nil, 58 | @ViewBuilder content: @escaping (Binding) -> Content 59 | ) -> some View { 60 | sheet(item: item[id: id], onDismiss: onDismiss) { 61 | content(Binding(unwrapping: item, default: $0.initialValue)) 62 | } 63 | } 64 | 65 | /// Presents a sheet using a binding as a data source for the sheet's content. 66 | /// 67 | /// A version of ``SwiftUI/View/sheet(item:id:onDismiss:content:)-1hi9l`` that takes an 68 | /// identifiable item. 69 | /// 70 | /// - Parameters: 71 | /// - item: A binding to an optional source of truth for the sheet. When `item` is non-`nil`, 72 | /// the system passes the item's content to the modifier's closure. You display this content 73 | /// in a sheet that you create that the system displays to the user. If `item`'s identity 74 | /// changes, the system dismisses the sheet and replaces it with a new one using the same 75 | /// process. 76 | /// - onDismiss: The closure to execute when dismissing the sheet. 77 | /// - content: A closure returning the content of the sheet. 78 | @_disfavoredOverload 79 | public func sheet( 80 | item: Binding, 81 | onDismiss: (() -> Void)? = nil, 82 | @ViewBuilder content: @escaping (Binding) -> Content 83 | ) -> some View { 84 | sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) 85 | } 86 | 87 | /// Presents a sheet using a binding as a data source for the sheet's content. 88 | /// 89 | /// A version of ``SwiftUI/View/sheet(item:id:onDismiss:content:)-1hi9l`` that is passed an item 90 | /// and not a binding to an item. 91 | /// 92 | /// - Parameters: 93 | /// - item: A binding to an optional source of truth for the sheet. When `item` is non-`nil`, 94 | /// the system passes the item's content to the modifier's closure. You display this content 95 | /// in a sheet that you create that the system displays to the user. If `item`'s identity 96 | /// changes, the system dismisses the sheet and replaces it with a new one using the same 97 | /// process. 98 | /// - id: The key path to the provided item's identifier. 99 | /// - onDismiss: The closure to execute when dismissing the sheet. 100 | /// - content: A closure returning the content of the sheet. 101 | public func sheet( 102 | item: Binding, 103 | id: KeyPath, 104 | onDismiss: (() -> Void)? = nil, 105 | @ViewBuilder content: @escaping (Item) -> Content 106 | ) -> some View { 107 | sheet(item: item, id: id, onDismiss: onDismiss) { 108 | content($0.wrappedValue) 109 | } 110 | } 111 | } 112 | #endif // canImport(SwiftUI) 113 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigation/WithState.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import SwiftUI 3 | 4 | /// A container view that provides a binding to another view. 5 | /// 6 | /// This view is most helpful for creating Xcode previews of views that require bindings. 7 | /// 8 | /// For example, if you wanted to create a preview for a text field, you cannot simply introduce 9 | /// some `@State` to the preview since `previews` is static: 10 | /// 11 | /// ```swift 12 | /// struct TextField_Previews: PreviewProvider { 13 | /// @State static var text = "" // ⚠️ @State static does not work. 14 | /// 15 | /// static var previews: some View { 16 | /// TextField("Test", text: self.$text) 17 | /// } 18 | /// } 19 | /// ``` 20 | /// 21 | /// So, instead you can use ``WithState``: 22 | /// 23 | /// ```swift 24 | /// struct TextField_Previews: PreviewProvider { 25 | /// static var previews: some View { 26 | /// WithState(initialValue: "") { $text in 27 | /// TextField("Test", text: $text) 28 | /// } 29 | /// } 30 | /// } 31 | /// ``` 32 | public struct WithState: View { 33 | @State var value: Value 34 | @ViewBuilder let content: (Binding) -> Content 35 | 36 | public init( 37 | initialValue value: Value, 38 | @ViewBuilder content: @escaping (Binding) -> Content 39 | ) { 40 | self._value = State(wrappedValue: value) 41 | self.content = content 42 | } 43 | 44 | public var body: some View { 45 | self.content(self.$value) 46 | } 47 | } 48 | #endif // canImport(SwiftUI) 49 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigationCore/Alert.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import SwiftUI 3 | 4 | // MARK: - Alert with dynamic title 5 | extension View { 6 | /// Presents an alert from a binding to an optional value. 7 | /// 8 | /// SwiftUI's `alert` view modifiers are driven by two disconnected pieces of state: an 9 | /// `isPresented` binding to a boolean that determines if the alert should be presented, and 10 | /// optional alert `data` that is used to customize its actions and message. 11 | /// 12 | /// Modeling the domain in this way unfortunately introduces a couple invalid runtime states: 13 | /// 14 | /// * `isPresented` can be `true`, but `data` can be `nil`. 15 | /// * `isPresented` can be `false`, but `data` can be non-`nil`. 16 | /// 17 | /// On top of that, SwiftUI's `alert` modifiers take static titles, which means the title cannot 18 | /// be dynamically computed from the alert data. 19 | /// 20 | /// This overload addresses these shortcomings with a streamlined API. First, it eliminates the 21 | /// invalid runtime states at compile time by driving the alert's presentation from a single, 22 | /// optional binding. When this binding is non-`nil`, the alert will be presented. Further, the 23 | /// title can be customized from the alert data. 24 | /// 25 | /// ```swift 26 | /// struct AlertDemo: View { 27 | /// @State var randomMovie: Movie? 28 | /// 29 | /// var body: some View { 30 | /// Button("Pick a random movie", action: self.getRandomMovie) 31 | /// .alert(item: self.$randomMovie) { 32 | /// Text($0.title) 33 | /// } actions: { _ in 34 | /// Button("Pick another", action: self.getRandomMovie) 35 | /// Button("I'm done", action: self.clearRandomMovie) 36 | /// } message: { 37 | /// Text($0.summary) 38 | /// } 39 | /// } 40 | /// 41 | /// func getRandomMovie() { 42 | /// self.randomMovie = Movie.allCases.randomElement() 43 | /// } 44 | /// 45 | /// func clearRandomMovie() { 46 | /// self.randomMovie = nil 47 | /// } 48 | /// } 49 | /// ``` 50 | /// 51 | /// - Parameters: 52 | /// - item: A binding to an optional value that determines whether an alert should be 53 | /// presented. When the binding is updated with non-`nil` value, it is unwrapped and passed 54 | /// to the modifier's closures. You can use this data to populate the fields of an alert 55 | /// that the system displays to the user. When the user presses or taps one of the alert's 56 | /// actions, the system sets this value to `nil` and dismisses the alert. 57 | /// - title: A closure returning the alert's title given the current alert state. 58 | /// - actions: A view builder returning the alert's actions given the current alert state. 59 | /// - message: A view builder returning the message for the alert given the current alert 60 | /// state. 61 | @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) 62 | public func alert( 63 | item: Binding, 64 | title: (Item) -> Text, 65 | @ViewBuilder actions: (Item) -> A, 66 | @ViewBuilder message: (Item) -> M 67 | ) -> some View { 68 | alert( 69 | item.wrappedValue.map(title) ?? Text(verbatim: ""), 70 | isPresented: Binding(item), 71 | presenting: item.wrappedValue, 72 | actions: actions, 73 | message: message 74 | ) 75 | } 76 | 77 | /// Presents an alert from a binding to an optional value. 78 | /// 79 | /// SwiftUI's `alert` view modifiers are driven by two disconnected pieces of state: an 80 | /// `isPresented` binding to a boolean that determines if the alert should be presented, and 81 | /// optional alert `data` that is used to customize its actions and message. 82 | /// 83 | /// Modeling the domain in this way unfortunately introduces a couple invalid runtime states: 84 | /// * `isPresented` can be `true`, but `data` can be `nil`. 85 | /// * `isPresented` can be `false`, but `data` can be non-`nil`. 86 | /// 87 | /// On top of that, SwiftUI's `alert` modifiers take static titles, which means the title cannot 88 | /// be dynamically computed from the alert data. 89 | /// 90 | /// This overload addresses these shortcomings with a streamlined API. First, it eliminates the 91 | /// invalid runtime states at compile time by driving the alert's presentation from a single, 92 | /// optional binding. When this binding is non-`nil`, the alert will be presented. Further, the 93 | /// title can be customized from the alert data. 94 | /// 95 | /// ```swift 96 | /// struct AlertDemo: View { 97 | /// @State var randomMovie: Movie? 98 | /// 99 | /// var body: some View { 100 | /// Button("Pick a random movie", action: self.getRandomMovie) 101 | /// .alert(item: self.$randomMovie) { 102 | /// Text($0.title) 103 | /// } actions: { _ in 104 | /// Button("Pick another", action: self.getRandomMovie) 105 | /// Button("I'm done", action: self.clearRandomMovie) 106 | /// } 107 | /// } 108 | /// 109 | /// func getRandomMovie() { 110 | /// self.randomMovie = Movie.allCases.randomElement() 111 | /// } 112 | /// 113 | /// func clearRandomMovie() { 114 | /// self.randomMovie = nil 115 | /// } 116 | /// } 117 | /// ``` 118 | /// 119 | /// - Parameters: 120 | /// - item: A binding to an optional value that determines whether an alert should be 121 | /// presented. When the binding is updated with non-`nil` value, it is unwrapped and passed 122 | /// to the modifier's closures. You can use this data to populate the fields of an alert 123 | /// that the system displays to the user. When the user presses or taps one of the alert's 124 | /// actions, the system sets this value to `nil` and dismisses the alert. 125 | /// - title: A closure returning the alert's title given the current alert state. 126 | /// - actions: A view builder returning the alert's actions given the current alert state. 127 | @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) 128 | public func alert( 129 | item: Binding, 130 | title: (Item) -> Text, 131 | @ViewBuilder actions: (Item) -> A 132 | ) -> some View { 133 | alert( 134 | item.wrappedValue.map(title) ?? Text(verbatim: ""), 135 | isPresented: Binding(item), 136 | presenting: item.wrappedValue, 137 | actions: actions 138 | ) 139 | } 140 | } 141 | #endif 142 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigationCore/AlertState.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import CustomDump 3 | import SwiftUI 4 | 5 | /// A data type that describes the state of an alert that can be shown to the user. The `Action` 6 | /// generic is the type of actions that can be sent from tapping on a button in the alert. 7 | /// 8 | /// This type can be used in your application's state in order to control the presentation and 9 | /// actions of alerts. This API can be used to push the logic of alert presentation and actions into 10 | /// your model, making it easier to test, and simplifying your view layer. 11 | /// 12 | /// To use this API, you first describe all of the actions that can take place in all of your 13 | /// alerts as an enum: 14 | /// 15 | /// ```swift 16 | /// @Observable 17 | /// class HomeScreenModel { 18 | /// enum AlertAction { 19 | /// case delete 20 | /// case removeFromHomeScreen 21 | /// } 22 | /// // ... 23 | /// } 24 | /// ``` 25 | /// 26 | /// Then you hold onto optional `AlertState` as a field in your model, which can 27 | /// start off as `nil`: 28 | /// 29 | /// ```swift 30 | /// @Observable 31 | /// class HomeScreenModel { 32 | /// var alert: AlertState? 33 | /// // ... 34 | /// } 35 | /// ``` 36 | /// 37 | /// And you define an endpoint for handling each alert action: 38 | /// 39 | /// ```swift 40 | /// @Observable 41 | /// class HomeScreenModel { 42 | /// // ... 43 | /// func alertButtonTapped(_ action: AlertAction?) { 44 | /// switch action { 45 | /// case .delete: 46 | /// // ... 47 | /// case .removeFromHomeScreen: 48 | /// // ... 49 | /// case .none: 50 | /// // ... 51 | /// } 52 | /// } 53 | /// } 54 | /// ``` 55 | /// 56 | /// Then, whenever you need to show an alert you can simply construct an `AlertState` value to 57 | /// represent the alert: 58 | /// 59 | /// ```swift 60 | /// @Observable 61 | /// class HomeScreenModel { 62 | /// // ... 63 | /// func deleteAppButtonTapped() { 64 | /// self.alert = AlertState { 65 | /// TextState(#"Remove "Twitter"?"#) 66 | /// } actions: { 67 | /// ButtonState(role: .destructive, action: .send(.delete)) { 68 | /// TextState("Delete App") 69 | /// } 70 | /// ButtonState(action: .send(.removeFromHomeScreen)) { 71 | /// TextState("Remove from Home Screen") 72 | /// } 73 | /// } message: { 74 | /// TextState( 75 | /// "Removing from Home Screen will keep the app in your App Library." 76 | /// ) 77 | /// } 78 | /// } 79 | /// } 80 | /// ``` 81 | /// 82 | /// And in your view you can use the `.alert(_:action:)` view modifier to present the alert: 83 | /// 84 | /// ```swift 85 | /// struct FeatureView: View { 86 | /// @ObservedObject var model: HomeScreenModel 87 | /// 88 | /// var body: some View { 89 | /// VStack { 90 | /// Button("Delete") { 91 | /// self.model.deleteAppButtonTapped() 92 | /// } 93 | /// } 94 | /// .alert(self.$model.alert) { action in 95 | /// self.model.alertButtonTapped(action) 96 | /// } 97 | /// } 98 | /// } 99 | /// ``` 100 | /// 101 | /// This makes your model in complete control of when the alert is shown or dismissed, and makes it 102 | /// so that any choice made in the alert is automatically fed back into the model so that you can 103 | /// handle its logic. 104 | /// 105 | /// Even better, because `AlertState` is equatable (when `Action` is equatable), you can instantly 106 | /// write tests that your alert behavior works as expected: 107 | /// 108 | /// ```swift 109 | /// let model = HomeScreenModel() 110 | /// 111 | /// model.deleteAppButtonTapped() 112 | /// XCTAssertEqual( 113 | /// model.alert, 114 | /// AlertState { 115 | /// TextState(#"Remove "Twitter"?"#) 116 | /// } actions: { 117 | /// ButtonState(role: .destructive, action: .deleteButtonTapped) { 118 | /// TextState("Delete App"), 119 | /// }, 120 | /// ButtonState(action: .removeFromHomeScreenButtonTapped) { 121 | /// TextState("Remove from Home Screen"), 122 | /// } 123 | /// } message: { 124 | /// TextState( 125 | /// "Removing from Home Screen will keep the app in your App Library." 126 | /// ) 127 | /// } 128 | /// ) 129 | /// 130 | /// model.alertButtonTapped(.delete) { 131 | /// // Also verify that delete logic executed correctly 132 | /// } 133 | /// model.alert = nil 134 | /// ``` 135 | public struct AlertState: Identifiable { 136 | public let id: UUID 137 | public var buttons: [ButtonState] 138 | public var message: TextState? 139 | public var title: TextState 140 | 141 | init( 142 | id: UUID, 143 | buttons: [ButtonState], 144 | message: TextState?, 145 | title: TextState 146 | ) { 147 | self.id = id 148 | self.buttons = buttons 149 | self.message = message 150 | self.title = title 151 | } 152 | 153 | /// Creates alert state. 154 | /// 155 | /// - Parameters: 156 | /// - title: The title of the alert. 157 | /// - actions: A ``ButtonStateBuilder`` returning the alert's actions. 158 | /// - message: The message for the alert. 159 | @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) 160 | public init( 161 | title: () -> TextState, 162 | @ButtonStateBuilder actions: () -> [ButtonState] = { [] }, 163 | message: (() -> TextState)? = nil 164 | ) { 165 | self.init( 166 | id: UUID(), 167 | buttons: actions(), 168 | message: message?(), 169 | title: title() 170 | ) 171 | } 172 | 173 | public func map(_ transform: (Action?) -> NewAction?) -> AlertState { 174 | AlertState( 175 | id: self.id, 176 | buttons: self.buttons.map { $0.map(transform) }, 177 | message: self.message, 178 | title: self.title 179 | ) 180 | } 181 | } 182 | 183 | extension AlertState: CustomDumpReflectable { 184 | public var customDumpMirror: Mirror { 185 | var children: [(label: String?, value: Any)] = [ 186 | ("title", self.title) 187 | ] 188 | if !self.buttons.isEmpty { 189 | children.append(("actions", self.buttons)) 190 | } 191 | if let message { 192 | children.append(("message", message)) 193 | } 194 | return Mirror( 195 | self, 196 | children: children, 197 | displayStyle: .struct 198 | ) 199 | } 200 | } 201 | 202 | extension AlertState: Equatable where Action: Equatable { 203 | public static func == (lhs: Self, rhs: Self) -> Bool { 204 | lhs.title == rhs.title 205 | && lhs.message == rhs.message 206 | && lhs.buttons == rhs.buttons 207 | } 208 | } 209 | 210 | extension AlertState: Hashable where Action: Hashable { 211 | public func hash(into hasher: inout Hasher) { 212 | hasher.combine(self.title) 213 | hasher.combine(self.message) 214 | hasher.combine(self.buttons) 215 | } 216 | } 217 | 218 | extension AlertState: Sendable where Action: Sendable {} 219 | 220 | // MARK: - SwiftUI bridging 221 | 222 | @available( 223 | iOS, introduced: 13, deprecated: 100000, message: "use 'View.alert(_:action:)' instead." 224 | ) 225 | @available( 226 | macOS, introduced: 10.15, deprecated: 100000, message: "use 'View.alert(_:action:)' instead." 227 | ) 228 | @available( 229 | tvOS, introduced: 13, deprecated: 100000, message: "use 'View.alert(_:action:)' instead." 230 | ) 231 | @available( 232 | watchOS, introduced: 6, deprecated: 100000, message: "use 'View.alert(_:action:)' instead." 233 | ) 234 | extension Alert { 235 | /// Creates an alert from alert state. 236 | /// 237 | /// - Parameters: 238 | /// - state: Alert state used to populate the alert. 239 | /// - action: An action handler, called when a button with an action is tapped, by passing the 240 | /// action to the closure. 241 | public init(_ state: AlertState, action: @escaping (Action?) -> Void) { 242 | if state.buttons.count == 2 { 243 | self.init( 244 | title: Text(state.title), 245 | message: state.message.map { Text($0) }, 246 | primaryButton: .init(state.buttons[0], action: action), 247 | secondaryButton: .init(state.buttons[1], action: action) 248 | ) 249 | } else { 250 | self.init( 251 | title: Text(state.title), 252 | message: state.message.map { Text($0) }, 253 | dismissButton: state.buttons.first.map { .init($0, action: action) } 254 | ) 255 | } 256 | } 257 | 258 | /// Creates an alert from alert state. 259 | /// 260 | /// - Parameters: 261 | /// - state: Alert state used to populate the alert. 262 | /// - action: An action handler, called when a button with an action is tapped, by passing the 263 | /// action to the closure. 264 | public init( 265 | _ state: AlertState, 266 | action: @escaping @Sendable (Action?) async -> Void 267 | ) { 268 | if state.buttons.count == 2 { 269 | self.init( 270 | title: Text(state.title), 271 | message: state.message.map { Text($0) }, 272 | primaryButton: .init(state.buttons[0], action: action), 273 | secondaryButton: .init(state.buttons[1], action: action) 274 | ) 275 | } else { 276 | self.init( 277 | title: Text(state.title), 278 | message: state.message.map { Text($0) }, 279 | dismissButton: state.buttons.first.map { .init($0, action: action) } 280 | ) 281 | } 282 | } 283 | } 284 | #endif // canImport(SwiftUI) 285 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigationCore/Bind.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import SwiftUI 3 | 4 | extension View { 5 | /// Synchronizes model state to view state via two-way bindings. 6 | /// 7 | /// SwiftUI comes with many property wrappers that can be used in views to drive view state, 8 | /// like field focus. Unfortunately, these property wrappers _must_ be used in views. It's not 9 | /// possible to extract this logic to an `@Observable` class and integrate it with the rest of 10 | /// the model's business logic, and be in a better position to test this state. 11 | /// 12 | /// We can work around these limitations by introducing a published field to your observable 13 | /// object and synchronizing it to view state with this view modifier. 14 | /// 15 | /// - Parameters: 16 | /// - modelValue: A binding from model state. _E.g._, a binding derived from a field 17 | /// on an observable class. 18 | /// - viewValue: A binding from view state. _E.g._, a focus binding. 19 | @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) 20 | public func bind( 21 | _ modelValue: ModelValue, to viewValue: ViewValue 22 | ) -> some View 23 | where ModelValue.Value == ViewValue.Value, ModelValue.Value: Equatable { 24 | self.modifier(_Bind(modelValue: modelValue, viewValue: viewValue)) 25 | } 26 | } 27 | 28 | @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) 29 | private struct _Bind: ViewModifier 30 | where ModelValue.Value == ViewValue.Value, ModelValue.Value: Equatable { 31 | let modelValue: ModelValue 32 | let viewValue: ViewValue 33 | 34 | @State var hasAppeared = false 35 | 36 | func body(content: Content) -> some View { 37 | content 38 | .onAppear { 39 | guard !self.hasAppeared else { return } 40 | self.hasAppeared = true 41 | guard self.viewValue.wrappedValue != self.modelValue.wrappedValue else { return } 42 | self.viewValue.wrappedValue = self.modelValue.wrappedValue 43 | } 44 | .onChange(of: self.modelValue.wrappedValue) { 45 | guard self.viewValue.wrappedValue != $0 46 | else { return } 47 | self.viewValue.wrappedValue = $0 48 | } 49 | .onChange(of: self.viewValue.wrappedValue) { 50 | guard self.modelValue.wrappedValue != $0 51 | else { return } 52 | self.modelValue.wrappedValue = $0 53 | } 54 | } 55 | } 56 | 57 | public protocol _Bindable { 58 | associatedtype Value 59 | var wrappedValue: Value { get nonmutating set } 60 | } 61 | 62 | @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) 63 | extension AccessibilityFocusState: _Bindable {} 64 | 65 | @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) 66 | extension AccessibilityFocusState.Binding: _Bindable {} 67 | 68 | @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) 69 | extension AppStorage: _Bindable {} 70 | 71 | extension Binding: _Bindable {} 72 | 73 | @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) 74 | extension FocusedBinding: _Bindable {} 75 | 76 | @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) 77 | extension FocusState: _Bindable {} 78 | 79 | @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) 80 | extension FocusState.Binding: _Bindable {} 81 | 82 | @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) 83 | extension SceneStorage: _Bindable {} 84 | 85 | extension State: _Bindable {} 86 | #endif // canImport(SwiftUI) 87 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigationCore/Binding.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import SwiftUI 3 | 4 | extension Binding { 5 | /// Creates a binding by projecting the base optional value to a Boolean value. 6 | /// 7 | /// Writing `false` to the binding will `nil` out the base value. Writing `true` does nothing. 8 | /// 9 | /// - Parameter base: A value to project to a Boolean value. 10 | public init(_ base: Binding) where Value == Bool { 11 | self = base._isPresent 12 | } 13 | } 14 | 15 | extension Optional { 16 | fileprivate var _isPresent: Bool { 17 | get { self != nil } 18 | set { 19 | guard !newValue else { return } 20 | self = nil 21 | } 22 | } 23 | } 24 | #endif 25 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigationCore/ButtonStateBuilder.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | @resultBuilder 3 | public enum ButtonStateBuilder { 4 | public static func buildArray(_ components: [[ButtonState]]) -> [ButtonState] { 5 | components.flatMap { $0 } 6 | } 7 | 8 | public static func buildBlock(_ components: [ButtonState]...) -> [ButtonState] { 9 | components.flatMap { $0 } 10 | } 11 | 12 | public static func buildLimitedAvailability( 13 | _ component: [ButtonState] 14 | ) -> [ButtonState] { 15 | component 16 | } 17 | 18 | public static func buildEither(first component: [ButtonState]) -> [ButtonState] 19 | { 20 | component 21 | } 22 | 23 | public static func buildEither(second component: [ButtonState]) -> [ButtonState] 24 | { 25 | component 26 | } 27 | 28 | public static func buildExpression(_ expression: ButtonState) -> [ButtonState] { 29 | [expression] 30 | } 31 | 32 | public static func buildOptional(_ component: [ButtonState]?) -> [ButtonState] { 33 | component ?? [] 34 | } 35 | } 36 | #endif // canImport(SwiftUI) 37 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigationCore/ConfirmationDialog.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import SwiftUI 3 | 4 | // MARK: - ConfirmationDialog with dynamic title 5 | 6 | extension View { 7 | /// Presents a confirmation dialog from a binding to an optional value. 8 | /// 9 | /// SwiftUI's `confirmationDialog` view modifiers are driven by two disconnected pieces of 10 | /// state: an `isPresented` binding to a boolean that determines if the dialog should be 11 | /// presented, and optional dialog `data` that is used to customize its actions and message. 12 | /// 13 | /// Modeling the domain in this way unfortunately introduces a couple invalid runtime states: 14 | /// 15 | /// * `isPresented` can be `true`, but `data` can be `nil`. 16 | /// * `isPresented` can be `false`, but `data` can be non-`nil`. 17 | /// 18 | /// On top of that, SwiftUI's `confirmationDialog` modifiers take static titles, which means the 19 | /// title cannot be dynamically computed from the dialog data. 20 | /// 21 | /// This overload addresses these shortcomings with a streamlined API. First, it eliminates the 22 | /// invalid runtime states at compile time by driving the dialog's presentation from a single, 23 | /// optional binding. When this binding is non-`nil`, the dialog will be presented. Further, the 24 | /// title can be customized from the dialog data. 25 | /// 26 | /// ```swift 27 | /// struct DialogDemo: View { 28 | /// @State var randomMovie: Movie? 29 | /// 30 | /// var body: some View { 31 | /// Button("Pick a random movie", action: self.getRandomMovie) 32 | /// .confirmationDialog(item: self.$randomMovie, titleVisibility: .always) { 33 | /// Text($0.title) 34 | /// } actions: { _ in 35 | /// Button("Pick another", action: self.getRandomMovie) 36 | /// Button("I'm done", action: self.clearRandomMovie) 37 | /// } message: { 38 | /// Text($0.summary) 39 | /// } 40 | /// } 41 | /// 42 | /// func getRandomMovie() { 43 | /// self.randomMovie = Movie.allCases.randomElement() 44 | /// } 45 | /// 46 | /// func clearRandomMovie() { 47 | /// self.randomMovie = nil 48 | /// } 49 | /// } 50 | /// ``` 51 | /// 52 | /// - Parameters: 53 | /// - item: A binding to an optional value that determines whether a dialog should be 54 | /// presented. When the binding is updated with non-`nil` value, it is unwrapped and passed 55 | /// to the modifier's closures. You can use this data to populate the fields of a dialog 56 | /// that the system displays to the user. When the user presses or taps one of the dialog's 57 | /// actions, the system sets this value to `nil` and dismisses the dialog. 58 | /// - title: A closure returning the dialog's title given the current dialog state. 59 | /// - titleVisibility: The visibility of the dialog's title. (default: .automatic) 60 | /// - actions: A view builder returning the dialog's actions given the current dialog state. 61 | /// - message: A view builder returning the message for the dialog given the current dialog 62 | /// state. 63 | @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) 64 | public func confirmationDialog( 65 | item: Binding, 66 | titleVisibility: Visibility = .automatic, 67 | title: (Item) -> Text, 68 | @ViewBuilder actions: (Item) -> A, 69 | @ViewBuilder message: (Item) -> M 70 | ) -> some View { 71 | confirmationDialog( 72 | item.wrappedValue.map(title) ?? Text(verbatim: ""), 73 | isPresented: Binding(item), 74 | titleVisibility: titleVisibility, 75 | presenting: item.wrappedValue, 76 | actions: actions, 77 | message: message 78 | ) 79 | } 80 | 81 | /// Presents a confirmation dialog from a binding to an optional value. 82 | /// 83 | /// SwiftUI's `confirmationDialog` view modifiers are driven by two disconnected pieces of 84 | /// state: an `isPresented` binding to a boolean that determines if the dialog should be 85 | /// presented, and optional dialog `data` that is used to customize its actions and message. 86 | /// 87 | /// Modeling the domain in this way unfortunately introduces a couple invalid runtime states: 88 | /// 89 | /// * `isPresented` can be `true`, but `data` can be `nil`. 90 | /// * `isPresented` can be `false`, but `data` can be non-`nil`. 91 | /// 92 | /// On top of that, SwiftUI's `confirmationDialog` modifiers take static titles, which means the 93 | /// title cannot be dynamically computed from the dialog data. 94 | /// 95 | /// This overload addresses these shortcomings with a streamlined API. First, it eliminates the 96 | /// invalid runtime states at compile time by driving the dialog's presentation from a single, 97 | /// optional binding. When this binding is non-`nil`, the dialog will be presented. Further, the 98 | /// title can be customized from the dialog data. 99 | /// 100 | /// struct DialogDemo: View { 101 | /// @State var randomMovie: Movie? 102 | /// 103 | /// var body: some View { 104 | /// Button("Pick a random movie", action: self.getRandomMovie) 105 | /// .confirmationDialog(item: self.$randomMovie, titleVisibility: .always) { 106 | /// Text($0.title) 107 | /// } actions: { _ in 108 | /// Button("Pick another", action: self.getRandomMovie) 109 | /// Button("I'm done", action: self.clearRandomMovie) 110 | /// } 111 | /// } 112 | /// 113 | /// func getRandomMovie() { 114 | /// self.randomMovie = Movie.allCases.randomElement() 115 | /// } 116 | /// 117 | /// func clearRandomMovie() { 118 | /// self.randomMovie = nil 119 | /// } 120 | /// } 121 | /// ``` 122 | /// 123 | /// See for more information on how to use this API. 124 | /// 125 | /// - Parameters: 126 | /// - item: A binding to an optional value that determines whether a dialog should be 127 | /// presented. When the binding is updated with non-`nil` value, it is unwrapped and passed 128 | /// to the modifier's closures. You can use this data to populate the fields of a dialog 129 | /// that the system displays to the user. When the user presses or taps one of the dialog's 130 | /// actions, the system sets this value to `nil` and dismisses the dialog. 131 | /// - title: A closure returning the dialog's title given the current dialog state. 132 | /// - titleVisibility: The visibility of the dialog's title. (default: .automatic) 133 | /// - actions: A view builder returning the dialog's actions given the current dialog state. 134 | @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) 135 | public func confirmationDialog( 136 | item: Binding, 137 | titleVisibility: Visibility = .automatic, 138 | title: (Item) -> Text, 139 | @ViewBuilder actions: (Item) -> A 140 | ) -> some View { 141 | confirmationDialog( 142 | item.wrappedValue.map(title) ?? Text(verbatim: ""), 143 | isPresented: Binding(item), 144 | titleVisibility: titleVisibility, 145 | presenting: item.wrappedValue, 146 | actions: actions 147 | ) 148 | } 149 | } 150 | #endif 151 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigationCore/ConfirmationDialogState.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import CustomDump 3 | import SwiftUI 4 | 5 | /// A data type that describes the state of a confirmation dialog that can be shown to the user. The 6 | /// `Action` generic is the type of actions that can be sent from tapping on a button in the sheet. 7 | /// 8 | /// This type can be used in your application's state in order to control the presentation and 9 | /// actions of dialogs. This API can be used to push the logic of alert presentation and action into 10 | /// your model, making it easier to test, and simplifying your view layer. 11 | /// 12 | /// To use this API, you describe all of a dialog's actions as cases in an enum: 13 | /// 14 | /// ```swift 15 | /// @Observable 16 | /// class FeatureModel { 17 | /// enum ConfirmationDialogAction { 18 | /// case delete 19 | /// case favorite 20 | /// } 21 | /// // ... 22 | /// } 23 | /// ``` 24 | /// 25 | /// You model the state for showing the alert in as a published field, which can start off `nil`: 26 | /// 27 | /// ```swift 28 | /// @Observable 29 | /// class FeatureModel { 30 | /// // ... 31 | /// var dialog: ConfirmationDialogState? 32 | /// // ... 33 | /// } 34 | /// ``` 35 | /// 36 | /// And you define an endpoint for handling each alert action: 37 | /// 38 | /// ```swift 39 | /// @Observable 40 | /// class FeatureModel { 41 | /// // ... 42 | /// func dialogButtonTapped(_ action: ConfirmationDialogAction) { 43 | /// switch action { 44 | /// case .delete: 45 | /// // ... 46 | /// case .favorite: 47 | /// // ... 48 | /// } 49 | /// } 50 | /// } 51 | /// ``` 52 | /// 53 | /// Then, in an endpoint that should display an alert, you can construct a 54 | /// ``ConfirmationDialogState`` value to represent it: 55 | /// 56 | /// ```swift 57 | /// @Observable 58 | /// class FeatureModel { 59 | /// // ... 60 | /// func infoButtonTapped() { 61 | /// self.dialog = ConfirmationDialogState( 62 | /// title: "What would you like to do?", 63 | /// buttons: [ 64 | /// .default(TextState("Favorite"), action: .send(.favorite)), 65 | /// .destructive(TextState("Delete"), action: .send(.delete)), 66 | /// .cancel(TextState("Cancel")), 67 | /// ] 68 | /// ) 69 | /// } 70 | /// } 71 | /// ``` 72 | /// 73 | /// And in your view you can use the `.confirmationDialog(_:action:)` view modifier to 74 | /// present the dialog: 75 | /// 76 | /// ```swift 77 | /// struct ItemView: View { 78 | /// @ObservedObject var model: FeatureModel 79 | /// 80 | /// var body: some View { 81 | /// VStack { 82 | /// Button("Info") { 83 | /// self.model.infoButtonTapped() 84 | /// } 85 | /// } 86 | /// .confirmationDialog($model.dialog) { action in 87 | /// self.model.dialogButtonTapped(action) 88 | /// } 89 | /// } 90 | /// } 91 | /// ``` 92 | /// 93 | /// This makes your model in complete control of when the alert is shown or dismissed, and makes it 94 | /// so that any choice made in the alert is automatically fed back into the model so that you can 95 | /// handle its logic. 96 | /// 97 | /// Even better, you can instantly write tests that your alert behavior works as expected: 98 | /// 99 | /// ```swift 100 | /// let model = FeatureModel() 101 | /// 102 | /// model.infoButtonTapped() 103 | /// XCTAssertEqual( 104 | /// model.dialog, 105 | /// ConfirmationDialogState( 106 | /// title: "What would you like to do?", 107 | /// buttons: [ 108 | /// .default(TextState("Favorite"), action: .send(.favorite)), 109 | /// .destructive(TextState("Delete"), action: .send(.delete)), 110 | /// .cancel(TextState("Cancel")), 111 | /// ] 112 | /// ) 113 | /// ) 114 | /// 115 | /// model.dialogButtonTapped(.favorite) 116 | /// // Verify that favorite logic executed correctly 117 | /// model.dialog = nil 118 | /// ``` 119 | @available(iOS 13, *) 120 | @available(macOS 12, *) 121 | @available(tvOS 13, *) 122 | @available(watchOS 6, *) 123 | public struct ConfirmationDialogState: Identifiable { 124 | public let id: UUID 125 | public var buttons: [ButtonState] 126 | public var message: TextState? 127 | public var title: TextState 128 | public var titleVisibility: ConfirmationDialogStateTitleVisibility 129 | 130 | init( 131 | id: UUID, 132 | buttons: [ButtonState], 133 | message: TextState?, 134 | title: TextState, 135 | titleVisibility: ConfirmationDialogStateTitleVisibility 136 | ) { 137 | self.id = id 138 | self.buttons = buttons 139 | self.message = message 140 | self.title = title 141 | self.titleVisibility = titleVisibility 142 | } 143 | 144 | /// Creates confirmation dialog state. 145 | /// 146 | /// - Parameters: 147 | /// - titleVisibility: The visibility of the dialog's title. 148 | /// - title: The title of the dialog. 149 | /// - actions: A ``ButtonStateBuilder`` returning the dialog's actions. 150 | /// - message: The message for the dialog. 151 | @available(iOS 15, *) 152 | @available(macOS 12, *) 153 | @available(tvOS 15, *) 154 | @available(watchOS 8, *) 155 | public init( 156 | titleVisibility: ConfirmationDialogStateTitleVisibility, 157 | title: () -> TextState, 158 | @ButtonStateBuilder actions: () -> [ButtonState] = { [] }, 159 | message: (() -> TextState)? = nil 160 | ) { 161 | self.init( 162 | id: UUID(), 163 | buttons: actions(), 164 | message: message?(), 165 | title: title(), 166 | titleVisibility: titleVisibility 167 | ) 168 | } 169 | 170 | /// Creates confirmation dialog state. 171 | /// 172 | /// - Parameters: 173 | /// - title: The title of the dialog. 174 | /// - actions: A ``ButtonStateBuilder`` returning the dialog's actions. 175 | /// - message: The message for the dialog. 176 | public init( 177 | title: () -> TextState, 178 | @ButtonStateBuilder actions: () -> [ButtonState] = { [] }, 179 | message: (() -> TextState)? = nil 180 | ) { 181 | self.init( 182 | id: UUID(), 183 | buttons: actions(), 184 | message: message?(), 185 | title: title(), 186 | titleVisibility: .automatic 187 | ) 188 | } 189 | 190 | public func map( 191 | _ transform: (Action?) -> NewAction? 192 | ) -> ConfirmationDialogState { 193 | ConfirmationDialogState( 194 | id: self.id, 195 | buttons: self.buttons.map { $0.map(transform) }, 196 | message: self.message, 197 | title: self.title, 198 | titleVisibility: self.titleVisibility 199 | ) 200 | } 201 | } 202 | 203 | /// The visibility of a confirmation dialog title element, chosen automatically based on the 204 | /// platform, current context, and other factors. 205 | /// 206 | /// See `SwiftUI.Visibility` for more information. 207 | public enum ConfirmationDialogStateTitleVisibility: Sendable { 208 | /// The element may be visible or hidden depending on the policies of the component accepting the 209 | /// visibility configuration. 210 | /// 211 | /// See `SwiftUI.Visibility.automatic` for more information. 212 | case automatic 213 | 214 | /// The element may be hidden. 215 | /// 216 | /// See `SwiftUI.Visibility.hidden` for more information. 217 | case hidden 218 | /// The element may be visible. 219 | /// 220 | /// See `SwiftUI.Visibility.visible` for more information. 221 | case visible 222 | } 223 | 224 | @available(iOS 13, *) 225 | @available(macOS 12, *) 226 | @available(tvOS 13, *) 227 | @available(watchOS 6, *) 228 | extension ConfirmationDialogState: CustomDumpReflectable { 229 | public var customDumpMirror: Mirror { 230 | var children: [(label: String?, value: Any)] = [] 231 | if self.titleVisibility != .automatic { 232 | children.append(("titleVisibility", self.titleVisibility)) 233 | } 234 | children.append(("title", self.title)) 235 | if !self.buttons.isEmpty { 236 | children.append(("actions", self.buttons)) 237 | } 238 | if let message { 239 | children.append(("message", message)) 240 | } 241 | return Mirror( 242 | self, 243 | children: children, 244 | displayStyle: .struct 245 | ) 246 | } 247 | } 248 | 249 | @available(iOS 13, *) 250 | @available(macOS 12, *) 251 | @available(tvOS 13, *) 252 | @available(watchOS 6, *) 253 | extension ConfirmationDialogState: Equatable where Action: Equatable { 254 | public static func == (lhs: Self, rhs: Self) -> Bool { 255 | lhs.title == rhs.title 256 | && lhs.message == rhs.message 257 | && lhs.buttons == rhs.buttons 258 | } 259 | } 260 | 261 | @available(iOS 13, *) 262 | @available(macOS 12, *) 263 | @available(tvOS 13, *) 264 | @available(watchOS 6, *) 265 | extension ConfirmationDialogState: Hashable where Action: Hashable { 266 | public func hash(into hasher: inout Hasher) { 267 | hasher.combine(self.title) 268 | hasher.combine(self.message) 269 | hasher.combine(self.buttons) 270 | } 271 | } 272 | 273 | @available(iOS 13, *) 274 | @available(macOS 12, *) 275 | @available(tvOS 13, *) 276 | @available(watchOS 6, *) 277 | extension ConfirmationDialogState: Sendable where Action: Sendable {} 278 | 279 | // MARK: - SwiftUI bridging 280 | 281 | @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) 282 | extension Visibility { 283 | public init(_ visibility: ConfirmationDialogStateTitleVisibility) { 284 | switch visibility { 285 | case .automatic: 286 | self = .automatic 287 | case .hidden: 288 | self = .hidden 289 | case .visible: 290 | self = .visible 291 | } 292 | } 293 | } 294 | #endif // canImport(SwiftUI) 295 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigationCore/Documentation.docc/Extensions/AlertState.md: -------------------------------------------------------------------------------- 1 | # ``SwiftUINavigationCore/AlertState`` 2 | 3 | ## Topics 4 | 5 | ### Creating alerts 6 | 7 | - ``init(title:actions:message:)`` 8 | 9 | ### Reading alert data 10 | 11 | - ``id`` 12 | - ``title`` 13 | - ``message`` 14 | - ``buttons`` 15 | 16 | ### Transforming alerts 17 | 18 | - ``map(_:)`` 19 | 20 | ### Deprecations 21 | 22 | - 23 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigationCore/Documentation.docc/Extensions/AlertStateDeprecations.md: -------------------------------------------------------------------------------- 1 | # Deprecations 2 | 3 | Review unsupported SwiftUI Navigation APIs and their replacements. 4 | 5 | ## Overview 6 | 7 | Avoid using deprecated APIs in your app. Select a method to see the replacement that you should use 8 | instead. 9 | 10 | ## Topics 11 | 12 | ### Creating alerts 13 | 14 | - ``AlertState/init(title:message:primaryButton:secondaryButton:)`` 15 | - ``AlertState/init(title:message:dismissButton:)`` 16 | - ``AlertState/init(title:message:buttons:)`` 17 | 18 | ### Supporting types 19 | 20 | - ``AlertState/Button`` 21 | - ``AlertState/ButtonAction`` 22 | - ``AlertState/ButtonRole`` 23 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigationCore/Documentation.docc/Extensions/ButtonState.md: -------------------------------------------------------------------------------- 1 | # ``SwiftUINavigationCore/ButtonState`` 2 | 3 | ## Topics 4 | 5 | ### Creating buttons 6 | 7 | - ``init(role:action:label:)-99wi3`` 8 | - ``init(role:action:label:)-2ixoi`` 9 | - ``ButtonStateRole`` 10 | - ``ButtonStateAction`` 11 | 12 | ### Composing buttons 13 | 14 | - ``ButtonStateBuilder`` 15 | 16 | ### Reading button data 17 | 18 | - ``id`` 19 | - ``role-swift.property`` 20 | - ``action`` 21 | - ``label`` 22 | 23 | ### Performing actions 24 | 25 | - ``withAction(_:)-56ifj`` 26 | - ``withAction(_:)-71nj4`` 27 | 28 | ### Transforming buttons 29 | 30 | - ``SwiftUI/Button`` 31 | - ``SwiftUI/ButtonRole`` 32 | - ``map(_:)`` 33 | 34 | ### Deprecations 35 | 36 | - 37 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigationCore/Documentation.docc/Extensions/ButtonStateDeprecations.md: -------------------------------------------------------------------------------- 1 | # Deprecations 2 | 3 | Review unsupported SwiftUI Navigation APIs and their replacements. 4 | 5 | ## Overview 6 | 7 | Avoid using deprecated APIs in your app. Select a method to see the replacement that you should use 8 | instead. 9 | 10 | ## Topics 11 | 12 | ### Creating buttons 13 | 14 | - ``ButtonState/cancel(_:action:)`` 15 | - ``ButtonState/default(_:action:)`` 16 | - ``ButtonState/destructive(_:action:)`` 17 | 18 | ### Readin 19 | 20 | ### Supporting types 21 | 22 | - ``ButtonState/ButtonAction`` 23 | - ``ButtonState/Handler`` 24 | - ``ButtonState/Role-swift.typealias`` 25 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigationCore/Documentation.docc/Extensions/ConfirmationDialogState.md: -------------------------------------------------------------------------------- 1 | # ``SwiftUINavigationCore/ConfirmationDialogState`` 2 | 3 | ## Topics 4 | 5 | ### Creating dialogs 6 | 7 | - ``init(title:actions:message:)`` 8 | - ``init(titleVisibility:title:actions:message:)`` 9 | - ``ConfirmationDialogStateTitleVisibility`` 10 | 11 | ### Reading dialog data 12 | 13 | - ``id`` 14 | - ``title`` 15 | - ``titleVisibility`` 16 | - ``message`` 17 | - ``buttons`` 18 | 19 | ### Transforming dialogs 20 | 21 | - ``map(_:)`` 22 | - ``SwiftUI/Visibility`` 23 | 24 | ### Deprecations 25 | 26 | - 27 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigationCore/Documentation.docc/Extensions/ConfirmationDialogStateDeprecations.md: -------------------------------------------------------------------------------- 1 | # Deprecations 2 | 3 | Review unsupported SwiftUI Navigation APIs and their replacements. 4 | 5 | ## Overview 6 | 7 | Avoid using deprecated APIs in your app. Select a method to see the replacement that you should use 8 | instead. 9 | 10 | ## Topics 11 | 12 | ### Creating dialogs 13 | 14 | - ``ActionSheetState`` 15 | - ``ConfirmationDialogState/init(title:message:buttons:)`` 16 | - ``ConfirmationDialogState/init(title:titleVisibility:message:buttons:)`` 17 | 18 | ### Supporting types 19 | 20 | - ``ConfirmationDialogState/Button`` 21 | - ``ConfirmationDialogState/Visibility`` 22 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigationCore/Documentation.docc/Extensions/Deprecations.md: -------------------------------------------------------------------------------- 1 | # Deprecations 2 | 3 | Review unsupported SwiftUI Navigation APIs and their replacements. 4 | 5 | ## Overview 6 | 7 | Avoid using deprecated APIs in your app. Select a method to see the replacement that you should use 8 | instead. 9 | 10 | ## Topics 11 | 12 | ### Bindings 13 | 14 | - ``SwiftUI/Binding/isPresent()`` 15 | 16 | ### Alerts and dialogs 17 | 18 | - ``SwiftUI/ActionSheet/init(_:action:)`` 19 | - ``SwiftUI/Alert`` 20 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigationCore/Documentation.docc/Extensions/TextState.md: -------------------------------------------------------------------------------- 1 | # ``SwiftUINavigationCore/TextState`` 2 | 3 | ## Topics 4 | 5 | ### Creating text state 6 | 7 | - ``init(_:)`` 8 | - ``init(_:tableName:bundle:comment:)`` 9 | - ``init(verbatim:)`` 10 | 11 | ### Text state transformations 12 | 13 | - ``SwiftUI/Text`` 14 | - ``Swift/String`` 15 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigationCore/Documentation.docc/SwiftUINavigationCore.md: -------------------------------------------------------------------------------- 1 | # ``SwiftUINavigationCore`` 2 | 3 | A few core types and modifiers included in SwiftUI Navigation. 4 | 5 | ## Topics 6 | 7 | ### State 8 | 9 | - ``TextState`` 10 | - ``AlertState`` 11 | - ``ConfirmationDialogState`` 12 | - ``ButtonState`` 13 | 14 | ### Alert and dialog modifiers 15 | 16 | - ``SwiftUI/View/alert(item:title:actions:message:)`` 17 | - ``SwiftUI/View/alert(item:title:actions:)`` 18 | - ``SwiftUI/View/confirmationDialog(item:titleVisibility:title:actions:message:)`` 19 | - ``SwiftUI/View/confirmationDialog(item:titleVisibility:title:actions:)`` 20 | 21 | ### Bindings 22 | 23 | - ``SwiftUI/Binding/init(_:)`` 24 | - ``SwiftUI/View/bind(_:to:)`` 25 | 26 | ### Navigation 27 | 28 | - ``SwiftUI/View/navigationDestination(item:destination:)`` 29 | 30 | ### Deprecations 31 | 32 | - 33 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigationCore/NavigationDestination.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import SwiftUI 3 | 4 | @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) 5 | extension View { 6 | /// Associates a destination view with a bound value for use within a navigation stack or 7 | /// navigation split view. 8 | /// 9 | /// See `SwiftUI.View.navigationDestination(item:destination:)` for more information. 10 | /// 11 | /// - Parameters: 12 | /// - item: A binding to the data presented, or `nil` if nothing is currently presented. 13 | /// - destination: A view builder that defines a view to display when `item` is not `nil`. 14 | public func navigationDestination( 15 | item: Binding, 16 | @ViewBuilder destination: @escaping (D) -> C 17 | ) -> some View { 18 | navigationDestination(isPresented: Binding(item)) { 19 | if let item = item.wrappedValue { 20 | destination(item) 21 | } 22 | } 23 | } 24 | } 25 | #endif // canImport(SwiftUI) 26 | -------------------------------------------------------------------------------- /SwiftUINavigation.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /SwiftUINavigation.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "combine-schedulers", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/pointfreeco/combine-schedulers", 7 | "state" : { 8 | "revision" : "9fa31f4403da54855f1e2aeaeff478f4f0e40b13", 9 | "version" : "1.0.2" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-case-paths", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/pointfreeco/swift-case-paths", 16 | "state" : { 17 | "revision" : "71344dd930fde41e8f3adafe260adcbb2fc2a3dc", 18 | "version" : "1.5.4" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-clocks", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/pointfreeco/swift-clocks", 25 | "state" : { 26 | "revision" : "3581e280bf0d90c3fb9236fb23e75a5d8c46b533", 27 | "version" : "1.0.4" 28 | } 29 | }, 30 | { 31 | "identity" : "swift-collections", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/apple/swift-collections", 34 | "state" : { 35 | "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", 36 | "version" : "1.1.2" 37 | } 38 | }, 39 | { 40 | "identity" : "swift-concurrency-extras", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/pointfreeco/swift-concurrency-extras.git", 43 | "state" : { 44 | "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", 45 | "version" : "1.1.0" 46 | } 47 | }, 48 | { 49 | "identity" : "swift-custom-dump", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 52 | "state" : { 53 | "revision" : "aec6a73f5c1dc1f1be4f61888094b95cf995d973", 54 | "version" : "1.3.2" 55 | } 56 | }, 57 | { 58 | "identity" : "swift-dependencies", 59 | "kind" : "remoteSourceControl", 60 | "location" : "http://github.com/pointfreeco/swift-dependencies", 61 | "state" : { 62 | "revision" : "cc26d06125dbc913c6d9e8a905a5db0b994509e0", 63 | "version" : "1.3.5" 64 | } 65 | }, 66 | { 67 | "identity" : "swift-docc-plugin", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/apple/swift-docc-plugin", 70 | "state" : { 71 | "revision" : "26ac5758409154cc448d7ab82389c520fa8a8247", 72 | "version" : "1.3.0" 73 | } 74 | }, 75 | { 76 | "identity" : "swift-docc-symbolkit", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/apple/swift-docc-symbolkit", 79 | "state" : { 80 | "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", 81 | "version" : "1.0.0" 82 | } 83 | }, 84 | { 85 | "identity" : "swift-identified-collections", 86 | "kind" : "remoteSourceControl", 87 | "location" : "https://github.com/pointfreeco/swift-identified-collections.git", 88 | "state" : { 89 | "revision" : "2f5ab6e091dd032b63dacbda052405756010dc3b", 90 | "version" : "1.1.0" 91 | } 92 | }, 93 | { 94 | "identity" : "swift-syntax", 95 | "kind" : "remoteSourceControl", 96 | "location" : "https://github.com/swiftlang/swift-syntax", 97 | "state" : { 98 | "revision" : "4c6cc0a3b9e8f14b3ae2307c5ccae4de6167ac2c", 99 | "version" : "600.0.0-prerelease-2024-06-12" 100 | } 101 | }, 102 | { 103 | "identity" : "swift-tagged", 104 | "kind" : "remoteSourceControl", 105 | "location" : "https://github.com/pointfreeco/swift-tagged.git", 106 | "state" : { 107 | "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", 108 | "version" : "0.10.0" 109 | } 110 | }, 111 | { 112 | "identity" : "xctest-dynamic-overlay", 113 | "kind" : "remoteSourceControl", 114 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 115 | "state" : { 116 | "revision" : "357ca1e5dd31f613a1d43320870ebc219386a495", 117 | "version" : "1.2.2" 118 | } 119 | } 120 | ], 121 | "version" : 2 122 | } 123 | -------------------------------------------------------------------------------- /SwiftUINavigation.xcworkspace/xcshareddata/xcschemes/SwiftUINavigation.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 54 | 60 | 61 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /Tests/SwiftUINavigationTests/AlertTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import CustomDump 3 | import SwiftUI 4 | import SwiftUINavigation 5 | import XCTest 6 | 7 | final class AlertTests: XCTestCase { 8 | func testAlertState() { 9 | let alert = AlertState( 10 | title: .init("Alert!"), 11 | message: .init("Something went wrong..."), 12 | primaryButton: .destructive(.init("Destroy"), action: .send(true, animation: .easeInOut)), 13 | secondaryButton: .cancel(.init("Cancel"), action: .send(false)) 14 | ) 15 | XCTAssertNoDifference( 16 | alert, 17 | AlertState( 18 | title: .init("Alert!"), 19 | message: .init("Something went wrong..."), 20 | primaryButton: .destructive(.init("Destroy"), action: .send(true, animation: .easeInOut)), 21 | secondaryButton: .cancel(.init("Cancel"), action: .send(false)) 22 | ) 23 | ) 24 | 25 | var dump = "" 26 | customDump(alert, to: &dump) 27 | XCTAssertNoDifference( 28 | dump, 29 | """ 30 | AlertState( 31 | title: "Alert!", 32 | actions: [ 33 | [0]: ButtonState( 34 | role: .destructive, 35 | action: .send( 36 | true, 37 | animation: Animation.easeInOut 38 | ), 39 | label: "Destroy" 40 | ), 41 | [1]: ButtonState( 42 | role: .cancel, 43 | action: .send( 44 | false 45 | ), 46 | label: "Cancel" 47 | ) 48 | ], 49 | message: "Something went wrong..." 50 | ) 51 | """ 52 | ) 53 | 54 | if #available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) { 55 | dump = "" 56 | customDump( 57 | ConfirmationDialogState( 58 | title: .init("Alert!"), 59 | message: .init("Something went wrong..."), 60 | buttons: [ 61 | .destructive(.init("Destroy"), action: .send(true, animation: .easeInOut)), 62 | .cancel(.init("Cancel"), action: .send(false)), 63 | ] 64 | ), 65 | to: &dump 66 | ) 67 | XCTAssertNoDifference( 68 | dump, 69 | """ 70 | ConfirmationDialogState( 71 | title: "Alert!", 72 | actions: [ 73 | [0]: ButtonState( 74 | role: .destructive, 75 | action: .send( 76 | true, 77 | animation: Animation.easeInOut 78 | ), 79 | label: "Destroy" 80 | ), 81 | [1]: ButtonState( 82 | role: .cancel, 83 | action: .send( 84 | false 85 | ), 86 | label: "Cancel" 87 | ) 88 | ], 89 | message: "Something went wrong..." 90 | ) 91 | """ 92 | ) 93 | } 94 | } 95 | } 96 | 97 | // NB: This is a compile time test to make sure that async action closures can be used in 98 | // Swift <5.7. 99 | @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) 100 | private struct TestView: View { 101 | @State var alert: AlertState? 102 | enum AlertAction { 103 | case confirm 104 | case deny 105 | } 106 | 107 | var body: some View { 108 | Text("") 109 | .alert(self.$alert) { 110 | await self.alertButtonTapped($0) 111 | } 112 | } 113 | 114 | private func alertButtonTapped(_ action: AlertAction?) async { 115 | switch action { 116 | case .some(.confirm), .some(.deny), .none: 117 | break 118 | } 119 | } 120 | } 121 | #endif // canImport(SwiftUI) 122 | -------------------------------------------------------------------------------- /Tests/SwiftUINavigationTests/BindingTests.swift: -------------------------------------------------------------------------------- 1 | #if swift(>=5.9) && canImport(SwiftUI) 2 | import CustomDump 3 | import SwiftUI 4 | import SwiftUINavigation 5 | import XCTest 6 | 7 | final class BindingTests: XCTestCase { 8 | @CasePathable 9 | @dynamicMemberLookup 10 | enum Status: Equatable { 11 | case inStock(quantity: Int) 12 | case outOfStock(isOnBackOrder: Bool) 13 | } 14 | 15 | @MainActor 16 | func testCaseLookup() throws { 17 | @Binding var status: Status 18 | _status = Binding(initialValue: .inStock(quantity: 1)) 19 | 20 | let inStock = try XCTUnwrap($status.inStock) 21 | inStock.wrappedValue += 1 22 | 23 | XCTAssertEqual(status, .inStock(quantity: 2)) 24 | } 25 | 26 | @MainActor 27 | func testCaseCannotReplaceOtherCase() throws { 28 | @Binding var status: Status 29 | _status = Binding(initialValue: .inStock(quantity: 1)) 30 | 31 | let inStock = try XCTUnwrap($status.inStock) 32 | 33 | status = .outOfStock(isOnBackOrder: true) 34 | 35 | inStock.wrappedValue = 42 36 | XCTAssertEqual(status, .outOfStock(isOnBackOrder: true)) 37 | } 38 | 39 | @MainActor 40 | func testDestinationCannotReplaceOtherDestination() throws { 41 | #if os(iOS) || os(macOS) 42 | try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] != nil) 43 | 44 | @Binding var destination: Status? 45 | _destination = Binding(initialValue: .inStock(quantity: 1)) 46 | 47 | let inStock = try XCTUnwrap($destination.inStock) 48 | 49 | destination = .outOfStock(isOnBackOrder: true) 50 | 51 | inStock.wrappedValue = 42 52 | XCTAssertEqual(destination, .outOfStock(isOnBackOrder: true)) 53 | #endif 54 | } 55 | } 56 | 57 | extension Binding { 58 | @MainActor 59 | fileprivate init(initialValue: Value) { 60 | var value = initialValue 61 | self.init( 62 | get: { value }, 63 | set: { value = $0 } 64 | ) 65 | } 66 | } 67 | #endif // canImport(SwiftUI) 68 | -------------------------------------------------------------------------------- /Tests/SwiftUINavigationTests/ButtonStateTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import CustomDump 3 | import SwiftUI 4 | import SwiftUINavigation 5 | import XCTest 6 | 7 | final class ButtonStateTests: XCTestCase { 8 | func testAsyncAnimationWarning() async { 9 | XCTExpectFailure { 10 | $0.compactDescription == """ 11 | failed - An animated action was performed asynchronously: … 12 | 13 | Action: 14 | ButtonStateAction.send( 15 | (), 16 | animation: Animation.easeInOut 17 | ) 18 | 19 | Asynchronous actions cannot be animated. Evaluate this action in a synchronous closure, or \ 20 | use 'SwiftUI.withAnimation' explicitly. 21 | """ 22 | } 23 | 24 | let button = ButtonState(action: .send((), animation: .easeInOut)) { 25 | TextState("Animate!") 26 | } 27 | 28 | await button.withAction { _ in 29 | await Task.yield() 30 | } 31 | } 32 | } 33 | #endif // canImport(SwiftUI) 34 | -------------------------------------------------------------------------------- /Tests/SwiftUINavigationTests/SwiftUINavigationTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import SwiftUI 3 | import XCTest 4 | 5 | @testable import SwiftUINavigation 6 | 7 | final class SwiftUINavigationTests: XCTestCase { 8 | @MainActor 9 | func testBindingUnwrap() throws { 10 | var value: Int? 11 | let binding = Binding(get: { value }, set: { value = $0 }) 12 | 13 | XCTAssertNil(Binding(unwrapping: binding)) 14 | 15 | binding.wrappedValue = 1 16 | let unwrapped = try XCTUnwrap(Binding(unwrapping: binding)) 17 | XCTAssertEqual(binding.wrappedValue, 1) 18 | XCTAssertEqual(unwrapped.wrappedValue, 1) 19 | 20 | unwrapped.wrappedValue = 42 21 | XCTAssertEqual(binding.wrappedValue, 42) 22 | XCTAssertEqual(unwrapped.wrappedValue, 42) 23 | 24 | binding.wrappedValue = 1729 25 | XCTAssertEqual(binding.wrappedValue, 1729) 26 | XCTAssertEqual(unwrapped.wrappedValue, 1729) 27 | 28 | binding.wrappedValue = nil 29 | XCTAssertEqual(binding.wrappedValue, nil) 30 | XCTAssertEqual(unwrapped.wrappedValue, 1729) 31 | } 32 | 33 | @MainActor 34 | func testBindingCase() throws { 35 | struct MyError: Error, Equatable {} 36 | var value: Result? = nil 37 | let binding = Binding(get: { value }, set: { value = $0 }) 38 | 39 | let success = binding.case(/Result.success) 40 | let failure = binding.case(/Result.failure) 41 | XCTAssertEqual(binding.wrappedValue, nil) 42 | XCTAssertEqual(success.wrappedValue, nil) 43 | XCTAssertEqual(failure.wrappedValue, nil) 44 | 45 | binding.wrappedValue = .success(1) 46 | XCTAssertEqual(binding.wrappedValue, .success(1)) 47 | XCTAssertEqual(success.wrappedValue, 1) 48 | XCTAssertEqual(failure.wrappedValue, nil) 49 | 50 | success.wrappedValue = 42 51 | XCTAssertEqual(binding.wrappedValue, .success(42)) 52 | XCTAssertEqual(success.wrappedValue, 42) 53 | XCTAssertEqual(failure.wrappedValue, nil) 54 | 55 | failure.wrappedValue = MyError() 56 | XCTAssertEqual(binding.wrappedValue, .failure(MyError())) 57 | XCTAssertEqual(success.wrappedValue, nil) 58 | XCTAssertEqual(failure.wrappedValue, MyError()) 59 | 60 | success.wrappedValue = nil 61 | XCTAssertEqual(binding.wrappedValue, nil) 62 | XCTAssertEqual(success.wrappedValue, nil) 63 | XCTAssertEqual(failure.wrappedValue, nil) 64 | } 65 | } 66 | #endif // canImport(SwiftUI) 67 | -------------------------------------------------------------------------------- /Tests/SwiftUINavigationTests/TextStateTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import CustomDump 3 | import SwiftUINavigation 4 | import XCTest 5 | 6 | final class TextStateTests: XCTestCase { 7 | func testTextState() { 8 | var dump = "" 9 | customDump(TextState("Hello, world!"), to: &dump) 10 | XCTAssertEqual( 11 | dump, 12 | """ 13 | "Hello, world!" 14 | """ 15 | ) 16 | 17 | dump = "" 18 | customDump( 19 | TextState("Hello, ") 20 | + TextState("world").bold().italic() 21 | + TextState("!"), 22 | to: &dump 23 | ) 24 | XCTAssertEqual( 25 | dump, 26 | """ 27 | "Hello, _**world**_!" 28 | """ 29 | ) 30 | 31 | dump = "" 32 | customDump( 33 | TextState("Offset by 10.5").baselineOffset(10.5) 34 | + TextState("\n") + TextState("Headline").font(.headline) 35 | + TextState("\n") + TextState("No font").font(nil) 36 | + TextState("\n") + TextState("Light font weight").fontWeight(.light) 37 | + TextState("\n") + TextState("No font weight").fontWeight(nil) 38 | + TextState("\n") + TextState("Red").foregroundColor(.red) 39 | + TextState("\n") + TextState("No color").foregroundColor(nil) 40 | + TextState("\n") + TextState("Italic").italic() 41 | + TextState("\n") + TextState("Kerning of 2.5").kerning(2.5) 42 | + TextState("\n") + TextState("Stricken").strikethrough() 43 | + TextState("\n") + TextState("Stricken green").strikethrough(color: .green) 44 | + TextState("\n") + TextState("Not stricken blue").strikethrough(false, color: .blue) 45 | + TextState("\n") + TextState("Tracking of 5.5").tracking(5.5) 46 | + TextState("\n") + TextState("Underlined").underline() 47 | + TextState("\n") + TextState("Underlined pink").underline(color: .pink) 48 | + TextState("\n") + TextState("Not underlined purple").underline(false, color: .pink), 49 | to: &dump 50 | ) 51 | XCTAssertNoDifference( 52 | dump, 53 | #""" 54 | """ 55 | Offset by 10.5 56 | Headline 57 | No font 58 | Light font weight 59 | No font weight 60 | Red 61 | No color 62 | _Italic_ 63 | Kerning of 2.5 64 | ~~Stricken~~ 65 | Stricken green 66 | Not stricken blue 67 | Tracking of 5.5 68 | Underlined 69 | Underlined pink 70 | Not underlined purple 71 | """ 72 | """# 73 | ) 74 | } 75 | } 76 | #endif // canImport(SwiftUI) 77 | --------------------------------------------------------------------------------