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/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index e54783919e..6b053415e1 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -25,7 +25,7 @@ body: 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/swiftui-navigation/issues) or [discussion](https://github.com/pointfreeco/swiftui-navigation/discussions). + - 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: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 54008c23e2..57a87b3290 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -2,13 +2,13 @@ blank_issues_enabled: false contact_links: - name: Project Discussion - url: https://github.com/pointfreeco/swiftui-navigation/discussions + url: https://github.com/pointfreeco/swift-navigation/discussions about: SwiftUI Navigation Q&A, ideas, and more - name: Documentation - url: https://pointfreeco.github.io/swiftui-navigation/main/documentation/swiftuinavigation/ + url: https://pointfreeco.github.io/swift-navigation/main/documentation/swiftnavigation/ about: Read SwiftUI Navigation's documentation - name: Videos - url: https://www.pointfree.co/collections/swiftui-navigation + 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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfdab77877..ef59e59fdc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,14 +15,95 @@ concurrency: jobs: library: - runs-on: macos-12 + runs-on: macos-15 strategy: matrix: - xcode: ['13.4.1', '14.1'] + xcode: + - '16.2' + variation: + - ios + - macos + - tvos + - watchos + - examples steps: - - uses: actions/checkout@v3 + - 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 + 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: + - '16.2' + 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: 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: 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 ac5eef39b2..0000000000 --- a/.github/workflows/documentation.yml +++ /dev/null @@ -1,74 +0,0 @@ -# Build and deploy DocC to GitHub pages. Based off of @karwa's work here: -# https://github.com/karwa/swift-url/blob/main/.github/workflows/docs.yml -name: Documentation - -on: - release: - types: - - published - push: - branches: - - main - workflow_dispatch: - -concurrency: - group: docs-${{ github.ref }} - cancel-in-progress: true - -jobs: - build: - runs-on: macos-12 - steps: - - name: Select Xcode 14.1 - run: sudo xcode-select -s /Applications/Xcode_14.1.app - - - name: Checkout Package - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - - name: Checkout gh-pages Branch - uses: actions/checkout@v2 - with: - ref: gh-pages - path: docs-out - - - name: Build documentation - run: > - rm -rf docs-out/.git; - rm -rf docs-out/main; - git tag -l --sort=-v:refname | grep -e "\d\+\.\d\+.0" | tail -n +6 | xargs -I {} rm -rf {}; - - for tag in $(echo "main"; git tag -l --sort=-v:refname | grep -e "\d\+\.\d\+.0" | head -6); - do - if [ -d "docs-out/$tag/data/documentation/swiftuinavigation" ] - then - echo "✅ Documentation for "$tag" already exists."; - else - echo "⏳ Generating documentation for SwiftUINavigation @ "$tag" release."; - rm -rf "docs-out/$tag"; - - git checkout .; - git checkout "$tag"; - - swift package \ - --allow-writing-to-directory docs-out/"$tag" \ - generate-documentation \ - --target SwiftUINavigation \ - --output-path docs-out/"$tag" \ - --transform-for-static-hosting \ - --hosting-base-path /swiftui-navigation/"$tag" \ - && echo "✅ Documentation generated for SwiftUINavigation @ "$tag" release." \ - || echo "⚠️ Documentation skipped for SwiftUINavigation @ "$tag"."; - fi; - done - - - name: Fix permissions - run: 'sudo chown -R $USER docs-out' - - - name: Publish documentation to GitHub Pages - uses: JamesIves/github-pages-deploy-action@4.1.7 - with: - branch: gh-pages - folder: docs-out - single-commit: true 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 index 894af77613..568c5c201b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,14 +17,14 @@ jobs: env: INCOMING_WEBHOOK_URL: ${{ secrets.SLACK_PROJECT_CHANNEL_WEBHOOK_URL }} with: - text: swiftui-navigation ${{ github.event.release.tag_name }} has been released. + text: swift-navigation ${{ github.event.release.tag_name }} has been released. blocks: | [ { "type": "header", "text": { "type": "plain_text", - "text": "swiftui-navigation ${{ github.event.release.tag_name}}" + "text": "swift-navigation ${{ github.event.release.tag_name}}" } }, { @@ -56,14 +56,14 @@ jobs: env: INCOMING_WEBHOOK_URL: ${{ secrets.SLACK_RELEASES_WEBHOOK_URL }} with: - text: swiftui-navigation ${{ github.event.release.tag_name }} has been released. + text: swift-navigation ${{ github.event.release.tag_name }} has been released. blocks: | [ { "type": "header", "text": { "type": "plain_text", - "text": "swiftui-navigation ${{ github.event.release.tag_name}}" + "text": "swift-navigation ${{ github.event.release.tag_name}}" } }, { diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000000..a840ae64ec --- /dev/null +++ b/.spi.yml @@ -0,0 +1,9 @@ +version: 1 +builder: + configs: + - 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 dc1baeafaf..0000000000 --- a/Examples/CaseStudies/01-Alerts.swift +++ /dev/null @@ -1,52 +0,0 @@ -import SwiftUI -import SwiftUINavigation - -@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) -struct OptionalAlerts: View { - @ObservedObject private var model = FeatureModel() - - var body: some View { - List { - Stepper("Number: \(self.model.count)", value: self.$model.count) - Button(action: { self.model.numberFactButtonTapped() }) { - HStack { - Text("Get number fact") - if self.model.isLoading { - Spacer() - ProgressView() - } - } - } - .disabled(self.model.isLoading) - } - .alert( - title: { Text("Fact about \($0.number)") }, - unwrapping: self.$model.fact, - actions: { - Button("Get another fact about \($0.number)") { - self.model.numberFactButtonTapped() - } - Button("Cancel", role: .cancel) { - self.model.fact = nil - } - }, - message: { Text($0.description) } - ) - .navigationTitle("Alerts") - } -} - -@MainActor -private class FeatureModel: ObservableObject { - @Published var count = 0 - @Published var isLoading = false - @Published var fact: Fact? - - func numberFactButtonTapped() { - Task { - self.isLoading = true - 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 312b080ad0..0000000000 --- a/Examples/CaseStudies/02-ConfirmationDialogs.swift +++ /dev/null @@ -1,50 +0,0 @@ -import SwiftUI -import SwiftUINavigation - -@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) -struct OptionalConfirmationDialogs: View { - @ObservedObject private var model = FeatureModel() - - var body: some View { - List { - Stepper("Number: \(self.model.count)", value: self.$model.count) - Button(action: { self.model.numberFactButtonTapped() }) { - HStack { - Text("Get number fact") - if self.model.isLoading { - Spacer() - ProgressView() - } - } - } - .disabled(self.model.isLoading) - .confirmationDialog( - title: { Text("Fact about \($0.number)") }, - titleVisibility: .visible, - unwrapping: self.$model.fact, - actions: { - Button("Get another fact about \($0.number)") { - self.model.numberFactButtonTapped() - } - }, - message: { Text($0.description) } - ) - } - .navigationTitle("Dialogs") - } -} - -@MainActor -private class FeatureModel: ObservableObject { - @Published var count = 0 - @Published var isLoading = false - @Published var fact: Fact? - - func numberFactButtonTapped() { - Task { - self.isLoading = true - 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 1067768a00..0000000000 --- a/Examples/CaseStudies/03-Sheets.swift +++ /dev/null @@ -1,109 +0,0 @@ -import SwiftUI -import SwiftUINavigation - -struct OptionalSheets: View { - @ObservedObject private var model = FeatureModel() - - var body: some View { - List { - Section { - Stepper("Number: \(self.model.count)", value: self.$model.count) - - HStack { - Button("Get number fact") { - self.model.numberFactButtonTapped() - } - - if self.model.isLoading { - Spacer() - ProgressView() - } - } - } header: { - Text("Fact Finder") - } - - Section { - ForEach(self.model.savedFacts) { fact in - Text(fact.description) - } - .onDelete { self.model.removeSavedFacts(atOffsets: $0) } - } header: { - Text("Saved Facts") - } - } - .sheet(unwrapping: self.$model.fact) { $fact in - NavigationView { - FactEditor(fact: $fact.description) - .disabled(self.model.isLoading) - .foregroundColor(self.model.isLoading ? .gray : nil) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - self.model.cancelButtonTapped() - } - } - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - self.model.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") - } -} - -@MainActor -private class FeatureModel: ObservableObject { - @Published var count = 0 - @Published var fact: Fact? - @Published var isLoading = false - @Published var savedFacts: [Fact] = [] - private var task: Task? - - deinit { - self.task?.cancel() - } - - func numberFactButtonTapped() { - self.isLoading = true - self.fact = Fact(description: "\(self.count) is still loading...", number: self.count) - self.task = Task { - 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 3582d89649..0000000000 --- a/Examples/CaseStudies/04-Popovers.swift +++ /dev/null @@ -1,105 +0,0 @@ -import SwiftUI -import SwiftUINavigation - -struct OptionalPopovers: View { - @ObservedObject private var model = FeatureModel() - - var body: some View { - List { - Section { - Stepper("Number: \(self.model.count)", value: self.$model.count) - - HStack { - Button("Get number fact") { - self.model.numberFactButtonTapped() - } - .popover(unwrapping: self.$model.fact, arrowEdge: .bottom) { $fact in - NavigationView { - FactEditor(fact: $fact.description) - .disabled(self.model.isLoading) - .foregroundColor(self.model.isLoading ? .gray : nil) - .navigationBarItems( - leading: Button("Cancel") { - self.model.cancelButtonTapped() - }, - trailing: Button("Save") { - self.model.saveButtonTapped(fact: fact) - } - ) - } - } - - if self.model.isLoading { - Spacer() - ProgressView() - } - } - } header: { - Text("Fact Finder") - } - - Section { - ForEach(self.model.savedFacts) { fact in - Text(fact.description) - } - .onDelete { self.model.removeSavedFacts(atOffsets: $0) } - } header: { - Text("Saved Facts") - } - } - .navigationTitle("Popovers") - } -} - -private struct FactEditor: View { - @Binding var fact: String - - var body: some View { - VStack { - TextEditor(text: self.$fact) - } - .padding() - .navigationTitle("Fact editor") - } -} - -@MainActor -private class FeatureModel: ObservableObject { - @Published var count = 0 - @Published var fact: Fact? - @Published var isLoading = false - @Published var savedFacts: [Fact] = [] - private var task: Task? - - deinit { - self.task?.cancel() - } - - func numberFactButtonTapped() { - self.isLoading = true - self.fact = Fact(description: "\(self.count) is still loading...", number: self.count) - self.task = Task { - 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 588d1c7663..0000000000 --- a/Examples/CaseStudies/05-FullScreenCovers.swift +++ /dev/null @@ -1,105 +0,0 @@ -import SwiftUI -import SwiftUINavigation - -struct OptionalFullScreenCovers: View { - @ObservedObject private var model = FeatureModel() - - var body: some View { - List { - Section { - Stepper("Number: \(self.model.count)", value: self.$model.count) - - HStack { - Button("Get number fact") { - self.model.numberFactButtonTapped() - } - - if self.model.isLoading { - Spacer() - ProgressView() - } - } - } header: { - Text("Fact Finder") - } - - Section { - ForEach(self.model.savedFacts) { fact in - Text(fact.description) - } - .onDelete { self.model.removeSavedFacts(atOffsets: $0) } - } header: { - Text("Saved Facts") - } - } - .fullScreenCover(unwrapping: self.$model.fact) { $fact in - NavigationView { - FactEditor(fact: $fact.description) - .disabled(self.model.isLoading) - .foregroundColor(self.model.isLoading ? .gray : nil) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - self.model.cancelButtonTapped() - } - } - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - self.model.saveButtonTapped(fact: fact) - } - } - } - } - } - .navigationTitle("Full-screen covers") - } -} - -private struct FactEditor: View { - @Binding var fact: String - - var body: some View { - VStack { - TextEditor(text: self.$fact) - } - .padding() - .navigationTitle("Fact editor") - } -} - -@MainActor -private class FeatureModel: 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 { - 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-NavigationDestinations.swift b/Examples/CaseStudies/06-NavigationDestinations.swift deleted file mode 100644 index 27b8c7aa9a..0000000000 --- a/Examples/CaseStudies/06-NavigationDestinations.swift +++ /dev/null @@ -1,119 +0,0 @@ -import SwiftUI -import SwiftUINavigation - -@available(iOS 16, *) -struct NavigationDestinations: View { - @ObservedObject private var model = FeatureModel() - - var body: some View { - List { - Section { - Stepper("Number: \(self.model.count)", value: self.$model.count) - - HStack { - Button("Get number fact") { - self.model.numberFactButtonTapped() - } - - if self.model.isLoading { - Spacer() - ProgressView() - } - } - } header: { - Text("Fact Finder") - } - - Section { - ForEach(self.model.savedFacts) { fact in - Text(fact.description) - } - .onDelete { self.model.removeSavedFacts(atOffsets: $0) } - } header: { - Text("Saved Facts") - } - } - .navigationTitle("Destinations") - .navigationDestination(unwrapping: self.$model.fact) { $fact in - FactEditor(fact: $fact.description) - .disabled(self.model.isLoading) - .foregroundColor(self.model.isLoading ? .gray : nil) - .navigationBarBackButtonHidden(true) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - self.model.cancelButtonTapped() - } - } - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - self.model.saveButtonTapped(fact: fact) - } - } - } - } - } -} - -private struct FactEditor: View { - @Binding var fact: String - - var body: some View { - VStack { - if #available(iOS 14, *) { - TextEditor(text: self.$fact) - } else { - TextField("Untitled", text: self.$fact) - } - } - .padding() - .navigationBarTitle("Fact Editor") - } -} - -@MainActor -private class FeatureModel: ObservableObject { - @Published var count = 0 - @Published var fact: Fact? - @Published var isLoading = false - @Published var savedFacts: [Fact] = [] - private var task: Task? - - deinit { - self.task?.cancel() - } - - func setFactNavigation(isActive: Bool) { - if isActive { - self.isLoading = true - self.fact = Fact(description: "\(self.count) is still loading...", number: self.count) - self.task = Task { - 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 numberFactButtonTapped() { - self.setFactNavigation(isActive: true) - } - - 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-NavigationLinks.swift b/Examples/CaseStudies/07-NavigationLinks.swift deleted file mode 100644 index 51ce98e2c3..0000000000 --- a/Examples/CaseStudies/07-NavigationLinks.swift +++ /dev/null @@ -1,111 +0,0 @@ -import SwiftUI -import SwiftUINavigation - -struct OptionalNavigationLinks: View { - @ObservedObject private var model = FeatureModel() - - var body: some View { - List { - Section { - Stepper("Number: \(self.model.count)", value: self.$model.count) - - HStack { - NavigationLink(unwrapping: self.$model.fact) { - self.model.setFactNavigation(isActive: $0) - } destination: { $fact in - FactEditor(fact: $fact.description) - .disabled(self.model.isLoading) - .foregroundColor(self.model.isLoading ? .gray : nil) - .navigationBarBackButtonHidden(true) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - self.model.cancelButtonTapped() - } - } - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - self.model.saveButtonTapped(fact: fact) - } - } - } - } label: { - Text("Get number fact") - } - - if self.model.isLoading { - Spacer() - ProgressView() - } - } - } header: { - Text("Fact Finder") - } - - Section { - ForEach(self.model.savedFacts) { fact in - Text(fact.description) - } - .onDelete { self.model.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") - } -} - -@MainActor -private class FeatureModel: ObservableObject { - @Published var count = 0 - @Published var fact: Fact? - @Published var isLoading = false - @Published var savedFacts: [Fact] = [] - private var task: Task? - - deinit { - self.task?.cancel() - } - - func setFactNavigation(isActive: Bool) { - if isActive { - self.isLoading = true - self.fact = Fact(description: "\(self.count) is still loading...", number: self.count) - self.task = Task { - 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/08-NavigationLinkList.swift b/Examples/CaseStudies/08-NavigationLinkList.swift deleted file mode 100644 index 170b1fe2b3..0000000000 --- a/Examples/CaseStudies/08-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 Destination enum, and it uses \ - the library's NavigationLink initializer to drive navigation from the destination enum. - """ - -struct ListOfNavigationLinks: View { - @ObservedObject var model: ListOfNavigationLinksModel - - var body: some View { - Form { - Section { - Text(readMe) - } - - List { - ForEach(self.model.rows) { rowModel in - RowView(model: rowModel) - } - .onDelete(perform: self.model.deleteButtonTapped(indexSet:)) - } - } - .navigationTitle("List of links") - .toolbar { - ToolbarItem { - Button("Add") { - self.model.addButtonTapped() - } - } - } - } -} - -class ListOfNavigationLinksModel: ObservableObject { - @Published var rows: [ListOfNavigationLinksRowModel] - - init(rows: [ListOfNavigationLinksRowModel] = []) { - 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 model: ListOfNavigationLinksRowModel - - var body: some View { - NavigationLink( - unwrapping: self.$model.destination, - case: /ListOfNavigationLinksRowModel.Destination.edit - ) { isActive in - self.model.setEditNavigation(isActive: isActive) - } destination: { $counter in - EditView(counter: $counter) - .navigationBarBackButtonHidden(true) - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button("Save") { self.model.saveButtonTapped(counter: counter) } - } - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { self.model.cancelButtonTapped() } - } - } - } label: { - Text("\(self.model.counter)") - } - } -} - -class ListOfNavigationLinksRowModel: Identifiable, ObservableObject { - let id = UUID() - @Published var counter: Int - @Published var destination: Destination? - - enum Destination { - case edit(Int) - } - - init( - counter: Int = 0, - destination: Destination? = nil - ) { - self.counter = counter - self.destination = destination - } - - func setEditNavigation(isActive: Bool) { - self.destination = isActive ? .edit(self.counter) : nil - } - - func saveButtonTapped(counter: Int) { - self.counter = counter - self.destination = nil - } - - func cancelButtonTapped() { - self.destination = 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( - model: .init( - rows: [ - .init(counter: 0), - .init(counter: 0), - .init(counter: 0), - .init(counter: 0), - .init(counter: 0), - ] - ) - ) - } - } -} diff --git a/Examples/CaseStudies/09-Routing.swift b/Examples/CaseStudies/09-Routing.swift deleted file mode 100644 index 4d60633b10..0000000000 --- a/Examples/CaseStudies/09-Routing.swift +++ /dev/null @@ -1,132 +0,0 @@ -import SwiftUI -import SwiftUINavigation - -private let readMe = """ - This case study demonstrates how to power multiple forms of navigation from a single destination \ - enum that describes all of the possible destinations one can travel to from this screen. - - The screen has four navigation destinations: an alert, a confirmation dialog, 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 Destination { - case alert(AlertState) - case confirmationDialog(ConfirmationDialogState) - case link(Int) - case sheet(Int) - - enum AlertAction { - case randomize - case reset - } - enum DialogAction { - case decrement - case increment - } -} - -struct Routing: View { - @State var count = 0 - @State var destination: Destination? - - var body: some View { - Form { - Section { - Text(readMe) - } - - Section { - Text("Count: \(self.count)") - } - - Button("Alert") { - self.destination = .alert( - AlertState { - TextState("Update count?") - } actions: { - ButtonState(action: .send(.randomize)) { - TextState("Randomize") - } - ButtonState(role: .destructive, action: .send(.reset)) { - TextState("Reset") - } - } - ) - } - - Button("Confirmation dialog") { - self.destination = .confirmationDialog( - ConfirmationDialogState(titleVisibility: .visible) { - TextState("Update count?") - } actions: { - ButtonState(action: .send(.increment)) { - TextState("Increment") - } - ButtonState(action: .send(.decrement)) { - TextState("Decrement") - } - } - ) - } - - NavigationLink(unwrapping: self.$destination, case: /Destination.link) { isActive in - if isActive { - self.destination = .link(self.count) - } - } destination: { $count in - Form { - Stepper("Count: \(count)", value: $count) - } - .navigationTitle("Routing link") - } label: { - Text("Link") - } - - Button("Sheet") { - self.destination = .sheet(self.count) - } - } - .navigationTitle("Routing") - .alert(unwrapping: self.$destination, case: /Destination.alert) { action in - switch action { - case .randomize?: - self.count = .random(in: 0...1_000) - case .reset?: - self.count = 0 - case nil: - break - } - } - .confirmationDialog( - unwrapping: self.$destination, - case: /Destination.confirmationDialog - ) { action in - switch action { - case .decrement?: - self.count -= 1 - case .increment?: - self.count += 1 - case nil: - break - } - } - .sheet(unwrapping: self.$destination, case: /Destination.sheet) { $count in - NavigationView { - Form { - Stepper("Count: \(count)", value: $count) - } - .navigationTitle("Routing sheet") - } - } - } -} - -struct Routing_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - Routing() - } - } -} diff --git a/Examples/CaseStudies/10-CustomComponents.swift b/Examples/CaseStudies/10-CustomComponents.swift deleted file mode 100644 index f537cf4f39..0000000000 --- a/Examples/CaseStudies/10-CustomComponents.swift +++ /dev/null @@ -1,124 +0,0 @@ -import SwiftUI -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/11-SynchronizedBindings.swift b/Examples/CaseStudies/11-SynchronizedBindings.swift deleted file mode 100644 index 80f718c5e5..0000000000 --- a/Examples/CaseStudies/11-SynchronizedBindings.swift +++ /dev/null @@ -1,65 +0,0 @@ -import SwiftUI -import SwiftUINavigation - -private let readMe = """ - This demonstrates how to synchronize model state with view state using the "bind" view modifier. \ - The model starts focused on the "Username" field, which is immediately focused when the form \ - first appears. When you tap the "Sign in" button, the focus will change to the first non-empty \ - field. - """ - -struct SynchronizedBindings: View { - @FocusState private var focusedField: FeatureModel.Field? - @ObservedObject private var model = FeatureModel() - - var body: some View { - Form { - Section { - Text(readMe) - } - - Section { - TextField("Username", text: self.$model.username) - .focused(self.$focusedField, equals: .username) - - SecureField("Password", text: self.$model.password) - .focused(self.$focusedField, equals: .password) - - Button("Sign In") { - self.model.signInButtonTapped() - } - .buttonStyle(.borderedProminent) - } - .textFieldStyle(.roundedBorder) - } - .bind(self.$model.focusedField, to: self.$focusedField) - .navigationTitle("Synchronized focus") - } -} - -private class FeatureModel: ObservableObject { - enum Field: String { - case username - case password - } - - @Published var focusedField: Field? = .username - @Published var password: String = "" - @Published var username: String = "" - - func signInButtonTapped() { - if self.username.isEmpty { - self.focusedField = .username - } else if self.password.isEmpty { - self.focusedField = .password - } else { - self.focusedField = nil - } - } -} - -struct SynchronizedBindings_Previews: PreviewProvider { - static var previews: some View { - SynchronizedBindings() - } -} diff --git a/Examples/CaseStudies/12-IfLet.swift b/Examples/CaseStudies/12-IfLet.swift deleted file mode 100644 index 0798ecd17b..0000000000 --- a/Examples/CaseStudies/12-IfLet.swift +++ /dev/null @@ -1,47 +0,0 @@ -import SwiftUI -import SwiftUINavigation - -private let readMe = """ - This demonstrates to use the IfLet view to unwrap a binding of an optional into a binding of \ - an honest value. - - Tap the "Edit" button to put the form into edit mode. Then you can make changes to the message \ - and either commit the changes by tapping "Save", or discard the changes by tapping "Discard". - """ - -struct IfLetCaseStudy: View { - @State var string: String = "Hello" - @State var editableString: String? - - var body: some View { - Form { - Section { - Text(readMe) - } - IfLet(self.$editableString) { $string in - TextField("Edit string", text: $string) - HStack { - Button("Discard") { - self.editableString = nil - } - Button("Save") { - self.string = string - self.editableString = nil - } - } - } else: { - Text("\(self.string)") - Button("Edit") { - self.editableString = self.string - } - } - .buttonStyle(.borderless) - } - } -} - -struct IfLetCaseStudy_EditStringView_Previews: PreviewProvider { - static var previews: some View { - IfLetCaseStudy() - } -} diff --git a/Examples/CaseStudies/13-IfCaseLet.swift b/Examples/CaseStudies/13-IfCaseLet.swift deleted file mode 100644 index 84a0ef6150..0000000000 --- a/Examples/CaseStudies/13-IfCaseLet.swift +++ /dev/null @@ -1,53 +0,0 @@ -import CasePaths -import SwiftUI -import SwiftUINavigation - -private let readMe = """ - This demonstrates to use the IfCaseLet view to destructure a binding of an enum into a binding \ - of one of its cases. - - Tap the "Edit" button to put the form into edit mode. Then you can make changes to the message \ - and either commit the changes by tapping "Save", or discard the changes by tapping "Discard". - """ - -struct IfCaseLetCaseStudy: View { - @State var string: String = "Hello" - @State var editableString: EditableString = .inactive - - enum EditableString { - case active(String) - case inactive - } - - var body: some View { - Form { - Section { - Text(readMe) - } - IfCaseLet(self.$editableString, pattern: /EditableString.active) { $string in - TextField("Edit string", text: $string) - HStack { - Button("Discard") { - self.editableString = .inactive - } - Button("Save") { - self.string = string - self.editableString = .inactive - } - } - } else: { - Text("\(self.string)") - Button("Edit") { - self.editableString = .active(self.string) - } - } - .buttonStyle(.borderless) - } - } -} - -struct IfCaseLetCaseStudy_EditStringView_Previews: PreviewProvider { - static var previews: some View { - IfCaseLetCaseStudy() - } -} 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