diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..410ff6c9ed --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +# editorconfig.org + +root = true + +[*] +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index 703a4725a4..6f9886f04d 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -2,83 +2,131 @@ ## Our Pledge -We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. -We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. ## Our Standards -Examples of behavior that contributes to a positive environment for our community include: +Examples of behavior that contributes to a positive environment for our +community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience -* Focusing on what is best not just for us as individuals, but for the overall community +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community Examples of unacceptable behavior include: -* The use of sexualized language or imagery, and sexual attention or - advances of any kind +* The use of sexualized language or imagery, and sexual attention or advances of + any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment -* Publishing others' private information, such as a physical or email - address, without their explicit permission +* Publishing others' private information, such as a physical or email address, + without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities -Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. -Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. ## Scope -This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at support@pointfree.co. All complaints will be reviewed and investigated promptly and fairly. +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +. +All complaints will be reviewed and investigated promptly and fairly. -All community leaders are obligated to respect the privacy and security of the reporter of any incident. +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. ## Enforcement Guidelines -Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction -**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. -**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. ### 2. Warning -**Community Impact**: A violation through a single incident or series of actions. +**Community Impact**: A violation through a single incident or series of +actions. -**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. ### 3. Temporary Ban -**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. -**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. ### 4. Permanent Ban -**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. -**Consequence**: A permanent ban from any sort of public interaction within the community. +**Consequence**: A permanent ban from any sort of public interaction within the +community. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, -available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. -Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). - -[homepage]: https://www.contributor-covenant.org +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index bb810f7097..0000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: '' -assignees: '' - ---- - -**Describe the bug** -Give a clear and concise description of what the bug is. - -**To Reproduce** -Zip up a project that reproduces the behavior and attach it by dragging it here. - -```swift -// And/or enter code that reproduces the behavior here. - -``` - -**Expected behavior** -Give a clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Environment** - - swiftui-navigation version [e.g. 0.1.0] - - Xcode [e.g. 13.1] - - Swift [e.g. 5.5] - - OS: [e.g. iOS 15] - -**Additional context** -Add any more context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000..6b053415e1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,73 @@ +name: Bug Report +description: Something isn't working as expected +labels: [bug] +body: +- type: markdown + attributes: + value: | + Thank you for contributing to the SwiftUI Navigation! + + 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. +- type: textarea + attributes: + label: Description + description: | + A short description of the incorrect behavior. + + 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. + validations: + required: true +- type: checkboxes + attributes: + label: Checklist + options: + - label: I have determined whether this bug is also reproducible in a vanilla SwiftUI project. + required: false + - label: If possible, I've reproduced the issue using the `main` branch of this package. + required: false + - label: This issue hasn't been addressed in an [existing GitHub issue](https://github.com/pointfreeco/swift-navigation/issues) or [discussion](https://github.com/pointfreeco/swiftui-navigation/discussions). + required: true +- type: textarea + attributes: + label: Expected behavior + description: Describe what you expected to happen. + validations: + required: false +- type: textarea + attributes: + label: Actual behavior + description: Describe or copy/paste the behavior you observe. + validations: + required: false +- type: textarea + attributes: + label: Steps to reproduce + description: | + Explanation of how to reproduce the incorrect behavior. + + This could include an attached project or link to code that is exhibiting the issue, and/or a screen recording. + placeholder: | + 1. ... + validations: + required: false +- type: input + attributes: + label: SwiftUI Navigation version information + description: The version of SwiftUI Navigation used to reproduce this issue. + placeholder: "'0.7.0' for example, or a commit hash" +- type: input + attributes: + label: Destination operating system + description: The OS running your application. + placeholder: "'iOS 16' for example" +- type: input + attributes: + label: Xcode version information + description: The version of Xcode used to reproduce this issue. + placeholder: "The version displayed from 'Xcode 〉About Xcode'" +- type: textarea + attributes: + label: Swift Compiler version information + description: The version of Swift used to reproduce this issue. + placeholder: Output from 'xcrun swiftc --version' + render: shell diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..57a87b3290 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,15 @@ +blank_issues_enabled: false + +contact_links: + - name: Project Discussion + url: https://github.com/pointfreeco/swift-navigation/discussions + about: SwiftUI Navigation Q&A, ideas, and more + - name: Documentation + url: https://pointfreeco.github.io/swift-navigation/main/documentation/swiftnavigation/ + about: Read SwiftUI Navigation's documentation + - name: Videos + url: https://www.pointfree.co/ + about: Watch videos to get a behind-the-scenes look at how SwiftUI Navigation was motivated and built + - name: Slack + url: https://www.pointfree.co/slack-invite + about: Community chat diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md deleted file mode 100644 index e9a7a08d03..0000000000 --- a/.github/ISSUE_TEMPLATE/question.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: Question -about: Have a question about the SwiftUI Navigation? -title: '' -labels: '' -assignees: '' - ---- - -SwiftUI Navigation uses GitHub issues for bugs. For more general discussion and help, please use [GitHub Discussions](https://github.com/pointfreeco/swiftui-navigation/discussions). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78cbb08ed8..ef59e59fdc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,22 +7,103 @@ on: pull_request: branches: - '*' + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true jobs: library: - runs-on: macos-11.0 + runs-on: macos-15 + strategy: + matrix: + xcode: + - '16.2' + variation: + - ios + - macos + - tvos + - watchos + - examples + + steps: + - uses: actions/checkout@v4 + - name: Select Xcode ${{ matrix.xcode }} + run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + - name: Skip macro validation + run: defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES + - name: Run tests + run: make test-${{ matrix.variation }} + + library-15-4-compatibility: + runs-on: macos-14 + strategy: + matrix: + xcode: + - '15.4' + ios_version: + - '17.5' + variation: + - ios + steps: + - uses: actions/checkout@v4 + - name: Select Xcode ${{ matrix.xcode }} + run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + - name: Skip macro validation + run: defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES + - name: Run tests + run: make IOS_VERSION=${{matrix.ios_version}} test-${{ matrix.variation }} + + library-evolution: + name: Library Evolution + runs-on: macos-15 strategy: matrix: xcode: - - '12.4' - - '12.5.1' - - '13.1' + - '16.2' steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Select Xcode ${{ matrix.xcode }} run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + - name: Skip macro validation + run: defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES + - name: Build for Library Evolution + run: make build-for-library-evolution + + wasm: + name: Wasm + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: bytecodealliance/actions/wasmtime/setup@v1 + - name: Install Swift and Swift SDK for WebAssembly + run: | + PREFIX=/opt/swift + set -ex + curl -f -o /tmp/swift.tar.gz "https://download.swift.org/swift-6.0.3-release/ubuntu2204/swift-6.0.3-RELEASE/swift-6.0.3-RELEASE-ubuntu22.04.tar.gz" + sudo mkdir -p $PREFIX; sudo tar -xzf /tmp/swift.tar.gz -C $PREFIX --strip-component 1 + $PREFIX/usr/bin/swift sdk install https://github.com/swiftwasm/swift/releases/download/swift-wasm-6.0.3-RELEASE/swift-wasm-6.0.3-RELEASE-wasm32-unknown-wasi.artifactbundle.zip --checksum 31d3585b06dd92de390bacc18527801480163188cd7473f492956b5e213a8618 + echo "$PREFIX/usr/bin" >> $GITHUB_PATH + + - name: Build tests + run: swift build --swift-sdk wasm32-unknown-wasi --build-tests -Xlinker -z -Xlinker stack-size=$((1024 * 1024)) - name: Run tests - run: make test - - name: Compile documentation - if: ${{ matrix.xcode == '13.1' }} - run: make test-docs + run: wasmtime --dir . .build/debug/swift-navigationPackageTests.wasm + + # windows: + # name: Windows + # strategy: + # matrix: + # os: [windows-latest] + # config: ['debug', 'release'] + # fail-fast: false + # runs-on: ${{ matrix.os }} + # steps: + # - uses: compnerd/gha-setup-swift@main + # with: + # branch: swift-5.10-release + # tag: 5.10-RELEASE + # - uses: actions/checkout@v4 + # - name: Build + # run: swift build -c ${{ matrix.config }} diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml deleted file mode 100644 index 3e980a292d..0000000000 --- a/.github/workflows/documentation.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Documentation -on: - release: - types: - - published - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - name: Generate Documentation - uses: SwiftDocOrg/swift-doc@master - with: - base-url: /swiftui-navigation/ - format: html - inputs: Sources/SwiftUINavigation - module-name: SwiftUINavigation - output: Documentation - - name: Update Permissions - run: 'sudo chown --recursive $USER Documentation' - - name: Deploy to GitHub Pages - uses: JamesIves/github-pages-deploy-action@releases/v3 - with: - ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} - BRANCH: gh-pages - FOLDER: Documentation diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index dc82144b9d..c20eb1686a 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -8,15 +8,13 @@ on: jobs: swift_format: name: swift-format - runs-on: macOS-11 + runs-on: macOS-14 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Xcode Select - run: sudo xcode-select -s /Applications/Xcode_13.0.app - - name: Tap - run: brew tap pointfreeco/formulae + run: sudo xcode-select -s /Applications/Xcode_15.4.app - name: Install - run: brew install Formulae/swift-format@5.5 + run: brew install swift-format - name: Format run: make format - uses: stefanzweifel/git-auto-commit-action@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..568c5c201b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,83 @@ +name: Release +on: + release: + types: [published] + workflow_dispatch: +jobs: + project-channel: + runs-on: ubuntu-latest + steps: + - name: Dump Github context + env: + GITHUB_CONTEXT: ${{ toJSON(github) }} + run: echo "$GITHUB_CONTEXT" + - name: Slack Notification on SUCCESS + if: success() + uses: tokorom/action-slack-incoming-webhook@main + env: + INCOMING_WEBHOOK_URL: ${{ secrets.SLACK_PROJECT_CHANNEL_WEBHOOK_URL }} + with: + text: swift-navigation ${{ github.event.release.tag_name }} has been released. + blocks: | + [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "swift-navigation ${{ github.event.release.tag_name}}" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ${{ toJSON(github.event.release.body) }} + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "${{ github.event.release.html_url }}" + } + } + ] + + releases-channel: + runs-on: ubuntu-latest + steps: + - name: Dump Github context + env: + GITHUB_CONTEXT: ${{ toJSON(github) }} + run: echo "$GITHUB_CONTEXT" + - name: Slack Notification on SUCCESS + if: success() + uses: tokorom/action-slack-incoming-webhook@main + env: + INCOMING_WEBHOOK_URL: ${{ secrets.SLACK_RELEASES_WEBHOOK_URL }} + with: + text: swift-navigation ${{ github.event.release.tag_name }} has been released. + blocks: | + [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "swift-navigation ${{ github.event.release.tag_name}}" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ${{ toJSON(github.event.release.body) }} + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "${{ github.event.release.html_url }}" + } + } + ] diff --git a/.gitignore b/.gitignore index bb460e7be9..f2c6e1ebaf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store /.build +/.swiftpm /Packages /*.xcodeproj xcuserdata/ diff --git a/.spi.yml b/.spi.yml index 8bfa4337f0..a840ae64ec 100644 --- a/.spi.yml +++ b/.spi.yml @@ -1,5 +1,9 @@ version: 1 builder: configs: - - platform: watchos - scheme: SwiftUINavigation_watchOS + - documentation_targets: + - SwiftNavigation + - AppKitNavigation + - SwiftUINavigation + - UIKitNavigation + swift_version: 6.0 diff --git a/Examples/CaseStudies/01-Alerts.swift b/Examples/CaseStudies/01-Alerts.swift deleted file mode 100644 index 60a0010990..0000000000 --- a/Examples/CaseStudies/01-Alerts.swift +++ /dev/null @@ -1,50 +0,0 @@ -import SwiftUINavigation - -@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) -struct OptionalAlerts: View { - @ObservedObject private var viewModel = ViewModel() - - var body: some View { - List { - Stepper("Number: \(self.viewModel.count)", value: self.$viewModel.count) - Button(action: { self.viewModel.numberFactButtonTapped() }) { - HStack { - Text("Get number fact") - if self.viewModel.isLoading { - Spacer() - ProgressView() - } - } - } - .disabled(self.viewModel.isLoading) - } - .alert( - title: { Text("Fact about \($0.number)") }, - unwrapping: self.$viewModel.fact, - actions: { - Button("Get another fact about \($0.number)") { - self.viewModel.numberFactButtonTapped() - } - Button("Cancel", role: .cancel) { - self.viewModel.fact = nil - } - }, - message: { Text($0.description) } - ) - .navigationTitle("Alerts") - } -} - -private class ViewModel: ObservableObject { - @Published var count = 0 - @Published var isLoading = false - @Published var fact: Fact? - - func numberFactButtonTapped() { - self.isLoading = true - Task { @MainActor in - self.fact = await getNumberFact(self.count) - self.isLoading = false - } - } -} diff --git a/Examples/CaseStudies/02-ConfirmationDialogs.swift b/Examples/CaseStudies/02-ConfirmationDialogs.swift deleted file mode 100644 index 58e760bf56..0000000000 --- a/Examples/CaseStudies/02-ConfirmationDialogs.swift +++ /dev/null @@ -1,49 +0,0 @@ -import SwiftUI -import SwiftUINavigation - -@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) -struct OptionalConfirmationDialogs: View { - @ObservedObject private var viewModel = ViewModel() - - var body: some View { - List { - Stepper("Number: \(self.viewModel.count)", value: self.$viewModel.count) - Button(action: { self.viewModel.numberFactButtonTapped() }) { - HStack { - Text("Get number fact") - if self.viewModel.isLoading { - Spacer() - ProgressView() - } - } - } - .disabled(self.viewModel.isLoading) - } - .confirmationDialog( - title: { Text("Fact about \($0.number)") }, - titleVisibility: .visible, - unwrapping: self.$viewModel.fact, - actions: { - Button("Get another fact about \($0.number)") { - self.viewModel.numberFactButtonTapped() - } - }, - message: { Text($0.description) } - ) - .navigationTitle("Confirmation dialogs") - } -} - -private class ViewModel: ObservableObject { - @Published var count = 0 - @Published var isLoading = false - @Published var fact: Fact? - - func numberFactButtonTapped() { - self.isLoading = true - Task { @MainActor in - self.fact = await getNumberFact(self.count) - self.isLoading = false - } - } -} diff --git a/Examples/CaseStudies/03-Sheets.swift b/Examples/CaseStudies/03-Sheets.swift deleted file mode 100644 index ddd99dacb6..0000000000 --- a/Examples/CaseStudies/03-Sheets.swift +++ /dev/null @@ -1,104 +0,0 @@ -import SwiftUI -import SwiftUINavigation - -struct OptionalSheets: View { - @ObservedObject private var viewModel = ViewModel() - - var body: some View { - List { - Section { - Stepper("Number: \(self.viewModel.count)", value: self.$viewModel.count) - - HStack { - Button("Get number fact") { - self.viewModel.numberFactButtonTapped() - } - - if self.viewModel.isLoading { - Spacer() - ProgressView() - } - } - } header: { - Text("Fact Finder") - } - - Section { - ForEach(self.viewModel.savedFacts) { fact in - Text(fact.description) - } - .onDelete { self.viewModel.removeSavedFacts(atOffsets: $0) } - } header: { - Text("Saved Facts") - } - } - .sheet(unwrapping: self.$viewModel.fact) { $fact in - NavigationView { - FactEditor(fact: $fact.description) - .disabled(self.viewModel.isLoading) - .foregroundColor(self.viewModel.isLoading ? .gray : nil) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - self.viewModel.cancelButtonTapped() - } - } - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - self.viewModel.saveButtonTapped(fact: fact) - } - } - } - } - } - .navigationTitle("Sheets") - } -} - -private struct FactEditor: View { - @Binding var fact: String - - var body: some View { - VStack { - TextEditor(text: self.$fact) - } - .padding() - .navigationTitle("Fact Editor") - } -} - -private class ViewModel: ObservableObject { - @Published var count = 0 - @Published var fact: Fact? - @Published var isLoading = false - @Published var savedFacts: [Fact] = [] - private var task: Task? - - func numberFactButtonTapped() { - self.isLoading = true - self.fact = Fact(description: "\(self.count) is still loading...", number: self.count) - self.task = Task { @MainActor in - let fact = await getNumberFact(self.count) - self.isLoading = false - try Task.checkCancellation() - self.fact = fact - } - } - - func cancelButtonTapped() { - self.task?.cancel() - self.task = nil - self.fact = nil - } - - func saveButtonTapped(fact: Fact) { - self.task?.cancel() - self.task = nil - self.savedFacts.append(fact) - self.fact = nil - } - - func removeSavedFacts(atOffsets offsets: IndexSet) { - self.savedFacts.remove(atOffsets: offsets) - } -} diff --git a/Examples/CaseStudies/04-Popovers.swift b/Examples/CaseStudies/04-Popovers.swift deleted file mode 100644 index 32703dd4ad..0000000000 --- a/Examples/CaseStudies/04-Popovers.swift +++ /dev/null @@ -1,104 +0,0 @@ -import SwiftUI -import SwiftUINavigation - -struct OptionalPopovers: View { - @ObservedObject private var viewModel = ViewModel() - - var body: some View { - List { - Section { - Stepper("Number: \(self.viewModel.count)", value: self.$viewModel.count) - - HStack { - Button("Get number fact") { - self.viewModel.numberFactButtonTapped() - } - - if self.viewModel.isLoading { - Spacer() - ProgressView() - } - } - } header: { - Text("Fact Finder") - } - - Section { - ForEach(self.viewModel.savedFacts) { fact in - Text(fact.description) - } - .onDelete { self.viewModel.removeSavedFacts(atOffsets: $0) } - } header: { - Text("Saved Facts") - } - } - .popover(unwrapping: self.$viewModel.fact) { $fact in - NavigationView { - FactEditor(fact: $fact.description) - .disabled(self.viewModel.isLoading) - .foregroundColor(self.viewModel.isLoading ? .gray : nil) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - self.viewModel.cancelButtonTapped() - } - } - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - self.viewModel.saveButtonTapped(fact: fact) - } - } - } - } - } - .navigationTitle("Sheets") - } -} - -private struct FactEditor: View { - @Binding var fact: String - - var body: some View { - VStack { - TextEditor(text: self.$fact) - } - .padding() - .navigationTitle("Fact Editor") - } -} - -private class ViewModel: ObservableObject { - @Published var count = 0 - @Published var fact: Fact? - @Published var isLoading = false - @Published var savedFacts: [Fact] = [] - private var task: Task? - - func numberFactButtonTapped() { - self.isLoading = true - self.fact = Fact(description: "\(self.count) is still loading...", number: self.count) - self.task = Task { @MainActor in - let fact = await getNumberFact(self.count) - self.isLoading = false - try Task.checkCancellation() - self.fact = fact - } - } - - func cancelButtonTapped() { - self.task?.cancel() - self.task = nil - self.fact = nil - } - - func saveButtonTapped(fact: Fact) { - self.task?.cancel() - self.task = nil - self.savedFacts.append(fact) - self.fact = nil - } - - func removeSavedFacts(atOffsets offsets: IndexSet) { - self.savedFacts.remove(atOffsets: offsets) - } -} diff --git a/Examples/CaseStudies/05-FullScreenCovers.swift b/Examples/CaseStudies/05-FullScreenCovers.swift deleted file mode 100644 index a980bea75f..0000000000 --- a/Examples/CaseStudies/05-FullScreenCovers.swift +++ /dev/null @@ -1,104 +0,0 @@ -import SwiftUI -import SwiftUINavigation - -struct OptionalFullScreenCovers: View { - @ObservedObject private var viewModel = ViewModel() - - var body: some View { - List { - Section { - Stepper("Number: \(self.viewModel.count)", value: self.$viewModel.count) - - HStack { - Button("Get number fact") { - self.viewModel.numberFactButtonTapped() - } - - if self.viewModel.isLoading { - Spacer() - ProgressView() - } - } - } header: { - Text("Fact Finder") - } - - Section { - ForEach(self.viewModel.savedFacts) { fact in - Text(fact.description) - } - .onDelete { self.viewModel.removeSavedFacts(atOffsets: $0) } - } header: { - Text("Saved Facts") - } - } - .fullScreenCover(unwrapping: self.$viewModel.fact) { $fact in - NavigationView { - FactEditor(fact: $fact.description) - .disabled(self.viewModel.isLoading) - .foregroundColor(self.viewModel.isLoading ? .gray : nil) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - self.viewModel.cancelButtonTapped() - } - } - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - self.viewModel.saveButtonTapped(fact: fact) - } - } - } - } - } - .navigationTitle("Sheets") - } -} - -private struct FactEditor: View { - @Binding var fact: String - - var body: some View { - VStack { - TextEditor(text: self.$fact) - } - .padding() - .navigationTitle("Fact Editor") - } -} - -private class ViewModel: ObservableObject { - @Published var count = 0 - @Published var fact: Fact? - @Published var isLoading = false - @Published var savedFacts: [Fact] = [] - private var task: Task? - - func numberFactButtonTapped() { - self.isLoading = true - self.fact = Fact(description: "\(self.count) is still loading...", number: self.count) - self.task = Task { @MainActor in - let fact = await getNumberFact(self.count) - self.isLoading = false - try Task.checkCancellation() - self.fact = fact - } - } - - func cancelButtonTapped() { - self.task?.cancel() - self.task = nil - self.fact = nil - } - - func saveButtonTapped(fact: Fact) { - self.task?.cancel() - self.task = nil - self.savedFacts.append(fact) - self.fact = nil - } - - func removeSavedFacts(atOffsets offsets: IndexSet) { - self.savedFacts.remove(atOffsets: offsets) - } -} diff --git a/Examples/CaseStudies/06-NavigationLinks.swift b/Examples/CaseStudies/06-NavigationLinks.swift deleted file mode 100644 index e2c8916537..0000000000 --- a/Examples/CaseStudies/06-NavigationLinks.swift +++ /dev/null @@ -1,108 +0,0 @@ -import SwiftUI -import SwiftUINavigation - -struct OptionalNavigationLinks: View { - @ObservedObject private var viewModel = ViewModel() - - var body: some View { - List { - Section { - Stepper("Number: \(self.viewModel.count)", value: self.$viewModel.count) - - HStack { - NavigationLink( - unwrapping: self.$viewModel.fact, - destination: { $fact in - FactEditor(fact: $fact.description) - .disabled(self.viewModel.isLoading) - .foregroundColor(self.viewModel.isLoading ? .gray : nil) - .navigationBarBackButtonHidden(true) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - self.viewModel.cancelButtonTapped() - } - } - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - self.viewModel.saveButtonTapped(fact: fact) - } - } - } - }, - onNavigate: { self.viewModel.setFactNavigation(isActive: $0) } - ) { - Text("Get number fact") - } - - if self.viewModel.isLoading { - Spacer() - ProgressView() - } - } - } header: { - Text("Fact Finder") - } - - Section { - ForEach(self.viewModel.savedFacts) { fact in - Text(fact.description) - } - .onDelete { self.viewModel.removeSavedFacts(atOffsets: $0) } - } header: { - Text("Saved Facts") - } - } - .navigationTitle("Links") - } -} - -private struct FactEditor: View { - @Binding var fact: String - - var body: some View { - VStack { - TextEditor(text: self.$fact) - } - .padding() - .navigationTitle("Fact Editor") - } -} - -private class ViewModel: ObservableObject { - @Published var count = 0 - @Published var fact: Fact? - @Published var isLoading = false - @Published var savedFacts: [Fact] = [] - private var task: Task? - - func setFactNavigation(isActive: Bool) { - if isActive { - self.isLoading = true - self.fact = Fact(description: "\(self.count) is still loading...", number: self.count) - self.task = Task { @MainActor in - let fact = await getNumberFact(self.count) - self.isLoading = false - try Task.checkCancellation() - self.fact = fact - } - } else { - self.task?.cancel() - self.task = nil - self.fact = nil - } - } - - func cancelButtonTapped() { - self.setFactNavigation(isActive: false) - } - - func saveButtonTapped(fact: Fact) { - self.savedFacts.append(fact) - self.setFactNavigation(isActive: false) - } - - func removeSavedFacts(atOffsets offsets: IndexSet) { - self.savedFacts.remove(atOffsets: offsets) - } -} diff --git a/Examples/CaseStudies/07-NavigationLinkList.swift b/Examples/CaseStudies/07-NavigationLinkList.swift deleted file mode 100644 index 4a5d0f68d0..0000000000 --- a/Examples/CaseStudies/07-NavigationLinkList.swift +++ /dev/null @@ -1,146 +0,0 @@ -import SwiftUI -import SwiftUINavigation - -private let readMe = """ - This case study demonstrates how to model a list of navigation links. Tap a row to drill down \ - and edit a counter. Edit screen allows cancelling or saving the edits. - - The domain for a row in the list has its own ObservableObject and Route enum, and it uses the \ - library's NavigationLink initializer to drive navigation from the route enum. - """ - -struct ListOfNavigationLinks: View { - @ObservedObject var viewModel: ListOfNavigationLinksViewModel - - var body: some View { - Form { - Section { - Text(readMe) - } - - List { - ForEach(self.viewModel.rows) { rowViewModel in - RowView(viewModel: rowViewModel) - } - .onDelete(perform: self.viewModel.deleteButtonTapped(indexSet:)) - } - } - .navigationTitle("List of Links") - .toolbar { - ToolbarItem { - Button("Add") { - self.viewModel.addButtonTapped() - } - } - } - } -} - -class ListOfNavigationLinksViewModel: ObservableObject { - @Published var rows: [ListOfNavigationLinksRowViewModel] - - init(rows: [ListOfNavigationLinksRowViewModel] = []) { - self.rows = rows - } - - func addButtonTapped() { - withAnimation { - self.rows.append(.init()) - } - } - - func deleteButtonTapped(indexSet: IndexSet) { - self.rows.remove(atOffsets: indexSet) - } -} - -private struct RowView: View { - @ObservedObject var viewModel: ListOfNavigationLinksRowViewModel - - var body: some View { - NavigationLink( - unwrapping: self.$viewModel.route, - case: /ListOfNavigationLinksRowViewModel.Route.edit - ) { $counter in - EditView(counter: $counter) - .navigationBarBackButtonHidden(true) - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button("Save") { self.viewModel.saveButtonTapped(counter: counter) } - } - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { self.viewModel.cancelButtonTapped() } - } - } - } onNavigate: { - self.viewModel.setEditNavigation(isActive: $0) - } label: { - Text("\(self.viewModel.counter)") - } - } -} - -class ListOfNavigationLinksRowViewModel: Identifiable, ObservableObject { - let id = UUID() - @Published var counter: Int - @Published var route: Route? - - enum Route { - case edit(Int) - } - - init( - counter: Int = 0, - route: Route? = nil - ) { - self.counter = counter - self.route = route - } - - func setEditNavigation(isActive: Bool) { - self.route = isActive ? .edit(self.counter) : nil - } - - func saveButtonTapped(counter: Int) { - self.counter = counter - self.route = nil - } - - func cancelButtonTapped() { - self.route = nil - } -} - -private struct EditView: View { - @Binding var counter: Int - - var body: some View { - Form { - Text("Count: \(self.counter)") - Button("Increment") { - self.counter += 1 - } - Button("Decrement") { - self.counter -= 1 - } - } - } -} - -struct ListOfNavigationLinks_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - ListOfNavigationLinks( - viewModel: .init( - rows: [ - .init(counter: 0), - .init(counter: 0), - .init(counter: 0), - .init(counter: 0), - .init(counter: 0), - ] - ) - ) - } - } -} diff --git a/Examples/CaseStudies/08-Routing.swift b/Examples/CaseStudies/08-Routing.swift deleted file mode 100644 index 2505ee5806..0000000000 --- a/Examples/CaseStudies/08-Routing.swift +++ /dev/null @@ -1,82 +0,0 @@ -import SwiftUINavigation - -private let readMe = """ - This case study demonstrates how to power multiple forms of navigation from a single route enum \ - that describes all of the possible destinations one can travel to from this screen. - - The screen has three navigation destinations: an alert, a navigation link to a count stepper, \ - and a modal sheet to a count stepper. The state for each of these destinations is held as \ - associated data of an enum, and bindings to the cases of that enum are derived using \ - the tools in this library. - """ - -enum Route { - case alert(String) - case link(Int) - case sheet(Int) -} - -struct Routing: View { - @State var route: Route? - - var body: some View { - Form { - Section { - Text(readMe) - } - - Button("Alert") { - self.route = .alert("Hello world!") - } - .alert( - title: { Text($0) }, - unwrapping: self.$route, - case: /Route.alert, - actions: { _ in - Button("Activate link") { - self.route = .link(0) - } - Button("Activate sheet") { - self.route = .sheet(0) - } - Button("Cancel", role: .cancel) { - } - }, - message: { _ in - - } - ) - - NavigationLink(unwrapping: self.$route, case: /Route.link) { $count in - Form { - Stepper("Number: \(count)", value: $count) - } - } onNavigate: { - self.route = $0 ? .link(0) : nil - } label: { - Text("Link") - } - - Button("Sheet") { - self.route = .sheet(0) - } - .sheet( - unwrapping: self.$route, - case: /Route.sheet - ) { $count in - Form { - Stepper("Number: \(count)", value: $count) - } - } - } - .navigationTitle("Routing") - } -} - -struct Routing_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - Routing() - } - } -} diff --git a/Examples/CaseStudies/09-CustomComponents.swift b/Examples/CaseStudies/09-CustomComponents.swift deleted file mode 100644 index 029806d322..0000000000 --- a/Examples/CaseStudies/09-CustomComponents.swift +++ /dev/null @@ -1,123 +0,0 @@ -import SwiftUINavigation - -private let readMe = """ - This case study demonstrates how to enhance an existing SwiftUI component so that it can be driven \ - off of optional and enum state. - - The BottomMenuModifier component in this is file is primarily powered by a simple boolean binding, \ - which means its content cannot be dynamic based off of the source of truth that drives its \ - presentation, and it cannot make mutations to the source of truth. - - However, by leveraging the binding transformations that come with this library we can extend the \ - bottom menu component with additional APIs that allow presentation and dismissal to be powered by \ - optionals and enums. - """ - -struct CustomComponents: View { - @State var count: Int? - - var body: some View { - Form { - Section { - Text(readMe) - } - - Button("Show bottom menu") { - withAnimation { - self.count = 0 - } - } - - if let count = self.count, count > 0 { - Text("Current count: \(count)") - .transition(.opacity) - } - } - .bottomMenu(unwrapping: self.$count) { $count in - Stepper("Number: \(count)", value: $count.animation()) - } - .navigationTitle("Custom components") - } -} - -private struct BottomMenuModifier: ViewModifier -where BottomMenuContent: View { - @Binding var isActive: Bool - let content: () -> BottomMenuContent - - func body(content: Content) -> some View { - content.overlay( - ZStack(alignment: .bottom) { - if self.isActive { - Rectangle() - .fill(Color.black.opacity(0.4)) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onTapGesture { - withAnimation { - self.isActive = false - } - } - .zIndex(1) - .transition(.opacity) - - self.content() - .padding() - .background(Color.white) - .cornerRadius(10) - .frame(maxWidth: .infinity) - .padding(24) - .padding(.bottom) - .zIndex(2) - .transition(.move(edge: .bottom)) - } - } - .ignoresSafeArea() - ) - } -} - -extension View { - fileprivate func bottomMenu( - isActive: Binding, - @ViewBuilder content: @escaping () -> Content - ) -> some View - where Content: View { - self.modifier( - BottomMenuModifier( - isActive: isActive, - content: content - ) - ) - } - - fileprivate func bottomMenu( - unwrapping value: Binding, - @ViewBuilder content: @escaping (Binding) -> Content - ) -> some View - where Content: View { - self.modifier( - BottomMenuModifier( - isActive: value.isPresent(), - content: { Binding(unwrapping: value).map(content) } - ) - ) - } - - fileprivate func bottomMenu( - unwrapping value: Binding, - case casePath: CasePath, - @ViewBuilder content: @escaping (Binding) -> Content - ) -> some View - where Content: View { - self.bottomMenu( - unwrapping: value.case(casePath), - content: content - ) - } -} - -struct CustomComponents_Previews: PreviewProvider { - static var previews: some View { - CustomComponents() - } -} diff --git a/Examples/CaseStudies/Internal/CaseStudy.swift b/Examples/CaseStudies/Internal/CaseStudy.swift new file mode 100644 index 0000000000..ef9c063e7f --- /dev/null +++ b/Examples/CaseStudies/Internal/CaseStudy.swift @@ -0,0 +1,221 @@ +import SwiftUI +import UIKitNavigation + +@MainActor +protocol CaseStudy { + var readMe: String { get } + var caseStudyTitle: String { get } + var caseStudyNavigationTitle: String { get } + var usesOwnLayout: Bool { get } + var isPresentedInSheet: Bool { get } +} +protocol SwiftUICaseStudy: CaseStudy, View {} +protocol UIKitCaseStudy: CaseStudy, UIViewController {} + +extension CaseStudy { + var caseStudyNavigationTitle: String { caseStudyTitle } + var isPresentedInSheet: Bool { false } +} +extension SwiftUICaseStudy { + var usesOwnLayout: Bool { false } +} +extension UIKitCaseStudy { + var usesOwnLayout: Bool { true } +} + +@resultBuilder +@MainActor +enum CaseStudyViewBuilder { + @ViewBuilder + static func buildBlock() -> some View {} + @ViewBuilder + static func buildExpression(_ caseStudy: some SwiftUICaseStudy) -> some View { + SwiftUICaseStudyButton(caseStudy: caseStudy) + } + @ViewBuilder + static func buildExpression(_ caseStudy: some UIKitCaseStudy) -> some View { + UIKitCaseStudyButton(caseStudy: caseStudy) + } + static func buildPartialBlock(first: some View) -> some View { + first + } + @ViewBuilder + static func buildPartialBlock(accumulated: some View, next: some View) -> some View { + accumulated + next + } +} + +struct SwiftUICaseStudyButton: View { + let caseStudy: C + @State var isPresented = false + var body: some View { + if caseStudy.isPresentedInSheet { + Button(caseStudy.caseStudyTitle) { + isPresented = true + } + .sheet(isPresented: $isPresented) { + CaseStudyView { + caseStudy + } + .modifier(CaseStudyModifier(caseStudy: caseStudy)) + } + } else { + NavigationLink(caseStudy.caseStudyTitle) { + CaseStudyView { + caseStudy + } + .modifier(CaseStudyModifier(caseStudy: caseStudy)) + } + } + } +} + +struct UIKitCaseStudyButton: View { + let caseStudy: C + @State var isPresented = false + var body: some View { + if caseStudy.isPresentedInSheet { + Button(caseStudy.caseStudyTitle) { + isPresented = true + } + .sheet(isPresented: $isPresented) { + UIViewControllerRepresenting { + ((caseStudy as? UINavigationController) + ?? UINavigationController(rootViewController: caseStudy)) + .setUp(caseStudy: caseStudy) + } + .modifier(CaseStudyModifier(caseStudy: caseStudy)) + } + } else { + NavigationLink(caseStudy.caseStudyTitle) { + UIViewControllerRepresenting { + caseStudy + } + .modifier(CaseStudyModifier(caseStudy: caseStudy)) + } + } + } +} + +extension UINavigationController { + func setUp(caseStudy: some CaseStudy) -> Self { + self.viewControllers[0].title = caseStudy.caseStudyNavigationTitle + self.viewControllers[0].navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "About", + primaryAction: UIAction { [weak self] _ in + self?.present( + UIHostingController( + rootView: Form { + Text(template: caseStudy.readMe) + } + .presentationDetents([.medium]) + ), + animated: true + ) + }) + return self + } +} + +struct CaseStudyModifier: ViewModifier { + let caseStudy: C + @State var isAboutPresented = false + func body(content: Content) -> some View { + content + .navigationTitle(caseStudy.caseStudyNavigationTitle) + .toolbar { + ToolbarItem { + Button("About") { isAboutPresented = true } + } + } + .sheet(isPresented: $isAboutPresented) { + Form { + Text(template: caseStudy.readMe) + } + .presentationDetents([.medium]) + } + } +} + +struct CaseStudyView: View { + @ViewBuilder let caseStudy: C + @State var isAboutPresented = false + var body: some View { + if caseStudy.usesOwnLayout { + VStack { + caseStudy + } + } else { + Form { + caseStudy + } + } + } +} + +struct CaseStudyGroupView: View { + @CaseStudyViewBuilder let content: Content + @ViewBuilder let title: Title + + var body: some View { + Section { + content + } header: { + title + } + } +} + +extension CaseStudyGroupView where Title == Text { + init(_ title: String, @CaseStudyViewBuilder content: () -> Content) { + self.init(content: content) { Text(title) } + } +} + +extension SwiftUICaseStudy { + fileprivate func navigationLink() -> some View { + NavigationLink(caseStudyTitle) { + self + } + } +} + +#Preview("SwiftUI case study") { + NavigationStack { + CaseStudyView { + DemoCaseStudy() + } + } +} + +#Preview("SwiftUI case study group") { + NavigationStack { + Form { + CaseStudyGroupView("Group") { + DemoCaseStudy() + } + } + } +} + +private struct DemoCaseStudy: SwiftUICaseStudy { + let caseStudyTitle = "Demo Case Study" + let readMe = """ + Hello! This is a demo case study. + + Enjoy! + """ + var body: some View { + Text("Hello!") + } +} + +private class DemoCaseStudyController: UIViewController, UIKitCaseStudy { + let caseStudyTitle = "Demo Case Study" + let readMe = """ + Hello! This is a demo case study. + + Enjoy! + """ +} diff --git a/Examples/CaseStudies/Internal/DetentsHelper.swift b/Examples/CaseStudies/Internal/DetentsHelper.swift new file mode 100644 index 0000000000..6f9ed41449 --- /dev/null +++ b/Examples/CaseStudies/Internal/DetentsHelper.swift @@ -0,0 +1,12 @@ +import UIKit + +extension UIViewController { + func mediumDetents() { + if let sheet = sheetPresentationController { + sheet.detents = [.medium()] + sheet.prefersScrollingExpandsWhenScrolledToEdge = false + sheet.prefersEdgeAttachedInCompactHeight = true + sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true + } + } +} diff --git a/Examples/CaseStudies/FactClient.swift b/Examples/CaseStudies/Internal/FactClient.swift similarity index 89% rename from Examples/CaseStudies/FactClient.swift rename to Examples/CaseStudies/Internal/FactClient.swift index d6ae71207c..7538b6d430 100644 --- a/Examples/CaseStudies/FactClient.swift +++ b/Examples/CaseStudies/Internal/FactClient.swift @@ -4,8 +4,8 @@ struct Fact: Identifiable { var description: String let number: Int - var id: AnyHashable { - [self.description as AnyHashable, self.number] + var id: Int { + number } } diff --git a/Examples/CaseStudies/Internal/Text+Template.swift b/Examples/CaseStudies/Internal/Text+Template.swift new file mode 100644 index 0000000000..435e8744d0 --- /dev/null +++ b/Examples/CaseStudies/Internal/Text+Template.swift @@ -0,0 +1,59 @@ +import SwiftUI + +extension Text { + init(template: String, _ style: Font.TextStyle = .body) { + enum Style: Hashable { + case code + case emphasis + case strong + } + + var segments: [Text] = [] + var currentValue = "" + var currentStyles: Set