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 12d9053adb..ef59e59fdc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,12 +15,37 @@ concurrency: jobs: library: + 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 }} @@ -28,21 +53,57 @@ jobs: - name: Skip macro validation run: defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES - name: Run tests - run: make test + run: make IOS_VERSION=${{matrix.ios_version}} test-${{ matrix.variation }} - windows: - name: Windows + library-evolution: + name: Library Evolution + runs-on: macos-15 strategy: matrix: - os: [windows-latest] - config: ['debug', 'release'] - fail-fast: false - runs-on: ${{ matrix.os }} + 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: 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 }} + - 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/format.yml b/.github/workflows/format.yml index c1c794ca99..c20eb1686a 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -8,11 +8,11 @@ on: jobs: swift_format: name: swift-format - runs-on: macOS-13 + runs-on: macOS-14 steps: - uses: actions/checkout@v4 - name: Xcode Select - run: sudo xcode-select -s /Applications/Xcode_15.0.app + run: sudo xcode-select -s /Applications/Xcode_15.4.app - name: Install run: brew install swift-format - name: Format 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 index eeeb1f4d06..a840ae64ec 100644 --- a/.spi.yml +++ b/.spi.yml @@ -1,4 +1,9 @@ version: 1 builder: configs: - - documentation_targets: [SwiftUINavigation, SwiftUINavigationCore] + - 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 9f91a46aa6..0000000000 --- a/Examples/CaseStudies/01-Alerts.swift +++ /dev/null @@ -1,56 +0,0 @@ -import SwiftUI -import SwiftUINavigation - -@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) -struct OptionalAlerts: View { - @State private var model = FeatureModel() - - var body: some View { - List { - Stepper("Number: \(model.count)", value: $model.count) - Button { - Task { await model.numberFactButtonTapped() } - } label: { - HStack { - Text("Get number fact") - if model.isLoading { - Spacer() - ProgressView() - } - } - } - .disabled(model.isLoading) - } - .alert(item: $model.fact) { - Text("Fact about \($0.number)") - } actions: { - Button("Get another fact about \($0.number)") { - Task { await model.numberFactButtonTapped() } - } - Button("Close", role: .cancel) { - model.fact = nil - } - } message: { - Text($0.description) - } - .navigationTitle("Alerts") - } -} - -@Observable -private class FeatureModel { - var count = 0 - var isLoading = false - var fact: Fact? - - @MainActor - func numberFactButtonTapped() async { - isLoading = true - defer { isLoading = false } - fact = await getNumberFact(count) - } -} - -#Preview { - OptionalAlerts() -} diff --git a/Examples/CaseStudies/02-ConfirmationDialogs.swift b/Examples/CaseStudies/02-ConfirmationDialogs.swift deleted file mode 100644 index 1c7c8e72d9..0000000000 --- a/Examples/CaseStudies/02-ConfirmationDialogs.swift +++ /dev/null @@ -1,53 +0,0 @@ -import SwiftUI -import SwiftUINavigation - -@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) -struct OptionalConfirmationDialogs: View { - @State private var model = FeatureModel() - - var body: some View { - List { - Stepper("Number: \(model.count)", value: $model.count) - Button { - Task { await model.numberFactButtonTapped() } - } label: { - HStack { - Text("Get number fact") - if model.isLoading { - Spacer() - ProgressView() - } - } - } - .disabled(model.isLoading) - .confirmationDialog(item: $model.fact, titleVisibility: .visible) { - Text("Fact about \($0.number)") - } actions: { - Button("Get another fact about \($0.number)") { - Task { await model.numberFactButtonTapped() } - } - } message: { - Text($0.description) - } - } - .navigationTitle("Dialogs") - } -} - -@Observable -private class FeatureModel { - var count = 0 - var isLoading = false - var fact: Fact? - - @MainActor - func numberFactButtonTapped() async { - isLoading = true - defer { isLoading = false } - fact = await getNumberFact(count) - } -} - -#Preview { - OptionalConfirmationDialogs() -} diff --git a/Examples/CaseStudies/03-Sheets.swift b/Examples/CaseStudies/03-Sheets.swift deleted file mode 100644 index f1d29169fc..0000000000 --- a/Examples/CaseStudies/03-Sheets.swift +++ /dev/null @@ -1,119 +0,0 @@ -import SwiftUI -import SwiftUINavigation - -struct OptionalSheets: View { - @State private var model = FeatureModel() - - var body: some View { - List { - Section { - Stepper("Number: \(model.count)", value: $model.count) - - HStack { - Button("Get number fact") { - Task { await model.numberFactButtonTapped() } - } - - if model.isLoading { - Spacer() - ProgressView() - } - } - } header: { - Text("Fact Finder") - } - - Section { - ForEach(model.savedFacts) { fact in - Text(fact.description) - } - .onDelete { model.removeSavedFacts(atOffsets: $0) } - } header: { - Text("Saved Facts") - } - } - .sheet(item: $model.fact) { $fact in - NavigationStack { - FactEditor(fact: $fact.description) - .disabled(model.isLoading) - .foregroundColor(model.isLoading ? .gray : nil) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - model.cancelButtonTapped() - } - } - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - model.saveButtonTapped(fact: fact) - } - } - } - } - } - .navigationTitle("Sheets") - } -} - -private struct FactEditor: View { - @Binding var fact: String - - var body: some View { - VStack { - TextEditor(text: $fact) - } - .padding() - .navigationTitle("Fact editor") - } -} - -@Observable -private class FeatureModel { - var count = 0 - var fact: Fact? - var isLoading = false - var savedFacts: [Fact] = [] - private var task: Task? - - deinit { - task?.cancel() - } - - @MainActor - func numberFactButtonTapped() async { - isLoading = true - fact = Fact(description: "\(count) is still loading...", number: count) - task = Task { - let fact = await getNumberFact(self.count) - isLoading = false - guard !Task.isCancelled - else { return } - self.fact = fact - } - await task?.value - } - - @MainActor - func cancelButtonTapped() { - task?.cancel() - task = nil - fact = nil - } - - @MainActor - func saveButtonTapped(fact: Fact) { - task?.cancel() - task = nil - savedFacts.append(fact) - self.fact = nil - } - - @MainActor - func removeSavedFacts(atOffsets offsets: IndexSet) { - savedFacts.remove(atOffsets: offsets) - } -} - -#Preview { - OptionalSheets() -} diff --git a/Examples/CaseStudies/04-Popovers.swift b/Examples/CaseStudies/04-Popovers.swift deleted file mode 100644 index 292816baf2..0000000000 --- a/Examples/CaseStudies/04-Popovers.swift +++ /dev/null @@ -1,115 +0,0 @@ -import SwiftUI -import SwiftUINavigation - -struct OptionalPopovers: View { - @State private var model = FeatureModel() - - var body: some View { - List { - Section { - Stepper("Number: \(model.count)", value: $model.count) - - HStack { - Button("Get number fact") { - Task { await model.numberFactButtonTapped() } - } - .popover(item: $model.fact, arrowEdge: .bottom) { $fact in - NavigationStack { - FactEditor(fact: $fact.description) - .disabled(model.isLoading) - .foregroundColor(model.isLoading ? .gray : nil) - .navigationBarItems( - leading: Button("Cancel") { - model.cancelButtonTapped() - }, - trailing: Button("Save") { - model.saveButtonTapped(fact: fact) - } - ) - } - } - - if model.isLoading { - Spacer() - ProgressView() - } - } - } header: { - Text("Fact Finder") - } - - Section { - ForEach(model.savedFacts) { fact in - Text(fact.description) - } - .onDelete { 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: $fact) - } - .padding() - .navigationTitle("Fact editor") - } -} - -@Observable -private class FeatureModel { - var count = 0 - var fact: Fact? - var isLoading = false - var savedFacts: [Fact] = [] - private var task: Task? - - deinit { - self.task?.cancel() - } - - @MainActor - func numberFactButtonTapped() async { - isLoading = true - fact = Fact(description: "\(count) is still loading...", number: count) - task = Task { - let fact = await getNumberFact(self.count) - isLoading = false - guard !Task.isCancelled - else { return } - self.fact = fact - } - await task?.value - } - - @MainActor - func cancelButtonTapped() { - task?.cancel() - task = nil - fact = nil - } - - @MainActor - func saveButtonTapped(fact: Fact) { - task?.cancel() - task = nil - savedFacts.append(fact) - self.fact = nil - } - - @MainActor - func removeSavedFacts(atOffsets offsets: IndexSet) { - savedFacts.remove(atOffsets: offsets) - } -} - -#Preview { - OptionalPopovers() -} diff --git a/Examples/CaseStudies/05-FullScreenCovers.swift b/Examples/CaseStudies/05-FullScreenCovers.swift deleted file mode 100644 index cb33d8f61c..0000000000 --- a/Examples/CaseStudies/05-FullScreenCovers.swift +++ /dev/null @@ -1,115 +0,0 @@ -import SwiftUI -import SwiftUINavigation - -struct OptionalFullScreenCovers: View { - @State private var model = FeatureModel() - - var body: some View { - List { - Section { - Stepper("Number: \(model.count)", value: $model.count) - - HStack { - Button("Get number fact") { - Task { await model.numberFactButtonTapped() } - } - - if model.isLoading { - Spacer() - ProgressView() - } - } - } header: { - Text("Fact Finder") - } - - Section { - ForEach(model.savedFacts) { fact in - Text(fact.description) - } - .onDelete { model.removeSavedFacts(atOffsets: $0) } - } header: { - Text("Saved Facts") - } - } - .fullScreenCover(item: $model.fact) { $fact in - NavigationStack { - FactEditor(fact: $fact.description) - .disabled(model.isLoading) - .foregroundColor(model.isLoading ? .gray : nil) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - model.cancelButtonTapped() - } - } - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - model.saveButtonTapped(fact: fact) - } - } - } - } - } - .navigationTitle("Full-screen covers") - } -} - -private struct FactEditor: View { - @Binding var fact: String - - var body: some View { - VStack { - TextEditor(text: $fact) - } - .padding() - .navigationTitle("Fact editor") - } -} - -@Observable -private class FeatureModel { - var count = 0 - var fact: Fact? - var isLoading = false - var savedFacts: [Fact] = [] - private var task: Task? - - @MainActor - func numberFactButtonTapped() async { - isLoading = true - fact = Fact(description: "\(count) is still loading...", number: count) - task = Task { - let fact = await getNumberFact(count) - isLoading = false - guard !Task.isCancelled - else { return } - self.fact = fact - } - await task?.value - } - - @MainActor - func cancelButtonTapped() { - task?.cancel() - task = nil - fact = nil - } - - @MainActor - func saveButtonTapped(fact: Fact) { - task?.cancel() - task = nil - savedFacts.append(fact) - self.fact = nil - } - - @MainActor - func removeSavedFacts(atOffsets offsets: IndexSet) { - savedFacts.remove(atOffsets: offsets) - } -} - -#Preview { - OptionalFullScreenCovers() -} diff --git a/Examples/CaseStudies/06-NavigationDestinations.swift b/Examples/CaseStudies/06-NavigationDestinations.swift deleted file mode 100644 index 33dd0a43be..0000000000 --- a/Examples/CaseStudies/06-NavigationDestinations.swift +++ /dev/null @@ -1,132 +0,0 @@ -import SwiftUI -import SwiftUINavigation - -@available(iOS 16, *) -struct NavigationDestinations: View { - @State private var model = FeatureModel() - - var body: some View { - List { - Section { - Stepper("Number: \(model.count)", value: $model.count) - - HStack { - Button("Get number fact") { - Task { await model.numberFactButtonTapped() } - } - - if model.isLoading { - Spacer() - ProgressView() - } - } - } header: { - Text("Fact Finder") - } - - Section { - ForEach(model.savedFacts) { fact in - Text(fact.description) - } - .onDelete { model.removeSavedFacts(atOffsets: $0) } - } header: { - Text("Saved Facts") - } - } - .navigationTitle("Destinations") - .navigationDestination(item: $model.fact) { $fact in - FactEditor(fact: $fact.description) - .disabled(model.isLoading) - .foregroundColor(model.isLoading ? .gray : nil) - .navigationBarBackButtonHidden(true) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - Task { await model.cancelButtonTapped() } - } - } - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - Task { await model.saveButtonTapped(fact: fact) } - } - } - } - } - } -} - -private struct FactEditor: View { - @Binding var fact: String - - var body: some View { - VStack { - if #available(iOS 14, *) { - TextEditor(text: $fact) - } else { - TextField("Untitled", text: $fact) - } - } - .padding() - .navigationBarTitle("Fact Editor") - } -} - -@Observable -private class FeatureModel { - var count = 0 - var fact: Fact? - var isLoading = false - var savedFacts: [Fact] = [] - private var task: Task? - - deinit { - task?.cancel() - } - - @MainActor - func setFactNavigation(isActive: Bool) async { - if isActive { - isLoading = true - fact = Fact(description: "\(count) is still loading...", number: count) - task = Task { - let fact = await getNumberFact(self.count) - isLoading = false - guard !Task.isCancelled - else { return } - self.fact = fact - } - await task?.value - } else { - task?.cancel() - task = nil - fact = nil - } - } - - @MainActor - func numberFactButtonTapped() async { - await setFactNavigation(isActive: true) - } - - @MainActor - func cancelButtonTapped() async { - await setFactNavigation(isActive: false) - } - - @MainActor - func saveButtonTapped(fact: Fact) async { - savedFacts.append(fact) - await setFactNavigation(isActive: false) - } - - @MainActor - func removeSavedFacts(atOffsets offsets: IndexSet) { - savedFacts.remove(atOffsets: offsets) - } -} - -#Preview { - NavigationStack { - NavigationDestinations() - } -} diff --git a/Examples/CaseStudies/07-NavigationLinks.swift b/Examples/CaseStudies/07-NavigationLinks.swift deleted file mode 100644 index 4b87b6b053..0000000000 --- a/Examples/CaseStudies/07-NavigationLinks.swift +++ /dev/null @@ -1,122 +0,0 @@ -import SwiftUI -import SwiftUINavigation - -struct OptionalNavigationLinks: View { - @State private var model = FeatureModel() - - var body: some View { - List { - Section { - Stepper("Number: \(model.count)", value: $model.count) - - HStack { - Button("Get number fact") { - Task { await model.setFactNavigation(isActive: true) } - } - - if self.model.isLoading { - Spacer() - ProgressView() - } - } - } header: { - Text("Fact Finder") - } - - Section { - ForEach(model.savedFacts) { fact in - Text(fact.description) - } - .onDelete { model.removeSavedFacts(atOffsets: $0) } - } header: { - Text("Saved Facts") - } - } - .navigationDestination(item: $model.fact) { $fact in - FactEditor(fact: $fact.description) - .disabled(model.isLoading) - .foregroundColor(model.isLoading ? .gray : nil) - .navigationBarBackButtonHidden(true) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - Task { await model.cancelButtonTapped() } - } - } - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - Task { await model.saveButtonTapped(fact: fact) } - } - } - } - } - .navigationTitle("Links") - } -} - -private struct FactEditor: View { - @Binding var fact: String - - var body: some View { - VStack { - TextEditor(text: $fact) - } - .padding() - .navigationTitle("Fact editor") - } -} - -@Observable -private class FeatureModel { - var count = 0 - var fact: Fact? - var isLoading = false - var savedFacts: [Fact] = [] - private var task: Task? - - deinit { - task?.cancel() - } - - @MainActor - func setFactNavigation(isActive: Bool) async { - if isActive { - isLoading = true - fact = Fact(description: "\(count) is still loading...", number: count) - task = Task { - let fact = await getNumberFact(self.count) - isLoading = false - guard !Task.isCancelled - else { return } - self.fact = fact - } - await task?.value - } else { - task?.cancel() - task = nil - fact = nil - } - } - - @MainActor - func cancelButtonTapped() async { - await setFactNavigation(isActive: false) - } - - @MainActor - func saveButtonTapped(fact: Fact) async { - savedFacts.append(fact) - await setFactNavigation(isActive: false) - } - - @MainActor - func removeSavedFacts(atOffsets offsets: IndexSet) { - savedFacts.remove(atOffsets: offsets) - } -} - -#Preview { - NavigationStack { - OptionalNavigationLinks() - } -} diff --git a/Examples/CaseStudies/08-Routing.swift b/Examples/CaseStudies/08-Routing.swift deleted file mode 100644 index 6c34662a68..0000000000 --- a/Examples/CaseStudies/08-Routing.swift +++ /dev/null @@ -1,125 +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. - """ - -@CasePathable -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: \(count)") - } - - Button("Alert") { - destination = .alert( - AlertState { - TextState("Update count?") - } actions: { - ButtonState(action: .send(.randomize)) { - TextState("Randomize") - } - ButtonState(role: .destructive, action: .send(.reset)) { - TextState("Reset") - } - } - ) - } - - Button("Confirmation dialog") { - destination = .confirmationDialog( - ConfirmationDialogState(titleVisibility: .visible) { - TextState("Update count?") - } actions: { - ButtonState(action: .send(.increment)) { - TextState("Increment") - } - ButtonState(action: .send(.decrement)) { - TextState("Decrement") - } - } - ) - } - - Button("Link") { - destination = .link(count) - } - - Button("Sheet") { - destination = .sheet(count) - } - } - .navigationTitle("Routing") - .alert($destination.alert) { action in - switch action { - case .randomize?: - count = .random(in: 0...1_000) - case .reset?: - count = 0 - case nil: - break - } - } - .confirmationDialog($destination.confirmationDialog) { action in - switch action { - case .decrement?: - count -= 1 - case .increment?: - count += 1 - case nil: - break - } - } - .navigationDestination(item: $destination.link) { $count in - Form { - Stepper("Count: \(count)", value: $count) - } - .navigationTitle("Routing link") - } - .sheet(item: $destination.sheet, id: \.self) { $count in - NavigationStack { - Form { - Stepper("Count: \(count)", value: $count) - } - .navigationTitle("Routing sheet") - } - } - } -} - -#Preview { - NavigationStack { - Routing() - } -} diff --git a/Examples/CaseStudies/09-CustomComponents.swift b/Examples/CaseStudies/09-CustomComponents.swift deleted file mode 100644 index 0533c22223..0000000000 --- a/Examples/CaseStudies/09-CustomComponents.swift +++ /dev/null @@ -1,110 +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 { - count = 0 - } - } - - if let count = count, count > 0 { - Text("Current count: \(count)") - .transition(.opacity) - } - } - .bottomMenu(item: $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 isActive { - Rectangle() - .fill(Color.black.opacity(0.4)) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onTapGesture { - withAnimation { - 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 { - modifier( - BottomMenuModifier( - isActive: isActive, - content: content - ) - ) - } - - fileprivate func bottomMenu( - item: Binding, - @ViewBuilder content: @escaping (Binding) -> Content - ) -> some View - where Content: View { - modifier( - BottomMenuModifier( - isActive: Binding(item), - content: { Binding(unwrapping: item).map(content) } - ) - ) - } -} - -#Preview { - CustomComponents() -} diff --git a/Examples/CaseStudies/10-SynchronizedBindings.swift b/Examples/CaseStudies/10-SynchronizedBindings.swift deleted file mode 100644 index c8abcaf132..0000000000 --- a/Examples/CaseStudies/10-SynchronizedBindings.swift +++ /dev/null @@ -1,64 +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? - @State private var model = FeatureModel() - - var body: some View { - Form { - Section { - Text(readMe) - } - - Section { - TextField("Username", text: $model.username) - .focused($focusedField, equals: .username) - - SecureField("Password", text: $model.password) - .focused($focusedField, equals: .password) - - Button("Sign In") { - model.signInButtonTapped() - } - .buttonStyle(.borderedProminent) - } - .textFieldStyle(.roundedBorder) - } - .bind($model.focusedField, to: $focusedField) - .navigationTitle("Synchronized focus") - } -} - -@Observable -private class FeatureModel { - enum Field: String { - case username - case password - } - - var focusedField: Field? = .username - var password: String = "" - var username: String = "" - - func signInButtonTapped() { - if username.isEmpty { - focusedField = .username - } else if password.isEmpty { - focusedField = .password - } else { - focusedField = nil - } - } -} - -#Preview { - SynchronizedBindings() -} diff --git a/Examples/CaseStudies/11-IfLet.swift b/Examples/CaseStudies/11-IfLet.swift deleted file mode 100644 index f6ec2cd4ec..0000000000 --- a/Examples/CaseStudies/11-IfLet.swift +++ /dev/null @@ -1,48 +0,0 @@ -import SwiftUI -import SwiftUINavigation - -private let readMe = """ - This demonstrates how 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) - } - Binding(unwrapping: $editableString).map { $string in - VStack { - TextField("Edit string", text: $string) - HStack { - Button("Discard") { - editableString = nil - } - Spacer() - Button("Save") { - string = string - editableString = nil - } - } - } - } - if editableString == nil { - Text("\(string)") - Button("Edit") { - editableString = string - } - } - } - .buttonStyle(.borderless) - } -} - -#Preview { - IfLetCaseStudy() -} diff --git a/Examples/CaseStudies/12-IfCaseLet.swift b/Examples/CaseStudies/12-IfCaseLet.swift deleted file mode 100644 index 5994f58988..0000000000 --- a/Examples/CaseStudies/12-IfCaseLet.swift +++ /dev/null @@ -1,55 +0,0 @@ -import CasePaths -import SwiftUI -import SwiftUINavigation - -private let readMe = """ - This demonstrates how 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 - - @CasePathable - enum EditableString { - case active(String) - case inactive - } - - var body: some View { - Form { - Section { - Text(readMe) - } - $editableString.active.map { $string in - VStack { - TextField("Edit string", text: $string) - HStack { - Button("Discard", role: .cancel) { - editableString = .inactive - } - Spacer() - Button("Save") { - string = string - editableString = .inactive - } - } - } - } - if !editableString.is(\.active) { - Text("\(string)") - Button("Edit") { - editableString = .active(string) - } - } - } - .buttonStyle(.borderless) - } -} - -#Preview { - 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 90% rename from Examples/CaseStudies/FactClient.swift rename to Examples/CaseStudies/Internal/FactClient.swift index 279b53ff00..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 { - [description as AnyHashable, 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