From 3fff7901a7813f544a707abaf00c8c42177515d5 Mon Sep 17 00:00:00 2001 From: David Peterson Date: Sat, 6 Apr 2024 02:14:03 +1000 Subject: [PATCH 01/97] Alert with binding (#145) * Updated `alert` and `confirmationDialog` wrapper functions - Deprecated the `alert(title:unwrapping:...)` and `confirmationDialog(title:unwrapping:...)` functions in `SwiftUINavigation` - Deprecated the read-only `Bindable.isPresent()` in `SwiftUINavigation. - Added `alert(_:title:actions:message)` functions to `SwiftUINavigationCore` to expose them to TCA. - Added `confirmationDialog(_:title:titleVisibility:actions:message)` functions to `SwiftUINavigationCore` to expose them to TCA. - Added read/write `Bindable.isPresent()` in `SwiftUINavigationCore`. - Added/updated documentation * Fixed bug when updating whether a binding is present. * Updated Alert/ConfirmationDialog methods - Reordered parameters to more closely match the built-in ones. - Added overloads without `message` closures. - Updated documentation * Removed dialog documentation. * Fixed bug in deprectaions call. * Removed static title overloads. * Reverted Binding implementation, moved into `SwiftUINavigationCore` * Update Sources/SwiftUINavigation/Internal/Deprecations.swift * Update Sources/SwiftUINavigation/Internal/Deprecations.swift * Restore SwiftUINavigationCore.md * Delete Sourcs/SwiftUINavigationCore/Documentation.docc/SwiftUINavigationCore.md * Create SwiftUINavigationCore.md --------- Co-authored-by: Stephen Celis --- Package.resolved | 4 +- Sources/SwiftUINavigation/Alert.swift | 73 +--- Sources/SwiftUINavigation/Binding.swift | 21 +- .../ConfirmationDialog.swift | 73 ---- .../Documentation.docc/Articles/Bindings.md | 4 +- .../Extensions/Deprecations.md | 1 + .../Internal/Deprecations.swift | 387 +++++++++--------- Sources/SwiftUINavigationCore/Alert.swift | 141 +++++++ Sources/SwiftUINavigationCore/Binding.swift | 27 ++ .../ConfirmationDialog.swift | 150 +++++++ .../ButtonStateTests.swift | 2 +- 11 files changed, 529 insertions(+), 354 deletions(-) create mode 100644 Sources/SwiftUINavigationCore/Alert.swift create mode 100644 Sources/SwiftUINavigationCore/Binding.swift create mode 100644 Sources/SwiftUINavigationCore/ConfirmationDialog.swift diff --git a/Package.resolved b/Package.resolved index bc0e7355d1..5962320b06 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/pointfreeco/swift-case-paths", "state": { "branch": null, - "revision": "bba1111185863c9288c5f047770f421c3b7793a4", - "version": "1.1.3" + "revision": "e593aba2c6222daad7c4f2732a431eed2c09bb07", + "version": "1.3.0" } }, { diff --git a/Sources/SwiftUINavigation/Alert.swift b/Sources/SwiftUINavigation/Alert.swift index bf27a0128c..45b1a2e689 100644 --- a/Sources/SwiftUINavigation/Alert.swift +++ b/Sources/SwiftUINavigation/Alert.swift @@ -1,73 +1,8 @@ #if canImport(SwiftUI) import SwiftUI - - extension View { - /// Presents an alert from a binding to an optional value. - /// - /// SwiftUI's `alert` view modifiers are driven by two disconnected pieces of state: an - /// `isPresented` binding to a boolean that determines if the alert should be presented, and - /// optional alert `data` that is used to customize its actions and message. - /// - /// Modeling the domain in this way unfortunately introduces a couple invalid runtime states: - /// - /// * `isPresented` can be `true`, but `data` can be `nil`. - /// * `isPresented` can be `false`, but `data` can be non-`nil`. - /// - /// On top of that, SwiftUI's `alert` modifiers take static titles, which means the title cannot - /// be dynamically computed from the alert data. - /// - /// This overload addresses these shortcomings with a streamlined API. First, it eliminates the - /// invalid runtime states at compile time by driving the alert's presentation from a single, - /// optional binding. When this binding is non-`nil`, the alert will be presented. Further, the - /// title can be customized from the alert data. - /// - /// ```swift - /// struct AlertDemo: View { - /// @State var randomMovie: Movie? - /// - /// var body: some View { - /// Button("Pick a random movie", action: self.getRandomMovie) - /// .alert( - /// title: { Text($0.title) }, - /// unwrapping: self.$randomMovie, - /// actions: { _ in - /// Button("Pick another", action: self.getRandomMovie) - /// }, - /// message: { Text($0.summary) } - /// ) - /// } - /// - /// func getRandomMovie() { - /// self.randomMovie = Movie.allCases.randomElement() - /// } - /// } - /// ``` - /// - /// - Parameters: - /// - title: A closure returning the alert's title given the current alert state. - /// - value: A binding to an optional value that determines whether an alert should be - /// presented. When the binding is updated with non-`nil` value, it is unwrapped and passed - /// to the modifier's closures. You can use this data to populate the fields of an alert - /// that the system displays to the user. When the user presses or taps one of the alert's - /// actions, the system sets this value to `nil` and dismisses the alert. - /// - actions: A view builder returning the alert's actions given the current alert state. - /// - message: A view builder returning the message for the alert given the current alert - /// state. - @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) - public func alert( - title: (Value) -> Text, - unwrapping value: Binding, - @ViewBuilder actions: (Value) -> A, - @ViewBuilder message: (Value) -> M - ) -> some View { - self.alert( - value.wrappedValue.map(title) ?? Text(verbatim: ""), - isPresented: value.isPresent(), - presenting: value.wrappedValue, - actions: actions, - message: message - ) - } + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + extension View { /// Presents an alert from a binding to optional alert state. /// @@ -81,7 +16,6 @@ /// dismisses the alert, and the action is fed to the `action` closure. /// - handler: A closure that is called with an action from a particular alert button when /// tapped. - @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) public func alert( _ state: Binding?>, action handler: @escaping (Value?) -> Void = { (_: Never?) in } @@ -114,7 +48,6 @@ /// dismisses the alert, and the action is fed to the `action` closure. /// - handler: A closure that is called with an action from a particular alert button when /// tapped. - @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) public func alert( _ state: Binding?>, action handler: @escaping (Value?) async -> Void = { (_: Never?) async in } diff --git a/Sources/SwiftUINavigation/Binding.swift b/Sources/SwiftUINavigation/Binding.swift index 9d65e4f25a..fd9012e7f6 100644 --- a/Sources/SwiftUINavigation/Binding.swift +++ b/Sources/SwiftUINavigation/Binding.swift @@ -50,17 +50,6 @@ self = base[default: DefaultSubscript(value)] } - /// Creates a binding by projecting the current optional value to a boolean describing if it's - /// non-`nil`. - /// - /// Writing `false` to the binding will `nil` out the base value. Writing `true` does nothing. - /// - /// - Returns: A binding to a boolean. Returns `true` if non-`nil`, otherwise `false`. - public func isPresent() -> Binding - where Value == Wrapped? { - self._isPresent - } - /// Creates a binding that ignores writes to its wrapped value when equivalent to the new value. /// /// Useful to minimize writes to bindings passed to SwiftUI APIs. For example, [`NavigationLink` @@ -113,14 +102,6 @@ } extension Optional { - fileprivate var _isPresent: Bool { - get { self != nil } - set { - guard !newValue else { return } - self = nil - } - } - fileprivate subscript(default defaultSubscript: DefaultSubscript) -> Wrapped { get { defaultSubscript.value = self ?? defaultSubscript.value @@ -175,4 +156,4 @@ } } } -#endif // canImport(SwiftUI) +#endif // canImport(SwiftUI) diff --git a/Sources/SwiftUINavigation/ConfirmationDialog.swift b/Sources/SwiftUINavigation/ConfirmationDialog.swift index 7f48fc8509..6543e2b9f9 100644 --- a/Sources/SwiftUINavigation/ConfirmationDialog.swift +++ b/Sources/SwiftUINavigation/ConfirmationDialog.swift @@ -2,79 +2,6 @@ import SwiftUI extension View { - /// Presents a confirmation dialog from a binding to an optional value. - /// - /// SwiftUI's `confirmationDialog` view modifiers are driven by two disconnected pieces of - /// state: an `isPresented` binding to a boolean that determines if the dialog should be - /// presented, and optional dialog `data` that is used to customize its actions and message. - /// - /// Modeling the domain in this way unfortunately introduces a couple invalid runtime states: - /// - /// * `isPresented` can be `true`, but `data` can be `nil`. - /// * `isPresented` can be `false`, but `data` can be non-`nil`. - /// - /// On top of that, SwiftUI's `confirmationDialog` modifiers take static titles, which means the - /// title cannot be dynamically computed from the dialog data. - /// - /// This overload addresses these shortcomings with a streamlined API. First, it eliminates the - /// invalid runtime states at compile time by driving the dialog's presentation from a single, - /// optional binding. When this binding is non-`nil`, the dialog will be presented. Further, the - /// title can be customized from the dialog data. - /// - /// ```swift - /// struct DialogDemo: View { - /// @State var randomMovie: Movie? - /// - /// var body: some View { - /// Button("Pick a random movie", action: self.getRandomMovie) - /// .confirmationDialog( - /// title: { Text($0.title) }, - /// titleVisibility: .always, - /// unwrapping: self.$randomMovie, - /// actions: { _ in - /// Button("Pick another", action: self.getRandomMovie) - /// }, - /// message: { Text($0.summary) } - /// ) - /// } - /// - /// func getRandomMovie() { - /// self.randomMovie = Movie.allCases.randomElement() - /// } - /// } - /// ``` - /// - /// See for more information on how to use this API. - /// - /// - Parameters: - /// - title: A closure returning the dialog's title given the current dialog state. - /// - titleVisibility: The visibility of the dialog's title. - /// - value: A binding to an optional value that determines whether a dialog should be - /// presented. When the binding is updated with non-`nil` value, it is unwrapped and passed - /// to the modifier's closures. You can use this data to populate the fields of a dialog - /// that the system displays to the user. When the user presses or taps one of the dialog's - /// actions, the system sets this value to `nil` and dismisses the dialog. - /// - actions: A view builder returning the dialog's actions given the current dialog state. - /// - message: A view builder returning the message for the dialog given the current dialog - /// state. - @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) - public func confirmationDialog( - title: (Value) -> Text, - titleVisibility: Visibility = .automatic, - unwrapping value: Binding, - @ViewBuilder actions: (Value) -> A, - @ViewBuilder message: (Value) -> M - ) -> some View { - self.confirmationDialog( - value.wrappedValue.map(title) ?? Text(verbatim: ""), - isPresented: value.isPresent(), - titleVisibility: titleVisibility, - presenting: value.wrappedValue, - actions: actions, - message: message - ) - } - /// Presents a confirmation dialog from a binding to optional confirmation dialog state. /// /// See for more information on how to use this API. diff --git a/Sources/SwiftUINavigation/Documentation.docc/Articles/Bindings.md b/Sources/SwiftUINavigation/Documentation.docc/Articles/Bindings.md index ba7575b66b..5dae2c5dfd 100644 --- a/Sources/SwiftUINavigation/Documentation.docc/Articles/Bindings.md +++ b/Sources/SwiftUINavigation/Documentation.docc/Articles/Bindings.md @@ -69,8 +69,8 @@ struct SignInView: View { ### Dynamic case lookup -- ``SwiftUI/Binding/subscript(dynamicMember:)-9akk`` -- ``SwiftUI/Binding/subscript(dynamicMember:)-9okch`` +- ``SwiftUI/Binding/subscript(dynamicMember:)-9abgy`` +- ``SwiftUI/Binding/subscript(dynamicMember:)-8vc80`` ### Unwrapping bindings diff --git a/Sources/SwiftUINavigation/Documentation.docc/Extensions/Deprecations.md b/Sources/SwiftUINavigation/Documentation.docc/Extensions/Deprecations.md index 4f37a20049..ed5b569664 100644 --- a/Sources/SwiftUINavigation/Documentation.docc/Extensions/Deprecations.md +++ b/Sources/SwiftUINavigation/Documentation.docc/Extensions/Deprecations.md @@ -21,6 +21,7 @@ instead. ### View modifiers - ``SwiftUI/View/alert(title:unwrapping:case:actions:message:)`` +- ``SwiftUI/View/alert(title:unwrapping:actions:message:)`` - ``SwiftUI/View/alert(unwrapping:action:)-7da26`` - ``SwiftUI/View/alert(unwrapping:action:)-6y2fk`` - ``SwiftUI/View/alert(unwrapping:action:)-867h5`` diff --git a/Sources/SwiftUINavigation/Internal/Deprecations.swift b/Sources/SwiftUINavigation/Internal/Deprecations.swift index fa3ccf1375..18b686c553 100644 --- a/Sources/SwiftUINavigation/Internal/Deprecations.swift +++ b/Sources/SwiftUINavigation/Internal/Deprecations.swift @@ -2,6 +2,39 @@ import SwiftUI @_spi(RuntimeWarn) import SwiftUINavigationCore + // NB: Deprecated after 1.2.1 + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + extension View { + @available(*, deprecated, renamed: "alert(item:title:actions:message:)") + public func alert( + title: (Value) -> Text, + unwrapping value: Binding, + @ViewBuilder actions: (Value) -> A, + @ViewBuilder message: (Value) -> M + ) -> some View { + alert(item: value, title: title, actions: actions, message: message) + } + + @available(*, deprecated, renamed: "confirmationDialog(item:textVisibility:title:actions:message:)") + public func confirmationDialog( + title: (Value) -> Text, + titleVisibility: Visibility = .automatic, + unwrapping value: Binding, + @ViewBuilder actions: (Value) -> A, + @ViewBuilder message: (Value) -> M + ) -> some View { + self.confirmationDialog( + value.wrappedValue.map(title) ?? Text(verbatim: ""), + isPresented: value.isPresent(), + titleVisibility: titleVisibility, + presenting: value.wrappedValue, + actions: actions, + message: message + ) + } + } + // NB: Deprecated after 1.0.2 @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) @@ -11,7 +44,7 @@ unwrapping value: Binding?>, action handler: @escaping (Value?) -> Void = { (_: Never?) in } ) -> some View { - self.alert( + alert( (value.wrappedValue?.title).map(Text.init) ?? Text(verbatim: ""), isPresented: value.isPresent(), presenting: value.wrappedValue, @@ -29,7 +62,7 @@ unwrapping value: Binding?>, action handler: @escaping (Value?) async -> Void = { (_: Never?) async in } ) -> some View { - self.alert( + alert( (value.wrappedValue?.title).map(Text.init) ?? Text(verbatim: ""), isPresented: value.isPresent(), presenting: value.wrappedValue, @@ -47,7 +80,7 @@ unwrapping value: Binding?>, action handler: @escaping (Value?) -> Void = { (_: Never?) in } ) -> some View { - self.confirmationDialog( + confirmationDialog( value.wrappedValue.flatMap { Text($0.title) } ?? Text(verbatim: ""), isPresented: value.isPresent(), titleVisibility: value.wrappedValue.map { .init($0.titleVisibility) } ?? .automatic, @@ -66,7 +99,7 @@ unwrapping value: Binding?>, action handler: @escaping (Value?) async -> Void = { (_: Never?) async in } ) -> some View { - self.confirmationDialog( + confirmationDialog( value.wrappedValue.flatMap { Text($0.title) } ?? Text(verbatim: ""), isPresented: value.isPresent(), titleVisibility: value.wrappedValue.map { .init($0.titleVisibility) } ?? .automatic, @@ -85,22 +118,22 @@ @available( iOS, introduced: 15, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( macOS, introduced: 12, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( tvOS, introduced: 15, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( watchOS, introduced: 8, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) public func alert( title: (Case) -> Text, @@ -109,9 +142,9 @@ @ViewBuilder actions: (Case) -> A, @ViewBuilder message: (Case) -> M ) -> some View { - self.alert( + alert( + item: `enum`.case(casePath), title: title, - unwrapping: `enum`.case(casePath), actions: actions, message: message ) @@ -120,78 +153,78 @@ @available( iOS, introduced: 15, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( macOS, introduced: 12, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( tvOS, introduced: 15, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( watchOS, introduced: 8, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) public func alert( - unwrapping `enum`: Binding, + unwrapping enum: Binding, case casePath: AnyCasePath>, action handler: @escaping (Value?) -> Void = { (_: Never?) in } ) -> some View { - self.alert(`enum`.case(casePath), action: handler) + alert(`enum`.case(casePath), action: handler) } @available( iOS, introduced: 15, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( macOS, introduced: 12, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( tvOS, introduced: 15, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( watchOS, introduced: 8, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) public func alert( - unwrapping `enum`: Binding, + unwrapping enum: Binding, case casePath: AnyCasePath>, action handler: @escaping (Value?) async -> Void = { (_: Never?) async in } ) -> some View { - self.alert(`enum`.case(casePath), action: handler) + alert(`enum`.case(casePath), action: handler) } @available( iOS, introduced: 15, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( macOS, introduced: 12, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( tvOS, introduced: 15, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( watchOS, introduced: 8, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) public func confirmationDialog( title: (Case) -> Text, @@ -201,10 +234,10 @@ @ViewBuilder actions: (Case) -> A, @ViewBuilder message: (Case) -> M ) -> some View { - self.confirmationDialog( - title: title, + confirmationDialog( + item: `enum`.case(casePath), titleVisibility: titleVisibility, - unwrapping: `enum`.case(casePath), + title: title, actions: actions, message: message ) @@ -213,29 +246,29 @@ @available( iOS, introduced: 15, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( macOS, introduced: 12, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( tvOS, introduced: 15, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( watchOS, introduced: 8, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) public func confirmationDialog( - unwrapping `enum`: Binding, + unwrapping enum: Binding, case casePath: AnyCasePath>, action handler: @escaping (Value?) -> Void = { (_: Never?) in } ) -> some View { - self.confirmationDialog( + confirmationDialog( `enum`.case(casePath), action: handler ) @@ -244,29 +277,29 @@ @available( iOS, introduced: 15, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( macOS, introduced: 12, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( tvOS, introduced: 15, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( watchOS, introduced: 8, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) public func confirmationDialog( - unwrapping `enum`: Binding, + unwrapping enum: Binding, case casePath: AnyCasePath>, action handler: @escaping (Value?) async -> Void = { (_: Never?) async in } ) -> some View { - self.confirmationDialog( + confirmationDialog( `enum`.case(casePath), action: handler ) @@ -275,18 +308,18 @@ @available( iOS, introduced: 14, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available(macOS, unavailable) @available( tvOS, introduced: 14, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( watchOS, introduced: 7, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) public func fullScreenCover( unwrapping enum: Binding, @@ -294,48 +327,48 @@ onDismiss: (() -> Void)? = nil, @ViewBuilder content: @escaping (Binding) -> Content ) -> some View - where Content: View { - self.fullScreenCover( + where Content: View { + fullScreenCover( unwrapping: `enum`.case(casePath), onDismiss: onDismiss, content: content) } @available( iOS, introduced: 16, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( macOS, introduced: 13, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( tvOS, introduced: 16, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( watchOS, introduced: 9, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) public func navigationDestination( unwrapping enum: Binding, case casePath: AnyCasePath, @ViewBuilder destination: (Binding) -> Destination ) -> some View { - self.navigationDestination(unwrapping: `enum`.case(casePath), destination: destination) + navigationDestination(unwrapping: `enum`.case(casePath), destination: destination) } @available( iOS, introduced: 13, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( macOS, introduced: 10.15, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available(tvOS, unavailable) @available(watchOS, unavailable) @@ -346,7 +379,7 @@ arrowEdge: Edge = .top, @ViewBuilder content: @escaping (Binding) -> Content ) -> some View where Content: View { - self.popover( + popover( unwrapping: `enum`.case(casePath), attachmentAnchor: attachmentAnchor, arrowEdge: arrowEdge, @@ -357,22 +390,22 @@ @available( iOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( macOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( tvOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( watchOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @MainActor public func sheet( @@ -381,8 +414,8 @@ onDismiss: (() -> Void)? = nil, @ViewBuilder content: @escaping (Binding) -> Content ) -> some View - where Content: View { - self.sheet(unwrapping: `enum`.case(casePath), onDismiss: onDismiss, content: content) + where Content: View { + sheet(unwrapping: `enum`.case(casePath), onDismiss: onDismiss, content: content) } } @@ -390,22 +423,22 @@ @available( iOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( macOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( tvOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( watchOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) public init?(unwrapping enum: Binding, case casePath: AnyCasePath) { guard var `case` = casePath.extract(from: `enum`.wrappedValue) @@ -427,25 +460,25 @@ @available( iOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( macOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( tvOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( watchOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) public func `case`(_ casePath: AnyCasePath) -> Binding - where Value == Enum? { + where Value == Enum? { .init( get: { self.wrappedValue.flatMap(casePath.extract(from:)) }, set: { newValue, transaction in @@ -457,31 +490,31 @@ @available( iOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( macOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( tvOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( watchOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) public func isPresent(_ casePath: AnyCasePath) -> Binding - where Value == Enum? { + where Value == Enum? { self.case(casePath).isPresent() } } public struct IfCaseLet: View - where IfContent: View, ElseContent: View { + where IfContent: View, ElseContent: View { public let `enum`: Binding public let casePath: AnyCasePath public let ifContent: (Binding) -> IfContent @@ -490,25 +523,25 @@ @available( iOS, deprecated: 9999, message: - "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." + "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." ) @available( macOS, deprecated: 9999, message: - "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." + "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." ) @available( tvOS, deprecated: 9999, message: - "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." + "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." ) @available( watchOS, deprecated: 9999, message: - "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." + "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." ) public init( - _ `enum`: Binding, + _ enum: Binding, pattern casePath: AnyCasePath, @ViewBuilder then ifContent: @escaping (Binding) -> IfContent, @ViewBuilder else elseContent: () -> ElseContent @@ -546,19 +579,19 @@ ) extension IfCaseLet where ElseContent == EmptyView { public init( - _ `enum`: Binding, + _ enum: Binding, pattern casePath: AnyCasePath, @ViewBuilder ifContent: @escaping (Binding) -> IfContent ) { self.casePath = casePath - self.elseContent = EmptyView() + elseContent = EmptyView() self.enum = `enum` self.ifContent = ifContent } } public struct IfLet: View - where IfContent: View, ElseContent: View { + where IfContent: View, ElseContent: View { public let value: Binding public let ifContent: (Binding) -> IfContent public let elseContent: ElseContent @@ -566,22 +599,22 @@ @available( iOS, deprecated: 9999, message: - "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." + "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." ) @available( macOS, deprecated: 9999, message: - "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." + "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." ) @available( tvOS, deprecated: 9999, message: - "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." + "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." ) @available( watchOS, deprecated: 9999, message: - "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." + "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." ) public init( _ value: Binding, @@ -631,22 +664,22 @@ @available( iOS, introduced: 13, deprecated: 16, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( macOS, introduced: 10.15, deprecated: 13, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( tvOS, introduced: 13, deprecated: 16, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( watchOS, introduced: 6, deprecated: 9, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) public init( unwrapping enum: Binding, @@ -667,22 +700,22 @@ @available( iOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) @available( macOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) @available( tvOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) @available( watchOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) public struct Switch: View { public let `enum`: Binding @@ -705,25 +738,25 @@ @available( iOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) @available( macOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) @available( tvOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) @available( watchOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) public struct CaseLet: View - where Content: View { + where Content: View { @EnvironmentObject private var `enum`: BindingObject public let casePath: AnyCasePath public let content: (Binding) -> Content @@ -744,22 +777,22 @@ @available( iOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) @available( macOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) @available( tvOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) @available( watchOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) public struct Default: View { private let content: Content @@ -776,22 +809,22 @@ @available( iOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) @available( macOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) @available( tvOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) @available( watchOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) extension Switch { public init( @@ -803,12 +836,11 @@ ) > ) - where + where Content == _ConditionalContent< CaseLet, Default - > - { + > { self.init(enum: `enum`) { let content = content().value if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { @@ -825,12 +857,11 @@ line: UInt = #line, @ViewBuilder content: () -> CaseLet ) - where + where Content == _ConditionalContent< CaseLet, Default<_ExhaustivityCheckView> - > - { + > { self.init(`enum`) { content() Default { _ExhaustivityCheckView(file: file, line: line) } @@ -847,15 +878,14 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< CaseLet, CaseLet >, Default - > - { + > { self.init(enum: `enum`) { let content = content().value if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { @@ -879,15 +909,14 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< CaseLet, CaseLet >, Default<_ExhaustivityCheckView> - > - { + > { let content = content() self.init(`enum`) { content.value.0 @@ -912,7 +941,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< CaseLet, @@ -922,8 +951,7 @@ CaseLet, Default > - > - { + > { self.init(enum: `enum`) { let content = content().value if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { @@ -950,7 +978,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< CaseLet, @@ -960,8 +988,7 @@ CaseLet, Default<_ExhaustivityCheckView> > - > - { + > { let content = content() self.init(`enum`) { content.value.0 @@ -989,7 +1016,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< _ConditionalContent< @@ -1002,8 +1029,7 @@ > >, Default - > - { + > { self.init(enum: `enum`) { let content = content().value if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { @@ -1038,7 +1064,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< _ConditionalContent< @@ -1051,8 +1077,7 @@ > >, Default<_ExhaustivityCheckView> - > - { + > { let content = content() self.init(`enum`) { content.value.0 @@ -1083,7 +1108,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< _ConditionalContent< @@ -1099,8 +1124,7 @@ CaseLet, Default > - > - { + > { self.init(enum: `enum`) { let content = content().value if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { @@ -1139,7 +1163,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< _ConditionalContent< @@ -1155,8 +1179,7 @@ CaseLet, Default<_ExhaustivityCheckView> > - > - { + > { let content = content() self.init(`enum`) { content.value.0 @@ -1190,7 +1213,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< _ConditionalContent< @@ -1209,8 +1232,7 @@ >, Default > - > - { + > { self.init(enum: `enum`) { let content = content().value if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { @@ -1253,7 +1275,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< _ConditionalContent< @@ -1272,8 +1294,7 @@ >, Default<_ExhaustivityCheckView> > - > - { + > { let content = content() self.init(`enum`) { content.value.0 @@ -1310,7 +1331,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< _ConditionalContent< @@ -1332,8 +1353,7 @@ Default > > - > - { + > { self.init(enum: `enum`) { let content = content().value if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { @@ -1380,7 +1400,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< _ConditionalContent< @@ -1402,8 +1422,7 @@ Default<_ExhaustivityCheckView> > > - > - { + > { let content = content() self.init(`enum`) { content.value.0 @@ -1443,7 +1462,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< _ConditionalContent< @@ -1468,8 +1487,7 @@ > >, Default - > - { + > { self.init(enum: `enum`) { let content = content().value if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { @@ -1520,7 +1538,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< _ConditionalContent< @@ -1545,8 +1563,7 @@ > >, Default<_ExhaustivityCheckView> - > - { + > { let content = content() self.init(`enum`) { content.value.0 @@ -1589,7 +1606,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< _ConditionalContent< @@ -1617,8 +1634,7 @@ CaseLet, Default > - > - { + > { self.init(enum: `enum`) { let content = content().value if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { @@ -1673,7 +1689,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< _ConditionalContent< @@ -1701,8 +1717,7 @@ CaseLet, Default<_ExhaustivityCheckView> > - > - { + > { let content = content() self.init(`enum`) { content.value.0 @@ -1727,13 +1742,13 @@ public var body: some View { #if DEBUG let message = """ - Warning: Switch.body@\(self.file):\(self.line) + Warning: Switch.body@\(self.file):\(self.line) - "Switch" did not handle "\(describeCase(self.enum.wrappedValue.wrappedValue))" + "Switch" did not handle "\(describeCase(self.enum.wrappedValue.wrappedValue))" - Make sure that you exhaustively provide a "CaseLet" view for each case in "\(Enum.self)", \ - provide a "Default" view at the end of the "Switch", or use an "IfCaseLet" view instead. - """ + Make sure that you exhaustively provide a "CaseLet" view for each case in "\(Enum.self)", \ + provide a "Default" view at the end of the "Switch", or use an "IfCaseLet" view instead. + """ VStack(spacing: 17) { self.exclamation() .font(.largeTitle) @@ -1763,7 +1778,7 @@ let wrappedValue: Binding init(binding: Binding) { - self.wrappedValue = binding + wrappedValue = binding } } @@ -1774,13 +1789,13 @@ let childMirror = Mirror(reflecting: child.value) let associatedValuesMirror = childMirror.displayStyle == .tuple - ? childMirror - : Mirror(`enum`, unlabeledChildren: [child.value], displayStyle: .tuple) + ? childMirror + : Mirror(`enum`, unlabeledChildren: [child.value], displayStyle: .tuple) `case` = """ - \(label)(\ - \(associatedValuesMirror.children.map { "\($0.label ?? "_"):" }.joined())\ - ) - """ + \(label)(\ + \(associatedValuesMirror.children.map { "\($0.label ?? "_"):" }.joined())\ + ) + """ } else { `case` = "\(`enum`)" } @@ -1800,13 +1815,13 @@ *, deprecated, message: - "'View.alert' now passes an optional action to its handler to allow you to handle action-less dismissals." + "'View.alert' now passes an optional action to its handler to allow you to handle action-less dismissals." ) public func alert( unwrapping value: Binding?>, action handler: @escaping (Value) async -> Void = { (_: Void) async in } ) -> some View { - self.alert(value) { (value: Value?) in + alert(value) { (value: Value?) in if let value = value { await handler(value) } @@ -1818,14 +1833,14 @@ *, deprecated, message: - "'View.alert' now passes an optional action to its handler to allow you to handle action-less dismissals." + "'View.alert' now passes an optional action to its handler to allow you to handle action-less dismissals." ) public func alert( - unwrapping `enum`: Binding, + unwrapping enum: Binding, case casePath: CasePath>, action handler: @escaping (Value) async -> Void = { (_: Void) async in } ) -> some View { - self.alert(unwrapping: `enum`, case: casePath) { (value: Value?) async in + alert(unwrapping: `enum`, case: casePath) { (value: Value?) async in if let value = value { await handler(value) } @@ -1837,13 +1852,13 @@ *, deprecated, message: - "'View.alert' now passes an optional action to its handler to allow you to handle action-less dismissals." + "'View.alert' now passes an optional action to its handler to allow you to handle action-less dismissals." ) public func confirmationDialog( unwrapping value: Binding?>, action handler: @escaping (Value) async -> Void = { (_: Void) async in } ) -> some View { - self.confirmationDialog(unwrapping: value) { (value: Value?) in + confirmationDialog(unwrapping: value) { (value: Value?) in if let value = value { await handler(value) } @@ -1855,14 +1870,14 @@ *, deprecated, message: - "'View.alert' now passes an optional action to its handler to allow you to handle action-less dismissals." + "'View.alert' now passes an optional action to its handler to allow you to handle action-less dismissals." ) public func confirmationDialog( - unwrapping `enum`: Binding, + unwrapping enum: Binding, case casePath: CasePath>, action handler: @escaping (Value) async -> Void = { (_: Void) async in } ) -> some View { - self.confirmationDialog(unwrapping: `enum`, case: casePath) { (value: Value?) async in + confirmationDialog(unwrapping: `enum`, case: casePath) { (value: Value?) async in if let value = value { await handler(value) } @@ -1875,7 +1890,7 @@ @available(*, deprecated, renamed: "init(_:pattern:then:else:)") extension IfCaseLet { public init( - _ `enum`: Binding, + _ enum: Binding, pattern casePath: CasePath, @ViewBuilder ifContent: @escaping (Binding) -> IfContent, @ViewBuilder elseContent: () -> ElseContent @@ -1917,4 +1932,4 @@ ) } } -#endif // canImport(SwiftUI) +#endif // canImport(SwiftUI) diff --git a/Sources/SwiftUINavigationCore/Alert.swift b/Sources/SwiftUINavigationCore/Alert.swift new file mode 100644 index 0000000000..448cda9f86 --- /dev/null +++ b/Sources/SwiftUINavigationCore/Alert.swift @@ -0,0 +1,141 @@ +#if canImport(SwiftUI) + import SwiftUI + + // MARK: - Alert with dynamic title + extension View { + /// Presents an alert from a binding to an optional value. + /// + /// SwiftUI's `alert` view modifiers are driven by two disconnected pieces of state: an + /// `isPresented` binding to a boolean that determines if the alert should be presented, and + /// optional alert `data` that is used to customize its actions and message. + /// + /// Modeling the domain in this way unfortunately introduces a couple invalid runtime states: + /// + /// * `isPresented` can be `true`, but `data` can be `nil`. + /// * `isPresented` can be `false`, but `data` can be non-`nil`. + /// + /// On top of that, SwiftUI's `alert` modifiers take static titles, which means the title cannot + /// be dynamically computed from the alert data. + /// + /// This overload addresses these shortcomings with a streamlined API. First, it eliminates the + /// invalid runtime states at compile time by driving the alert's presentation from a single, + /// optional binding. When this binding is non-`nil`, the alert will be presented. Further, the + /// title can be customized from the alert data. + /// + /// ```swift + /// struct AlertDemo: View { + /// @State var randomMovie: Movie? + /// + /// var body: some View { + /// Button("Pick a random movie", action: self.getRandomMovie) + /// .alert(item: self.$randomMovie) { + /// Text($0.title) + /// } actions: { _ in + /// Button("Pick another", action: self.getRandomMovie) + /// Button("I'm done", action: self.clearRandomMovie) + /// } message: { + /// Text($0.summary) + /// } + /// } + /// + /// func getRandomMovie() { + /// self.randomMovie = Movie.allCases.randomElement() + /// } + /// + /// func clearRandomMovie() { + /// self.randomMovie = nil + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - item: A binding to an optional value that determines whether an alert should be + /// presented. When the binding is updated with non-`nil` value, it is unwrapped and passed + /// to the modifier's closures. You can use this data to populate the fields of an alert + /// that the system displays to the user. When the user presses or taps one of the alert's + /// actions, the system sets this value to `nil` and dismisses the alert. + /// - title: A closure returning the alert's title given the current alert state. + /// - actions: A view builder returning the alert's actions given the current alert state. + /// - message: A view builder returning the message for the alert given the current alert + /// state. + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + public func alert( + item: Binding, + title: (Item) -> Text, + @ViewBuilder actions: (Item) -> A, + @ViewBuilder message: (Item) -> M + ) -> some View { + alert( + item.wrappedValue.map(title) ?? Text(verbatim: ""), + isPresented: item.isPresent(), + presenting: item.wrappedValue, + actions: actions, + message: message + ) + } + + /// Presents an alert from a binding to an optional value. + /// + /// SwiftUI's `alert` view modifiers are driven by two disconnected pieces of state: an + /// `isPresented` binding to a boolean that determines if the alert should be presented, and + /// optional alert `data` that is used to customize its actions and message. + /// + /// Modeling the domain in this way unfortunately introduces a couple invalid runtime states: + /// * `isPresented` can be `true`, but `data` can be `nil`. + /// * `isPresented` can be `false`, but `data` can be non-`nil`. + /// + /// On top of that, SwiftUI's `alert` modifiers take static titles, which means the title cannot + /// be dynamically computed from the alert data. + /// + /// This overload addresses these shortcomings with a streamlined API. First, it eliminates the + /// invalid runtime states at compile time by driving the alert's presentation from a single, + /// optional binding. When this binding is non-`nil`, the alert will be presented. Further, the + /// title can be customized from the alert data. + /// + /// ```swift + /// struct AlertDemo: View { + /// @State var randomMovie: Movie? + /// + /// var body: some View { + /// Button("Pick a random movie", action: self.getRandomMovie) + /// .alert(item: self.$randomMovie) { + /// Text($0.title) + /// } actions: { _ in + /// Button("Pick another", action: self.getRandomMovie) + /// Button("I'm done", action: self.clearRandomMovie) + /// } + /// } + /// + /// func getRandomMovie() { + /// self.randomMovie = Movie.allCases.randomElement() + /// } + /// + /// func clearRandomMovie() { + /// self.randomMovie = nil + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - item: A binding to an optional value that determines whether an alert should be + /// presented. When the binding is updated with non-`nil` value, it is unwrapped and passed + /// to the modifier's closures. You can use this data to populate the fields of an alert + /// that the system displays to the user. When the user presses or taps one of the alert's + /// actions, the system sets this value to `nil` and dismisses the alert. + /// - title: A closure returning the alert's title given the current alert state. + /// - actions: A view builder returning the alert's actions given the current alert state. + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + public func alert( + item: Binding, + title: (Item) -> Text, + @ViewBuilder actions: (Item) -> A + ) -> some View { + alert( + item.wrappedValue.map(title) ?? Text(verbatim: ""), + isPresented: item.isPresent(), + presenting: item.wrappedValue, + actions: actions + ) + } + } +#endif diff --git a/Sources/SwiftUINavigationCore/Binding.swift b/Sources/SwiftUINavigationCore/Binding.swift new file mode 100644 index 0000000000..22de85a1ab --- /dev/null +++ b/Sources/SwiftUINavigationCore/Binding.swift @@ -0,0 +1,27 @@ +#if canImport(SwiftUI) + import SwiftUI + + extension Binding { + /// Creates a binding by projecting the current optional value to a boolean describing if it's + /// non-`nil`. + /// + /// Writing `false` to the binding will `nil` out the base value. Writing `true` does nothing. + /// + /// - Returns: A binding to a boolean. Returns `true` if non-`nil`, otherwise `false`. + public func isPresent() -> Binding + where Value == Wrapped? { + self._isPresent + } + } + + extension Optional { + fileprivate var _isPresent: Bool { + get { self != nil } + set { + guard !newValue else { return } + self = nil + } + } + } + +#endif diff --git a/Sources/SwiftUINavigationCore/ConfirmationDialog.swift b/Sources/SwiftUINavigationCore/ConfirmationDialog.swift new file mode 100644 index 0000000000..f84f139280 --- /dev/null +++ b/Sources/SwiftUINavigationCore/ConfirmationDialog.swift @@ -0,0 +1,150 @@ +#if canImport(SwiftUI) + import SwiftUI + + // MARK: - ConfirmationDialog with dynamic title + + extension View { + /// Presents a confirmation dialog from a binding to an optional value. + /// + /// SwiftUI's `confirmationDialog` view modifiers are driven by two disconnected pieces of + /// state: an `isPresented` binding to a boolean that determines if the dialog should be + /// presented, and optional dialog `data` that is used to customize its actions and message. + /// + /// Modeling the domain in this way unfortunately introduces a couple invalid runtime states: + /// + /// * `isPresented` can be `true`, but `data` can be `nil`. + /// * `isPresented` can be `false`, but `data` can be non-`nil`. + /// + /// On top of that, SwiftUI's `confirmationDialog` modifiers take static titles, which means the + /// title cannot be dynamically computed from the dialog data. + /// + /// This overload addresses these shortcomings with a streamlined API. First, it eliminates the + /// invalid runtime states at compile time by driving the dialog's presentation from a single, + /// optional binding. When this binding is non-`nil`, the dialog will be presented. Further, the + /// title can be customized from the dialog data. + /// + /// ```swift + /// struct DialogDemo: View { + /// @State var randomMovie: Movie? + /// + /// var body: some View { + /// Button("Pick a random movie", action: self.getRandomMovie) + /// .confirmationDialog(item: self.$randomMovie, titleVisibility: .always) { + /// Text($0.title) + /// } actions: { _ in + /// Button("Pick another", action: self.getRandomMovie) + /// Button("I'm done", action: self.clearRandomMovie) + /// } message: { + /// Text($0.summary) + /// } + /// } + /// + /// func getRandomMovie() { + /// self.randomMovie = Movie.allCases.randomElement() + /// } + /// + /// func clearRandomMovie() { + /// self.randomMovie = nil + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - item: A binding to an optional value that determines whether a dialog should be + /// presented. When the binding is updated with non-`nil` value, it is unwrapped and passed + /// to the modifier's closures. You can use this data to populate the fields of a dialog + /// that the system displays to the user. When the user presses or taps one of the dialog's + /// actions, the system sets this value to `nil` and dismisses the dialog. + /// - title: A closure returning the dialog's title given the current dialog state. + /// - titleVisibility: The visibility of the dialog's title. (default: .automatic) + /// - actions: A view builder returning the dialog's actions given the current dialog state. + /// - message: A view builder returning the message for the dialog given the current dialog + /// state. + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + public func confirmationDialog( + item: Binding, + titleVisibility: Visibility = .automatic, + title: (Item) -> Text, + @ViewBuilder actions: (Item) -> A, + @ViewBuilder message: (Item) -> M + ) -> some View { + confirmationDialog( + item.wrappedValue.map(title) ?? Text(verbatim: ""), + isPresented: item.isPresent(), + titleVisibility: titleVisibility, + presenting: item.wrappedValue, + actions: actions, + message: message + ) + } + + /// Presents a confirmation dialog from a binding to an optional value. + /// + /// SwiftUI's `confirmationDialog` view modifiers are driven by two disconnected pieces of + /// state: an `isPresented` binding to a boolean that determines if the dialog should be + /// presented, and optional dialog `data` that is used to customize its actions and message. + /// + /// Modeling the domain in this way unfortunately introduces a couple invalid runtime states: + /// + /// * `isPresented` can be `true`, but `data` can be `nil`. + /// * `isPresented` can be `false`, but `data` can be non-`nil`. + /// + /// On top of that, SwiftUI's `confirmationDialog` modifiers take static titles, which means the + /// title cannot be dynamically computed from the dialog data. + /// + /// This overload addresses these shortcomings with a streamlined API. First, it eliminates the + /// invalid runtime states at compile time by driving the dialog's presentation from a single, + /// optional binding. When this binding is non-`nil`, the dialog will be presented. Further, the + /// title can be customized from the dialog data. + /// + /// struct DialogDemo: View { + /// @State var randomMovie: Movie? + /// + /// var body: some View { + /// Button("Pick a random movie", action: self.getRandomMovie) + /// .confirmationDialog(item: self.$randomMovie, titleVisibility: .always) { + /// Text($0.title) + /// } actions: { _ in + /// Button("Pick another", action: self.getRandomMovie) + /// Button("I'm done", action: self.clearRandomMovie) + /// } + /// } + /// + /// func getRandomMovie() { + /// self.randomMovie = Movie.allCases.randomElement() + /// } + /// + /// func clearRandomMovie() { + /// self.randomMovie = nil + /// } + /// } + /// ``` + /// + /// See for more information on how to use this API. + /// + /// - Parameters: + /// - item: A binding to an optional value that determines whether a dialog should be + /// presented. When the binding is updated with non-`nil` value, it is unwrapped and passed + /// to the modifier's closures. You can use this data to populate the fields of a dialog + /// that the system displays to the user. When the user presses or taps one of the dialog's + /// actions, the system sets this value to `nil` and dismisses the dialog. + /// - title: A closure returning the dialog's title given the current dialog state. + /// - titleVisibility: The visibility of the dialog's title. (default: .automatic) + /// - actions: A view builder returning the dialog's actions given the current dialog state. + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + public func confirmationDialog( + item: Binding, + titleVisibility: Visibility = .automatic, + title: (Item) -> Text, + @ViewBuilder actions: (Item) -> A + ) -> some View { + confirmationDialog( + item.wrappedValue.map(title) ?? Text(verbatim: ""), + isPresented: item.isPresent(), + titleVisibility: titleVisibility, + presenting: item.wrappedValue, + actions: actions + ) + } + } +#endif diff --git a/Tests/SwiftUINavigationTests/ButtonStateTests.swift b/Tests/SwiftUINavigationTests/ButtonStateTests.swift index 788a8faff8..857d268063 100644 --- a/Tests/SwiftUINavigationTests/ButtonStateTests.swift +++ b/Tests/SwiftUINavigationTests/ButtonStateTests.swift @@ -4,8 +4,8 @@ import SwiftUINavigation import XCTest - @MainActor final class ButtonStateTests: XCTestCase { + @MainActor func testAsyncAnimationWarning() async { XCTExpectFailure { $0.compactDescription == """ From a2180625c3689840e0807683f3b7533e99661109 Mon Sep 17 00:00:00 2001 From: stephencelis Date: Fri, 5 Apr 2024 16:15:37 +0000 Subject: [PATCH 02/97] Run swift-format --- Sources/SwiftUINavigation/Alert.swift | 4 +- Sources/SwiftUINavigation/Binding.swift | 2 +- .../Internal/Deprecations.swift | 296 ++++++++++-------- Sources/SwiftUINavigationCore/Binding.swift | 4 +- 4 files changed, 163 insertions(+), 143 deletions(-) diff --git a/Sources/SwiftUINavigation/Alert.swift b/Sources/SwiftUINavigation/Alert.swift index 45b1a2e689..d3f0e10853 100644 --- a/Sources/SwiftUINavigation/Alert.swift +++ b/Sources/SwiftUINavigation/Alert.swift @@ -1,8 +1,8 @@ #if canImport(SwiftUI) import SwiftUI - + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) - extension View { + extension View { /// Presents an alert from a binding to optional alert state. /// diff --git a/Sources/SwiftUINavigation/Binding.swift b/Sources/SwiftUINavigation/Binding.swift index fd9012e7f6..bd1547b7fd 100644 --- a/Sources/SwiftUINavigation/Binding.swift +++ b/Sources/SwiftUINavigation/Binding.swift @@ -156,4 +156,4 @@ } } } -#endif // canImport(SwiftUI) +#endif // canImport(SwiftUI) diff --git a/Sources/SwiftUINavigation/Internal/Deprecations.swift b/Sources/SwiftUINavigation/Internal/Deprecations.swift index 18b686c553..111d61d4c0 100644 --- a/Sources/SwiftUINavigation/Internal/Deprecations.swift +++ b/Sources/SwiftUINavigation/Internal/Deprecations.swift @@ -15,8 +15,10 @@ ) -> some View { alert(item: value, title: title, actions: actions, message: message) } - - @available(*, deprecated, renamed: "confirmationDialog(item:textVisibility:title:actions:message:)") + + @available( + *, deprecated, renamed: "confirmationDialog(item:textVisibility:title:actions:message:)" + ) public func confirmationDialog( title: (Value) -> Text, titleVisibility: Visibility = .automatic, @@ -118,22 +120,22 @@ @available( iOS, introduced: 15, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( macOS, introduced: 12, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( tvOS, introduced: 15, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( watchOS, introduced: 8, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) public func alert( title: (Case) -> Text, @@ -153,22 +155,22 @@ @available( iOS, introduced: 15, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( macOS, introduced: 12, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( tvOS, introduced: 15, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( watchOS, introduced: 8, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) public func alert( unwrapping enum: Binding, @@ -181,22 +183,22 @@ @available( iOS, introduced: 15, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( macOS, introduced: 12, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( tvOS, introduced: 15, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( watchOS, introduced: 8, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) public func alert( unwrapping enum: Binding, @@ -209,22 +211,22 @@ @available( iOS, introduced: 15, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( macOS, introduced: 12, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( tvOS, introduced: 15, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( watchOS, introduced: 8, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) public func confirmationDialog( title: (Case) -> Text, @@ -246,22 +248,22 @@ @available( iOS, introduced: 15, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( macOS, introduced: 12, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( tvOS, introduced: 15, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( watchOS, introduced: 8, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) public func confirmationDialog( unwrapping enum: Binding, @@ -277,22 +279,22 @@ @available( iOS, introduced: 15, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( macOS, introduced: 12, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( tvOS, introduced: 15, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( watchOS, introduced: 8, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) public func confirmationDialog( unwrapping enum: Binding, @@ -308,18 +310,18 @@ @available( iOS, introduced: 14, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available(macOS, unavailable) @available( tvOS, introduced: 14, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( watchOS, introduced: 7, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) public func fullScreenCover( unwrapping enum: Binding, @@ -327,7 +329,7 @@ onDismiss: (() -> Void)? = nil, @ViewBuilder content: @escaping (Binding) -> Content ) -> some View - where Content: View { + where Content: View { fullScreenCover( unwrapping: `enum`.case(casePath), onDismiss: onDismiss, content: content) } @@ -335,22 +337,22 @@ @available( iOS, introduced: 16, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( macOS, introduced: 13, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( tvOS, introduced: 16, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( watchOS, introduced: 9, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) public func navigationDestination( unwrapping enum: Binding, @@ -363,12 +365,12 @@ @available( iOS, introduced: 13, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( macOS, introduced: 10.15, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available(tvOS, unavailable) @available(watchOS, unavailable) @@ -390,22 +392,22 @@ @available( iOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( macOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( tvOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( watchOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @MainActor public func sheet( @@ -414,7 +416,7 @@ onDismiss: (() -> Void)? = nil, @ViewBuilder content: @escaping (Binding) -> Content ) -> some View - where Content: View { + where Content: View { sheet(unwrapping: `enum`.case(casePath), onDismiss: onDismiss, content: content) } } @@ -423,22 +425,22 @@ @available( iOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( macOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( tvOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( watchOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) public init?(unwrapping enum: Binding, case casePath: AnyCasePath) { guard var `case` = casePath.extract(from: `enum`.wrappedValue) @@ -460,25 +462,25 @@ @available( iOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( macOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( tvOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( watchOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) public func `case`(_ casePath: AnyCasePath) -> Binding - where Value == Enum? { + where Value == Enum? { .init( get: { self.wrappedValue.flatMap(casePath.extract(from:)) }, set: { newValue, transaction in @@ -490,31 +492,31 @@ @available( iOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( macOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( tvOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( watchOS, deprecated: 9999, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) public func isPresent(_ casePath: AnyCasePath) -> Binding - where Value == Enum? { + where Value == Enum? { self.case(casePath).isPresent() } } public struct IfCaseLet: View - where IfContent: View, ElseContent: View { + where IfContent: View, ElseContent: View { public let `enum`: Binding public let casePath: AnyCasePath public let ifContent: (Binding) -> IfContent @@ -523,22 +525,22 @@ @available( iOS, deprecated: 9999, message: - "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." + "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." ) @available( macOS, deprecated: 9999, message: - "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." + "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." ) @available( tvOS, deprecated: 9999, message: - "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." + "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." ) @available( watchOS, deprecated: 9999, message: - "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." + "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." ) public init( _ enum: Binding, @@ -591,7 +593,7 @@ } public struct IfLet: View - where IfContent: View, ElseContent: View { + where IfContent: View, ElseContent: View { public let value: Binding public let ifContent: (Binding) -> IfContent public let elseContent: ElseContent @@ -599,22 +601,22 @@ @available( iOS, deprecated: 9999, message: - "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." + "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." ) @available( macOS, deprecated: 9999, message: - "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." + "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." ) @available( tvOS, deprecated: 9999, message: - "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." + "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." ) @available( watchOS, deprecated: 9999, message: - "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." + "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." ) public init( _ value: Binding, @@ -664,22 +666,22 @@ @available( iOS, introduced: 13, deprecated: 16, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( macOS, introduced: 10.15, deprecated: 13, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( tvOS, introduced: 13, deprecated: 16, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @available( watchOS, introduced: 6, deprecated: 9, message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) public init( unwrapping enum: Binding, @@ -700,22 +702,22 @@ @available( iOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) @available( macOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) @available( tvOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) @available( watchOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) public struct Switch: View { public let `enum`: Binding @@ -738,25 +740,25 @@ @available( iOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) @available( macOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) @available( tvOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) @available( watchOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) public struct CaseLet: View - where Content: View { + where Content: View { @EnvironmentObject private var `enum`: BindingObject public let casePath: AnyCasePath public let content: (Binding) -> Content @@ -777,22 +779,22 @@ @available( iOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) @available( macOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) @available( tvOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) @available( watchOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) public struct Default: View { private let content: Content @@ -809,22 +811,22 @@ @available( iOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) @available( macOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) @available( tvOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) @available( watchOS, deprecated: 9999, message: - "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) extension Switch { public init( @@ -836,11 +838,12 @@ ) > ) - where + where Content == _ConditionalContent< CaseLet, Default - > { + > + { self.init(enum: `enum`) { let content = content().value if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { @@ -857,11 +860,12 @@ line: UInt = #line, @ViewBuilder content: () -> CaseLet ) - where + where Content == _ConditionalContent< CaseLet, Default<_ExhaustivityCheckView> - > { + > + { self.init(`enum`) { content() Default { _ExhaustivityCheckView(file: file, line: line) } @@ -878,14 +882,15 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< CaseLet, CaseLet >, Default - > { + > + { self.init(enum: `enum`) { let content = content().value if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { @@ -909,14 +914,15 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< CaseLet, CaseLet >, Default<_ExhaustivityCheckView> - > { + > + { let content = content() self.init(`enum`) { content.value.0 @@ -941,7 +947,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< CaseLet, @@ -951,7 +957,8 @@ CaseLet, Default > - > { + > + { self.init(enum: `enum`) { let content = content().value if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { @@ -978,7 +985,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< CaseLet, @@ -988,7 +995,8 @@ CaseLet, Default<_ExhaustivityCheckView> > - > { + > + { let content = content() self.init(`enum`) { content.value.0 @@ -1016,7 +1024,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< _ConditionalContent< @@ -1029,7 +1037,8 @@ > >, Default - > { + > + { self.init(enum: `enum`) { let content = content().value if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { @@ -1064,7 +1073,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< _ConditionalContent< @@ -1077,7 +1086,8 @@ > >, Default<_ExhaustivityCheckView> - > { + > + { let content = content() self.init(`enum`) { content.value.0 @@ -1108,7 +1118,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< _ConditionalContent< @@ -1124,7 +1134,8 @@ CaseLet, Default > - > { + > + { self.init(enum: `enum`) { let content = content().value if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { @@ -1163,7 +1174,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< _ConditionalContent< @@ -1179,7 +1190,8 @@ CaseLet, Default<_ExhaustivityCheckView> > - > { + > + { let content = content() self.init(`enum`) { content.value.0 @@ -1213,7 +1225,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< _ConditionalContent< @@ -1232,7 +1244,8 @@ >, Default > - > { + > + { self.init(enum: `enum`) { let content = content().value if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { @@ -1275,7 +1288,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< _ConditionalContent< @@ -1294,7 +1307,8 @@ >, Default<_ExhaustivityCheckView> > - > { + > + { let content = content() self.init(`enum`) { content.value.0 @@ -1331,7 +1345,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< _ConditionalContent< @@ -1353,7 +1367,8 @@ Default > > - > { + > + { self.init(enum: `enum`) { let content = content().value if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { @@ -1400,7 +1415,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< _ConditionalContent< @@ -1422,7 +1437,8 @@ Default<_ExhaustivityCheckView> > > - > { + > + { let content = content() self.init(`enum`) { content.value.0 @@ -1462,7 +1478,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< _ConditionalContent< @@ -1487,7 +1503,8 @@ > >, Default - > { + > + { self.init(enum: `enum`) { let content = content().value if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { @@ -1538,7 +1555,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< _ConditionalContent< @@ -1563,7 +1580,8 @@ > >, Default<_ExhaustivityCheckView> - > { + > + { let content = content() self.init(`enum`) { content.value.0 @@ -1606,7 +1624,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< _ConditionalContent< @@ -1634,7 +1652,8 @@ CaseLet, Default > - > { + > + { self.init(enum: `enum`) { let content = content().value if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { @@ -1689,7 +1708,7 @@ ) > ) - where + where Content == _ConditionalContent< _ConditionalContent< _ConditionalContent< @@ -1717,7 +1736,8 @@ CaseLet, Default<_ExhaustivityCheckView> > - > { + > + { let content = content() self.init(`enum`) { content.value.0 @@ -1742,13 +1762,13 @@ public var body: some View { #if DEBUG let message = """ - Warning: Switch.body@\(self.file):\(self.line) + Warning: Switch.body@\(self.file):\(self.line) - "Switch" did not handle "\(describeCase(self.enum.wrappedValue.wrappedValue))" + "Switch" did not handle "\(describeCase(self.enum.wrappedValue.wrappedValue))" - Make sure that you exhaustively provide a "CaseLet" view for each case in "\(Enum.self)", \ - provide a "Default" view at the end of the "Switch", or use an "IfCaseLet" view instead. - """ + Make sure that you exhaustively provide a "CaseLet" view for each case in "\(Enum.self)", \ + provide a "Default" view at the end of the "Switch", or use an "IfCaseLet" view instead. + """ VStack(spacing: 17) { self.exclamation() .font(.largeTitle) @@ -1789,13 +1809,13 @@ let childMirror = Mirror(reflecting: child.value) let associatedValuesMirror = childMirror.displayStyle == .tuple - ? childMirror - : Mirror(`enum`, unlabeledChildren: [child.value], displayStyle: .tuple) + ? childMirror + : Mirror(`enum`, unlabeledChildren: [child.value], displayStyle: .tuple) `case` = """ - \(label)(\ - \(associatedValuesMirror.children.map { "\($0.label ?? "_"):" }.joined())\ - ) - """ + \(label)(\ + \(associatedValuesMirror.children.map { "\($0.label ?? "_"):" }.joined())\ + ) + """ } else { `case` = "\(`enum`)" } @@ -1815,7 +1835,7 @@ *, deprecated, message: - "'View.alert' now passes an optional action to its handler to allow you to handle action-less dismissals." + "'View.alert' now passes an optional action to its handler to allow you to handle action-less dismissals." ) public func alert( unwrapping value: Binding?>, @@ -1833,7 +1853,7 @@ *, deprecated, message: - "'View.alert' now passes an optional action to its handler to allow you to handle action-less dismissals." + "'View.alert' now passes an optional action to its handler to allow you to handle action-less dismissals." ) public func alert( unwrapping enum: Binding, @@ -1852,7 +1872,7 @@ *, deprecated, message: - "'View.alert' now passes an optional action to its handler to allow you to handle action-less dismissals." + "'View.alert' now passes an optional action to its handler to allow you to handle action-less dismissals." ) public func confirmationDialog( unwrapping value: Binding?>, @@ -1870,7 +1890,7 @@ *, deprecated, message: - "'View.alert' now passes an optional action to its handler to allow you to handle action-less dismissals." + "'View.alert' now passes an optional action to its handler to allow you to handle action-less dismissals." ) public func confirmationDialog( unwrapping enum: Binding, @@ -1932,4 +1952,4 @@ ) } } -#endif // canImport(SwiftUI) +#endif // canImport(SwiftUI) diff --git a/Sources/SwiftUINavigationCore/Binding.swift b/Sources/SwiftUINavigationCore/Binding.swift index 22de85a1ab..c1c4c89f07 100644 --- a/Sources/SwiftUINavigationCore/Binding.swift +++ b/Sources/SwiftUINavigationCore/Binding.swift @@ -9,8 +9,8 @@ /// /// - Returns: A binding to a boolean. Returns `true` if non-`nil`, otherwise `false`. public func isPresent() -> Binding - where Value == Wrapped? { - self._isPresent + where Value == Wrapped? { + self._isPresent } } From 35526c29f1420a04694f45ca4bf4de09922a73b7 Mon Sep 17 00:00:00 2001 From: x_0o0 Date: Sat, 6 Apr 2024 02:05:58 +0900 Subject: [PATCH 03/97] [VER] Bump up Swift Compiler version to 5.7.1 (#134) Co-authored-by: Stephen Celis --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index ff10a97ad2..ee0d514c20 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.5 +// swift-tools-version:5.7.1 import PackageDescription From 2ec6c3a15293efff6083966b38439a4004f25565 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 5 Apr 2024 10:48:55 -0700 Subject: [PATCH 04/97] Add iOS 16-compatible `navigationDestination(item:)` to core (#148) * Add iOS 16-compatible `navigationDestination(item:)` * wip --- .../Documentation.docc/Articles/Bindings.md | 1 - .../SwiftUINavigationCore.md | 18 ++++++++++++- .../NavigationDestination.swift | 25 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 Sources/SwiftUINavigationCore/NavigationDestination.swift diff --git a/Sources/SwiftUINavigation/Documentation.docc/Articles/Bindings.md b/Sources/SwiftUINavigation/Documentation.docc/Articles/Bindings.md index 5dae2c5dfd..07dd305f3c 100644 --- a/Sources/SwiftUINavigation/Documentation.docc/Articles/Bindings.md +++ b/Sources/SwiftUINavigation/Documentation.docc/Articles/Bindings.md @@ -78,7 +78,6 @@ struct SignInView: View { ### Binding transformations -- ``SwiftUI/Binding/isPresent()`` - ``SwiftUI/Binding/removeDuplicates()`` - ``SwiftUI/Binding/removeDuplicates(by:)`` diff --git a/Sources/SwiftUINavigationCore/Documentation.docc/SwiftUINavigationCore.md b/Sources/SwiftUINavigationCore/Documentation.docc/SwiftUINavigationCore.md index 9807e67d2d..ae7be6d215 100644 --- a/Sources/SwiftUINavigationCore/Documentation.docc/SwiftUINavigationCore.md +++ b/Sources/SwiftUINavigationCore/Documentation.docc/SwiftUINavigationCore.md @@ -1,6 +1,6 @@ # ``SwiftUINavigationCore`` -A few core types included in SwiftUI Navigation. +A few core types and modifiers included in SwiftUI Navigation. ## Topics @@ -10,3 +10,19 @@ A few core types included in SwiftUI Navigation. - ``AlertState`` - ``ConfirmationDialogState`` - ``ButtonState`` + +### Alert and dialog modifiers + +- ``SwiftUI/View/alert(item:title:actions:message:)`` +- ``SwiftUI/View/alert(item:title:actions:)`` +- ``SwiftUI/View/confirmationDialog(item:titleVisibility:title:actions:message:)`` +- ``SwiftUI/View/confirmationDialog(item:titleVisibility:title:actions:)`` + +### Bindings + +- ``SwiftUI/Binding/isPresent()`` +- ``SwiftUI/View/bind(_:to:)`` + +### Navigation + +- ``SwiftUI/View/navigationDestination(item:destination:)`` diff --git a/Sources/SwiftUINavigationCore/NavigationDestination.swift b/Sources/SwiftUINavigationCore/NavigationDestination.swift new file mode 100644 index 0000000000..9b593da463 --- /dev/null +++ b/Sources/SwiftUINavigationCore/NavigationDestination.swift @@ -0,0 +1,25 @@ +#if canImport(SwiftUI) + import SwiftUI + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension View { + /// Associates a destination view with a bound value for use within a navigation stack or + /// navigation split view. + /// + /// See `SwiftUI.View.navigationDestination(item:destination:)` for more information. + /// + /// - Parameters: + /// - item: A binding to the data presented, or `nil` if nothing is currently presented. + /// - destination: A view builder that defines a view to display when `item` is not `nil`. + public func navigationDestination( + item: Binding, + @ViewBuilder destination: @escaping (D) -> C + ) -> some View { + navigationDestination(isPresented: item.isPresent()) { + if let item = item.wrappedValue { + destination(item) + } + } + } + } +#endif // canImport(SwiftUI) From 5f77d0ac68e35013bef787590486e634f235d769 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 9 May 2024 14:12:21 -0700 Subject: [PATCH 05/97] Prevent case navigation binding from writing to other cases (#149) * wip * wip * wip --- Sources/SwiftUINavigation/Binding.swift | 16 ++--- .../SwiftUINavigationTests/BindingTests.swift | 59 +++++++++++++++++++ 2 files changed, 67 insertions(+), 8 deletions(-) create mode 100644 Tests/SwiftUINavigationTests/BindingTests.swift diff --git a/Sources/SwiftUINavigation/Binding.swift b/Sources/SwiftUINavigation/Binding.swift index bd1547b7fd..c6e0441f81 100644 --- a/Sources/SwiftUINavigation/Binding.swift +++ b/Sources/SwiftUINavigation/Binding.swift @@ -131,7 +131,9 @@ fileprivate subscript( keyPath: KeyPath> ) -> Member? { - get { Self.allCasePaths[keyPath: keyPath].extract(from: self) } + get { + Self.allCasePaths[keyPath: keyPath].extract(from: self) + } set { guard let newValue else { return } self = Self.allCasePaths[keyPath: keyPath].embed(newValue) @@ -144,15 +146,13 @@ keyPath: KeyPath> ) -> Member? { get { - guard let wrapped = self else { return nil } - return Wrapped.allCasePaths[keyPath: keyPath].extract(from: wrapped) + self.flatMap(Wrapped.allCasePaths[keyPath: keyPath].extract(from:)) } set { - guard let newValue else { - self = nil - return - } - self = Wrapped.allCasePaths[keyPath: keyPath].embed(newValue) + let casePath = Wrapped.allCasePaths[keyPath: keyPath] + guard self.flatMap(casePath.extract(from:)) != nil + else { return } + self = newValue.map(casePath.embed) } } } diff --git a/Tests/SwiftUINavigationTests/BindingTests.swift b/Tests/SwiftUINavigationTests/BindingTests.swift new file mode 100644 index 0000000000..7c44bde77d --- /dev/null +++ b/Tests/SwiftUINavigationTests/BindingTests.swift @@ -0,0 +1,59 @@ +#if swift(>=5.9) && canImport(SwiftUI) + import CustomDump + import SwiftUI + import SwiftUINavigation + import XCTest + + final class BindingTests: XCTestCase { + @CasePathable + @dynamicMemberLookup + enum Status: Equatable { + case inStock(quantity: Int) + case outOfStock(isOnBackOrder: Bool) + } + + func testCaseLookup() throws { + @Binding var status: Status + _status = Binding(initialValue: .inStock(quantity: 1)) + + let inStock = try XCTUnwrap($status.inStock) + inStock.wrappedValue += 1 + + XCTAssertEqual(status, .inStock(quantity: 2)) + } + + func testCaseCannotReplaceOtherCase() throws { + @Binding var status: Status + _status = Binding(initialValue: .inStock(quantity: 1)) + + let inStock = try XCTUnwrap($status.inStock) + + status = .outOfStock(isOnBackOrder: true) + + inStock.wrappedValue = 42 + XCTAssertEqual(status, .outOfStock(isOnBackOrder: true)) + } + + func testDestinationCannotReplaceOtherDestination() throws { + @Binding var destination: Status? + _destination = Binding(initialValue: .inStock(quantity: 1)) + + let inStock = try XCTUnwrap($destination.inStock) + + destination = .outOfStock(isOnBackOrder: true) + + inStock.wrappedValue = 42 + XCTAssertEqual(destination, .outOfStock(isOnBackOrder: true)) + } + } + + private extension Binding { + init(initialValue: Value) { + var value = initialValue + self.init( + get: { value }, + set: { value = $0 } + ) + } + } +#endif // canImport(SwiftUI) From 97da14204e10d895591a864902f7a883f901bc7a Mon Sep 17 00:00:00 2001 From: stephencelis Date: Thu, 9 May 2024 21:13:42 +0000 Subject: [PATCH 06/97] Run swift-format --- Tests/SwiftUINavigationTests/BindingTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/SwiftUINavigationTests/BindingTests.swift b/Tests/SwiftUINavigationTests/BindingTests.swift index 7c44bde77d..3ee0db10a4 100644 --- a/Tests/SwiftUINavigationTests/BindingTests.swift +++ b/Tests/SwiftUINavigationTests/BindingTests.swift @@ -47,8 +47,8 @@ } } - private extension Binding { - init(initialValue: Value) { + extension Binding { + fileprivate init(initialValue: Value) { var value = initialValue self.init( get: { value }, From ec3b0f9882e004e8f906c7ef43870615abf32a56 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 13 May 2024 10:03:18 -0700 Subject: [PATCH 07/97] Update Package.resolved. --- Package.resolved | 114 +++++++++++++++++++++++------------------------ 1 file changed, 56 insertions(+), 58 deletions(-) diff --git a/Package.resolved b/Package.resolved index 5962320b06..1f81df2040 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,61 +1,59 @@ { - "object": { - "pins": [ - { - "package": "swift-case-paths", - "repositoryURL": "https://github.com/pointfreeco/swift-case-paths", - "state": { - "branch": null, - "revision": "e593aba2c6222daad7c4f2732a431eed2c09bb07", - "version": "1.3.0" - } - }, - { - "package": "swift-custom-dump", - "repositoryURL": "https://github.com/pointfreeco/swift-custom-dump", - "state": { - "branch": null, - "revision": "aedcf6f4cd486ccef5b312ccac85d4b3f6e58605", - "version": "1.1.2" - } - }, - { - "package": "SwiftDocCPlugin", - "repositoryURL": "https://github.com/apple/swift-docc-plugin", - "state": { - "branch": null, - "revision": "26ac5758409154cc448d7ab82389c520fa8a8247", - "version": "1.3.0" - } - }, - { - "package": "SymbolKit", - "repositoryURL": "https://github.com/apple/swift-docc-symbolkit", - "state": { - "branch": null, - "revision": "b45d1f2ed151d057b54504d653e0da5552844e34", - "version": "1.0.0" - } - }, - { - "package": "swift-syntax", - "repositoryURL": "https://github.com/apple/swift-syntax.git", - "state": { - "branch": null, - "revision": "6ad4ea24b01559dde0773e3d091f1b9e36175036", - "version": "509.0.2" - } - }, - { - "package": "xctest-dynamic-overlay", - "repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay", - "state": { - "branch": null, - "revision": "23cbf2294e350076ea4dbd7d5d047c1e76b03631", - "version": "1.0.2" - } + "pins" : [ + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "8d712376c99fc0267aa0e41fea732babe365270a", + "version" : "1.3.3" } - ] - }, - "version": 1 + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "f01efb26f3a192a0e88dcdb7c3c391ec2fc25d9c", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "revision" : "26ac5758409154cc448d7ab82389c520fa8a8247", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax", + "state" : { + "revision" : "303e5c5c36d6a558407d364878df131c3546fad8", + "version" : "510.0.2" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "6f30bdba373bbd7fbfe241dddd732651f2fbd1e2", + "version" : "1.1.2" + } + } + ], + "version" : 2 } From 2b7a69b8031b1f79fb212bae2fbd6de0d47b594b Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 20 May 2024 13:24:39 -0700 Subject: [PATCH 08/97] Update examples and fix deprecation warning (#153) * Update examples and fix deprecation warning * wip * wip --- Examples/CaseStudies/01-Alerts.swift | 25 +- .../CaseStudies/02-ConfirmationDialogs.swift | 20 +- .../Internal/Deprecations.swift | 2 +- .../xcshareddata/swiftpm/Package.resolved | 240 +++++++++--------- .../SwiftUINavigationTests/BindingTests.swift | 16 +- 5 files changed, 151 insertions(+), 152 deletions(-) diff --git a/Examples/CaseStudies/01-Alerts.swift b/Examples/CaseStudies/01-Alerts.swift index d72b8f1149..afdc846271 100644 --- a/Examples/CaseStudies/01-Alerts.swift +++ b/Examples/CaseStudies/01-Alerts.swift @@ -21,19 +21,18 @@ struct OptionalAlerts: View { } .disabled(self.model.isLoading) } - .alert( - title: { Text("Fact about \($0.number)") }, - unwrapping: self.$model.fact, - actions: { - Button("Get another fact about \($0.number)") { - Task { await self.model.numberFactButtonTapped() } - } - Button("Close", role: .cancel) { - self.model.fact = nil - } - }, - message: { Text($0.description) } - ) + .alert(item: self.$model.fact) { + Text("Fact about \($0.number)") + } actions: { + Button("Get another fact about \($0.number)") { + Task { await self.model.numberFactButtonTapped() } + } + Button("Close", role: .cancel) { + self.model.fact = nil + } + } message: { + Text($0.description) + } .navigationTitle("Alerts") } } diff --git a/Examples/CaseStudies/02-ConfirmationDialogs.swift b/Examples/CaseStudies/02-ConfirmationDialogs.swift index ffa1b26065..a53d62c82c 100644 --- a/Examples/CaseStudies/02-ConfirmationDialogs.swift +++ b/Examples/CaseStudies/02-ConfirmationDialogs.swift @@ -20,17 +20,15 @@ struct OptionalConfirmationDialogs: View { } } .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)") { - Task { await self.model.numberFactButtonTapped() } - } - }, - message: { Text($0.description) } - ) + .confirmationDialog(item: self.$model.fact, titleVisibility: .visible) { + Text("Fact about \($0.number)") + } actions: { + Button("Get another fact about \($0.number)") { + Task { await self.model.numberFactButtonTapped() } + } + } message: { + Text($0.description) + } } .navigationTitle("Dialogs") } diff --git a/Sources/SwiftUINavigation/Internal/Deprecations.swift b/Sources/SwiftUINavigation/Internal/Deprecations.swift index 111d61d4c0..cb417f7950 100644 --- a/Sources/SwiftUINavigation/Internal/Deprecations.swift +++ b/Sources/SwiftUINavigation/Internal/Deprecations.swift @@ -17,7 +17,7 @@ } @available( - *, deprecated, renamed: "confirmationDialog(item:textVisibility:title:actions:message:)" + *, deprecated, renamed: "confirmationDialog(item:titleVisibility:title:actions:message:)" ) public func confirmationDialog( title: (Value) -> Text, diff --git a/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved index a6a4bc1692..114ce490b8 100644 --- a/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,124 +1,122 @@ { - "object": { - "pins": [ - { - "package": "combine-schedulers", - "repositoryURL": "https://github.com/pointfreeco/combine-schedulers", - "state": { - "branch": null, - "revision": "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", - "version": "1.0.0" - } - }, - { - "package": "swift-case-paths", - "repositoryURL": "https://github.com/pointfreeco/swift-case-paths", - "state": { - "branch": null, - "revision": "8cc3bc05d0cc956f7374c6c208a11f66a7cac3db", - "version": "1.2.2" - } - }, - { - "package": "swift-clocks", - "repositoryURL": "https://github.com/pointfreeco/swift-clocks", - "state": { - "branch": null, - "revision": "a8421d68068d8f45fbceb418fbf22c5dad4afd33", - "version": "1.0.2" - } - }, - { - "package": "swift-collections", - "repositoryURL": "https://github.com/apple/swift-collections", - "state": { - "branch": null, - "revision": "d029d9d39c87bed85b1c50adee7c41795261a192", - "version": "1.0.6" - } - }, - { - "package": "swift-concurrency-extras", - "repositoryURL": "https://github.com/pointfreeco/swift-concurrency-extras", - "state": { - "branch": null, - "revision": "bb5059bde9022d69ac516803f4f227d8ac967f71", - "version": "1.1.0" - } - }, - { - "package": "swift-custom-dump", - "repositoryURL": "https://github.com/pointfreeco/swift-custom-dump", - "state": { - "branch": null, - "revision": "aedcf6f4cd486ccef5b312ccac85d4b3f6e58605", - "version": "1.1.2" - } - }, - { - "package": "swift-dependencies", - "repositoryURL": "http://github.com/pointfreeco/swift-dependencies", - "state": { - "branch": null, - "revision": "c31b1445c4fae49e6fdb75496b895a3653f6aefc", - "version": "1.1.5" - } - }, - { - "package": "SwiftDocCPlugin", - "repositoryURL": "https://github.com/apple/swift-docc-plugin", - "state": { - "branch": null, - "revision": "26ac5758409154cc448d7ab82389c520fa8a8247", - "version": "1.3.0" - } - }, - { - "package": "SymbolKit", - "repositoryURL": "https://github.com/apple/swift-docc-symbolkit", - "state": { - "branch": null, - "revision": "b45d1f2ed151d057b54504d653e0da5552844e34", - "version": "1.0.0" - } - }, - { - "package": "swift-identified-collections", - "repositoryURL": "https://github.com/pointfreeco/swift-identified-collections.git", - "state": { - "branch": null, - "revision": "d1e45f3e1eee2c9193f5369fa9d70a6ddad635e8", - "version": "1.0.0" - } - }, - { - "package": "swift-syntax", - "repositoryURL": "https://github.com/apple/swift-syntax.git", - "state": { - "branch": null, - "revision": "6ad4ea24b01559dde0773e3d091f1b9e36175036", - "version": "509.0.2" - } - }, - { - "package": "swift-tagged", - "repositoryURL": "https://github.com/pointfreeco/swift-tagged.git", - "state": { - "branch": null, - "revision": "3907a9438f5b57d317001dc99f3f11b46882272b", - "version": "0.10.0" - } - }, - { - "package": "xctest-dynamic-overlay", - "repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay", - "state": { - "branch": null, - "revision": "23cbf2294e350076ea4dbd7d5d047c1e76b03631", - "version": "1.0.2" - } + "pins" : [ + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", + "version" : "1.0.0" } - ] - }, - "version": 1 + }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "8cc3bc05d0cc956f7374c6c208a11f66a7cac3db", + "version" : "1.2.2" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "a8421d68068d8f45fbceb418fbf22c5dad4afd33", + "version" : "1.0.2" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "d029d9d39c87bed85b1c50adee7c41795261a192", + "version" : "1.0.6" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "aedcf6f4cd486ccef5b312ccac85d4b3f6e58605", + "version" : "1.1.2" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "http://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "c31b1445c4fae49e6fdb75496b895a3653f6aefc", + "version" : "1.1.5" + } + }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "revision" : "26ac5758409154cc448d7ab82389c520fa8a8247", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections.git", + "state" : { + "revision" : "d1e45f3e1eee2c9193f5369fa9d70a6ddad635e8", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "6ad4ea24b01559dde0773e3d091f1b9e36175036", + "version" : "509.0.2" + } + }, + { + "identity" : "swift-tagged", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-tagged.git", + "state" : { + "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", + "version" : "0.10.0" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "23cbf2294e350076ea4dbd7d5d047c1e76b03631", + "version" : "1.0.2" + } + } + ], + "version" : 2 } diff --git a/Tests/SwiftUINavigationTests/BindingTests.swift b/Tests/SwiftUINavigationTests/BindingTests.swift index 3ee0db10a4..ba85a092e2 100644 --- a/Tests/SwiftUINavigationTests/BindingTests.swift +++ b/Tests/SwiftUINavigationTests/BindingTests.swift @@ -35,15 +35,19 @@ } func testDestinationCannotReplaceOtherDestination() throws { - @Binding var destination: Status? - _destination = Binding(initialValue: .inStock(quantity: 1)) + #if os(iOS) || os(macOS) + try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] != nil) - let inStock = try XCTUnwrap($destination.inStock) + @Binding var destination: Status? + _destination = Binding(initialValue: .inStock(quantity: 1)) - destination = .outOfStock(isOnBackOrder: true) + let inStock = try XCTUnwrap($destination.inStock) - inStock.wrappedValue = 42 - XCTAssertEqual(destination, .outOfStock(isOnBackOrder: true)) + destination = .outOfStock(isOnBackOrder: true) + + inStock.wrappedValue = 42 + XCTAssertEqual(destination, .outOfStock(isOnBackOrder: true)) + #endif } } From 72dbb2a5cac2e877242c553a3b4ad6840fe33da9 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 27 May 2024 14:18:18 -0700 Subject: [PATCH 09/97] Add `sheet(item:id:)` (#155) * Add `sheet(item:id:)` `ForEach` has a convenient initializer that takes a key path to some hashable identifier so that the element isn't forced into an identifiable conformance, but `sheet`, `fullScreenCover` and `popover` aren't given the same affordances. Let's close the gap. * fix --- .../SwiftUINavigation/FullScreenCover.swift | 34 +++++++++++++--- .../Internal/Identified.swift | 10 +++++ Sources/SwiftUINavigation/Popover.swift | 39 +++++++++++++++++-- Sources/SwiftUINavigation/Sheet.swift | 22 +++++++++++ 4 files changed, 95 insertions(+), 10 deletions(-) create mode 100644 Sources/SwiftUINavigation/Internal/Identified.swift diff --git a/Sources/SwiftUINavigation/FullScreenCover.swift b/Sources/SwiftUINavigation/FullScreenCover.swift index 317b40e733..ad2d6a8eb5 100644 --- a/Sources/SwiftUINavigation/FullScreenCover.swift +++ b/Sources/SwiftUINavigation/FullScreenCover.swift @@ -1,7 +1,31 @@ #if canImport(SwiftUI) import SwiftUI + @available(iOS 14, tvOS 14, watchOS 7, *) + @available(macOS, unavailable) extension View { + /// Presents a full-screen cover using a binding as a data source for the sheet's content based + /// on the identity of the underlying item. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the sheet. When `item` is non-`nil`, + /// the system passes the item's content to the modifier's closure. You display this content + /// in a sheet that you create that the system displays to the user. If `item` changes, the + /// system dismisses the sheet and replaces it with a new one using the same process. + /// - id: The key path to the provided item's identifier. + /// - onDismiss: The closure to execute when dismissing the sheet. + /// - content: A closure returning the content of the sheet. + public func fullScreenCover( + item: Binding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping (Item) -> Content + ) -> some View { + self.fullScreenCover(item: item[id: id], onDismiss: onDismiss) { _ in + item.wrappedValue.map(content) + } + } + /// Presents a full-screen cover using a binding as a data source for the sheet's content. /// /// SwiftUI comes with a `fullScreenCover(item:)` view modifier that is powered by a binding to @@ -36,15 +60,13 @@ /// /// - Parameters: /// - value: A binding to a source of truth for the sheet. When `value` is non-`nil`, a - /// non-optional binding to the value is passed to the `content` closure. You use this binding - /// to produce content that the system presents to the user in a sheet. Changes made to the - /// sheet's binding will be reflected back in the source of truth. Likewise, changes to - /// `value` are instantly reflected in the sheet. If `value` becomes `nil`, the sheet is + /// non-optional binding to the value is passed to the `content` closure. You use this + /// binding to produce content that the system presents to the user in a sheet. Changes made + /// to the sheet's binding will be reflected back in the source of truth. Likewise, changes + /// to `value` are instantly reflected in the sheet. If `value` becomes `nil`, the sheet is /// dismissed. /// - onDismiss: The closure to execute when dismissing the sheet. /// - content: A closure returning the content of the sheet. - @available(iOS 14, tvOS 14, watchOS 7, *) - @available(macOS, unavailable) public func fullScreenCover( unwrapping value: Binding, onDismiss: (() -> Void)? = nil, diff --git a/Sources/SwiftUINavigation/Internal/Identified.swift b/Sources/SwiftUINavigation/Internal/Identified.swift new file mode 100644 index 0000000000..f8add1e0a3 --- /dev/null +++ b/Sources/SwiftUINavigation/Internal/Identified.swift @@ -0,0 +1,10 @@ +struct Identified: Identifiable { + let id: ID +} + +extension Optional { + subscript(id keyPath: KeyPath) -> Identified? { + get { (self?[keyPath: keyPath]).map(Identified.init) } + set { if newValue == nil { self = nil } } + } +} diff --git a/Sources/SwiftUINavigation/Popover.swift b/Sources/SwiftUINavigation/Popover.swift index 43ce6238c9..f2ec1290db 100644 --- a/Sources/SwiftUINavigation/Popover.swift +++ b/Sources/SwiftUINavigation/Popover.swift @@ -1,7 +1,40 @@ #if canImport(SwiftUI) import SwiftUI + @available(tvOS, unavailable) + @available(watchOS, unavailable) extension View { + /// Presents a popover using a binding as a data source for the sheet's content based on the + /// identity of the underlying item. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the popover. When `item` is + /// non-`nil`, the system passes the item's content to the modifier's closure. You display + /// this content in a popover that you create that the system displays to the user. If `item` + /// changes, the system dismisses the popover and replaces it with a new one using the same + /// process. + /// - id: The key path to the provided item's identifier. + /// - attachmentAnchor: The positioning anchor that defines the attachment point of the + /// popover. + /// - arrowEdge: The edge of the `attachmentAnchor` that defines the location of the popover's + /// arrow. + /// - content: A closure returning the content of the popover. + public func popover( + item: Binding, + id: KeyPath, + attachmentAnchor: PopoverAttachmentAnchor = .rect(.bounds), + arrowEdge: Edge = .top, + @ViewBuilder content: @escaping (Item) -> Content + ) -> some View { + self.popover( + item: item[id: id], + attachmentAnchor: attachmentAnchor, + arrowEdge: arrowEdge + ) { _ in + item.wrappedValue.map(content) + } + } + /// Presents a popover using a binding as a data source for the popover's content. /// /// SwiftUI comes with a `popover(item:)` view modifier that is powered by a binding to some @@ -46,14 +79,12 @@ /// - arrowEdge: The edge of the `attachmentAnchor` that defines the location of the popover's /// arrow. /// - content: A closure returning the content of the popover. - @available(tvOS, unavailable) - @available(watchOS, unavailable) - public func popover( + public func popover( unwrapping value: Binding, attachmentAnchor: PopoverAttachmentAnchor = .rect(.bounds), arrowEdge: Edge = .top, @ViewBuilder content: @escaping (Binding) -> Content - ) -> some View where Content: View { + ) -> some View { self.popover( isPresented: value.isPresent(), attachmentAnchor: attachmentAnchor, diff --git a/Sources/SwiftUINavigation/Sheet.swift b/Sources/SwiftUINavigation/Sheet.swift index 6823b78e3b..f8c711b4cf 100644 --- a/Sources/SwiftUINavigation/Sheet.swift +++ b/Sources/SwiftUINavigation/Sheet.swift @@ -8,6 +8,28 @@ #endif extension View { + /// Presents a sheet using a binding as a data source for the sheet's content based on the + /// identity of the underlying item. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the sheet. When `item` is non-`nil`, + /// the system passes the item's content to the modifier's closure. You display this content + /// in a sheet that you create that the system displays to the user. If `item` changes, the + /// system dismisses the sheet and replaces it with a new one using the same process. + /// - id: The key path to the provided item's identifier. + /// - onDismiss: The closure to execute when dismissing the sheet. + /// - content: A closure returning the content of the sheet. + public func sheet( + item: Binding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping (Item) -> Content + ) -> some View { + self.sheet(item: item[id: id], onDismiss: onDismiss) { _ in + item.wrappedValue.map(content) + } + } + /// Presents a sheet using a binding as a data source for the sheet's content. /// /// SwiftUI comes with a `sheet(item:)` view modifier that is powered by a binding to some From a0ede333b8afd7c2f3c47aaa8f1447ad17db1595 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 28 May 2024 08:30:37 -0700 Subject: [PATCH 10/97] Leverage `alert(item:)` in `alert(_ state:)` (#156) --- Sources/SwiftUINavigation/Alert.swift | 40 +++++++--------- .../ConfirmationDialog.swift | 48 +++++++++---------- 2 files changed, 42 insertions(+), 46 deletions(-) diff --git a/Sources/SwiftUINavigation/Alert.swift b/Sources/SwiftUINavigation/Alert.swift index d3f0e10853..cfe8fced57 100644 --- a/Sources/SwiftUINavigation/Alert.swift +++ b/Sources/SwiftUINavigation/Alert.swift @@ -20,17 +20,15 @@ _ state: Binding?>, action handler: @escaping (Value?) -> Void = { (_: Never?) in } ) -> some View { - self.alert( - (state.wrappedValue?.title).map(Text.init) ?? Text(verbatim: ""), - isPresented: state.isPresent(), - presenting: state.wrappedValue, - actions: { - ForEach($0.buttons) { - Button($0, action: handler) - } - }, - message: { $0.message.map { Text($0) } } - ) + alert(item: state) { + Text($0.title) + } actions: { + ForEach($0.buttons) { + Button($0, action: handler) + } + } message: { + $0.message.map(Text.init) + } } /// Presents an alert from a binding to optional alert state. @@ -52,17 +50,15 @@ _ state: Binding?>, action handler: @escaping (Value?) async -> Void = { (_: Never?) async in } ) -> some View { - self.alert( - (state.wrappedValue?.title).map(Text.init) ?? Text(verbatim: ""), - isPresented: state.isPresent(), - presenting: state.wrappedValue, - actions: { - ForEach($0.buttons) { - Button($0, action: handler) - } - }, - message: { $0.message.map { Text($0) } } - ) + alert(item: state) { + Text($0.title) + } actions: { + ForEach($0.buttons) { + Button($0, action: handler) + } + } message: { + $0.message.map(Text.init) + } } } #endif // canImport(SwiftUI) diff --git a/Sources/SwiftUINavigation/ConfirmationDialog.swift b/Sources/SwiftUINavigation/ConfirmationDialog.swift index 6543e2b9f9..eae1840819 100644 --- a/Sources/SwiftUINavigation/ConfirmationDialog.swift +++ b/Sources/SwiftUINavigation/ConfirmationDialog.swift @@ -19,18 +19,18 @@ _ state: Binding?>, action handler: @escaping (Value?) -> Void = { (_: Never?) in } ) -> some View { - self.confirmationDialog( - state.wrappedValue.flatMap { Text($0.title) } ?? Text(verbatim: ""), - isPresented: state.isPresent(), - titleVisibility: state.wrappedValue.map { .init($0.titleVisibility) } ?? .automatic, - presenting: state.wrappedValue, - actions: { - ForEach($0.buttons) { - Button($0, action: handler) - } - }, - message: { $0.message.map { Text($0) } } - ) + confirmationDialog( + item: state, + titleVisibility: state.wrappedValue.map { .init($0.titleVisibility) } ?? .automatic + ) { + Text($0.title) + } actions: { + ForEach($0.buttons) { + Button($0, action: handler) + } + } message: { + $0.message.map(Text.init) + } } /// Presents a confirmation dialog from a binding to optional confirmation dialog state. @@ -53,18 +53,18 @@ _ state: Binding?>, action handler: @escaping (Value?) async -> Void = { (_: Never?) async in } ) -> some View { - self.confirmationDialog( - state.wrappedValue.flatMap { Text($0.title) } ?? Text(verbatim: ""), - isPresented: state.isPresent(), - titleVisibility: state.wrappedValue.map { .init($0.titleVisibility) } ?? .automatic, - presenting: state.wrappedValue, - actions: { - ForEach($0.buttons) { - Button($0, action: handler) - } - }, - message: { $0.message.map { Text($0) } } - ) + confirmationDialog( + item: state, + titleVisibility: state.wrappedValue.map { .init($0.titleVisibility) } ?? .automatic + ) { + Text($0.title) + } actions: { + ForEach($0.buttons) { + Button($0, action: handler) + } + } message: { + $0.message.map(Text.init) + } } } #endif // canImport(SwiftUI) From 7f163578703446955a53510ee3f4d3ff10919d28 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 28 May 2024 09:33:01 -0700 Subject: [PATCH 11/97] Remove `sheet(unwrapping:)`, etc., helpers for `sheet(item:)` overloads (#157) * Remove `sheet(unwrapping:)`, etc., helpers for `sheet(item:)` overloads * wip * wip --- Examples/CaseStudies/01-Alerts.swift | 20 +- .../CaseStudies/02-ConfirmationDialogs.swift | 18 +- Examples/CaseStudies/03-Sheets.swift | 48 +-- Examples/CaseStudies/04-Popovers.swift | 46 +-- .../CaseStudies/05-FullScreenCovers.swift | 48 +-- .../06-NavigationDestinations.swift | 52 ++-- Examples/CaseStudies/07-NavigationLinks.swift | 46 +-- Examples/CaseStudies/08-Routing.swift | 26 +- .../CaseStudies/09-CustomComponents.swift | 36 +-- .../CaseStudies/10-SynchronizedBindings.swift | 22 +- Examples/CaseStudies/11-IfLet.swift | 14 +- Examples/CaseStudies/12-IfCaseLet.swift | 14 +- Examples/CaseStudies/FactClient.swift | 2 +- Examples/Inventory/Inventory.swift | 42 +-- Examples/Inventory/ItemRow.swift | 44 +-- .../Articles/AlertsDialogs.md | 70 +++-- .../Documentation.docc/Articles/Bindings.md | 4 +- .../Documentation.docc/Articles/Navigation.md | 32 +- .../Articles/SheetsPopoversCovers.md | 30 +- .../Articles/WhatIsNavigation.md | 72 ++--- .../Extensions/Deprecations.md | 6 + .../Documentation.docc/SwiftUINavigation.md | 24 +- .../SwiftUINavigation/FullScreenCover.swift | 110 ++++--- .../Internal/Deprecations.swift | 286 +++++++++--------- .../NavigationDestination.swift | 59 +--- Sources/SwiftUINavigation/Popover.swift | 131 +++++--- Sources/SwiftUINavigation/Sheet.swift | 105 ++++--- 27 files changed, 729 insertions(+), 678 deletions(-) diff --git a/Examples/CaseStudies/01-Alerts.swift b/Examples/CaseStudies/01-Alerts.swift index afdc846271..9f91a46aa6 100644 --- a/Examples/CaseStudies/01-Alerts.swift +++ b/Examples/CaseStudies/01-Alerts.swift @@ -7,28 +7,28 @@ struct OptionalAlerts: View { var body: some View { List { - Stepper("Number: \(self.model.count)", value: self.$model.count) + Stepper("Number: \(model.count)", value: $model.count) Button { - Task { await self.model.numberFactButtonTapped() } + Task { await model.numberFactButtonTapped() } } label: { HStack { Text("Get number fact") - if self.model.isLoading { + if model.isLoading { Spacer() ProgressView() } } } - .disabled(self.model.isLoading) + .disabled(model.isLoading) } - .alert(item: self.$model.fact) { + .alert(item: $model.fact) { Text("Fact about \($0.number)") } actions: { Button("Get another fact about \($0.number)") { - Task { await self.model.numberFactButtonTapped() } + Task { await model.numberFactButtonTapped() } } Button("Close", role: .cancel) { - self.model.fact = nil + model.fact = nil } } message: { Text($0.description) @@ -45,9 +45,9 @@ private class FeatureModel { @MainActor func numberFactButtonTapped() async { - self.isLoading = true - self.fact = await getNumberFact(self.count) - self.isLoading = false + isLoading = true + defer { isLoading = false } + fact = await getNumberFact(count) } } diff --git a/Examples/CaseStudies/02-ConfirmationDialogs.swift b/Examples/CaseStudies/02-ConfirmationDialogs.swift index a53d62c82c..1c7c8e72d9 100644 --- a/Examples/CaseStudies/02-ConfirmationDialogs.swift +++ b/Examples/CaseStudies/02-ConfirmationDialogs.swift @@ -7,24 +7,24 @@ struct OptionalConfirmationDialogs: View { var body: some View { List { - Stepper("Number: \(self.model.count)", value: self.$model.count) + Stepper("Number: \(model.count)", value: $model.count) Button { - Task { await self.model.numberFactButtonTapped() } + Task { await model.numberFactButtonTapped() } } label: { HStack { Text("Get number fact") - if self.model.isLoading { + if model.isLoading { Spacer() ProgressView() } } } - .disabled(self.model.isLoading) - .confirmationDialog(item: self.$model.fact, titleVisibility: .visible) { + .disabled(model.isLoading) + .confirmationDialog(item: $model.fact, titleVisibility: .visible) { Text("Fact about \($0.number)") } actions: { Button("Get another fact about \($0.number)") { - Task { await self.model.numberFactButtonTapped() } + Task { await model.numberFactButtonTapped() } } } message: { Text($0.description) @@ -42,9 +42,9 @@ private class FeatureModel { @MainActor func numberFactButtonTapped() async { - self.isLoading = true - self.fact = await getNumberFact(self.count) - self.isLoading = false + isLoading = true + defer { isLoading = false } + fact = await getNumberFact(count) } } diff --git a/Examples/CaseStudies/03-Sheets.swift b/Examples/CaseStudies/03-Sheets.swift index 80e2ab5577..f1d29169fc 100644 --- a/Examples/CaseStudies/03-Sheets.swift +++ b/Examples/CaseStudies/03-Sheets.swift @@ -7,14 +7,14 @@ struct OptionalSheets: View { var body: some View { List { Section { - Stepper("Number: \(self.model.count)", value: self.$model.count) + Stepper("Number: \(model.count)", value: $model.count) HStack { Button("Get number fact") { - Task { await self.model.numberFactButtonTapped() } + Task { await model.numberFactButtonTapped() } } - if self.model.isLoading { + if model.isLoading { Spacer() ProgressView() } @@ -24,28 +24,28 @@ struct OptionalSheets: View { } Section { - ForEach(self.model.savedFacts) { fact in + ForEach(model.savedFacts) { fact in Text(fact.description) } - .onDelete { self.model.removeSavedFacts(atOffsets: $0) } + .onDelete { model.removeSavedFacts(atOffsets: $0) } } header: { Text("Saved Facts") } } - .sheet(unwrapping: self.$model.fact) { $fact in + .sheet(item: $model.fact) { $fact in NavigationStack { FactEditor(fact: $fact.description) - .disabled(self.model.isLoading) - .foregroundColor(self.model.isLoading ? .gray : nil) + .disabled(model.isLoading) + .foregroundColor(model.isLoading ? .gray : nil) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { - self.model.cancelButtonTapped() + model.cancelButtonTapped() } } ToolbarItem(placement: .confirmationAction) { Button("Save") { - self.model.saveButtonTapped(fact: fact) + model.saveButtonTapped(fact: fact) } } } @@ -60,7 +60,7 @@ private struct FactEditor: View { var body: some View { VStack { - TextEditor(text: self.$fact) + TextEditor(text: $fact) } .padding() .navigationTitle("Fact editor") @@ -76,41 +76,41 @@ private class FeatureModel { private var task: Task? deinit { - self.task?.cancel() + task?.cancel() } @MainActor func numberFactButtonTapped() async { - self.isLoading = true - self.fact = Fact(description: "\(self.count) is still loading...", number: self.count) - self.task = Task { + isLoading = true + fact = Fact(description: "\(count) is still loading...", number: count) + task = Task { let fact = await getNumberFact(self.count) - self.isLoading = false + isLoading = false guard !Task.isCancelled else { return } self.fact = fact } - await self.task?.value + await task?.value } @MainActor func cancelButtonTapped() { - self.task?.cancel() - self.task = nil - self.fact = nil + task?.cancel() + task = nil + fact = nil } @MainActor func saveButtonTapped(fact: Fact) { - self.task?.cancel() - self.task = nil - self.savedFacts.append(fact) + task?.cancel() + task = nil + savedFacts.append(fact) self.fact = nil } @MainActor func removeSavedFacts(atOffsets offsets: IndexSet) { - self.savedFacts.remove(atOffsets: offsets) + savedFacts.remove(atOffsets: offsets) } } diff --git a/Examples/CaseStudies/04-Popovers.swift b/Examples/CaseStudies/04-Popovers.swift index da6d85de9d..292816baf2 100644 --- a/Examples/CaseStudies/04-Popovers.swift +++ b/Examples/CaseStudies/04-Popovers.swift @@ -7,29 +7,29 @@ struct OptionalPopovers: View { var body: some View { List { Section { - Stepper("Number: \(self.model.count)", value: self.$model.count) + Stepper("Number: \(model.count)", value: $model.count) HStack { Button("Get number fact") { - Task { await self.model.numberFactButtonTapped() } + Task { await model.numberFactButtonTapped() } } - .popover(unwrapping: self.$model.fact, arrowEdge: .bottom) { $fact in + .popover(item: $model.fact, arrowEdge: .bottom) { $fact in NavigationStack { FactEditor(fact: $fact.description) - .disabled(self.model.isLoading) - .foregroundColor(self.model.isLoading ? .gray : nil) + .disabled(model.isLoading) + .foregroundColor(model.isLoading ? .gray : nil) .navigationBarItems( leading: Button("Cancel") { - self.model.cancelButtonTapped() + model.cancelButtonTapped() }, trailing: Button("Save") { - self.model.saveButtonTapped(fact: fact) + model.saveButtonTapped(fact: fact) } ) } } - if self.model.isLoading { + if model.isLoading { Spacer() ProgressView() } @@ -39,10 +39,10 @@ struct OptionalPopovers: View { } Section { - ForEach(self.model.savedFacts) { fact in + ForEach(model.savedFacts) { fact in Text(fact.description) } - .onDelete { self.model.removeSavedFacts(atOffsets: $0) } + .onDelete { model.removeSavedFacts(atOffsets: $0) } } header: { Text("Saved Facts") } @@ -56,7 +56,7 @@ private struct FactEditor: View { var body: some View { VStack { - TextEditor(text: self.$fact) + TextEditor(text: $fact) } .padding() .navigationTitle("Fact editor") @@ -77,36 +77,36 @@ private class FeatureModel { @MainActor func numberFactButtonTapped() async { - self.isLoading = true - self.fact = Fact(description: "\(self.count) is still loading...", number: self.count) - self.task = Task { + isLoading = true + fact = Fact(description: "\(count) is still loading...", number: count) + task = Task { let fact = await getNumberFact(self.count) - self.isLoading = false + isLoading = false guard !Task.isCancelled else { return } self.fact = fact } - await self.task?.value + await task?.value } @MainActor func cancelButtonTapped() { - self.task?.cancel() - self.task = nil - self.fact = nil + task?.cancel() + task = nil + fact = nil } @MainActor func saveButtonTapped(fact: Fact) { - self.task?.cancel() - self.task = nil - self.savedFacts.append(fact) + task?.cancel() + task = nil + savedFacts.append(fact) self.fact = nil } @MainActor func removeSavedFacts(atOffsets offsets: IndexSet) { - self.savedFacts.remove(atOffsets: offsets) + savedFacts.remove(atOffsets: offsets) } } diff --git a/Examples/CaseStudies/05-FullScreenCovers.swift b/Examples/CaseStudies/05-FullScreenCovers.swift index 2fb26c50d7..cb33d8f61c 100644 --- a/Examples/CaseStudies/05-FullScreenCovers.swift +++ b/Examples/CaseStudies/05-FullScreenCovers.swift @@ -7,14 +7,14 @@ struct OptionalFullScreenCovers: View { var body: some View { List { Section { - Stepper("Number: \(self.model.count)", value: self.$model.count) + Stepper("Number: \(model.count)", value: $model.count) HStack { Button("Get number fact") { - Task { await self.model.numberFactButtonTapped() } + Task { await model.numberFactButtonTapped() } } - if self.model.isLoading { + if model.isLoading { Spacer() ProgressView() } @@ -24,28 +24,28 @@ struct OptionalFullScreenCovers: View { } Section { - ForEach(self.model.savedFacts) { fact in + ForEach(model.savedFacts) { fact in Text(fact.description) } - .onDelete { self.model.removeSavedFacts(atOffsets: $0) } + .onDelete { model.removeSavedFacts(atOffsets: $0) } } header: { Text("Saved Facts") } } - .fullScreenCover(unwrapping: self.$model.fact) { $fact in + .fullScreenCover(item: $model.fact) { $fact in NavigationStack { FactEditor(fact: $fact.description) - .disabled(self.model.isLoading) - .foregroundColor(self.model.isLoading ? .gray : nil) + .disabled(model.isLoading) + .foregroundColor(model.isLoading ? .gray : nil) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { - self.model.cancelButtonTapped() + model.cancelButtonTapped() } } ToolbarItem(placement: .confirmationAction) { Button("Save") { - self.model.saveButtonTapped(fact: fact) + model.saveButtonTapped(fact: fact) } } } @@ -60,7 +60,7 @@ private struct FactEditor: View { var body: some View { VStack { - TextEditor(text: self.$fact) + TextEditor(text: $fact) } .padding() .navigationTitle("Fact editor") @@ -77,36 +77,36 @@ private class FeatureModel { @MainActor func numberFactButtonTapped() async { - 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 + 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 self.task?.value + await task?.value } @MainActor func cancelButtonTapped() { - self.task?.cancel() - self.task = nil - self.fact = nil + task?.cancel() + task = nil + fact = nil } @MainActor func saveButtonTapped(fact: Fact) { - self.task?.cancel() - self.task = nil - self.savedFacts.append(fact) + task?.cancel() + task = nil + savedFacts.append(fact) self.fact = nil } @MainActor func removeSavedFacts(atOffsets offsets: IndexSet) { - self.savedFacts.remove(atOffsets: offsets) + savedFacts.remove(atOffsets: offsets) } } diff --git a/Examples/CaseStudies/06-NavigationDestinations.swift b/Examples/CaseStudies/06-NavigationDestinations.swift index 426ae5e065..33dd0a43be 100644 --- a/Examples/CaseStudies/06-NavigationDestinations.swift +++ b/Examples/CaseStudies/06-NavigationDestinations.swift @@ -8,14 +8,14 @@ struct NavigationDestinations: View { var body: some View { List { Section { - Stepper("Number: \(self.model.count)", value: self.$model.count) + Stepper("Number: \(model.count)", value: $model.count) HStack { Button("Get number fact") { - Task { await self.model.numberFactButtonTapped() } + Task { await model.numberFactButtonTapped() } } - if self.model.isLoading { + if model.isLoading { Spacer() ProgressView() } @@ -25,29 +25,29 @@ struct NavigationDestinations: View { } Section { - ForEach(self.model.savedFacts) { fact in + ForEach(model.savedFacts) { fact in Text(fact.description) } - .onDelete { self.model.removeSavedFacts(atOffsets: $0) } + .onDelete { model.removeSavedFacts(atOffsets: $0) } } header: { Text("Saved Facts") } } .navigationTitle("Destinations") - .navigationDestination(unwrapping: self.$model.fact) { $fact in + .navigationDestination(item: $model.fact) { $fact in FactEditor(fact: $fact.description) - .disabled(self.model.isLoading) - .foregroundColor(self.model.isLoading ? .gray : nil) + .disabled(model.isLoading) + .foregroundColor(model.isLoading ? .gray : nil) .navigationBarBackButtonHidden(true) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { - Task { await self.model.cancelButtonTapped() } + Task { await model.cancelButtonTapped() } } } ToolbarItem(placement: .confirmationAction) { Button("Save") { - Task { await self.model.saveButtonTapped(fact: fact) } + Task { await model.saveButtonTapped(fact: fact) } } } } @@ -61,9 +61,9 @@ private struct FactEditor: View { var body: some View { VStack { if #available(iOS 14, *) { - TextEditor(text: self.$fact) + TextEditor(text: $fact) } else { - TextField("Untitled", text: self.$fact) + TextField("Untitled", text: $fact) } } .padding() @@ -80,48 +80,48 @@ private class FeatureModel { private var task: Task? deinit { - self.task?.cancel() + task?.cancel() } @MainActor func setFactNavigation(isActive: Bool) async { if isActive { - self.isLoading = true - self.fact = Fact(description: "\(self.count) is still loading...", number: self.count) - self.task = Task { + isLoading = true + fact = Fact(description: "\(count) is still loading...", number: count) + task = Task { let fact = await getNumberFact(self.count) - self.isLoading = false + isLoading = false guard !Task.isCancelled else { return } self.fact = fact } - await self.task?.value + await task?.value } else { - self.task?.cancel() - self.task = nil - self.fact = nil + task?.cancel() + task = nil + fact = nil } } @MainActor func numberFactButtonTapped() async { - await self.setFactNavigation(isActive: true) + await setFactNavigation(isActive: true) } @MainActor func cancelButtonTapped() async { - await self.setFactNavigation(isActive: false) + await setFactNavigation(isActive: false) } @MainActor func saveButtonTapped(fact: Fact) async { - self.savedFacts.append(fact) - await self.setFactNavigation(isActive: false) + savedFacts.append(fact) + await setFactNavigation(isActive: false) } @MainActor func removeSavedFacts(atOffsets offsets: IndexSet) { - self.savedFacts.remove(atOffsets: offsets) + savedFacts.remove(atOffsets: offsets) } } diff --git a/Examples/CaseStudies/07-NavigationLinks.swift b/Examples/CaseStudies/07-NavigationLinks.swift index 88aa512392..4b87b6b053 100644 --- a/Examples/CaseStudies/07-NavigationLinks.swift +++ b/Examples/CaseStudies/07-NavigationLinks.swift @@ -7,11 +7,11 @@ struct OptionalNavigationLinks: View { var body: some View { List { Section { - Stepper("Number: \(self.model.count)", value: self.$model.count) + Stepper("Number: \(model.count)", value: $model.count) HStack { Button("Get number fact") { - Task { await self.model.setFactNavigation(isActive: true) } + Task { await model.setFactNavigation(isActive: true) } } if self.model.isLoading { @@ -24,28 +24,28 @@ struct OptionalNavigationLinks: View { } Section { - ForEach(self.model.savedFacts) { fact in + ForEach(model.savedFacts) { fact in Text(fact.description) } - .onDelete { self.model.removeSavedFacts(atOffsets: $0) } + .onDelete { model.removeSavedFacts(atOffsets: $0) } } header: { Text("Saved Facts") } } - .navigationDestination(unwrapping: self.$model.fact) { $fact in + .navigationDestination(item: $model.fact) { $fact in FactEditor(fact: $fact.description) - .disabled(self.model.isLoading) - .foregroundColor(self.model.isLoading ? .gray : nil) + .disabled(model.isLoading) + .foregroundColor(model.isLoading ? .gray : nil) .navigationBarBackButtonHidden(true) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { - Task { await self.model.cancelButtonTapped() } + Task { await model.cancelButtonTapped() } } } ToolbarItem(placement: .confirmationAction) { Button("Save") { - Task { await self.model.saveButtonTapped(fact: fact) } + Task { await model.saveButtonTapped(fact: fact) } } } } @@ -59,7 +59,7 @@ private struct FactEditor: View { var body: some View { VStack { - TextEditor(text: self.$fact) + TextEditor(text: $fact) } .padding() .navigationTitle("Fact editor") @@ -75,43 +75,43 @@ private class FeatureModel { private var task: Task? deinit { - self.task?.cancel() + task?.cancel() } @MainActor func setFactNavigation(isActive: Bool) async { if isActive { - self.isLoading = true - self.fact = Fact(description: "\(self.count) is still loading...", number: self.count) - self.task = Task { + isLoading = true + fact = Fact(description: "\(count) is still loading...", number: count) + task = Task { let fact = await getNumberFact(self.count) - self.isLoading = false + isLoading = false guard !Task.isCancelled else { return } self.fact = fact } - await self.task?.value + await task?.value } else { - self.task?.cancel() - self.task = nil - self.fact = nil + task?.cancel() + task = nil + fact = nil } } @MainActor func cancelButtonTapped() async { - await self.setFactNavigation(isActive: false) + await setFactNavigation(isActive: false) } @MainActor func saveButtonTapped(fact: Fact) async { - self.savedFacts.append(fact) - await self.setFactNavigation(isActive: false) + savedFacts.append(fact) + await setFactNavigation(isActive: false) } @MainActor func removeSavedFacts(atOffsets offsets: IndexSet) { - self.savedFacts.remove(atOffsets: offsets) + savedFacts.remove(atOffsets: offsets) } } diff --git a/Examples/CaseStudies/08-Routing.swift b/Examples/CaseStudies/08-Routing.swift index 0b75f9c649..6c34662a68 100644 --- a/Examples/CaseStudies/08-Routing.swift +++ b/Examples/CaseStudies/08-Routing.swift @@ -39,11 +39,11 @@ struct Routing: View { } Section { - Text("Count: \(self.count)") + Text("Count: \(count)") } Button("Alert") { - self.destination = .alert( + destination = .alert( AlertState { TextState("Update count?") } actions: { @@ -58,7 +58,7 @@ struct Routing: View { } Button("Confirmation dialog") { - self.destination = .confirmationDialog( + destination = .confirmationDialog( ConfirmationDialogState(titleVisibility: .visible) { TextState("Update count?") } actions: { @@ -73,41 +73,41 @@ struct Routing: View { } Button("Link") { - self.destination = .link(self.count) + destination = .link(count) } Button("Sheet") { - self.destination = .sheet(self.count) + destination = .sheet(count) } } .navigationTitle("Routing") - .alert(self.$destination.alert) { action in + .alert($destination.alert) { action in switch action { case .randomize?: - self.count = .random(in: 0...1_000) + count = .random(in: 0...1_000) case .reset?: - self.count = 0 + count = 0 case nil: break } } - .confirmationDialog(self.$destination.confirmationDialog) { action in + .confirmationDialog($destination.confirmationDialog) { action in switch action { case .decrement?: - self.count -= 1 + count -= 1 case .increment?: - self.count += 1 + count += 1 case nil: break } } - .navigationDestination(unwrapping: self.$destination.link) { $count in + .navigationDestination(item: $destination.link) { $count in Form { Stepper("Count: \(count)", value: $count) } .navigationTitle("Routing link") } - .sheet(unwrapping: self.$destination.sheet) { $count in + .sheet(item: $destination.sheet, id: \.self) { $count in NavigationStack { Form { Stepper("Count: \(count)", value: $count) diff --git a/Examples/CaseStudies/09-CustomComponents.swift b/Examples/CaseStudies/09-CustomComponents.swift index 329b8e61c0..54d7b36749 100644 --- a/Examples/CaseStudies/09-CustomComponents.swift +++ b/Examples/CaseStudies/09-CustomComponents.swift @@ -25,16 +25,16 @@ struct CustomComponents: View { Button("Show bottom menu") { withAnimation { - self.count = 0 + count = 0 } } - if let count = self.count, count > 0 { + if let count = count, count > 0 { Text("Current count: \(count)") .transition(.opacity) } } - .bottomMenu(unwrapping: self.$count) { $count in + .bottomMenu(item: $count) { $count in Stepper("Number: \(count)", value: $count.animation()) } .navigationTitle("Custom components") @@ -49,13 +49,13 @@ where BottomMenuContent: View { func body(content: Content) -> some View { content.overlay( ZStack(alignment: .bottom) { - if self.isActive { + if isActive { Rectangle() .fill(Color.black.opacity(0.4)) .frame(maxWidth: .infinity, maxHeight: .infinity) .onTapGesture { withAnimation { - self.isActive = false + isActive = false } } .zIndex(1) @@ -83,7 +83,7 @@ extension View { @ViewBuilder content: @escaping () -> Content ) -> some View where Content: View { - self.modifier( + modifier( BottomMenuModifier( isActive: isActive, content: content @@ -91,30 +91,18 @@ extension View { ) } - fileprivate func bottomMenu( - unwrapping value: Binding, - @ViewBuilder content: @escaping (Binding) -> Content + fileprivate func bottomMenu( + item: Binding, + @ViewBuilder content: @escaping (Binding) -> Content ) -> some View where Content: View { - self.modifier( + modifier( BottomMenuModifier( - isActive: value.isPresent(), - content: { Binding(unwrapping: value).map(content) } + isActive: item.isPresent(), + content: { Binding(unwrapping: item).map(content) } ) ) } - - fileprivate func bottomMenu( - unwrapping value: Binding, - case casePath: AnyCasePath, - @ViewBuilder content: @escaping (Binding) -> Content - ) -> some View - where Content: View { - self.bottomMenu( - unwrapping: value.case(casePath), - content: content - ) - } } #Preview { diff --git a/Examples/CaseStudies/10-SynchronizedBindings.swift b/Examples/CaseStudies/10-SynchronizedBindings.swift index c141de8330..c8abcaf132 100644 --- a/Examples/CaseStudies/10-SynchronizedBindings.swift +++ b/Examples/CaseStudies/10-SynchronizedBindings.swift @@ -19,20 +19,20 @@ struct SynchronizedBindings: View { } Section { - TextField("Username", text: self.$model.username) - .focused(self.$focusedField, equals: .username) + TextField("Username", text: $model.username) + .focused($focusedField, equals: .username) - SecureField("Password", text: self.$model.password) - .focused(self.$focusedField, equals: .password) + SecureField("Password", text: $model.password) + .focused($focusedField, equals: .password) Button("Sign In") { - self.model.signInButtonTapped() + model.signInButtonTapped() } .buttonStyle(.borderedProminent) } .textFieldStyle(.roundedBorder) } - .bind(self.$model.focusedField, to: self.$focusedField) + .bind($model.focusedField, to: $focusedField) .navigationTitle("Synchronized focus") } } @@ -49,12 +49,12 @@ private class FeatureModel { var username: String = "" func signInButtonTapped() { - if self.username.isEmpty { - self.focusedField = .username - } else if self.password.isEmpty { - self.focusedField = .password + if username.isEmpty { + focusedField = .username + } else if password.isEmpty { + focusedField = .password } else { - self.focusedField = nil + focusedField = nil } } } diff --git a/Examples/CaseStudies/11-IfLet.swift b/Examples/CaseStudies/11-IfLet.swift index caa116580e..f6ec2cd4ec 100644 --- a/Examples/CaseStudies/11-IfLet.swift +++ b/Examples/CaseStudies/11-IfLet.swift @@ -17,25 +17,25 @@ struct IfLetCaseStudy: View { Section { Text(readMe) } - Binding(unwrapping: self.$editableString).map { $string in + Binding(unwrapping: $editableString).map { $string in VStack { TextField("Edit string", text: $string) HStack { Button("Discard") { - self.editableString = nil + editableString = nil } Spacer() Button("Save") { - self.string = string - self.editableString = nil + string = string + editableString = nil } } } } - if self.editableString == nil { - Text("\(self.string)") + if editableString == nil { + Text("\(string)") Button("Edit") { - self.editableString = self.string + editableString = string } } } diff --git a/Examples/CaseStudies/12-IfCaseLet.swift b/Examples/CaseStudies/12-IfCaseLet.swift index 47f4df3499..5994f58988 100644 --- a/Examples/CaseStudies/12-IfCaseLet.swift +++ b/Examples/CaseStudies/12-IfCaseLet.swift @@ -24,25 +24,25 @@ struct IfCaseLetCaseStudy: View { Section { Text(readMe) } - self.$editableString.active.map { $string in + $editableString.active.map { $string in VStack { TextField("Edit string", text: $string) HStack { Button("Discard", role: .cancel) { - self.editableString = .inactive + editableString = .inactive } Spacer() Button("Save") { - self.string = string - self.editableString = .inactive + string = string + editableString = .inactive } } } } - if !self.editableString.is(\.active) { - Text("\(self.string)") + if !editableString.is(\.active) { + Text("\(string)") Button("Edit") { - self.editableString = .active(self.string) + editableString = .active(string) } } } diff --git a/Examples/CaseStudies/FactClient.swift b/Examples/CaseStudies/FactClient.swift index d6ae71207c..279b53ff00 100644 --- a/Examples/CaseStudies/FactClient.swift +++ b/Examples/CaseStudies/FactClient.swift @@ -5,7 +5,7 @@ struct Fact: Identifiable { let number: Int var id: AnyHashable { - [self.description as AnyHashable, self.number] + [description as AnyHashable, number] } } diff --git a/Examples/Inventory/Inventory.swift b/Examples/Inventory/Inventory.swift index 1ec1a260e7..12843b3ece 100644 --- a/Examples/Inventory/Inventory.swift +++ b/Examples/Inventory/Inventory.swift @@ -5,7 +5,7 @@ import SwiftUINavigation @Observable class InventoryModel { var inventory: IdentifiedArrayOf { - didSet { self.bind() } + didSet { bind() } } var destination: Destination? @@ -25,46 +25,46 @@ class InventoryModel { } func delete(item: Item) { - _ = self.inventory.remove(id: item.id) + _ = inventory.remove(id: item.id) } func add(item: Item) { withAnimation { - self.inventory.append(ItemRowModel(item: item)) - self.destination = nil + inventory.append(ItemRowModel(item: item)) + destination = nil } } func addButtonTapped() { - self.destination = .add(Item(color: nil, name: "", status: .inStock(quantity: 1))) + destination = .add(Item(color: nil, name: "", status: .inStock(quantity: 1))) } func cancelButtonTapped() { - self.destination = nil + destination = nil } func cancelEditButtonTapped() { - self.destination = nil + destination = nil } func commitEdit(item: Item) { - self.inventory[id: item.id]?.item = item - self.destination = nil + inventory[id: item.id]?.item = item + destination = nil } private func bind() { - for itemRowModel in self.inventory { + for itemRowModel in inventory { itemRowModel.onDelete = { [weak self, weak itemRowModel] in guard let self, let itemRowModel else { return } - self.delete(item: itemRowModel.item) + delete(item: itemRowModel.item) } itemRowModel.onDuplicate = { [weak self] item in guard let self else { return } - self.add(item: item) + add(item: item) } itemRowModel.onTap = { [weak self, weak itemRowModel] in guard let self, let itemRowModel else { return } - self.destination = .edit(itemRowModel.item) + destination = .edit(itemRowModel.item) } } } @@ -75,43 +75,43 @@ struct InventoryView: View { var body: some View { List { - ForEach(self.model.inventory) { + ForEach(model.inventory) { ItemRowView(model: $0) } } .toolbar { ToolbarItem(placement: .primaryAction) { - Button("Add") { self.model.addButtonTapped() } + Button("Add") { model.addButtonTapped() } } } .navigationTitle("Inventory") - .navigationDestination(unwrapping: self.$model.destination.edit) { $item in + .navigationDestination(item: $model.destination.edit) { $item in ItemView(item: $item) .navigationBarTitle("Edit") .navigationBarBackButtonHidden(true) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { - self.model.cancelEditButtonTapped() + model.cancelEditButtonTapped() } } ToolbarItem(placement: .primaryAction) { Button("Save") { - self.model.commitEdit(item: item) + model.commitEdit(item: item) } } } } - .sheet(unwrapping: self.$model.destination.add) { $itemToAdd in + .sheet(item: $model.destination.add) { $itemToAdd in NavigationStack { ItemView(item: $itemToAdd) .navigationTitle("Add") .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { self.model.cancelButtonTapped() } + Button("Cancel") { model.cancelButtonTapped() } } ToolbarItem(placement: .primaryAction) { - Button("Save") { self.model.add(item: itemToAdd) } + Button("Save") { model.add(item: itemToAdd) } } } } diff --git a/Examples/Inventory/ItemRow.swift b/Examples/Inventory/ItemRow.swift index bc30cf5ca3..93e78f803c 100644 --- a/Examples/Inventory/ItemRow.swift +++ b/Examples/Inventory/ItemRow.swift @@ -21,16 +21,16 @@ class ItemRowModel: Identifiable { var onDuplicate: (Item) -> Void = unimplemented("ItemRowModel.onDuplicate") var onTap: () -> Void = unimplemented("ItemRowModel.onTap") - var id: Item.ID { self.item.id } + var id: Item.ID { item.id } init(item: Item) { self.item = item } func deleteButtonTapped() { - self.destination = .alert( + destination = .alert( AlertState { - TextState(self.item.name) + TextState(item.name) } actions: { ButtonState(role: .destructive, action: .send(.deleteConfirmation, animation: .default)) { TextState("Delete") @@ -44,33 +44,33 @@ class ItemRowModel: Identifiable { func alertButtonTapped(_ action: AlertAction?) { switch action { case .deleteConfirmation?: - self.onDelete() + onDelete() case nil: break } } func cancelButtonTapped() { - self.destination = nil + destination = nil } func duplicateButtonTapped() { - self.destination = .duplicate(self.item.duplicate()) + destination = .duplicate(item.duplicate()) } func duplicate(item: Item) { - self.onDuplicate(item) - self.destination = nil + onDuplicate(item) + destination = nil } func rowTapped() { - self.onTap() + onTap() } } extension Item { func duplicate() -> Self { - Self(color: self.color, name: self.name, status: self.status) + Self(color: color, name: name, status: status) } } @@ -79,14 +79,14 @@ struct ItemRowView: View { var body: some View { Button { - self.model.rowTapped() + model.rowTapped() } label: { HStack { VStack(alignment: .leading) { - Text(self.model.item.name) + Text(model.item.name) .font(.title3) - switch self.model.item.status { + switch model.item.status { case let .inStock(quantity): Text("In stock: \(quantity)") case let .outOfStock(isOnBackOrder): @@ -96,41 +96,41 @@ struct ItemRowView: View { Spacer() - if let color = self.model.item.color { + if let color = model.item.color { Rectangle() .frame(width: 30, height: 30) .foregroundColor(color.swiftUIColor) .border(Color.black, width: 1) } - Button(action: { self.model.duplicateButtonTapped() }) { + Button(action: { model.duplicateButtonTapped() }) { Image(systemName: "square.fill.on.square.fill") } .padding(.leading) - Button(action: { self.model.deleteButtonTapped() }) { + Button(action: { model.deleteButtonTapped() }) { Image(systemName: "trash.fill") } .padding(.leading) } .buttonStyle(.plain) - .foregroundColor(self.model.item.status.is(\.inStock) ? nil : Color.gray) - .alert(self.$model.destination.alert) { - self.model.alertButtonTapped($0) + .foregroundColor(model.item.status.is(\.inStock) ? nil : Color.gray) + .alert($model.destination.alert) { + model.alertButtonTapped($0) } - .popover(unwrapping: self.$model.destination.duplicate) { $item in + .popover(item: $model.destination.duplicate) { $item in NavigationStack { ItemView(item: $item) .navigationBarTitle("Duplicate") .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { - self.model.cancelButtonTapped() + model.cancelButtonTapped() } } ToolbarItem(placement: .primaryAction) { Button("Add") { - self.model.duplicate(item: item) + model.duplicate(item: item) } } } diff --git a/Sources/SwiftUINavigation/Documentation.docc/Articles/AlertsDialogs.md b/Sources/SwiftUINavigation/Documentation.docc/Articles/AlertsDialogs.md index 25d07b3c47..848a88229f 100644 --- a/Sources/SwiftUINavigation/Documentation.docc/Articles/AlertsDialogs.md +++ b/Sources/SwiftUINavigation/Documentation.docc/Articles/AlertsDialogs.md @@ -19,7 +19,7 @@ your model, as well as an enum that describes every action that can happen in th class FeatureModel { var alert: AlertState? enum AlertAction { - case deletionConfirmed + case confirmDelete } // ... @@ -34,8 +34,12 @@ func deleteButtonTapped() { self.alert = AlertState { TextState("Are you sure?") } actions: { - ButtonState("Delete", action: .send(.delete)) - ButtonState("Nevermind", role: .cancel) + ButtonState(role: .destructive, action: .confirmDelete) { + TextState("Delete") + } + ButtonState(role: .cancel) { + TextState("Nevermind") + } } message: { TextState("Deleting this item cannot be undone.") } @@ -49,11 +53,17 @@ equatability. This makes it possible to write tests against these values. > ```swift > } actions: { > if item.isLocked { -> ButtonState("Unlock and delete", action: .send(.unlockAndDelete)) +> ButtonState(role: .destructive, action: .confirmDelete) { +> TextState("Unlock and delete") +> } > } else { -> ButtonState("Delete", action: .send(.delete)) +> ButtonState(role: .destructive, action: .confirmDelete) { +> TextState("Delete") +> } +> } +> ButtonState(role: .cancel) { +> TextState("Nevermind") > } -> ButtonState("Nevermind", role: .cancel) > } > ``` @@ -62,7 +72,7 @@ Next you can provide an endpoint that will be called when the alert is interacte ```swift func alertButtonTapped(_ action: AlertAction?) { switch action { - case .deletionConfirmed: + case .confirmDelete: // NB: Perform deletion logic here case nil: // NB: Perform cancel button logic here @@ -81,8 +91,8 @@ struct ContentView: View { List { // ... } - .alert(self.$model.alert) { action in - self.model.alertButtonTapped(action) + .alert($model.alert) { action in + model.alertButtonTapped(action) } } } @@ -98,7 +108,7 @@ func testDelete() { model.deleteButtonTapped() XCTAssertEqual(model.alert?.title, TextState("Are you sure?")) - model.alertButtonTapped(.deletionConfirmation) + model.alertButtonTapped(.confirmDelete) // NB: Assert that deletion actually occurred. } ``` @@ -123,7 +133,7 @@ class FeatureModel { } enum AlertAction { - case deletionConfirmed + case confirmDelete } // ... @@ -134,8 +144,8 @@ With this kind of set up you can use an alternative `alert` view modifier that t argument for specifying which case of the enum drives the presentation of the alert: ```swift -.alert(self.$model.destination.alert) { action in - self.model.alertButtonTapped(action) +.alert($model.destination.alert) { action in + model.alertButtonTapped(action) } ``` @@ -155,24 +165,27 @@ For example, the model for a delete confirmation could look like this: class FeatureModel { var dialog: ConfirmationDialogState? enum DialogAction { - case deletionConfirmed + case confirmDelete } func deleteButtonTapped() { - self.dialog = ConfirmationDialogState( - title: TextState("Are you sure?"), - titleVisibility: .visible, - message: TextState("Deleting this item cannot be undone."), - buttons: [ - .destructive(TextState("Delete"), action: .send(.delete)), - .cancel(TextState("Nevermind")), - ] - ) + dialog = ConfirmationDialogState(titleVisibility: .visible) { + TextState("Are you sure?") + } actions: { + ButtonState(role: .destructive, action: .confirmDelete) { + TextState("Delete") + } + ButtonState(role: .cancel) { + TextState("Nevermind") + } + } message: { + TextState("Deleting this item cannot be undone.") + } } func dialogButtonTapped(_ action: DialogAction?) { switch action { - case .deletionConfirmed: + case .confirmDelete: // NB: Perform deletion logic here case nil: // NB: Perform cancel button logic here @@ -191,8 +204,8 @@ struct ContentView: View { List { // ... } - .confirmationDialog(self.$model.dialog) { action in - self.dialogButtonTapped(action) + .confirmationDialog($model.dialog) { action in + dialogButtonTapped(action) } } } @@ -200,11 +213,6 @@ struct ContentView: View { ## Topics -### Alert and dialog modifiers - -- ``SwiftUI/View/alert(title:unwrapping:actions:message:)`` -- ``SwiftUI/View/confirmationDialog(title:titleVisibility:unwrapping:actions:message:)`` - ### Alert state and dialog state - ``SwiftUI/View/alert(_:action:)-sgyk`` diff --git a/Sources/SwiftUINavigation/Documentation.docc/Articles/Bindings.md b/Sources/SwiftUINavigation/Documentation.docc/Articles/Bindings.md index 07dd305f3c..f7e6b9ca36 100644 --- a/Sources/SwiftUINavigation/Documentation.docc/Articles/Bindings.md +++ b/Sources/SwiftUINavigation/Documentation.docc/Articles/Bindings.md @@ -4,7 +4,7 @@ Learn how to manage certain view state, such as `@FocusState` directly in your o ## Overview -SwiftUI comes with many property wrappers that can be used in views to drive view state, such as +SwiftUI comes with many property wrappers that can be used in views to drive view state, such as `@FocusState`. Unfortunately, these property wrappers _must_ be used in views. It's not possible to extract this logic to an `@Observable` class and integrate it with the rest of the model's business @@ -38,7 +38,7 @@ Notice that we store the focus as a regular `var` property in the model rather t This is because `@FocusState` only works when installed directly in a view. It cannot be used in an observable class. -You can implement the view as you would normally, except you must also use `@FocusState` for the +You can implement the view as you would normally, except you must also use `@FocusState` for the focus _and_ use the `bind` helper to make sure that changes to the model's focus are replayed to the view, and vice versa. diff --git a/Sources/SwiftUINavigation/Documentation.docc/Articles/Navigation.md b/Sources/SwiftUINavigation/Documentation.docc/Articles/Navigation.md index 14bf6a481c..2320d5bdd8 100644 --- a/Sources/SwiftUINavigation/Documentation.docc/Articles/Navigation.md +++ b/Sources/SwiftUINavigation/Documentation.docc/Articles/Navigation.md @@ -9,7 +9,7 @@ The library comes with new tools for driving drill-down navigation with optional This includes new initializers on `NavigationLink` and new overloads of the `navigationDestination` view modifier. -Suppose your view or model holds a piece of optional state that represents whether or not a +Suppose your view or model holds a piece of optional state that represents whether or not a drill-down should occur: ```swift @@ -20,14 +20,14 @@ struct ContentView: View { } ``` -Further suppose that the screen being navigated to wants a binding to the integer when it is -non-`nil`. You can construct a `NavigationLink` that will activate when that state becomes +Further suppose that the screen being navigated to wants a binding to the integer when it is +non-`nil`. You can construct a `NavigationLink` that will activate when that state becomes non-`nil`, and will deactivate when the state becomes `nil`: ```swift -NavigationLink(unwrapping: self.$destination) { isActive in - self.destination = isActive ? 42 : nil -} destination: { $number in +NavigationLink(unwrapping: $destination) { isActive in + destination = isActive ? 42 : nil +} destination: { $number in CounterView(number: $number) } label: { Text("Go to counter") @@ -46,13 +46,11 @@ For iOS 16+ you can use the `navigationDestination` overload: ```swift Button { - self.destination = 42 + destination = 42 } label: { Text("Go to counter") } -.navigationDestination( - unwrapping: self.$model.destination -) { $item in +.navigationDestination(item: $model.destination) { $item in CounterView(number: $number) } ``` @@ -60,7 +58,7 @@ Button { Sometimes it is not optimal to model navigation destinations as optionals. In particular, if a feature can navigate to multiple, mutually exclusive screens, then an enum is more appropriate. -Suppose that in addition to be able to drill down to a counter view that one can also open a +Suppose that in addition to be able to drill down to a counter view that one can also open a sheet with some text. We can model those destinations as an enum: ```swift @@ -88,9 +86,9 @@ With this set up you can make use of the which case of the enum you want driving navigation: ```swift -NavigationLink(unwrapping: self.$destination.counter) { isActive in - self.destination = isActive ? .counter(42) : nil -} destination: { $number in +NavigationLink(unwrapping: $destination.counter) { isActive in + destination = isActive ? .counter(42) : nil +} destination: { $number in CounterView(number: $number) } label: { Text("Go to counter") @@ -101,11 +99,11 @@ And similarly for ``SwiftUI/View/navigationDestination(unwrapping:destination:)` ```swift Button { - self.destination = .counter(42) + destination = .counter(42) } label: { Text("Go to counter") } -.navigationDestination(unwrapping: self.$model.destination.counter) { $number in +.navigationDestination(unwrapping: $model.destination.counter) { $number in CounterView(number: $number) } ``` @@ -114,7 +112,7 @@ Button { ### Navigation views and modifiers -- ``SwiftUI/View/navigationDestination(unwrapping:destination:)`` +- ``SwiftUI/View/navigationDestination(item:destination:)`` - ``SwiftUI/NavigationLink/init(unwrapping:onNavigate:destination:label:)`` ### Supporting types diff --git a/Sources/SwiftUINavigation/Documentation.docc/Articles/SheetsPopoversCovers.md b/Sources/SwiftUINavigation/Documentation.docc/Articles/SheetsPopoversCovers.md index 7e03c029b8..5b95ada77d 100644 --- a/Sources/SwiftUINavigation/Documentation.docc/Articles/SheetsPopoversCovers.md +++ b/Sources/SwiftUINavigation/Documentation.docc/Articles/SheetsPopoversCovers.md @@ -4,7 +4,7 @@ Learn how to present sheets, popovers and covers in a concise and testable manne ## Overview -The library comes with new tools for driving sheets, popovers and covers from optional and enum +The library comes with new tools for driving sheets, popovers and covers from optional and enum state. * [Sheets](#Sheets) @@ -25,14 +25,14 @@ struct ContentView: View { ``` Further suppose that the screen being presented wants a binding to the integer when it is non-`nil`. -You can use the `sheet(unwrapping:)` view modifier that comes with the library: +You can use the `sheet(item:)` overload that comes with the library: ```swift var body: some View { List { // ... } - .sheet(unwrapping: self.$destination) { $number in + .sheet(item: $destination) { $number in CounterView(number: $number) } } @@ -42,7 +42,7 @@ Notice that the trailing closure is handed a binding to the unwrapped state. Thi handed to the child view, and any changes made by the parent will be reflected in the child, and vice-versa. -Sometimes it is not optimal to model presentation destinations as optionals. In particular, if a +Sometimes it is not optimal to model presentation destinations as optionals. In particular, if a feature can navigate to multiple, mutually exclusive screens, then an enum is more appropriate. There is an additional overload of the `sheet` for this situation. If you model your destinations @@ -65,7 +65,7 @@ var body: some View { List { // ... } - .sheet(unwrapping: self.$destination.counter) { $number in + .sheet(item: $destination.counter) { $number in CounterView(number: $number) } } @@ -84,7 +84,7 @@ struct ContentView: View { List { // ... } - .popover(unwrapping: self.$destination) { $number in + .popover(item: $destination) { $number in CounterView(number: $number) } } @@ -107,7 +107,7 @@ struct ContentView: View { List { // ... } - .popover(unwrapping: self.$destination.counter) { $number in + .popover(item: $destination.counter) { $number in CounterView(number: $number) } } @@ -127,7 +127,7 @@ struct ContentView: View { List { // ... } - .fullscreenCover(unwrapping: self.$destination) { $number in + .fullscreenCover(item: $destination) { $number in CounterView(number: $number) } } @@ -150,7 +150,7 @@ struct ContentView: View { List { // ... } - .fullscreenCover(unwrapping: self.$destination.counter) { $number in + .fullscreenCover(item: $destination.counter) { $number in CounterView(number: $number) } } @@ -161,6 +161,12 @@ struct ContentView: View { ### Presentation modifiers -- ``SwiftUI/View/fullScreenCover(unwrapping:onDismiss:content:)`` -- ``SwiftUI/View/popover(unwrapping:attachmentAnchor:arrowEdge:content:)`` -- ``SwiftUI/View/sheet(unwrapping:onDismiss:content:)`` +- ``SwiftUI/View/fullScreenCover(item:id:onDismiss:content:)-9csbq`` +- ``SwiftUI/View/fullScreenCover(item:onDismiss:content:)`` +- ``SwiftUI/View/fullScreenCover(item:id:onDismiss:content:)-14to1`` +- ``SwiftUI/View/popover(item:id:attachmentAnchor:arrowEdge:content:)-3un96`` +- ``SwiftUI/View/popover(item:attachmentAnchor:arrowEdge:content:)`` +- ``SwiftUI/View/popover(item:id:attachmentAnchor:arrowEdge:content:)-57svy`` +- ``SwiftUI/View/sheet(item:id:onDismiss:content:)-1hi9l`` +- ``SwiftUI/View/sheet(item:onDismiss:content:)`` +- ``SwiftUI/View/sheet(item:id:onDismiss:content:)-6tgux`` diff --git a/Sources/SwiftUINavigation/Documentation.docc/Articles/WhatIsNavigation.md b/Sources/SwiftUINavigation/Documentation.docc/Articles/WhatIsNavigation.md index 3eea4ab2c0..188202a5b5 100644 --- a/Sources/SwiftUINavigation/Documentation.docc/Articles/WhatIsNavigation.md +++ b/Sources/SwiftUINavigation/Documentation.docc/Articles/WhatIsNavigation.md @@ -1,12 +1,12 @@ # What is navigation? -Learn how one can think of navigation as a domain modeling problem, and how that leads to the +Learn how one can think of navigation as a domain modeling problem, and how that leads to the creation of concise and testable APIs for navigation. ## Overview -We will define navigation as a "mode" change in an application. The most prototypical example of -this in SwiftUI are navigation stacks and links. A user taps a button, and a right-to-left +We will define navigation as a "mode" change in an application. The most prototypical example of +this in SwiftUI are navigation stacks and links. A user taps a button, and a right-to-left animation transitions you from the current screen to the next screen. But there are more examples of navigation beyond that one example. Modal sheets can be thought of @@ -15,25 +15,25 @@ new screen. Full screen covers and popovers are also an example of navigation, a similar to sheets except they either take over the full screen (i.e. covers) or only partially take over the screen (i.e. popovers). -Even alerts and confirmation dialogs can be thought of navigation as they take full control over -the interface and force you to make a selection. It's also possible for you to define your own +Even alerts and confirmation dialogs can be thought of navigation as they take full control over +the interface and force you to make a selection. It's also possible for you to define your own notions of navigation, such as bottom sheets, toasts, and more. ## State-driven navigation -All of these seemingly disparate examples of navigation can be unified under a single API. The -presentation and dismissal of a screen can be described with an optional piece of state. When the -state changes from `nil` to non-`nil` the screen will be presented, whether that be via a +All of these seemingly disparate examples of navigation can be unified under a single API. The +presentation and dismissal of a screen can be described with an optional piece of state. When the +state changes from `nil` to non-`nil` the screen will be presented, whether that be via a drill-down, modal, popover, etc. And when the state changes from non-`nil` to `nil` the screen will be dismissed. Driving navigation from state like this can be incredibly powerful: - * It guarantees that your model will always be in sync with the visual representation of the UI. + * It guarantees that your model will always be in sync with the visual representation of the UI. It shouldn't be possible for a piece of state to be non-`nil` and not have the corresponding view present. * It easily enables deep linking capabilities. If all forms of navigation in your application are - driven off of state, then you can instantly open your application into any state imaginable by + driven off of state, then you can instantly open your application into any state imaginable by simply constructing a piece of state, handing it to SwiftUI, and letting it do its thing. * It also allows you to write unit tests for navigation logic without resorting to UI tests, which can be slow, flakey and introduce instability into your test suite. If you write a unit test @@ -69,7 +69,7 @@ sheet for editing the item: class FeatureModel { var editingItem: Item? func tapped(item: Item) { - self.editingItem = item + editingItem = item } // ... } @@ -79,20 +79,20 @@ struct FeatureView: View { var body: some View { List { - ForEach(self.model.items) { item in + ForEach(model.items) { item in Button(item.name) { - self.model.tapped(item: item) + model.tapped(item: item) } } } - .sheet(item: self.$model.editingItem) { item in + .sheet(item: $model.editingItem) { item in EditItemView(item: item) } } } ``` -This works really great. When the button is tapped, the `tapped(item:)` method is called on the +This works really great. When the button is tapped, the `tapped(item:)` method is called on the model causing the `editingItem` state to be hydrated, and then SwiftUI sees that value is no longer `nil` and so causes the sheet to be presented. @@ -124,11 +124,11 @@ sheet view presented is handed a plain, inert value, and if that view wants to m will need to find a way to communicate that back to the parent. However, two-way communication is already a solved problem in SwiftUI with bindings. -So, it might be better if the `sheet(item:content:)` API handed a binding to the unwrapped item so +So, it might be better if the `sheet(item:content:)` API handed a binding to the unwrapped item so that any mutations in the sheet would be instantly observable by the parent: ```swift -.sheet(item: self.$model.editingItem) { $item in +.sheet(item: $model.editingItem) { $item in EditItemView(item: $item) } ``` @@ -139,7 +139,7 @@ The second problem is that while optional state is a great way to drive navigati scale to multiple navigation destinations. For example, suppose that in addition to being able to edit an item, the feature can also add an -item and duplicate an item, and you can navigate to a help screen. That can technically be +item and duplicate an item, and you can navigate to a help screen. That can technically be represented as four optionals: ```swift @@ -153,17 +153,17 @@ class FeatureModel { } ``` -But this is not the most concise way to model this domain. Four optional values means there are +But this is not the most concise way to model this domain. Four optional values means there are `2⁴=16` different states this feature can be in, but only 5 of those states are valid. Either all -can be `nil`, representing we are not navigated anywhere, or at most one can be non-`nil`, +can be `nil`, representing we are not navigated anywhere, or at most one can be non-`nil`, representing navigation to a single screen. But it is not valid to have 2, 3 or 4 non-`nil` values. That would represent multiple screens being simultaneously navigated to, such as two sheets being presented, which is invalid in SwiftUI and can even cause crashes. -This is showing that four optional values is not the best way to represent 4 navigation -destinations. Instead, it is more concise to model the 4 destinations as an enum with a case for +This is showing that four optional values is not the best way to represent 4 navigation +destinations. Instead, it is more concise to model the 4 destinations as an enum with a case for each destination, and then hold onto a single optional value to represent which destination is currently active: @@ -192,8 +192,8 @@ and more from a particular case of that enum. ## SwiftUINavigation's tools -The tools that ship with this library aim to solve the problems discussed above, and more. There are -new APIs for sheets, popovers, covers, alerts, confirmation dialogs _and_ navigation links that +The tools that ship with this library aim to solve the problems discussed above, and more. There are +new APIs for sheets, popovers, covers, alerts, confirmation dialogs _and_ navigation links that allow you to model destinations as an enum and drive navigation by a particular case of the enum. All of the APIs for these seemingly disparate forms of navigation are unified by a single pattern. @@ -203,9 +203,9 @@ content that takes a binding to a non-optional value. For example, the new sheet API now takes a binding to an optional: ```swift -func sheet( - unwrapping: Binding, - content: @escaping (Binding) -> Content +func sheet( + item: Binding, + content: @escaping (Binding) -> Content ) -> some View ``` @@ -214,9 +214,9 @@ optional value, but also from a particular case of an enum. In order to isolate a specific case of an enum we make use of our [CasePaths][case-paths-gh] library. A case path is like a key path, except it is specifically tuned for abstracting over the -shape of enums rather than structs. A key path abstractly bundles up the functionality of getting +shape of enums rather than structs. A key path abstractly bundles up the functionality of getting and setting a property on a struct, whereas a case path bundles up the functionality of "extracting" -a value from an enum and "embedding" a value into an enum. They are an indispensable tool for +a value from an enum and "embedding" a value into an enum. They are an indispensable tool for transforming bindings. Similar APIs are defined for popovers, covers, and more. @@ -246,13 +246,13 @@ shown in a popover, and the `edit` destination in a drill-down. We can do so eas that ship with this library: ```swift -.popover(unwrapping: self.$model.destination.duplicate) { $item in +.popover(item: $model.destination.duplicate) { $item in DuplicateItemView(item: $item) } -.sheet(unwrapping: self.$model.destination.add) { $item in +.sheet(item: $model.destination.add) { $item in AddItemView(item: $item) } -.navigationDestination(unwrapping: self.$model.destination.edit) { $item in +.navigationDestination(item: $model.destination.edit) { $item in EditItemView(item: $item) } ``` @@ -266,9 +266,9 @@ later. If you must support iOS 15 and earlier, you can use the following initial `NavigationLink`, which also has a very similar API to the above: ```swift -NavigationLink(unwrapping: self.$model.destination.edit) { isActive in - self.model.setEditIsActive(isActive) -} destination: { $item in +NavigationLink(unwrapping: $model.destination.edit) { isActive in + model.setEditIsActive(isActive) +} destination: { $item in EditItemView(item: $item) } label: { Text("\(item.name)") @@ -283,7 +283,7 @@ reading the articles below. ### Tools Read the following articles to learn more about the tools that ship with this library for presenting -alerts, dialogs, sheets, popovers, covers, and navigation links all from bindings of enum state. +alerts, dialogs, sheets, popovers, covers, and navigation links all from bindings of enum state. - - diff --git a/Sources/SwiftUINavigation/Documentation.docc/Extensions/Deprecations.md b/Sources/SwiftUINavigation/Documentation.docc/Extensions/Deprecations.md index ed5b569664..c9c45d6276 100644 --- a/Sources/SwiftUINavigation/Documentation.docc/Extensions/Deprecations.md +++ b/Sources/SwiftUINavigation/Documentation.docc/Extensions/Deprecations.md @@ -20,6 +20,7 @@ instead. ### View modifiers +- ``SwiftUI/View/alert(title:unwrapping:actions:message:)`` - ``SwiftUI/View/alert(title:unwrapping:case:actions:message:)`` - ``SwiftUI/View/alert(title:unwrapping:actions:message:)`` - ``SwiftUI/View/alert(unwrapping:action:)-7da26`` @@ -28,6 +29,7 @@ instead. - ``SwiftUI/View/alert(unwrapping:case:action:)-14fwn`` - ``SwiftUI/View/alert(unwrapping:case:action:)-3yw6u`` - ``SwiftUI/View/alert(unwrapping:case:action:)-4w3oq`` +- ``SwiftUI/View/confirmationDialog(title:titleVisibility:unwrapping:actions:message:)`` - ``SwiftUI/View/confirmationDialog(title:titleVisibility:unwrapping:case:actions:message:)`` - ``SwiftUI/View/confirmationDialog(unwrapping:action:)-9465l`` - ``SwiftUI/View/confirmationDialog(unwrapping:action:)-4f8ze`` @@ -35,9 +37,13 @@ instead. - ``SwiftUI/View/confirmationDialog(unwrapping:case:action:)-uncl`` - ``SwiftUI/View/confirmationDialog(unwrapping:case:action:)-2ddxv`` - ``SwiftUI/View/confirmationDialog(unwrapping:case:action:)-7oi9`` +- ``SwiftUI/View/fullScreenCover(unwrapping:onDismiss:content:)`` - ``SwiftUI/View/fullScreenCover(unwrapping:case:onDismiss:content:)`` +- ``SwiftUI/View/navigationDestination(unwrapping:destination:)`` - ``SwiftUI/View/navigationDestination(unwrapping:case:destination:)`` +- ``SwiftUI/View/popover(unwrapping:attachmentAnchor:arrowEdge:content:)`` - ``SwiftUI/View/popover(unwrapping:case:attachmentAnchor:arrowEdge:content:)`` +- ``SwiftUI/View/sheet(unwrapping:onDismiss:content:)`` - ``SwiftUI/View/sheet(unwrapping:case:onDismiss:content:)`` ### Bindings diff --git a/Sources/SwiftUINavigation/Documentation.docc/SwiftUINavigation.md b/Sources/SwiftUINavigation/Documentation.docc/SwiftUINavigation.md index 77cbfdfcca..2c046a071c 100644 --- a/Sources/SwiftUINavigation/Documentation.docc/SwiftUINavigation.md +++ b/Sources/SwiftUINavigation/Documentation.docc/SwiftUINavigation.md @@ -10,27 +10,27 @@ Tools for making SwiftUI navigation simpler, more ergonomic and more precise. ## Overview -SwiftUI comes with many forms of navigation (tabs, alerts, dialogs, modal sheets, popovers, -navigation links, and more), and each comes with a few ways to construct them. These ways roughly +SwiftUI comes with many forms of navigation (tabs, alerts, dialogs, modal sheets, popovers, +navigation links, and more), and each comes with a few ways to construct them. These ways roughly fall in two categories: - * "Fire-and-forget": These are initializers and methods that do not take binding arguments, which - means SwiftUI fully manages navigation state internally. This makes it is easy to get something - on the screen quickly, but you also have no programmatic control over the navigation. Examples - of this are the initializers on [`TabView`][TabView.init] and + * "Fire-and-forget": These are initializers and methods that do not take binding arguments, which + means SwiftUI fully manages navigation state internally. This makes it is easy to get something + on the screen quickly, but you also have no programmatic control over the navigation. Examples + of this are the initializers on [`TabView`][TabView.init] and [`NavigationLink`][NavigationLink.init] that do not take a binding. - * "State-driven": Most other initializers and methods do take a binding, which means you can - mutate state in your domain to tell SwiftUI when it should activate or deactivate navigation. - Using these APIs is more complicated than the "fire-and-forget" style, but doing so instantly - gives you the ability to deep-link into any state of your application by just constructing a + * "State-driven": Most other initializers and methods do take a binding, which means you can + mutate state in your domain to tell SwiftUI when it should activate or deactivate navigation. + Using these APIs is more complicated than the "fire-and-forget" style, but doing so instantly + gives you the ability to deep-link into any state of your application by just constructing a piece of data, handing it to a SwiftUI view, and letting SwiftUI handle the rest. -Navigation that is "state-driven" is the more powerful form of navigation, albeit slightly more +Navigation that is "state-driven" is the more powerful form of navigation, albeit slightly more complicated. To wield it correctly you must be able to model your domain as concisely as possible, and this usually means using enums. -Unfortunately, SwiftUI does not ship with all of the tools necessary to model our domains with +Unfortunately, SwiftUI does not ship with all of the tools necessary to model our domains with enums and make use of navigation APIs. This library bridges that gap by providing APIs that allow you to model your navigation destinations as an enum, and then drive navigation by a binding to that enum. diff --git a/Sources/SwiftUINavigation/FullScreenCover.swift b/Sources/SwiftUINavigation/FullScreenCover.swift index ad2d6a8eb5..92daf52e29 100644 --- a/Sources/SwiftUINavigation/FullScreenCover.swift +++ b/Sources/SwiftUINavigation/FullScreenCover.swift @@ -4,39 +4,18 @@ @available(iOS 14, tvOS 14, watchOS 7, *) @available(macOS, unavailable) extension View { - /// Presents a full-screen cover using a binding as a data source for the sheet's content based - /// on the identity of the underlying item. - /// - /// - Parameters: - /// - item: A binding to an optional source of truth for the sheet. When `item` is non-`nil`, - /// the system passes the item's content to the modifier's closure. You display this content - /// in a sheet that you create that the system displays to the user. If `item` changes, the - /// system dismisses the sheet and replaces it with a new one using the same process. - /// - id: The key path to the provided item's identifier. - /// - onDismiss: The closure to execute when dismissing the sheet. - /// - content: A closure returning the content of the sheet. - public func fullScreenCover( - item: Binding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - @ViewBuilder content: @escaping (Item) -> Content - ) -> some View { - self.fullScreenCover(item: item[id: id], onDismiss: onDismiss) { _ in - item.wrappedValue.map(content) - } - } - /// Presents a full-screen cover using a binding as a data source for the sheet's content. /// /// SwiftUI comes with a `fullScreenCover(item:)` view modifier that is powered by a binding to - /// some hashable state. When this state becomes non-`nil`, it passes an unwrapped value to the - /// content closure. This value, however, is completely static, which prevents the sheet from - /// modifying it. + /// some identifiable state. When this state becomes non-`nil`, it passes an unwrapped value to + /// the content closure. This value, however, is completely static, which prevents the sheet + /// from modifying it. /// /// This overload differs in that it passes a _binding_ to the unwrapped value, instead. This /// gives the sheet the ability to write changes back to its source of truth. /// - /// Also unlike `fullScreenCover(item:)`, the binding's value does _not_ need to be hashable. + /// Also unlike `fullScreenCover(item:)`, the binding's value does _not_ need to be + /// identifiable, and can instead specify a key path to the provided data's identifier. /// /// ```swift /// struct TimelineView: View { @@ -46,7 +25,7 @@ /// Button("Compose") { /// self.draft = Post() /// } - /// .fullScreenCover(unwrapping: self.$draft) { $draft in + /// .fullScreenCover(item: $draft, id: \.id) { $draft in /// ComposeView(post: $draft, onSubmit: { ... }) /// } /// } @@ -59,25 +38,70 @@ /// ``` /// /// - Parameters: - /// - value: A binding to a source of truth for the sheet. When `value` is non-`nil`, a - /// non-optional binding to the value is passed to the `content` closure. You use this - /// binding to produce content that the system presents to the user in a sheet. Changes made - /// to the sheet's binding will be reflected back in the source of truth. Likewise, changes - /// to `value` are instantly reflected in the sheet. If `value` becomes `nil`, the sheet is - /// dismissed. + /// - item: A binding to an optional source of truth for the sheet. When `item` is non-`nil`, + /// the system passes the item's content to the modifier's closure. You display this content + /// in a sheet that you create that the system displays to the user. If `item`'s identity + /// changes, the system dismisses the sheet and replaces it with a new one using the same + /// process. + /// - id: The key path to the provided item's identifier. + /// - onDismiss: The closure to execute when dismissing the sheet. + /// - content: A closure returning the content of the sheet. + @_disfavoredOverload + public func fullScreenCover( + item: Binding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping (Binding) -> Content + ) -> some View { + fullScreenCover(item: item[id: id], onDismiss: onDismiss) { _ in + Binding(unwrapping: item).map(content) + } + } + + /// Presents a full-screen cover using a binding as a data source for the sheet's content. + /// + /// A version of ``fullScreenCover(item:id:onDismiss:content:)-14to1`` that takes an + /// identifiable item. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the sheet. When `item` is non-`nil`, + /// the system passes the item's content to the modifier's closure. You display this content + /// in a sheet that you create that the system displays to the user. If `item`'s identity + /// changes, the system dismisses the sheet and replaces it with a new one using the same + /// process. /// - onDismiss: The closure to execute when dismissing the sheet. /// - content: A closure returning the content of the sheet. - public func fullScreenCover( - unwrapping value: Binding, + @_disfavoredOverload + public func fullScreenCover( + item: Binding, onDismiss: (() -> Void)? = nil, - @ViewBuilder content: @escaping (Binding) -> Content - ) -> some View - where Content: View { - self.fullScreenCover( - isPresented: value.isPresent(), - onDismiss: onDismiss - ) { - Binding(unwrapping: value).map(content) + @ViewBuilder content: @escaping (Binding) -> Content + ) -> some View { + fullScreenCover(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + /// Presents a full-screen cover using a binding as a data source for the sheet's content. + /// + /// A version of ``fullScreenCover(item:id:onDismiss:content:)-14to1`` that is passed an item + /// and not a binding to an item. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the sheet. When `item` is non-`nil`, + /// the system passes the item's content to the modifier's closure. You display this content + /// in a sheet that you create that the system displays to the user. If `item`'s identity + /// changes, the system dismisses the sheet and replaces it with a new one using the same + /// process. + /// - id: The key path to the provided item's identifier. + /// - onDismiss: The closure to execute when dismissing the sheet. + /// - content: A closure returning the content of the sheet. + public func fullScreenCover( + item: Binding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping (Item) -> Content + ) -> some View { + fullScreenCover(item: item, id: id, onDismiss: onDismiss) { + content($0.wrappedValue) } } } diff --git a/Sources/SwiftUINavigation/Internal/Deprecations.swift b/Sources/SwiftUINavigation/Internal/Deprecations.swift index cb417f7950..76751085ed 100644 --- a/Sources/SwiftUINavigation/Internal/Deprecations.swift +++ b/Sources/SwiftUINavigation/Internal/Deprecations.swift @@ -2,6 +2,122 @@ import SwiftUI @_spi(RuntimeWarn) import SwiftUINavigationCore + // NB: Deprecated after 1.3.0 + + @available(iOS 14, tvOS 14, watchOS 7, *) + @available(macOS, unavailable) + extension View { + @available( + *, deprecated, + message: + "Use the 'fullScreenCover(item:)' (or 'fullScreenCover(item:id:)') overload that passes a binding" + ) + public func fullScreenCover( + unwrapping value: Binding, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping (Binding) -> Content + ) -> some View + where Content: View { + self.fullScreenCover( + isPresented: value.isPresent(), + onDismiss: onDismiss + ) { + Binding(unwrapping: value).map(content) + } + } + } + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension View { + @available( + *, deprecated, + message: "Use the 'navigationDestination(item:)' overload that passes a binding" + ) + @ViewBuilder + public func navigationDestination( + unwrapping value: Binding, + @ViewBuilder destination: (Binding) -> Destination + ) -> some View { + if requiresBindWorkaround { + self.modifier( + _NavigationDestinationBindWorkaround( + isPresented: value.isPresent(), + destination: Binding(unwrapping: value).map(destination) + ) + ) + } else { + self.navigationDestination(isPresented: value.isPresent()) { + Binding(unwrapping: value).map(destination) + } + } + } + } + + // NB: This view modifier works around a bug in SwiftUI's built-in modifier: + // https://gist.github.com/mbrandonw/f8b94957031160336cac6898a919cbb7#file-fb11056434-md + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + private struct _NavigationDestinationBindWorkaround: ViewModifier { + @Binding var isPresented: Bool + let destination: Destination + + @State private var isPresentedState = false + + public func body(content: Content) -> some View { + content + .navigationDestination(isPresented: self.$isPresentedState) { self.destination } + .bind(self.$isPresented, to: self.$isPresentedState) + } + } + + private let requiresBindWorkaround = { + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + return true + } + guard #available(iOS 16.4, macOS 13.3, tvOS 16.4, watchOS 9.4, *) + else { return true } + return false + }() + + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension View { + @available( + *, deprecated, + message: "Use the 'popover(item:)' (or 'popover(item:id:)') overload that passes a binding" + ) + public func popover( + unwrapping value: Binding, + attachmentAnchor: PopoverAttachmentAnchor = .rect(.bounds), + arrowEdge: Edge = .top, + @ViewBuilder content: @escaping (Binding) -> Content + ) -> some View { + self.popover( + isPresented: value.isPresent(), + attachmentAnchor: attachmentAnchor, + arrowEdge: arrowEdge + ) { + Binding(unwrapping: value).map(content) + } + } + } + + extension View { + @available( + *, deprecated, + message: "Use the 'sheet(item:)' (or 'sheet(item:id:)') overload that passes a binding" + ) + public func sheet( + unwrapping value: Binding, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping (Binding) -> Content + ) -> some View + where Content: View { + self.sheet(isPresented: value.isPresent(), onDismiss: onDismiss) { + Binding(unwrapping: value).map(content) + } + } + } + // NB: Deprecated after 1.2.1 @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) @@ -116,24 +232,10 @@ } } + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) extension View { @available( - iOS, introduced: 15, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available( - macOS, introduced: 12, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available( - tvOS, introduced: 15, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available( - watchOS, introduced: 8, deprecated: 9999, + *, deprecated, message: "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @@ -153,22 +255,7 @@ } @available( - iOS, introduced: 15, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available( - macOS, introduced: 12, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available( - tvOS, introduced: 15, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available( - watchOS, introduced: 8, deprecated: 9999, + *, deprecated, message: "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @@ -181,22 +268,7 @@ } @available( - iOS, introduced: 15, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available( - macOS, introduced: 12, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available( - tvOS, introduced: 15, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available( - watchOS, introduced: 8, deprecated: 9999, + *, deprecated, message: "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @@ -209,22 +281,7 @@ } @available( - iOS, introduced: 15, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available( - macOS, introduced: 12, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available( - tvOS, introduced: 15, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available( - watchOS, introduced: 8, deprecated: 9999, + *, deprecated, message: "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @@ -246,22 +303,7 @@ } @available( - iOS, introduced: 15, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available( - macOS, introduced: 12, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available( - tvOS, introduced: 15, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available( - watchOS, introduced: 8, deprecated: 9999, + *, deprecated, message: "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @@ -277,22 +319,7 @@ } @available( - iOS, introduced: 15, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available( - macOS, introduced: 12, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available( - tvOS, introduced: 15, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available( - watchOS, introduced: 8, deprecated: 9999, + *, deprecated, message: "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @@ -306,20 +333,13 @@ action: handler ) } + } + @available(macOS, unavailable) + @available(iOS 14, tvOS 14, watchOS 8, *) + extension View { @available( - iOS, introduced: 14, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available(macOS, unavailable) - @available( - tvOS, introduced: 14, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available( - watchOS, introduced: 7, deprecated: 9999, + *, deprecated, message: "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @@ -333,24 +353,12 @@ fullScreenCover( unwrapping: `enum`.case(casePath), onDismiss: onDismiss, content: content) } + } + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension View { @available( - iOS, introduced: 16, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available( - macOS, introduced: 13, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available( - tvOS, introduced: 16, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available( - watchOS, introduced: 9, deprecated: 9999, + *, deprecated, message: "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @@ -361,19 +369,16 @@ ) -> some View { navigationDestination(unwrapping: `enum`.case(casePath), destination: destination) } + } + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension View { @available( - iOS, introduced: 13, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available( - macOS, introduced: 10.15, deprecated: 9999, + *, deprecated, message: "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) - @available(tvOS, unavailable) - @available(watchOS, unavailable) public func popover( unwrapping enum: Binding, case casePath: AnyCasePath, @@ -390,22 +395,7 @@ } @available( - iOS, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available( - macOS, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available( - tvOS, deprecated: 9999, - message: - "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." - ) - @available( - watchOS, deprecated: 9999, + *, deprecated, message: "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) diff --git a/Sources/SwiftUINavigation/NavigationDestination.swift b/Sources/SwiftUINavigation/NavigationDestination.swift index 56e534060b..fdf72340c2 100644 --- a/Sources/SwiftUINavigation/NavigationDestination.swift +++ b/Sources/SwiftUINavigation/NavigationDestination.swift @@ -6,9 +6,8 @@ /// Pushes a view onto a `NavigationStack` using a binding as a data source for the /// destination's content. /// - /// This is a version of SwiftUI's `navigationDestination(isPresented:)` modifier, but powered - /// by a binding to optional state instead of a binding to a boolean. When state becomes - /// non-`nil`, a _binding_ to the unwrapped value is passed to the destination closure. + /// This is a version of SwiftUI's `navigationDestination(item:)` modifier that passes a + /// _binding_ to the unwrapped item to the destination closure. /// /// ```swift /// struct TimelineView: View { @@ -18,7 +17,7 @@ /// Button("Compose") { /// self.draft = Post() /// } - /// .navigationDestination(unwrapping: self.$draft) { $draft in + /// .navigationDestination(unwrapping: $draft) { $draft in /// ComposeView(post: $draft, onSubmit: { ... }) /// } /// } @@ -31,55 +30,21 @@ /// ``` /// /// - Parameters: - /// - value: A binding to an optional source of truth for the destination. When `value` is + /// - item: A binding to an optional source of truth for the destination. When `item` is /// non-`nil`, a non-optional binding to the value is passed to the `destination` closure. /// You use this binding to produce content that the system pushes to the user in a /// navigation stack. Changes made to the destination's binding will be reflected back in - /// the source of truth. Likewise, changes to `value` are instantly reflected in the - /// destination. If `value` becomes `nil`, the destination is popped. + /// the source of truth. Likewise, changes to `item` are instantly reflected in the + /// destination. If `item` becomes `nil`, the destination is popped. /// - destination: A closure returning the content of the destination. - @ViewBuilder - public func navigationDestination( - unwrapping value: Binding, - @ViewBuilder destination: (Binding) -> Destination + @_disfavoredOverload + public func navigationDestination( + item: Binding, + @ViewBuilder destination: @escaping (Binding) -> C ) -> some View { - if requiresBindWorkaround { - self.modifier( - _NavigationDestinationBindWorkaround( - isPresented: value.isPresent(), - destination: Binding(unwrapping: value).map(destination) - ) - ) - } else { - self.navigationDestination(isPresented: value.isPresent()) { - Binding(unwrapping: value).map(destination) - } + navigationDestination(item: item) { _ in + Binding(unwrapping: item).map(destination) } } } - - // NB: This view modifier works around a bug in SwiftUI's built-in modifier: - // https://gist.github.com/mbrandonw/f8b94957031160336cac6898a919cbb7#file-fb11056434-md - @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) - private struct _NavigationDestinationBindWorkaround: ViewModifier { - @Binding var isPresented: Bool - let destination: Destination - - @State private var isPresentedState = false - - public func body(content: Content) -> some View { - content - .navigationDestination(isPresented: self.$isPresentedState) { self.destination } - .bind(self.$isPresented, to: self.$isPresentedState) - } - } - - private let requiresBindWorkaround = { - if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { - return true - } - guard #available(iOS 16.4, macOS 13.3, tvOS 16.4, watchOS 9.4, *) - else { return true } - return false - }() #endif // canImport(SwiftUI) diff --git a/Sources/SwiftUINavigation/Popover.swift b/Sources/SwiftUINavigation/Popover.swift index f2ec1290db..574acd2f3a 100644 --- a/Sources/SwiftUINavigation/Popover.swift +++ b/Sources/SwiftUINavigation/Popover.swift @@ -1,51 +1,20 @@ #if canImport(SwiftUI) import SwiftUI - @available(tvOS, unavailable) - @available(watchOS, unavailable) + // NB: Moving `@available(tvOS, unavailable)` to the extension causes tvOS builds to fail extension View { - /// Presents a popover using a binding as a data source for the sheet's content based on the - /// identity of the underlying item. - /// - /// - Parameters: - /// - item: A binding to an optional source of truth for the popover. When `item` is - /// non-`nil`, the system passes the item's content to the modifier's closure. You display - /// this content in a popover that you create that the system displays to the user. If `item` - /// changes, the system dismisses the popover and replaces it with a new one using the same - /// process. - /// - id: The key path to the provided item's identifier. - /// - attachmentAnchor: The positioning anchor that defines the attachment point of the - /// popover. - /// - arrowEdge: The edge of the `attachmentAnchor` that defines the location of the popover's - /// arrow. - /// - content: A closure returning the content of the popover. - public func popover( - item: Binding, - id: KeyPath, - attachmentAnchor: PopoverAttachmentAnchor = .rect(.bounds), - arrowEdge: Edge = .top, - @ViewBuilder content: @escaping (Item) -> Content - ) -> some View { - self.popover( - item: item[id: id], - attachmentAnchor: attachmentAnchor, - arrowEdge: arrowEdge - ) { _ in - item.wrappedValue.map(content) - } - } - /// Presents a popover using a binding as a data source for the popover's content. /// /// SwiftUI comes with a `popover(item:)` view modifier that is powered by a binding to some - /// hashable state. When this state becomes non-`nil`, it passes an unwrapped value to the + /// identifiable state. When this state becomes non-`nil`, it passes an unwrapped value to the /// content closure. This value, however, is completely static, which prevents the popover from /// modifying it. /// /// This overload differs in that it passes a _binding_ to the unwrapped value, instead. This /// gives the popover the ability to write changes back to its source of truth. /// - /// Also unlike `popover(item:)`, the binding's value does _not_ need to be hashable. + /// Also unlike `popover(item:)`, the binding's value does _not_ need to be identifiable, and + /// can instead specify a key path to the provided data's identifier. /// /// ```swift /// struct TimelineView: View { @@ -55,7 +24,7 @@ /// Button("Compose") { /// self.draft = Post() /// } - /// .popover(unwrapping: self.$draft) { $draft in + /// .popover(unwrapping: $draft) { $draft in /// ComposeView(post: $draft, onSubmit: { ... }) /// } /// } @@ -68,29 +37,101 @@ /// ``` /// /// - Parameters: - /// - value: A binding to an optional source of truth for the popover. When `value` is + /// - item: A binding to an optional source of truth for the popover. When `item` is /// non-`nil`, a non-optional binding to the value is passed to the `content` closure. You /// use this binding to produce content that the system presents to the user in a popover. /// Changes made to the popover's binding will be reflected back in the source of truth. - /// Likewise, changes to `value` are instantly reflected in the popover. If `value` becomes + /// Likewise, changes to `item` are instantly reflected in the popover. If `item` becomes /// `nil`, the popover is dismissed. + /// - id: The key path to the provided item's identifier. /// - attachmentAnchor: The positioning anchor that defines the attachment point of the /// popover. /// - arrowEdge: The edge of the `attachmentAnchor` that defines the location of the popover's /// arrow. /// - content: A closure returning the content of the popover. - public func popover( - unwrapping value: Binding, + @_disfavoredOverload + @available(tvOS, unavailable) + @available(watchOS, unavailable) + public func popover( + item: Binding, + id: KeyPath, attachmentAnchor: PopoverAttachmentAnchor = .rect(.bounds), arrowEdge: Edge = .top, - @ViewBuilder content: @escaping (Binding) -> Content + @ViewBuilder content: @escaping (Binding) -> Content ) -> some View { - self.popover( - isPresented: value.isPresent(), + popover( + item: item[id: id], attachmentAnchor: attachmentAnchor, arrowEdge: arrowEdge - ) { - Binding(unwrapping: value).map(content) + ) { _ in + Binding(unwrapping: item).map(content) + } + } + + /// Presents a full-screen cover using a binding as a data source for the sheet's content. + /// + /// A version of ``fullScreenCover(item:id:onDismiss:content:)-14to1`` that takes an + /// identifiable item. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the popover. When `item` is + /// non-`nil`, a non-optional binding to the value is passed to the `content` closure. You + /// use this binding to produce content that the system presents to the user in a popover. + /// Changes made to the popover's binding will be reflected back in the source of truth. + /// Likewise, changes to `item` are instantly reflected in the popover. If `item` becomes + /// `nil`, the popover is dismissed. + /// - attachmentAnchor: The positioning anchor that defines the attachment point of the + /// popover. + /// - arrowEdge: The edge of the `attachmentAnchor` that defines the location of the popover's + /// arrow. + /// - content: A closure returning the content of the popover. + @_disfavoredOverload + @available(tvOS, unavailable) + @available(watchOS, unavailable) + public func popover( + item: Binding, + attachmentAnchor: PopoverAttachmentAnchor = .rect(.bounds), + arrowEdge: Edge = .top, + @ViewBuilder content: @escaping (Binding) -> Content + ) -> some View { + popover( + item: item, + id: \.id, + attachmentAnchor: attachmentAnchor, + arrowEdge: arrowEdge, + content: content + ) + } + + /// Presents a popover using a binding as a data source for the sheet's content based on the + /// identity of the underlying item. + /// + /// A version of ``popover(item:id:attachmentAnchor:arrowEdge:content:)-3un96`` that is passed + /// an item and not a binding to an item. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the popover. When `item` is + /// non-`nil`, the system passes the item's content to the modifier's closure. You display + /// this content in a popover that you create that the system displays to the user. If `item` + /// changes, the system dismisses the popover and replaces it with a new one using the same + /// process. + /// - id: The key path to the provided item's identifier. + /// - attachmentAnchor: The positioning anchor that defines the attachment point of the + /// popover. + /// - arrowEdge: The edge of the `attachmentAnchor` that defines the location of the popover's + /// arrow. + /// - content: A closure returning the content of the popover. + @available(tvOS, unavailable) + @available(watchOS, unavailable) + public func popover( + item: Binding, + id: KeyPath, + attachmentAnchor: PopoverAttachmentAnchor = .rect(.bounds), + arrowEdge: Edge = .top, + @ViewBuilder content: @escaping (Item) -> Content + ) -> some View { + popover(item: item, id: id, attachmentAnchor: attachmentAnchor, arrowEdge: arrowEdge) { + content($0.wrappedValue) } } } diff --git a/Sources/SwiftUINavigation/Sheet.swift b/Sources/SwiftUINavigation/Sheet.swift index f8c711b4cf..22d9f86104 100644 --- a/Sources/SwiftUINavigation/Sheet.swift +++ b/Sources/SwiftUINavigation/Sheet.swift @@ -8,39 +8,18 @@ #endif extension View { - /// Presents a sheet using a binding as a data source for the sheet's content based on the - /// identity of the underlying item. - /// - /// - Parameters: - /// - item: A binding to an optional source of truth for the sheet. When `item` is non-`nil`, - /// the system passes the item's content to the modifier's closure. You display this content - /// in a sheet that you create that the system displays to the user. If `item` changes, the - /// system dismisses the sheet and replaces it with a new one using the same process. - /// - id: The key path to the provided item's identifier. - /// - onDismiss: The closure to execute when dismissing the sheet. - /// - content: A closure returning the content of the sheet. - public func sheet( - item: Binding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - @ViewBuilder content: @escaping (Item) -> Content - ) -> some View { - self.sheet(item: item[id: id], onDismiss: onDismiss) { _ in - item.wrappedValue.map(content) - } - } - /// Presents a sheet using a binding as a data source for the sheet's content. /// /// SwiftUI comes with a `sheet(item:)` view modifier that is powered by a binding to some - /// hashable state. When this state becomes non-`nil`, it passes an unwrapped value to the + /// identifiable state. When this state becomes non-`nil`, it passes an unwrapped value to the /// content closure. This value, however, is completely static, which prevents the sheet from /// modifying it. /// - /// This overload differs in that it passes a _binding_ to the content closure, instead. This + /// This overload differs in that it passes a _binding_ to the unwrapped value, instead. This /// gives the sheet the ability to write changes back to its source of truth. /// - /// Also unlike `sheet(item:)`, the binding's value does _not_ need to be hashable. + /// Also unlike `sheet(item:)`, the binding's value does _not_ need to be identifiable, and can + /// instead specify a key path to the provided data's identifier. /// /// ```swift /// struct TimelineView: View { @@ -50,7 +29,7 @@ /// Button("Compose") { /// self.draft = Post() /// } - /// .sheet(unwrapping: self.$draft) { $draft in + /// .sheet(item: $draft, id: \.id) { $draft in /// ComposeView(post: $draft, onSubmit: { ... }) /// } /// } @@ -63,23 +42,69 @@ /// ``` /// /// - Parameters: - /// - value: A binding to an optional source of truth for the sheet. When `value` is - /// non-`nil`, a non-optional binding to the value is passed to the `content` closure. You - /// use this binding to produce content that the system presents to the user in a sheet. - /// Changes made to the sheet's binding will be reflected back in the source of truth. - /// Likewise, changes to `value` are instantly reflected in the sheet. If `value` becomes - /// `nil`, the sheet is dismissed. + /// - item: A binding to an optional source of truth for the sheet. When `item` is non-`nil`, + /// the system passes the item's content to the modifier's closure. You display this content + /// in a sheet that you create that the system displays to the user. If `item`'s identity + /// changes, the system dismisses the sheet and replaces it with a new one using the same + /// process. + /// - id: The key path to the provided item's identifier. + /// - onDismiss: The closure to execute when dismissing the sheet. + /// - content: A closure returning the content of the sheet. + @_disfavoredOverload + public func sheet( + item: Binding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping (Binding) -> Content + ) -> some View { + sheet(item: item[id: id], onDismiss: onDismiss) { _ in + Binding(unwrapping: item).map(content) + } + } + + /// Presents a sheet using a binding as a data source for the sheet's content. + /// + /// A version of ``sheet(item:id:onDismiss:content:)-1hi9l`` that takes an identifiable item. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the sheet. When `item` is non-`nil`, + /// the system passes the item's content to the modifier's closure. You display this content + /// in a sheet that you create that the system displays to the user. If `item`'s identity + /// changes, the system dismisses the sheet and replaces it with a new one using the same + /// process. /// - onDismiss: The closure to execute when dismissing the sheet. /// - content: A closure returning the content of the sheet. - @MainActor - public func sheet( - unwrapping value: Binding, + @_disfavoredOverload + public func sheet( + item: Binding, onDismiss: (() -> Void)? = nil, - @ViewBuilder content: @escaping (Binding) -> Content - ) -> some View - where Content: View { - self.sheet(isPresented: value.isPresent(), onDismiss: onDismiss) { - Binding(unwrapping: value).map(content) + @ViewBuilder content: @escaping (Binding) -> Content + ) -> some View { + sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + /// Presents a sheet using a binding as a data source for the sheet's content. + /// + /// A version of ``sheet(item:id:onDismiss:content:)-1hi9l`` that is passed an item and not a + /// binding to an item. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the sheet. When `item` is non-`nil`, + /// the system passes the item's content to the modifier's closure. You display this content + /// in a sheet that you create that the system displays to the user. If `item`'s identity + /// changes, the system dismisses the sheet and replaces it with a new one using the same + /// process. + /// - id: The key path to the provided item's identifier. + /// - onDismiss: The closure to execute when dismissing the sheet. + /// - content: A closure returning the content of the sheet. + public func sheet( + item: Binding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping (Item) -> Content + ) -> some View { + sheet(item: item, id: id, onDismiss: onDismiss) { + content($0.wrappedValue) } } } From 7ab04c6e2e6a73d34d5a762970ef88bf0aedb084 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 28 May 2024 10:16:33 -0700 Subject: [PATCH 12/97] Fix DocC and update `NavigationLink(unwrapping:)` (#158) * Fix DocC and update `NavigationLink(unwrapping:)` * wip * wip --- .../Documentation.docc/Articles/Navigation.md | 12 +++++----- .../Articles/WhatIsNavigation.md | 2 +- .../Extensions/Deprecations.md | 1 + .../SwiftUINavigation/FullScreenCover.swift | 8 +++---- .../Internal/Deprecations.swift | 22 ++++++++++++++++- .../NavigationDestination.swift | 2 +- .../SwiftUINavigation/NavigationLink.swift | 24 +++++++++---------- Sources/SwiftUINavigation/Popover.swift | 10 ++++---- Sources/SwiftUINavigation/Sheet.swift | 7 +++--- .../ConfirmationDialogState.swift | 4 ++-- 10 files changed, 57 insertions(+), 35 deletions(-) diff --git a/Sources/SwiftUINavigation/Documentation.docc/Articles/Navigation.md b/Sources/SwiftUINavigation/Documentation.docc/Articles/Navigation.md index 2320d5bdd8..93310cd8c8 100644 --- a/Sources/SwiftUINavigation/Documentation.docc/Articles/Navigation.md +++ b/Sources/SwiftUINavigation/Documentation.docc/Articles/Navigation.md @@ -25,7 +25,7 @@ non-`nil`. You can construct a `NavigationLink` that will activate when that sta non-`nil`, and will deactivate when the state becomes `nil`: ```swift -NavigationLink(unwrapping: $destination) { isActive in +NavigationLink(item: $destination) { isActive in destination = isActive ? 42 : nil } destination: { $number in CounterView(number: $number) @@ -81,12 +81,12 @@ one of these destinations: ``` With this set up you can make use of the -``SwiftUI/NavigationLink/init(unwrapping:onNavigate:destination:label:)`` initializer on +``SwiftUI/NavigationLink/init(item:onNavigate:destination:label:)`` initializer on `NavigationLink` in order to specify a binding to the optional destination, and further specify which case of the enum you want driving navigation: ```swift -NavigationLink(unwrapping: $destination.counter) { isActive in +NavigationLink(item: $destination.counter) { isActive in destination = isActive ? .counter(42) : nil } destination: { $number in CounterView(number: $number) @@ -95,7 +95,7 @@ NavigationLink(unwrapping: $destination.counter) { isActive in } ``` -And similarly for ``SwiftUI/View/navigationDestination(unwrapping:destination:)``: +And similarly for ``SwiftUI/View/navigationDestination(item:destination:)``: ```swift Button { @@ -103,7 +103,7 @@ Button { } label: { Text("Go to counter") } -.navigationDestination(unwrapping: $model.destination.counter) { $number in +.navigationDestination(item: $model.destination.counter) { $number in CounterView(number: $number) } ``` @@ -113,7 +113,7 @@ Button { ### Navigation views and modifiers - ``SwiftUI/View/navigationDestination(item:destination:)`` -- ``SwiftUI/NavigationLink/init(unwrapping:onNavigate:destination:label:)`` +- ``SwiftUI/NavigationLink/init(item:onNavigate:destination:label:)`` ### Supporting types diff --git a/Sources/SwiftUINavigation/Documentation.docc/Articles/WhatIsNavigation.md b/Sources/SwiftUINavigation/Documentation.docc/Articles/WhatIsNavigation.md index 188202a5b5..2ef7ab4853 100644 --- a/Sources/SwiftUINavigation/Documentation.docc/Articles/WhatIsNavigation.md +++ b/Sources/SwiftUINavigation/Documentation.docc/Articles/WhatIsNavigation.md @@ -266,7 +266,7 @@ later. If you must support iOS 15 and earlier, you can use the following initial `NavigationLink`, which also has a very similar API to the above: ```swift -NavigationLink(unwrapping: $model.destination.edit) { isActive in +NavigationLink(item: $model.destination.edit) { isActive in model.setEditIsActive(isActive) } destination: { $item in EditItemView(item: $item) diff --git a/Sources/SwiftUINavigation/Documentation.docc/Extensions/Deprecations.md b/Sources/SwiftUINavigation/Documentation.docc/Extensions/Deprecations.md index c9c45d6276..d220b913af 100644 --- a/Sources/SwiftUINavigation/Documentation.docc/Extensions/Deprecations.md +++ b/Sources/SwiftUINavigation/Documentation.docc/Extensions/Deprecations.md @@ -13,6 +13,7 @@ instead. - ``IfLet`` - ``IfCaseLet`` +- ``SwiftUI/NavigationLink/init(unwrapping:onNavigate:destination:label:)`` - ``SwiftUI/NavigationLink/init(unwrapping:case:onNavigate:destination:label:)`` - ``SwiftUI/NavigationLink/init(unwrapping:destination:onNavigate:label:)`` - ``SwiftUI/NavigationLink/init(unwrapping:case:destination:onNavigate:label:)`` diff --git a/Sources/SwiftUINavigation/FullScreenCover.swift b/Sources/SwiftUINavigation/FullScreenCover.swift index 92daf52e29..75cb77af23 100644 --- a/Sources/SwiftUINavigation/FullScreenCover.swift +++ b/Sources/SwiftUINavigation/FullScreenCover.swift @@ -60,8 +60,8 @@ /// Presents a full-screen cover using a binding as a data source for the sheet's content. /// - /// A version of ``fullScreenCover(item:id:onDismiss:content:)-14to1`` that takes an - /// identifiable item. + /// A version of ``SwiftUI/View/fullScreenCover(item:id:onDismiss:content:)-14to1`` that takes + /// an identifiable item. /// /// - Parameters: /// - item: A binding to an optional source of truth for the sheet. When `item` is non-`nil`, @@ -82,8 +82,8 @@ /// Presents a full-screen cover using a binding as a data source for the sheet's content. /// - /// A version of ``fullScreenCover(item:id:onDismiss:content:)-14to1`` that is passed an item - /// and not a binding to an item. + /// A version of ``SwiftUI/View/fullScreenCover(item:id:onDismiss:content:)-14to1`` that is + /// passed an item and not a binding to an item. /// /// - Parameters: /// - item: A binding to an optional source of truth for the sheet. When `item` is non-`nil`, diff --git a/Sources/SwiftUINavigation/Internal/Deprecations.swift b/Sources/SwiftUINavigation/Internal/Deprecations.swift index 76751085ed..4afc9722fe 100644 --- a/Sources/SwiftUINavigation/Internal/Deprecations.swift +++ b/Sources/SwiftUINavigation/Internal/Deprecations.swift @@ -118,6 +118,26 @@ } } + @available(iOS, introduced: 13, deprecated: 16) + @available(macOS, introduced: 10.15, deprecated: 13) + @available(tvOS, introduced: 13, deprecated: 16) + @available(watchOS, introduced: 6, deprecated: 9) + extension NavigationLink { + @available(*, deprecated, renamed: "init(item:onNavigate:destination:label:)") + public init( + unwrapping value: Binding, + onNavigate: @escaping (_ isActive: Bool) -> Void, + @ViewBuilder destination: @escaping (Binding) -> WrappedDestination, + @ViewBuilder label: () -> Label + ) where Destination == WrappedDestination? { + self.init( + destination: Binding(unwrapping: value).map(destination), + isActive: value.isPresent().didSet(onNavigate), + label: label + ) + } + } + // NB: Deprecated after 1.2.1 @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) @@ -681,7 +701,7 @@ @ViewBuilder label: () -> Label ) where Destination == WrappedDestination? { self.init( - unwrapping: `enum`.case(casePath), + item: `enum`.case(casePath), onNavigate: onNavigate, destination: destination, label: label diff --git a/Sources/SwiftUINavigation/NavigationDestination.swift b/Sources/SwiftUINavigation/NavigationDestination.swift index fdf72340c2..fbd3ba95b8 100644 --- a/Sources/SwiftUINavigation/NavigationDestination.swift +++ b/Sources/SwiftUINavigation/NavigationDestination.swift @@ -17,7 +17,7 @@ /// Button("Compose") { /// self.draft = Post() /// } - /// .navigationDestination(unwrapping: $draft) { $draft in + /// .navigationDestination(item: $draft) { $draft in /// ComposeView(post: $draft, onSubmit: { ... }) /// } /// } diff --git a/Sources/SwiftUINavigation/NavigationLink.swift b/Sources/SwiftUINavigation/NavigationLink.swift index b604a7919e..0dd89b713c 100644 --- a/Sources/SwiftUINavigation/NavigationLink.swift +++ b/Sources/SwiftUINavigation/NavigationLink.swift @@ -7,7 +7,7 @@ /// /// > Note: This interface is deprecated to match the availability of the corresponding SwiftUI /// > API. If you are targeting iOS 16 or later, use - /// > ``SwiftUI/View/navigationDestination(unwrapping:destination:)``, instead. + /// > ``SwiftUI/View/navigationDestination(item:destination:)``, instead. /// /// This allows you to drive navigation to a destination from an optional value. When the /// optional value becomes non-`nil` a binding to an honest value is derived and passed to the @@ -21,8 +21,8 @@ /// /// var body: some View { /// ForEach(self.posts) { post in - /// NavigationLink(unwrapping: self.$postToEdit) { isActive in - /// self.postToEdit = isActive ? post : nil + /// NavigationLink(item: $postToEdit) { isActive in + /// postToEdit = isActive ? post : nil /// } destination: { $draft in /// EditPostView(post: $draft) /// } label: { @@ -39,30 +39,30 @@ /// ``` /// /// - Parameters: - /// - value: A binding to an optional source of truth for the destination. When `value` is + /// - item: A binding to an optional source of truth for the destination. When `item` is /// non-`nil`, a non-optional binding to the value is passed to the `destination` closure. /// The destination can use this binding to produce its content and write changes back to - /// the source of truth. Upstream changes to `value` will also be instantly reflected in the - /// destination. If `value` becomes `nil`, the destination is dismissed. + /// the source of truth. Upstream changes to `item` will also be instantly reflected in the + /// destination. If `item` becomes `nil`, the destination is dismissed. /// - onNavigate: A closure that executes when the link becomes active or inactive with a /// boolean that describes if the link was activated or not. Use this closure to populate /// the source of truth when it is passed a value of `true`. When passed `false`, the system - /// will automatically write `nil` to `value`. + /// will automatically write `nil` to `item`. /// - destination: A view for the navigation link to present. /// - label: A view builder to produce a label describing the `destination` to present. @available(iOS, introduced: 13, deprecated: 16) @available(macOS, introduced: 10.15, deprecated: 13) @available(tvOS, introduced: 13, deprecated: 16) @available(watchOS, introduced: 6, deprecated: 9) - public init( - unwrapping value: Binding, + public init( + item: Binding, onNavigate: @escaping (_ isActive: Bool) -> Void, - @ViewBuilder destination: @escaping (Binding) -> WrappedDestination, + @ViewBuilder destination: @escaping (Binding) -> WrappedDestination, @ViewBuilder label: () -> Label ) where Destination == WrappedDestination? { self.init( - destination: Binding(unwrapping: value).map(destination), - isActive: value.isPresent().didSet(onNavigate), + destination: Binding(unwrapping: item).map(destination), + isActive: item.isPresent().didSet(onNavigate), label: label ) } diff --git a/Sources/SwiftUINavigation/Popover.swift b/Sources/SwiftUINavigation/Popover.swift index 574acd2f3a..ee17ae2327 100644 --- a/Sources/SwiftUINavigation/Popover.swift +++ b/Sources/SwiftUINavigation/Popover.swift @@ -24,7 +24,7 @@ /// Button("Compose") { /// self.draft = Post() /// } - /// .popover(unwrapping: $draft) { $draft in + /// .popover(item: $draft) { $draft in /// ComposeView(post: $draft, onSubmit: { ... }) /// } /// } @@ -70,8 +70,8 @@ /// Presents a full-screen cover using a binding as a data source for the sheet's content. /// - /// A version of ``fullScreenCover(item:id:onDismiss:content:)-14to1`` that takes an - /// identifiable item. + /// A version of ``SwiftUI/View/fullScreenCover(item:id:onDismiss:content:)-14to1`` that takes + /// an identifiable item. /// /// - Parameters: /// - item: A binding to an optional source of truth for the popover. When `item` is @@ -106,8 +106,8 @@ /// Presents a popover using a binding as a data source for the sheet's content based on the /// identity of the underlying item. /// - /// A version of ``popover(item:id:attachmentAnchor:arrowEdge:content:)-3un96`` that is passed - /// an item and not a binding to an item. + /// A version of ``SwiftUI/View/popover(item:id:attachmentAnchor:arrowEdge:content:)-3un96`` + /// that is passed an item and not a binding to an item. /// /// - Parameters: /// - item: A binding to an optional source of truth for the popover. When `item` is diff --git a/Sources/SwiftUINavigation/Sheet.swift b/Sources/SwiftUINavigation/Sheet.swift index 22d9f86104..21ea1621d3 100644 --- a/Sources/SwiftUINavigation/Sheet.swift +++ b/Sources/SwiftUINavigation/Sheet.swift @@ -64,7 +64,8 @@ /// Presents a sheet using a binding as a data source for the sheet's content. /// - /// A version of ``sheet(item:id:onDismiss:content:)-1hi9l`` that takes an identifiable item. + /// A version of ``SwiftUI/View/sheet(item:id:onDismiss:content:)-1hi9l`` that takes an + /// identifiable item. /// /// - Parameters: /// - item: A binding to an optional source of truth for the sheet. When `item` is non-`nil`, @@ -85,8 +86,8 @@ /// Presents a sheet using a binding as a data source for the sheet's content. /// - /// A version of ``sheet(item:id:onDismiss:content:)-1hi9l`` that is passed an item and not a - /// binding to an item. + /// A version of ``SwiftUI/View/sheet(item:id:onDismiss:content:)-1hi9l`` that is passed an item + /// and not a binding to an item. /// /// - Parameters: /// - item: A binding to an optional source of truth for the sheet. When `item` is non-`nil`, diff --git a/Sources/SwiftUINavigationCore/ConfirmationDialogState.swift b/Sources/SwiftUINavigationCore/ConfirmationDialogState.swift index ef9f86a10d..0fa20e7e7b 100644 --- a/Sources/SwiftUINavigationCore/ConfirmationDialogState.swift +++ b/Sources/SwiftUINavigationCore/ConfirmationDialogState.swift @@ -70,7 +70,7 @@ /// } /// ``` /// - /// And in your view you can use the `.confirmationDialog(unwrapping:action:)` view modifier to + /// And in your view you can use the `.confirmationDialog(_:action:)` view modifier to /// present the dialog: /// /// ```swift @@ -83,7 +83,7 @@ /// self.model.infoButtonTapped() /// } /// } - /// .confirmationDialog(unwrapping: self.$model.dialog) { action in + /// .confirmationDialog($model.dialog) { action in /// self.model.dialogButtonTapped(action) /// } /// } From dacc3997c0c6e582898f393aabbc8eaa6ee3de4b Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Tue, 28 May 2024 10:42:20 -0700 Subject: [PATCH 13/97] Fix a few sendability warnings in core. (#159) --- Package@swift-5.9.swift | 63 +++++++++++++++++++ Sources/SwiftUINavigation/Alert.swift | 4 +- .../ConfirmationDialog.swift | 4 +- .../SwiftUINavigationCore/AlertState.swift | 5 +- .../SwiftUINavigationCore/ButtonState.swift | 10 ++- 5 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 Package@swift-5.9.swift diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift new file mode 100644 index 0000000000..23ac97bb79 --- /dev/null +++ b/Package@swift-5.9.swift @@ -0,0 +1,63 @@ +// swift-tools-version:5.9 + +import PackageDescription + +let package = Package( + name: "swiftui-navigation", + platforms: [ + .iOS(.v13), + .macOS(.v10_15), + .tvOS(.v13), + .watchOS(.v6), + ], + products: [ + .library( + name: "SwiftUINavigation", + targets: ["SwiftUINavigation"] + ), + .library( + name: "SwiftUINavigationCore", + targets: ["SwiftUINavigationCore"] + ), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), + .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.2.2"), + .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.0.0"), + .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.0.0"), + ], + targets: [ + .target( + name: "SwiftUINavigation", + dependencies: [ + "SwiftUINavigationCore", + .product(name: "CasePaths", package: "swift-case-paths"), + ] + ), + .testTarget( + name: "SwiftUINavigationTests", + dependencies: [ + "SwiftUINavigation" + ] + ), + .target( + name: "SwiftUINavigationCore", + dependencies: [ + .product(name: "CustomDump", package: "swift-custom-dump"), + .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), + ] + ), + ] +) + +for target in package.targets { + target.swiftSettings = target.swiftSettings ?? [] + target.swiftSettings!.append(contentsOf: [ + .enableExperimentalFeature("StrictConcurrency") + ]) + // target.swiftSettings?.append( + // .unsafeFlags([ + // "-enable-library-evolution", + // ]) + // ) +} diff --git a/Sources/SwiftUINavigation/Alert.swift b/Sources/SwiftUINavigation/Alert.swift index cfe8fced57..6cd7524cb6 100644 --- a/Sources/SwiftUINavigation/Alert.swift +++ b/Sources/SwiftUINavigation/Alert.swift @@ -46,9 +46,9 @@ /// dismisses the alert, and the action is fed to the `action` closure. /// - handler: A closure that is called with an action from a particular alert button when /// tapped. - public func alert( + public func alert( _ state: Binding?>, - action handler: @escaping (Value?) async -> Void = { (_: Never?) async in } + action handler: @escaping @Sendable (Value?) async -> Void = { (_: Never?) async in } ) -> some View { alert(item: state) { Text($0.title) diff --git a/Sources/SwiftUINavigation/ConfirmationDialog.swift b/Sources/SwiftUINavigation/ConfirmationDialog.swift index eae1840819..89d5b1a14d 100644 --- a/Sources/SwiftUINavigation/ConfirmationDialog.swift +++ b/Sources/SwiftUINavigation/ConfirmationDialog.swift @@ -49,9 +49,9 @@ /// - handler: A closure that is called with an action from a particular dialog button when /// tapped. @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) - public func confirmationDialog( + public func confirmationDialog( _ state: Binding?>, - action handler: @escaping (Value?) async -> Void = { (_: Never?) async in } + action handler: @escaping @Sendable (Value?) async -> Void = { (_: Never?) async in } ) -> some View { confirmationDialog( item: state, diff --git a/Sources/SwiftUINavigationCore/AlertState.swift b/Sources/SwiftUINavigationCore/AlertState.swift index aff3f6ed53..30506c023a 100644 --- a/Sources/SwiftUINavigationCore/AlertState.swift +++ b/Sources/SwiftUINavigationCore/AlertState.swift @@ -249,7 +249,10 @@ /// - state: Alert state used to populate the alert. /// - action: An action handler, called when a button with an action is tapped, by passing the /// action to the closure. - public init(_ state: AlertState, action: @escaping (Action?) async -> Void) { + public init( + _ state: AlertState, + action: @escaping @Sendable (Action?) async -> Void + ) { if state.buttons.count == 2 { self.init( title: Text(state.title), diff --git a/Sources/SwiftUINavigationCore/ButtonState.swift b/Sources/SwiftUINavigationCore/ButtonState.swift index 91f062a7ea..efe8acbcf2 100644 --- a/Sources/SwiftUINavigationCore/ButtonState.swift +++ b/Sources/SwiftUINavigationCore/ButtonState.swift @@ -260,7 +260,10 @@ /// - Parameters: /// - button: Button state. /// - action: An action closure that is invoked when the button is tapped. - public init(_ button: ButtonState, action: @escaping (Action?) async -> Void) { + public init( + _ button: ButtonState, + action: @escaping @Sendable (Action?) async -> Void + ) { let action = { _ = Task { await button.withAction(action) } } switch button.role { case .cancel: @@ -310,7 +313,10 @@ /// - button: Button state. /// - action: An action closure that is invoked when the button is tapped. @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) - public init(_ button: ButtonState, action: @escaping (Action?) async -> Void) { + public init( + _ button: ButtonState, + action: @escaping @Sendable (Action?) async -> Void + ) { self.init( role: button.role.map(ButtonRole.init), action: { Task { await button.withAction(action) } } From 393e8ff960b1b2fd4ff1436b0bb6a41fbe7585a1 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 4 Jun 2024 14:54:38 -0700 Subject: [PATCH 14/97] Introduce `Binding.init(_: Binding)` (#160) * Introduce `Binding.init(_: Binding)` We currently have an `isPresent()` method for transforming bindings, but it seems to be an outlier compared to most binding transformations that come with SwiftUI, which use initializers. While updating docs I noticed the core module wasn't really organized at all, so took a quick pass. * wip * wip --- .../CaseStudies/09-CustomComponents.swift | 2 +- .../Internal/Deprecations.swift | 60 +++++++++---------- .../SwiftUINavigation/NavigationLink.swift | 2 +- Sources/SwiftUINavigationCore/Alert.swift | 4 +- .../SwiftUINavigationCore/AlertState.swift | 12 ++++ Sources/SwiftUINavigationCore/Binding.swift | 11 ++-- .../ConfirmationDialog.swift | 4 +- .../Extensions/AlertState.md | 22 +++++++ .../Extensions/AlertStateDeprecations.md | 22 +++++++ .../Extensions/ButtonState.md | 36 +++++++++++ .../Extensions/ButtonStateDeprecations.md | 24 ++++++++ .../Extensions/ConfirmationDialogState.md | 26 ++++++++ .../ConfirmationDialogStateDeprecations.md | 21 +++++++ .../Extensions/Deprecations.md | 19 ++++++ .../Extensions/TextState.md | 14 +++++ .../SwiftUINavigationCore.md | 6 +- .../Internal/Deprecations.swift | 13 ++++ .../NavigationDestination.swift | 2 +- 18 files changed, 255 insertions(+), 45 deletions(-) create mode 100644 Sources/SwiftUINavigationCore/Documentation.docc/Extensions/AlertState.md create mode 100644 Sources/SwiftUINavigationCore/Documentation.docc/Extensions/AlertStateDeprecations.md create mode 100644 Sources/SwiftUINavigationCore/Documentation.docc/Extensions/ButtonState.md create mode 100644 Sources/SwiftUINavigationCore/Documentation.docc/Extensions/ButtonStateDeprecations.md create mode 100644 Sources/SwiftUINavigationCore/Documentation.docc/Extensions/ConfirmationDialogState.md create mode 100644 Sources/SwiftUINavigationCore/Documentation.docc/Extensions/ConfirmationDialogStateDeprecations.md create mode 100644 Sources/SwiftUINavigationCore/Documentation.docc/Extensions/Deprecations.md create mode 100644 Sources/SwiftUINavigationCore/Documentation.docc/Extensions/TextState.md diff --git a/Examples/CaseStudies/09-CustomComponents.swift b/Examples/CaseStudies/09-CustomComponents.swift index 54d7b36749..0533c22223 100644 --- a/Examples/CaseStudies/09-CustomComponents.swift +++ b/Examples/CaseStudies/09-CustomComponents.swift @@ -98,7 +98,7 @@ extension View { where Content: View { modifier( BottomMenuModifier( - isActive: item.isPresent(), + isActive: Binding(item), content: { Binding(unwrapping: item).map(content) } ) ) diff --git a/Sources/SwiftUINavigation/Internal/Deprecations.swift b/Sources/SwiftUINavigation/Internal/Deprecations.swift index 4afc9722fe..1f28c27b4a 100644 --- a/Sources/SwiftUINavigation/Internal/Deprecations.swift +++ b/Sources/SwiftUINavigation/Internal/Deprecations.swift @@ -19,7 +19,7 @@ ) -> some View where Content: View { self.fullScreenCover( - isPresented: value.isPresent(), + isPresented: Binding(value), onDismiss: onDismiss ) { Binding(unwrapping: value).map(content) @@ -41,12 +41,12 @@ if requiresBindWorkaround { self.modifier( _NavigationDestinationBindWorkaround( - isPresented: value.isPresent(), + isPresented: Binding(value), destination: Binding(unwrapping: value).map(destination) ) ) } else { - self.navigationDestination(isPresented: value.isPresent()) { + self.navigationDestination(isPresented: Binding(value)) { Binding(unwrapping: value).map(destination) } } @@ -92,7 +92,7 @@ @ViewBuilder content: @escaping (Binding) -> Content ) -> some View { self.popover( - isPresented: value.isPresent(), + isPresented: Binding(value), attachmentAnchor: attachmentAnchor, arrowEdge: arrowEdge ) { @@ -112,7 +112,7 @@ @ViewBuilder content: @escaping (Binding) -> Content ) -> some View where Content: View { - self.sheet(isPresented: value.isPresent(), onDismiss: onDismiss) { + self.sheet(isPresented: Binding(value), onDismiss: onDismiss) { Binding(unwrapping: value).map(content) } } @@ -132,7 +132,7 @@ ) where Destination == WrappedDestination? { self.init( destination: Binding(unwrapping: value).map(destination), - isActive: value.isPresent().didSet(onNavigate), + isActive: Binding(value).didSet(onNavigate), label: label ) } @@ -164,7 +164,7 @@ ) -> some View { self.confirmationDialog( value.wrappedValue.map(title) ?? Text(verbatim: ""), - isPresented: value.isPresent(), + isPresented: Binding(value), titleVisibility: titleVisibility, presenting: value.wrappedValue, actions: actions, @@ -184,7 +184,7 @@ ) -> some View { alert( (value.wrappedValue?.title).map(Text.init) ?? Text(verbatim: ""), - isPresented: value.isPresent(), + isPresented: Binding(value), presenting: value.wrappedValue, actions: { ForEach($0.buttons) { @@ -196,13 +196,13 @@ } @available(*, deprecated, renamed: "alert(_:action:)") - public func alert( + public func alert( unwrapping value: Binding?>, - action handler: @escaping (Value?) async -> Void = { (_: Never?) async in } + action handler: @escaping @Sendable (Value?) async -> Void = { (_: Never?) async in } ) -> some View { alert( (value.wrappedValue?.title).map(Text.init) ?? Text(verbatim: ""), - isPresented: value.isPresent(), + isPresented: Binding(value), presenting: value.wrappedValue, actions: { ForEach($0.buttons) { @@ -220,7 +220,7 @@ ) -> some View { confirmationDialog( value.wrappedValue.flatMap { Text($0.title) } ?? Text(verbatim: ""), - isPresented: value.isPresent(), + isPresented: Binding(value), titleVisibility: value.wrappedValue.map { .init($0.titleVisibility) } ?? .automatic, presenting: value.wrappedValue, actions: { @@ -233,13 +233,13 @@ } @available(*, deprecated, renamed: "confirmationDialog(_:action:)") - public func confirmationDialog( + public func confirmationDialog( unwrapping value: Binding?>, - action handler: @escaping (Value?) async -> Void = { (_: Never?) async in } + action handler: @escaping @Sendable (Value?) async -> Void = { (_: Never?) async in } ) -> some View { confirmationDialog( value.wrappedValue.flatMap { Text($0.title) } ?? Text(verbatim: ""), - isPresented: value.isPresent(), + isPresented: Binding(value), titleVisibility: value.wrappedValue.map { .init($0.titleVisibility) } ?? .automatic, presenting: value.wrappedValue, actions: { @@ -292,10 +292,10 @@ message: "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) - public func alert( + public func alert( unwrapping enum: Binding, case casePath: AnyCasePath>, - action handler: @escaping (Value?) async -> Void = { (_: Never?) async in } + action handler: @escaping @Sendable (Value?) async -> Void = { (_: Never?) async in } ) -> some View { alert(`enum`.case(casePath), action: handler) } @@ -343,10 +343,10 @@ message: "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) - public func confirmationDialog( + public func confirmationDialog( unwrapping enum: Binding, case casePath: AnyCasePath>, - action handler: @escaping (Value?) async -> Void = { (_: Never?) async in } + action handler: @escaping @Sendable (Value?) async -> Void = { (_: Never?) async in } ) -> some View { confirmationDialog( `enum`.case(casePath), @@ -521,7 +521,7 @@ ) public func isPresent(_ casePath: AnyCasePath) -> Binding where Value == Enum? { - self.case(casePath).isPresent() + .init(self.case(casePath)) } } @@ -767,7 +767,7 @@ message: "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) - public struct CaseLet: View + public struct CaseLet: Sendable, View where Content: View { @EnvironmentObject private var `enum`: BindingObject public let casePath: AnyCasePath @@ -1847,9 +1847,9 @@ message: "'View.alert' now passes an optional action to its handler to allow you to handle action-less dismissals." ) - public func alert( + public func alert( unwrapping value: Binding?>, - action handler: @escaping (Value) async -> Void = { (_: Void) async in } + action handler: @escaping @Sendable (Value) async -> Void = { (_: Void) async in } ) -> some View { alert(value) { (value: Value?) in if let value = value { @@ -1865,10 +1865,10 @@ message: "'View.alert' now passes an optional action to its handler to allow you to handle action-less dismissals." ) - public func alert( + public func alert( unwrapping enum: Binding, case casePath: CasePath>, - action handler: @escaping (Value) async -> Void = { (_: Void) async in } + action handler: @escaping @Sendable (Value) async -> Void = { (_: Void) async in } ) -> some View { alert(unwrapping: `enum`, case: casePath) { (value: Value?) async in if let value = value { @@ -1884,9 +1884,9 @@ message: "'View.alert' now passes an optional action to its handler to allow you to handle action-less dismissals." ) - public func confirmationDialog( + public func confirmationDialog( unwrapping value: Binding?>, - action handler: @escaping (Value) async -> Void = { (_: Void) async in } + action handler: @escaping @Sendable (Value) async -> Void = { (_: Void) async in } ) -> some View { confirmationDialog(unwrapping: value) { (value: Value?) in if let value = value { @@ -1902,10 +1902,10 @@ message: "'View.alert' now passes an optional action to its handler to allow you to handle action-less dismissals." ) - public func confirmationDialog( + public func confirmationDialog( unwrapping enum: Binding, case casePath: CasePath>, - action handler: @escaping (Value) async -> Void = { (_: Void) async in } + action handler: @escaping @Sendable (Value) async -> Void = { (_: Void) async in } ) -> some View { confirmationDialog(unwrapping: `enum`, case: casePath) { (value: Value?) async in if let value = value { @@ -1941,7 +1941,7 @@ ) where Destination == WrappedDestination? { self.init( destination: Binding(unwrapping: value).map(destination), - isActive: value.isPresent().didSet(onNavigate), + isActive: Binding(value).didSet(onNavigate), label: label ) } diff --git a/Sources/SwiftUINavigation/NavigationLink.swift b/Sources/SwiftUINavigation/NavigationLink.swift index 0dd89b713c..5e05277b89 100644 --- a/Sources/SwiftUINavigation/NavigationLink.swift +++ b/Sources/SwiftUINavigation/NavigationLink.swift @@ -62,7 +62,7 @@ ) where Destination == WrappedDestination? { self.init( destination: Binding(unwrapping: item).map(destination), - isActive: item.isPresent().didSet(onNavigate), + isActive: Binding(item).didSet(onNavigate), label: label ) } diff --git a/Sources/SwiftUINavigationCore/Alert.swift b/Sources/SwiftUINavigationCore/Alert.swift index 448cda9f86..ba3479a5b0 100644 --- a/Sources/SwiftUINavigationCore/Alert.swift +++ b/Sources/SwiftUINavigationCore/Alert.swift @@ -67,7 +67,7 @@ ) -> some View { alert( item.wrappedValue.map(title) ?? Text(verbatim: ""), - isPresented: item.isPresent(), + isPresented: Binding(item), presenting: item.wrappedValue, actions: actions, message: message @@ -132,7 +132,7 @@ ) -> some View { alert( item.wrappedValue.map(title) ?? Text(verbatim: ""), - isPresented: item.isPresent(), + isPresented: Binding(item), presenting: item.wrappedValue, actions: actions ) diff --git a/Sources/SwiftUINavigationCore/AlertState.swift b/Sources/SwiftUINavigationCore/AlertState.swift index 30506c023a..a8e4fdb5c8 100644 --- a/Sources/SwiftUINavigationCore/AlertState.swift +++ b/Sources/SwiftUINavigationCore/AlertState.swift @@ -219,6 +219,18 @@ // MARK: - SwiftUI bridging + @available( + iOS, introduced: 13, deprecated: 100000, message: "use 'View.alert(_:action:)' instead." + ) + @available( + macOS, introduced: 10.15, deprecated: 100000, message: "use 'View.alert(_:action:)' instead." + ) + @available( + tvOS, introduced: 13, deprecated: 100000, message: "use 'View.alert(_:action:)' instead." + ) + @available( + watchOS, introduced: 6, deprecated: 100000, message: "use 'View.alert(_:action:)' instead." + ) extension Alert { /// Creates an alert from alert state. /// diff --git a/Sources/SwiftUINavigationCore/Binding.swift b/Sources/SwiftUINavigationCore/Binding.swift index c1c4c89f07..e20b55095e 100644 --- a/Sources/SwiftUINavigationCore/Binding.swift +++ b/Sources/SwiftUINavigationCore/Binding.swift @@ -2,15 +2,13 @@ import SwiftUI extension Binding { - /// Creates a binding by projecting the current optional value to a boolean describing if it's - /// non-`nil`. + /// Creates a binding by projecting the base optional value to a Boolean value. /// /// Writing `false` to the binding will `nil` out the base value. Writing `true` does nothing. /// - /// - Returns: A binding to a boolean. Returns `true` if non-`nil`, otherwise `false`. - public func isPresent() -> Binding - where Value == Wrapped? { - self._isPresent + /// - Parameter base: A value to project to a Boolean value. + public init(_ base: Binding) where Value == Bool { + self = base._isPresent } } @@ -23,5 +21,4 @@ } } } - #endif diff --git a/Sources/SwiftUINavigationCore/ConfirmationDialog.swift b/Sources/SwiftUINavigationCore/ConfirmationDialog.swift index f84f139280..0336ea5d68 100644 --- a/Sources/SwiftUINavigationCore/ConfirmationDialog.swift +++ b/Sources/SwiftUINavigationCore/ConfirmationDialog.swift @@ -70,7 +70,7 @@ ) -> some View { confirmationDialog( item.wrappedValue.map(title) ?? Text(verbatim: ""), - isPresented: item.isPresent(), + isPresented: Binding(item), titleVisibility: titleVisibility, presenting: item.wrappedValue, actions: actions, @@ -140,7 +140,7 @@ ) -> some View { confirmationDialog( item.wrappedValue.map(title) ?? Text(verbatim: ""), - isPresented: item.isPresent(), + isPresented: Binding(item), titleVisibility: titleVisibility, presenting: item.wrappedValue, actions: actions diff --git a/Sources/SwiftUINavigationCore/Documentation.docc/Extensions/AlertState.md b/Sources/SwiftUINavigationCore/Documentation.docc/Extensions/AlertState.md new file mode 100644 index 0000000000..f8866bd317 --- /dev/null +++ b/Sources/SwiftUINavigationCore/Documentation.docc/Extensions/AlertState.md @@ -0,0 +1,22 @@ +# ``SwiftUINavigationCore/AlertState`` + +## Topics + +### Creating alerts + +- ``init(title:actions:message:)`` + +### Reading alert data + +- ``id`` +- ``title`` +- ``message`` +- ``buttons`` + +### Transforming alerts + +- ``map(_:)`` + +### Deprecations + +- diff --git a/Sources/SwiftUINavigationCore/Documentation.docc/Extensions/AlertStateDeprecations.md b/Sources/SwiftUINavigationCore/Documentation.docc/Extensions/AlertStateDeprecations.md new file mode 100644 index 0000000000..6a0db22b86 --- /dev/null +++ b/Sources/SwiftUINavigationCore/Documentation.docc/Extensions/AlertStateDeprecations.md @@ -0,0 +1,22 @@ +# Deprecations + +Review unsupported SwiftUI Navigation APIs and their replacements. + +## Overview + +Avoid using deprecated APIs in your app. Select a method to see the replacement that you should use +instead. + +## Topics + +### Creating alerts + +- ``AlertState/init(title:message:primaryButton:secondaryButton:)`` +- ``AlertState/init(title:message:dismissButton:)`` +- ``AlertState/init(title:message:buttons:)`` + +### Supporting types + +- ``AlertState/Button`` +- ``AlertState/ButtonAction`` +- ``AlertState/ButtonRole`` diff --git a/Sources/SwiftUINavigationCore/Documentation.docc/Extensions/ButtonState.md b/Sources/SwiftUINavigationCore/Documentation.docc/Extensions/ButtonState.md new file mode 100644 index 0000000000..bb12492039 --- /dev/null +++ b/Sources/SwiftUINavigationCore/Documentation.docc/Extensions/ButtonState.md @@ -0,0 +1,36 @@ +# ``SwiftUINavigationCore/ButtonState`` + +## Topics + +### Creating buttons + +- ``init(role:action:label:)-99wi3`` +- ``init(role:action:label:)-2ixoi`` +- ``ButtonStateRole`` +- ``ButtonStateAction`` + +### Composing buttons + +- ``ButtonStateBuilder`` + +### Reading button data + +- ``id`` +- ``role-swift.property`` +- ``action`` +- ``label`` + +### Performing actions + +- ``withAction(_:)-56ifj`` +- ``withAction(_:)-71nj4`` + +### Transforming buttons + +- ``SwiftUI/Button`` +- ``SwiftUI/ButtonRole`` +- ``map(_:)`` + +### Deprecations + +- diff --git a/Sources/SwiftUINavigationCore/Documentation.docc/Extensions/ButtonStateDeprecations.md b/Sources/SwiftUINavigationCore/Documentation.docc/Extensions/ButtonStateDeprecations.md new file mode 100644 index 0000000000..94ef02d78b --- /dev/null +++ b/Sources/SwiftUINavigationCore/Documentation.docc/Extensions/ButtonStateDeprecations.md @@ -0,0 +1,24 @@ +# Deprecations + +Review unsupported SwiftUI Navigation APIs and their replacements. + +## Overview + +Avoid using deprecated APIs in your app. Select a method to see the replacement that you should use +instead. + +## Topics + +### Creating buttons + +- ``ButtonState/cancel(_:action:)`` +- ``ButtonState/default(_:action:)`` +- ``ButtonState/destructive(_:action:)`` + +### Readin + +### Supporting types + +- ``ButtonState/ButtonAction`` +- ``ButtonState/Handler`` +- ``ButtonState/Role-swift.typealias`` diff --git a/Sources/SwiftUINavigationCore/Documentation.docc/Extensions/ConfirmationDialogState.md b/Sources/SwiftUINavigationCore/Documentation.docc/Extensions/ConfirmationDialogState.md new file mode 100644 index 0000000000..9badcf10a4 --- /dev/null +++ b/Sources/SwiftUINavigationCore/Documentation.docc/Extensions/ConfirmationDialogState.md @@ -0,0 +1,26 @@ +# ``SwiftUINavigationCore/ConfirmationDialogState`` + +## Topics + +### Creating dialogs + +- ``init(title:actions:message:)`` +- ``init(titleVisibility:title:actions:message:)`` +- ``ConfirmationDialogStateTitleVisibility`` + +### Reading dialog data + +- ``id`` +- ``title`` +- ``titleVisibility`` +- ``message`` +- ``buttons`` + +### Transforming dialogs + +- ``map(_:)`` +- ``SwiftUI/Visibility`` + +### Deprecations + +- diff --git a/Sources/SwiftUINavigationCore/Documentation.docc/Extensions/ConfirmationDialogStateDeprecations.md b/Sources/SwiftUINavigationCore/Documentation.docc/Extensions/ConfirmationDialogStateDeprecations.md new file mode 100644 index 0000000000..6dcb7d73ec --- /dev/null +++ b/Sources/SwiftUINavigationCore/Documentation.docc/Extensions/ConfirmationDialogStateDeprecations.md @@ -0,0 +1,21 @@ +# Deprecations + +Review unsupported SwiftUI Navigation APIs and their replacements. + +## Overview + +Avoid using deprecated APIs in your app. Select a method to see the replacement that you should use +instead. + +## Topics + +### Creating dialogs + +- ``ActionSheetState`` +- ``ConfirmationDialogState/init(title:message:buttons:)`` +- ``ConfirmationDialogState/init(title:titleVisibility:message:buttons:)`` + +### Supporting types + +- ``ConfirmationDialogState/Button`` +- ``ConfirmationDialogState/Visibility`` diff --git a/Sources/SwiftUINavigationCore/Documentation.docc/Extensions/Deprecations.md b/Sources/SwiftUINavigationCore/Documentation.docc/Extensions/Deprecations.md new file mode 100644 index 0000000000..970202ea47 --- /dev/null +++ b/Sources/SwiftUINavigationCore/Documentation.docc/Extensions/Deprecations.md @@ -0,0 +1,19 @@ +# Deprecations + +Review unsupported SwiftUI Navigation APIs and their replacements. + +## Overview + +Avoid using deprecated APIs in your app. Select a method to see the replacement that you should use +instead. + +## Topics + +### Bindings + +- ``SwiftUI/Binding/isPresent()`` + +### Alerts and dialogs + +- ``SwiftUI/ActionSheet/init(_:action:)`` +- ``SwiftUI/Alert`` diff --git a/Sources/SwiftUINavigationCore/Documentation.docc/Extensions/TextState.md b/Sources/SwiftUINavigationCore/Documentation.docc/Extensions/TextState.md new file mode 100644 index 0000000000..b2b63acd42 --- /dev/null +++ b/Sources/SwiftUINavigationCore/Documentation.docc/Extensions/TextState.md @@ -0,0 +1,14 @@ +# ``SwiftUINavigationCore/TextState`` + +## Topics + +### Creating text state + +- ``init(_:)`` +- ``init(_:tableName:bundle:comment:)`` +- ``init(verbatim:)`` + +### Text state transformations + +- ``SwiftUI/Text`` +- ``Swift/String`` diff --git a/Sources/SwiftUINavigationCore/Documentation.docc/SwiftUINavigationCore.md b/Sources/SwiftUINavigationCore/Documentation.docc/SwiftUINavigationCore.md index ae7be6d215..c51d6dd995 100644 --- a/Sources/SwiftUINavigationCore/Documentation.docc/SwiftUINavigationCore.md +++ b/Sources/SwiftUINavigationCore/Documentation.docc/SwiftUINavigationCore.md @@ -20,9 +20,13 @@ A few core types and modifiers included in SwiftUI Navigation. ### Bindings -- ``SwiftUI/Binding/isPresent()`` +- ``SwiftUI/Binding/init(_:)`` - ``SwiftUI/View/bind(_:to:)`` ### Navigation - ``SwiftUI/View/navigationDestination(item:destination:)`` + +### Deprecations + +- diff --git a/Sources/SwiftUINavigationCore/Internal/Deprecations.swift b/Sources/SwiftUINavigationCore/Internal/Deprecations.swift index b473de04b7..1b9c55a848 100644 --- a/Sources/SwiftUINavigationCore/Internal/Deprecations.swift +++ b/Sources/SwiftUINavigationCore/Internal/Deprecations.swift @@ -1,6 +1,19 @@ #if canImport(SwiftUI) import SwiftUI + // NB: Deprecated after 1.4.0 + + extension Binding { + @available( + *, deprecated, + message: "Use 'Binding.init(_:)' to project an optional binding to a Boolean, instead." + ) + public func isPresent() -> Binding + where Value == Wrapped? { + Binding(self) + } + } + // NB: Deprecated after 0.5.0 extension ButtonState { diff --git a/Sources/SwiftUINavigationCore/NavigationDestination.swift b/Sources/SwiftUINavigationCore/NavigationDestination.swift index 9b593da463..5ff9915aa0 100644 --- a/Sources/SwiftUINavigationCore/NavigationDestination.swift +++ b/Sources/SwiftUINavigationCore/NavigationDestination.swift @@ -15,7 +15,7 @@ item: Binding, @ViewBuilder destination: @escaping (D) -> C ) -> some View { - navigationDestination(isPresented: item.isPresent()) { + navigationDestination(isPresented: Binding(item)) { if let item = item.wrappedValue { destination(item) } From b7c9a79f6f6b1fefb87d3e5a83a9c2fe7cdc9720 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 13 Jun 2024 09:00:18 -0700 Subject: [PATCH 15/97] Pass `CI` environment variable to `xcodebuild` (#162) Apparently Xcode 15.3+ only makes environment variables prefixed with `TEST_RUNNER_` available to tests: https://forums.developer.apple.com/forums/thread/749185 --- Makefile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Makefile b/Makefile index f0a2a7715b..83e13b1026 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,8 @@ PLATFORM_MACOS = macOS PLATFORM_TVOS = tvOS Simulator,name=Apple TV PLATFORM_WATCHOS = watchOS Simulator,name=Apple Watch Series 7 (45mm) +TEST_RUNNER_CI = $(CI) + default: test test: From 7b6c34a0f87e8a991b36ff59c5b6390a2ce16355 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Thu, 13 Jun 2024 13:49:28 -0400 Subject: [PATCH 16/97] Fix some docs. (#163) --- .../Articles/SheetsPopoversCovers.md | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/Sources/SwiftUINavigation/Documentation.docc/Articles/SheetsPopoversCovers.md b/Sources/SwiftUINavigation/Documentation.docc/Articles/SheetsPopoversCovers.md index 5b95ada77d..e093f9fa05 100644 --- a/Sources/SwiftUINavigation/Documentation.docc/Articles/SheetsPopoversCovers.md +++ b/Sources/SwiftUINavigation/Documentation.docc/Articles/SheetsPopoversCovers.md @@ -42,6 +42,22 @@ Notice that the trailing closure is handed a binding to the unwrapped state. Thi handed to the child view, and any changes made by the parent will be reflected in the child, and vice-versa. +However, this does not compile just yet because `sheet(item:)` requires that the item being +presented conform to `Identifable`, and `Int` does not conform. This library comes with an overload +of `sheet`, called ``SwiftUI/View/sheet(item:id:onDismiss:content:)-1hi9l``, that allows you to +specify the ID of the item being presented: + +```swift +var body: some View { + List { + // ... + } + .sheet(item: $destination, id: \.self) { $number in + CounterView(number: $number) + } +} +``` + Sometimes it is not optimal to model presentation destinations as optionals. In particular, if a feature can navigate to multiple, mutually exclusive screens, then an enum is more appropriate. @@ -65,7 +81,7 @@ var body: some View { List { // ... } - .sheet(item: $destination.counter) { $number in + .sheet(item: $destination.counter, id: \.self) { $number in CounterView(number: $number) } } @@ -73,7 +89,7 @@ var body: some View { ### Popovers -Popovers work similarly to covers. If the popover's state is represented as an optional you can do +Popovers work similarly to sheets. If the popover's state is represented as an optional you can do the following: ```swift @@ -84,7 +100,7 @@ struct ContentView: View { List { // ... } - .popover(item: $destination) { $number in + .popover(item: $destination, id: \.self) { $number in CounterView(number: $number) } } @@ -107,7 +123,7 @@ struct ContentView: View { List { // ... } - .popover(item: $destination.counter) { $number in + .popover(item: $destination.counter, id: \.self) { $number in CounterView(number: $number) } } @@ -116,7 +132,7 @@ struct ContentView: View { ### Covers -Full screen covers work similarly to covers and sheets. If the cover's state is represented as an +Full screen covers work similarly to sheets and popovers. If the cover's state is represented as an optional you can do the following: ```swift @@ -127,7 +143,7 @@ struct ContentView: View { List { // ... } - .fullscreenCover(item: $destination) { $number in + .fullscreenCover(item: $destination, id: \.self) { $number in CounterView(number: $number) } } @@ -150,7 +166,7 @@ struct ContentView: View { List { // ... } - .fullscreenCover(item: $destination.counter) { $number in + .fullscreenCover(item: $destination.counter, id: \.self) { $number in CounterView(number: $number) } } From b9cfa9ab2bce9429e11f2502555a0fad5d09c8f5 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 14 Jun 2024 08:39:35 -0700 Subject: [PATCH 17/97] Update .spi.yml --- .spi.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.spi.yml b/.spi.yml index 980ef4497e..eeeb1f4d06 100644 --- a/.spi.yml +++ b/.spi.yml @@ -2,4 +2,3 @@ version: 1 builder: configs: - documentation_targets: [SwiftUINavigation, SwiftUINavigationCore] - swift_version: 5.9 From 0a0deb234b481760cb4bb6a421d6f28670b00146 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 14 Jun 2024 13:32:14 -0700 Subject: [PATCH 18/97] Swift 6 Language Mode (#165) * Address Xcode 16 warnings * Swift 6 Language Mode * Update Package@swift-6.0.swift --- Package@swift-6.0.swift | 52 +++++ Sources/SwiftUINavigation/Binding.swift | 6 +- .../Internal/Binding+Internal.swift | 2 +- .../Internal/Deprecations.swift | 201 +++++++++--------- .../Internal/LockIsolated.swift | 18 ++ .../SwiftUINavigation/NavigationLink.swift | 2 +- .../Internal/RuntimeWarnings.swift | 16 +- 7 files changed, 191 insertions(+), 106 deletions(-) create mode 100644 Package@swift-6.0.swift create mode 100644 Sources/SwiftUINavigation/Internal/LockIsolated.swift diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift new file mode 100644 index 0000000000..b3a8b94dc8 --- /dev/null +++ b/Package@swift-6.0.swift @@ -0,0 +1,52 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "swiftui-navigation", + platforms: [ + .iOS(.v13), + .macOS(.v10_15), + .tvOS(.v13), + .watchOS(.v6), + ], + products: [ + .library( + name: "SwiftUINavigation", + targets: ["SwiftUINavigation"] + ), + .library( + name: "SwiftUINavigationCore", + targets: ["SwiftUINavigationCore"] + ), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), + .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.2.2"), + .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.0.0"), + .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.0.0"), + ], + targets: [ + .target( + name: "SwiftUINavigation", + dependencies: [ + "SwiftUINavigationCore", + .product(name: "CasePaths", package: "swift-case-paths"), + ] + ), + .testTarget( + name: "SwiftUINavigationTests", + dependencies: [ + "SwiftUINavigation" + ] + ), + .target( + name: "SwiftUINavigationCore", + dependencies: [ + .product(name: "CustomDump", package: "swift-custom-dump"), + .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), + ] + ), + ], + swiftLanguageVersions: [.v6] +) diff --git a/Sources/SwiftUINavigation/Binding.swift b/Sources/SwiftUINavigation/Binding.swift index c6e0441f81..8ed49d61da 100644 --- a/Sources/SwiftUINavigation/Binding.swift +++ b/Sources/SwiftUINavigation/Binding.swift @@ -61,7 +61,9 @@ /// - Parameter isDuplicate: A closure to evaluate whether two elements are equivalent, for /// purposes of filtering writes. Return `true` from this closure to indicate that the second /// element is a duplicate of the first. - public func removeDuplicates(by isDuplicate: @escaping (Value, Value) -> Bool) -> Self { + public func removeDuplicates( + by isDuplicate: @Sendable @escaping (Value, Value) -> Bool + ) -> Self { .init( get: { self.wrappedValue }, set: { newValue, transaction in @@ -81,7 +83,7 @@ /// /// [FB9404926]: https://gist.github.com/mbrandonw/70df235e42d505b3b1b9b7d0d006b049 public func removeDuplicates() -> Self { - self.removeDuplicates(by: ==) + self.removeDuplicates(by: { $0 == $1 }) } } diff --git a/Sources/SwiftUINavigation/Internal/Binding+Internal.swift b/Sources/SwiftUINavigation/Internal/Binding+Internal.swift index 670a5b1336..b313740b50 100644 --- a/Sources/SwiftUINavigation/Internal/Binding+Internal.swift +++ b/Sources/SwiftUINavigation/Internal/Binding+Internal.swift @@ -2,7 +2,7 @@ import SwiftUI extension Binding { - func didSet(_ perform: @escaping (Value) -> Void) -> Self { + func didSet(_ perform: @escaping @Sendable (Value) -> Void) -> Self { .init( get: { self.wrappedValue }, set: { newValue, transaction in diff --git a/Sources/SwiftUINavigation/Internal/Deprecations.swift b/Sources/SwiftUINavigation/Internal/Deprecations.swift index 1f28c27b4a..c47c53be2c 100644 --- a/Sources/SwiftUINavigation/Internal/Deprecations.swift +++ b/Sources/SwiftUINavigation/Internal/Deprecations.swift @@ -126,7 +126,7 @@ @available(*, deprecated, renamed: "init(item:onNavigate:destination:label:)") public init( unwrapping value: Binding, - onNavigate: @escaping (_ isActive: Bool) -> Void, + onNavigate: @escaping @Sendable (_ isActive: Bool) -> Void, @ViewBuilder destination: @escaping (Binding) -> WrappedDestination, @ViewBuilder label: () -> Label ) where Destination == WrappedDestination? { @@ -452,19 +452,22 @@ message: "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) - public init?(unwrapping enum: Binding, case casePath: AnyCasePath) { - guard var `case` = casePath.extract(from: `enum`.wrappedValue) + public init?(unwrapping enum: Binding, case casePath: AnyCasePath) + where Value: Sendable { + guard let `case` = casePath.extract(from: `enum`.wrappedValue).map({ LockIsolated($0) }) else { return nil } self.init( get: { - `case` = casePath.extract(from: `enum`.wrappedValue) ?? `case` - return `case` + `case`.withLock { + $0 = casePath.extract(from: `enum`.wrappedValue) ?? $0 + return $0 + } }, - set: { + set: { newValue, transaction in guard casePath.extract(from: `enum`.wrappedValue) != nil else { return } - `case` = $0 - `enum`.transaction($1).wrappedValue = casePath.embed($0) + `case`.withLock { $0 = newValue } + `enum`.transaction(transaction).wrappedValue = casePath.embed(newValue) } ) } @@ -525,7 +528,7 @@ } } - public struct IfCaseLet: View + public struct IfCaseLet: View where IfContent: View, ElseContent: View { public let `enum`: Binding public let casePath: AnyCasePath @@ -696,7 +699,7 @@ public init( unwrapping enum: Binding, case casePath: AnyCasePath, - onNavigate: @escaping (Bool) -> Void, + onNavigate: @escaping @Sendable (Bool) -> Void, @ViewBuilder destination: @escaping (Binding) -> WrappedDestination, @ViewBuilder label: () -> Label ) where Destination == WrappedDestination? { @@ -767,7 +770,7 @@ message: "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) - public struct CaseLet: Sendable, View + public struct CaseLet: Sendable, View where Content: View { @EnvironmentObject private var `enum`: BindingObject public let casePath: AnyCasePath @@ -839,7 +842,7 @@ "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) extension Switch { - public init( + public init( _ enum: Binding, @ViewBuilder content: () -> TupleView< ( @@ -864,7 +867,7 @@ } } - public init( + public init( _ enum: Binding, file: StaticString = #fileID, line: UInt = #line, @@ -882,7 +885,7 @@ } } - public init( + public init( _ enum: Binding, @ViewBuilder content: () -> TupleView< ( @@ -913,7 +916,7 @@ } } - public init( + public init( _ enum: Binding, file: StaticString = #fileID, line: UInt = #line, @@ -942,9 +945,9 @@ } public init< - Case1, Content1, - Case2, Content2, - Case3, Content3, + Case1: Sendable, Content1, + Case2: Sendable, Content2, + Case3: Sendable, Content3, DefaultContent >( _ enum: Binding, @@ -983,7 +986,7 @@ } } - public init( + public init( _ enum: Binding, file: StaticString = #fileID, line: UInt = #line, @@ -1017,10 +1020,10 @@ } public init< - Case1, Content1, - Case2, Content2, - Case3, Content3, - Case4, Content4, + Case1: Sendable, Content1, + Case2: Sendable, Content2, + Case3: Sendable, Content3, + Case4: Sendable, Content4, DefaultContent >( _ enum: Binding, @@ -1066,10 +1069,10 @@ } public init< - Case1, Content1, - Case2, Content2, - Case3, Content3, - Case4, Content4 + Case1: Sendable, Content1, + Case2: Sendable, Content2, + Case3: Sendable, Content3, + Case4: Sendable, Content4 >( _ enum: Binding, file: StaticString = #fileID, @@ -1109,11 +1112,11 @@ } public init< - Case1, Content1, - Case2, Content2, - Case3, Content3, - Case4, Content4, - Case5, Content5, + Case1: Sendable, Content1, + Case2: Sendable, Content2, + Case3: Sendable, Content3, + Case4: Sendable, Content4, + Case5: Sendable, Content5, DefaultContent >( _ enum: Binding, @@ -1165,11 +1168,11 @@ } public init< - Case1, Content1, - Case2, Content2, - Case3, Content3, - Case4, Content4, - Case5, Content5 + Case1: Sendable, Content1, + Case2: Sendable, Content2, + Case3: Sendable, Content3, + Case4: Sendable, Content4, + Case5: Sendable, Content5 >( _ enum: Binding, file: StaticString = #fileID, @@ -1214,12 +1217,12 @@ } public init< - Case1, Content1, - Case2, Content2, - Case3, Content3, - Case4, Content4, - Case5, Content5, - Case6, Content6, + Case1: Sendable, Content1, + Case2: Sendable, Content2, + Case3: Sendable, Content3, + Case4: Sendable, Content4, + Case5: Sendable, Content5, + Case6: Sendable, Content6, DefaultContent >( _ enum: Binding, @@ -1277,12 +1280,12 @@ } public init< - Case1, Content1, - Case2, Content2, - Case3, Content3, - Case4, Content4, - Case5, Content5, - Case6, Content6 + Case1: Sendable, Content1, + Case2: Sendable, Content2, + Case3: Sendable, Content3, + Case4: Sendable, Content4, + Case5: Sendable, Content5, + Case6: Sendable, Content6 >( _ enum: Binding, file: StaticString = #fileID, @@ -1332,13 +1335,13 @@ } public init< - Case1, Content1, - Case2, Content2, - Case3, Content3, - Case4, Content4, - Case5, Content5, - Case6, Content6, - Case7, Content7, + Case1: Sendable, Content1, + Case2: Sendable, Content2, + Case3: Sendable, Content3, + Case4: Sendable, Content4, + Case5: Sendable, Content5, + Case6: Sendable, Content6, + Case7: Sendable, Content7, DefaultContent >( _ enum: Binding, @@ -1402,13 +1405,13 @@ } public init< - Case1, Content1, - Case2, Content2, - Case3, Content3, - Case4, Content4, - Case5, Content5, - Case6, Content6, - Case7, Content7 + Case1: Sendable, Content1, + Case2: Sendable, Content2, + Case3: Sendable, Content3, + Case4: Sendable, Content4, + Case5: Sendable, Content5, + Case6: Sendable, Content6, + Case7: Sendable, Content7 >( _ enum: Binding, file: StaticString = #fileID, @@ -1463,14 +1466,14 @@ } public init< - Case1, Content1, - Case2, Content2, - Case3, Content3, - Case4, Content4, - Case5, Content5, - Case6, Content6, - Case7, Content7, - Case8, Content8, + Case1: Sendable, Content1, + Case2: Sendable, Content2, + Case3: Sendable, Content3, + Case4: Sendable, Content4, + Case5: Sendable, Content5, + Case6: Sendable, Content6, + Case7: Sendable, Content7, + Case8: Sendable, Content8, DefaultContent >( _ enum: Binding, @@ -1540,14 +1543,14 @@ } public init< - Case1, Content1, - Case2, Content2, - Case3, Content3, - Case4, Content4, - Case5, Content5, - Case6, Content6, - Case7, Content7, - Case8, Content8 + Case1: Sendable, Content1, + Case2: Sendable, Content2, + Case3: Sendable, Content3, + Case4: Sendable, Content4, + Case5: Sendable, Content5, + Case6: Sendable, Content6, + Case7: Sendable, Content7, + Case8: Sendable, Content8 >( _ enum: Binding, file: StaticString = #fileID, @@ -1607,15 +1610,15 @@ } public init< - Case1, Content1, - Case2, Content2, - Case3, Content3, - Case4, Content4, - Case5, Content5, - Case6, Content6, - Case7, Content7, - Case8, Content8, - Case9, Content9, + Case1: Sendable, Content1, + Case2: Sendable, Content2, + Case3: Sendable, Content3, + Case4: Sendable, Content4, + Case5: Sendable, Content5, + Case6: Sendable, Content6, + Case7: Sendable, Content7, + Case8: Sendable, Content8, + Case9: Sendable, Content9, DefaultContent >( _ enum: Binding, @@ -1691,15 +1694,15 @@ } public init< - Case1, Content1, - Case2, Content2, - Case3, Content3, - Case4, Content4, - Case5, Content5, - Case6, Content6, - Case7, Content7, - Case8, Content8, - Case9, Content9 + Case1: Sendable, Content1, + Case2: Sendable, Content2, + Case3: Sendable, Content3, + Case4: Sendable, Content4, + Case5: Sendable, Content5, + Case6: Sendable, Content6, + Case7: Sendable, Content7, + Case8: Sendable, Content8, + Case9: Sendable, Content9 >( _ enum: Binding, file: StaticString = #fileID, @@ -1936,7 +1939,7 @@ public init( unwrapping value: Binding, @ViewBuilder destination: @escaping (Binding) -> WrappedDestination, - onNavigate: @escaping (_ isActive: Bool) -> Void, + onNavigate: @escaping @Sendable (_ isActive: Bool) -> Void, @ViewBuilder label: () -> Label ) where Destination == WrappedDestination? { self.init( @@ -1951,7 +1954,7 @@ unwrapping enum: Binding, case casePath: CasePath, @ViewBuilder destination: @escaping (Binding) -> WrappedDestination, - onNavigate: @escaping (Bool) -> Void, + onNavigate: @escaping @Sendable (Bool) -> Void, @ViewBuilder label: () -> Label ) where Destination == WrappedDestination? { self.init( diff --git a/Sources/SwiftUINavigation/Internal/LockIsolated.swift b/Sources/SwiftUINavigation/Internal/LockIsolated.swift new file mode 100644 index 0000000000..40a89ad82e --- /dev/null +++ b/Sources/SwiftUINavigation/Internal/LockIsolated.swift @@ -0,0 +1,18 @@ +import Foundation + +final class LockIsolated: @unchecked Sendable { + private var _value: Value + private let lock = NSRecursiveLock() + init(_ value: @autoclosure @Sendable () throws -> Value) rethrows { + self._value = try value() + } + func withLock( + _ operation: @Sendable (inout Value) throws -> T + ) rethrows -> T { + lock.lock() + defer { lock.unlock() } + var value = _value + defer { _value = value } + return try operation(&value) + } +} diff --git a/Sources/SwiftUINavigation/NavigationLink.swift b/Sources/SwiftUINavigation/NavigationLink.swift index 5e05277b89..53d53cefff 100644 --- a/Sources/SwiftUINavigation/NavigationLink.swift +++ b/Sources/SwiftUINavigation/NavigationLink.swift @@ -56,7 +56,7 @@ @available(watchOS, introduced: 6, deprecated: 9) public init( item: Binding, - onNavigate: @escaping (_ isActive: Bool) -> Void, + onNavigate: @escaping @Sendable (_ isActive: Bool) -> Void, @ViewBuilder destination: @escaping (Binding) -> WrappedDestination, @ViewBuilder label: () -> Label ) where Destination == WrappedDestination? { diff --git a/Sources/SwiftUINavigationCore/Internal/RuntimeWarnings.swift b/Sources/SwiftUINavigationCore/Internal/RuntimeWarnings.swift index 5d188c1ebb..7d968a2fe0 100644 --- a/Sources/SwiftUINavigationCore/Internal/RuntimeWarnings.swift +++ b/Sources/SwiftUINavigationCore/Internal/RuntimeWarnings.swift @@ -21,7 +21,7 @@ #if canImport(os) os_log( .fault, - dso: dso, + dso: dso.wrappedValue, log: OSLog(subsystem: "com.apple.runtime-issues", category: category), "%@", message @@ -46,7 +46,7 @@ // // Feedback filed: https://gist.github.com/stephencelis/a8d06383ed6ccde3e5ef5d1b3ad52bbc @usableFromInline - let dso = { () -> UnsafeMutableRawPointer in + let dso = UncheckedSendable({ let count = _dyld_image_count() for i in 0..: @unchecked Sendable { + @usableFromInline + var wrappedValue: Value + init(_ value: Value) { + self.wrappedValue = value + } + public var projectedValue: Self { self } + } #else import Foundation From a09c56e4442ee06b246b5adf6ce86b3167b5691b Mon Sep 17 00:00:00 2001 From: stephencelis Date: Fri, 14 Jun 2024 20:34:22 +0000 Subject: [PATCH 19/97] Run swift-format --- .../Internal/RuntimeWarnings.swift | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/Sources/SwiftUINavigationCore/Internal/RuntimeWarnings.swift b/Sources/SwiftUINavigationCore/Internal/RuntimeWarnings.swift index 7d968a2fe0..ee1435997d 100644 --- a/Sources/SwiftUINavigationCore/Internal/RuntimeWarnings.swift +++ b/Sources/SwiftUINavigationCore/Internal/RuntimeWarnings.swift @@ -46,20 +46,21 @@ // // Feedback filed: https://gist.github.com/stephencelis/a8d06383ed6ccde3e5ef5d1b3ad52bbc @usableFromInline - let dso = UncheckedSendable({ - let count = _dyld_image_count() - for i in 0..( + { + let count = _dyld_image_count() + for i in 0..: @unchecked Sendable { From 3a5fcc15cdfefc4d63b9c783b01baae796a8685b Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 14 Jun 2024 13:54:41 -0700 Subject: [PATCH 20/97] Fix --- Sources/SwiftUINavigationCore/Internal/RuntimeWarnings.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/SwiftUINavigationCore/Internal/RuntimeWarnings.swift b/Sources/SwiftUINavigationCore/Internal/RuntimeWarnings.swift index ee1435997d..25c01c169f 100644 --- a/Sources/SwiftUINavigationCore/Internal/RuntimeWarnings.swift +++ b/Sources/SwiftUINavigationCore/Internal/RuntimeWarnings.swift @@ -69,7 +69,6 @@ init(_ value: Value) { self.wrappedValue = value } - public var projectedValue: Self { self } } #else import Foundation From 47404dea1dffe1bf5c826724b627d1196ccde4ae Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Sat, 15 Jun 2024 10:42:23 -0700 Subject: [PATCH 21/97] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e8c1eeecd1..887da116a5 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ dependencies: [ ## Documentation The latest documentation for the SwiftUI Navigation APIs is available -[here](http://pointfreeco.github.io/swiftui-navigation/main/documentation/swiftuinavigation/). +[here](https://swiftpackageindex.com/pointfreeco/swiftui-navigation/main/documentation). ## License From 6f16652f103047cfc46089376cafb77dfa72a3d1 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Sat, 15 Jun 2024 10:43:47 -0700 Subject: [PATCH 22/97] Delete .github/workflows/documentation.yml --- .github/workflows/documentation.yml | 74 ----------------------------- 1 file changed, 74 deletions(-) delete mode 100644 .github/workflows/documentation.yml 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 From 94987c35f7e906e15f9d344b860c7b749d6764f6 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 17 Jun 2024 09:45:28 -0700 Subject: [PATCH 23/97] Swift Language Support: Drop <5.9 (#166) * Swift Language Support: Drop <5.9 * wip --- .github/workflows/ci.yml | 9 ++- Package.resolved | 4 +- Package.swift | 9 ++- Package@swift-5.9.swift | 63 ------------------- .../Internal/Deprecations.swift | 41 +++++------- .../SwiftUINavigationCore/AlertState.swift | 2 +- .../ConfirmationDialogState.swift | 2 +- .../Internal/RuntimeWarnings.swift | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- 9 files changed, 36 insertions(+), 100 deletions(-) delete mode 100644 Package@swift-5.9.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6600c27c1..12d9053adb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,12 +15,11 @@ concurrency: jobs: library: - runs-on: macos-13 + runs-on: macos-14 strategy: matrix: xcode: - - '15.0' - - '14.3.1' + - '15.4' steps: - uses: actions/checkout@v4 @@ -42,8 +41,8 @@ jobs: steps: - uses: compnerd/gha-setup-swift@main with: - branch: swift-5.9.1-release - tag: 5.9.1-RELEASE + branch: swift-5.10-release + tag: 5.10-RELEASE - uses: actions/checkout@v4 - name: Build run: swift build -c ${{ matrix.config }} diff --git a/Package.resolved b/Package.resolved index 1f81df2040..dc5a3d88df 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "8d712376c99fc0267aa0e41fea732babe365270a", - "version" : "1.3.3" + "revision" : "b871e5ed11a23e52c2896a92ce2c829982ff8619", + "version" : "1.4.2" } }, { diff --git a/Package.swift b/Package.swift index ee0d514c20..1af8379830 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.7.1 +// swift-tools-version: 5.9 import PackageDescription @@ -49,3 +49,10 @@ let package = Package( ), ] ) + +for target in package.targets { + target.swiftSettings = target.swiftSettings ?? [] + target.swiftSettings!.append(contentsOf: [ + .enableExperimentalFeature("StrictConcurrency") + ]) +} diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift deleted file mode 100644 index 23ac97bb79..0000000000 --- a/Package@swift-5.9.swift +++ /dev/null @@ -1,63 +0,0 @@ -// swift-tools-version:5.9 - -import PackageDescription - -let package = Package( - name: "swiftui-navigation", - platforms: [ - .iOS(.v13), - .macOS(.v10_15), - .tvOS(.v13), - .watchOS(.v6), - ], - products: [ - .library( - name: "SwiftUINavigation", - targets: ["SwiftUINavigation"] - ), - .library( - name: "SwiftUINavigationCore", - targets: ["SwiftUINavigationCore"] - ), - ], - dependencies: [ - .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), - .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.2.2"), - .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.0.0"), - .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.0.0"), - ], - targets: [ - .target( - name: "SwiftUINavigation", - dependencies: [ - "SwiftUINavigationCore", - .product(name: "CasePaths", package: "swift-case-paths"), - ] - ), - .testTarget( - name: "SwiftUINavigationTests", - dependencies: [ - "SwiftUINavigation" - ] - ), - .target( - name: "SwiftUINavigationCore", - dependencies: [ - .product(name: "CustomDump", package: "swift-custom-dump"), - .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), - ] - ), - ] -) - -for target in package.targets { - target.swiftSettings = target.swiftSettings ?? [] - target.swiftSettings!.append(contentsOf: [ - .enableExperimentalFeature("StrictConcurrency") - ]) - // target.swiftSettings?.append( - // .unsafeFlags([ - // "-enable-library-evolution", - // ]) - // ) -} diff --git a/Sources/SwiftUINavigation/Internal/Deprecations.swift b/Sources/SwiftUINavigation/Internal/Deprecations.swift index c47c53be2c..641171d0a0 100644 --- a/Sources/SwiftUINavigation/Internal/Deprecations.swift +++ b/Sources/SwiftUINavigation/Internal/Deprecations.swift @@ -12,12 +12,11 @@ message: "Use the 'fullScreenCover(item:)' (or 'fullScreenCover(item:id:)') overload that passes a binding" ) - public func fullScreenCover( + public func fullScreenCover( unwrapping value: Binding, onDismiss: (() -> Void)? = nil, @ViewBuilder content: @escaping (Binding) -> Content - ) -> some View - where Content: View { + ) -> some View { self.fullScreenCover( isPresented: Binding(value), onDismiss: onDismiss @@ -106,12 +105,11 @@ *, deprecated, message: "Use the 'sheet(item:)' (or 'sheet(item:id:)') overload that passes a binding" ) - public func sheet( + public func sheet( unwrapping value: Binding, onDismiss: (() -> Void)? = nil, @ViewBuilder content: @escaping (Binding) -> Content - ) -> some View - where Content: View { + ) -> some View { self.sheet(isPresented: Binding(value), onDismiss: onDismiss) { Binding(unwrapping: value).map(content) } @@ -363,13 +361,12 @@ message: "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) - public func fullScreenCover( + public func fullScreenCover( unwrapping enum: Binding, case casePath: AnyCasePath, onDismiss: (() -> Void)? = nil, @ViewBuilder content: @escaping (Binding) -> Content - ) -> some View - where Content: View { + ) -> some View { fullScreenCover( unwrapping: `enum`.case(casePath), onDismiss: onDismiss, content: content) } @@ -399,13 +396,13 @@ message: "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) - public func popover( + public func popover( unwrapping enum: Binding, case casePath: AnyCasePath, attachmentAnchor: PopoverAttachmentAnchor = .rect(.bounds), arrowEdge: Edge = .top, @ViewBuilder content: @escaping (Binding) -> Content - ) -> some View where Content: View { + ) -> some View { popover( unwrapping: `enum`.case(casePath), attachmentAnchor: attachmentAnchor, @@ -420,13 +417,12 @@ "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." ) @MainActor - public func sheet( + public func sheet( unwrapping enum: Binding, case casePath: AnyCasePath, onDismiss: (() -> Void)? = nil, @ViewBuilder content: @escaping (Binding) -> Content - ) -> some View - where Content: View { + ) -> some View { sheet(unwrapping: `enum`.case(casePath), onDismiss: onDismiss, content: content) } } @@ -528,8 +524,7 @@ } } - public struct IfCaseLet: View - where IfContent: View, ElseContent: View { + public struct IfCaseLet: View { public let `enum`: Binding public let casePath: AnyCasePath public let ifContent: (Binding) -> IfContent @@ -605,8 +600,7 @@ } } - public struct IfLet: View - where IfContent: View, ElseContent: View { + public struct IfLet: View { public let value: Binding public let ifContent: (Binding) -> IfContent public let elseContent: ElseContent @@ -770,8 +764,7 @@ message: "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." ) - public struct CaseLet: Sendable, View - where Content: View { + public struct CaseLet: Sendable, View { @EnvironmentObject private var `enum`: BindingObject public let casePath: AnyCasePath public let content: (Binding) -> Content @@ -1855,7 +1848,7 @@ action handler: @escaping @Sendable (Value) async -> Void = { (_: Void) async in } ) -> some View { alert(value) { (value: Value?) in - if let value = value { + if let value { await handler(value) } } @@ -1874,7 +1867,7 @@ action handler: @escaping @Sendable (Value) async -> Void = { (_: Void) async in } ) -> some View { alert(unwrapping: `enum`, case: casePath) { (value: Value?) async in - if let value = value { + if let value { await handler(value) } } @@ -1892,7 +1885,7 @@ action handler: @escaping @Sendable (Value) async -> Void = { (_: Void) async in } ) -> some View { confirmationDialog(unwrapping: value) { (value: Value?) in - if let value = value { + if let value { await handler(value) } } @@ -1911,7 +1904,7 @@ action handler: @escaping @Sendable (Value) async -> Void = { (_: Void) async in } ) -> some View { confirmationDialog(unwrapping: `enum`, case: casePath) { (value: Value?) async in - if let value = value { + if let value { await handler(value) } } diff --git a/Sources/SwiftUINavigationCore/AlertState.swift b/Sources/SwiftUINavigationCore/AlertState.swift index a8e4fdb5c8..2d27e8cd1e 100644 --- a/Sources/SwiftUINavigationCore/AlertState.swift +++ b/Sources/SwiftUINavigationCore/AlertState.swift @@ -188,7 +188,7 @@ if !self.buttons.isEmpty { children.append(("actions", self.buttons)) } - if let message = self.message { + if let message { children.append(("message", message)) } return Mirror( diff --git a/Sources/SwiftUINavigationCore/ConfirmationDialogState.swift b/Sources/SwiftUINavigationCore/ConfirmationDialogState.swift index 0fa20e7e7b..a2247803fa 100644 --- a/Sources/SwiftUINavigationCore/ConfirmationDialogState.swift +++ b/Sources/SwiftUINavigationCore/ConfirmationDialogState.swift @@ -235,7 +235,7 @@ if !self.buttons.isEmpty { children.append(("actions", self.buttons)) } - if let message = self.message { + if let message { children.append(("message", message)) } return Mirror( diff --git a/Sources/SwiftUINavigationCore/Internal/RuntimeWarnings.swift b/Sources/SwiftUINavigationCore/Internal/RuntimeWarnings.swift index 25c01c169f..6212de6f29 100644 --- a/Sources/SwiftUINavigationCore/Internal/RuntimeWarnings.swift +++ b/Sources/SwiftUINavigationCore/Internal/RuntimeWarnings.swift @@ -12,7 +12,7 @@ let message = message() let category = category ?? "Runtime Warning" if _XCTIsTesting { - if let file = file, let line = line { + if let file, let line { XCTFail(message, file: file, line: line) } else { XCTFail(message) diff --git a/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved index 114ce490b8..7416d722b7 100644 --- a/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "8cc3bc05d0cc956f7374c6c208a11f66a7cac3db", - "version" : "1.2.2" + "revision" : "b871e5ed11a23e52c2896a92ce2c829982ff8619", + "version" : "1.4.2" } }, { From 2a9882cc5492545ff1a86579314de105f57e3f29 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Sun, 30 Jun 2024 11:21:27 -0700 Subject: [PATCH 24/97] Update README.md Fix #178. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 887da116a5..bba0b2d03d 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ dependencies: [ ## Documentation The latest documentation for the SwiftUI Navigation APIs is available -[here](https://swiftpackageindex.com/pointfreeco/swiftui-navigation/main/documentation). +[here](https://swiftpackageindex.com/pointfreeco/swiftui-navigation/main/documentation/swiftuinavigation). ## License From 434f58630607708a6e2fdcb1840999889a1d1052 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20B=C4=85k?= Date: Thu, 4 Jul 2024 03:02:58 +0200 Subject: [PATCH 25/97] Add `.editorconfig` for consistent code formatting (#179) --- .editorconfig | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .editorconfig 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 From 1a781f0a37ee42ab372bb7d032c2e33636636c7e Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Fri, 19 Jul 2024 07:10:47 -0500 Subject: [PATCH 26/97] Update Makefile --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 83e13b1026..d4081517e9 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ test: -workspace SwiftUINavigation.xcworkspace \ -scheme SwiftUINavigation \ -destination platform="$(PLATFORM_TVOS)" - xcodebuild \ + xcodebuild test \ -workspace SwiftUINavigation.xcworkspace \ -scheme SwiftUINavigation \ -destination platform="$(PLATFORM_WATCHOS)" From bc0e37936a6a09a47b34cf655d0059f1fd4dd38d Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 22 Jul 2024 16:28:30 -0700 Subject: [PATCH 27/97] Swift Testing support for runtime warnings (#181) * Swift Testing support for runtime warnings * wip * bump * wip * wip * wip * wip * wip --------- Co-authored-by: Brandon Williams --- Examples/Examples.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 222 ++++++++++-------- Package.resolved | 24 +- Package.swift | 9 +- Package@swift-6.0.swift | 9 +- .../Internal/Deprecations.swift | 113 +++++++-- .../SwiftUINavigationCore/ButtonState.swift | 3 +- .../Internal/RuntimeWarnings.swift | 84 ------- .../xcshareddata/swiftpm/Package.resolved | 39 +-- .../SwiftUINavigationTests/BindingTests.swift | 4 + .../ButtonStateTests.swift | 3 +- .../SwiftUINavigationTests.swift | 2 + 12 files changed, 264 insertions(+), 250 deletions(-) delete mode 100644 Sources/SwiftUINavigationCore/Internal/RuntimeWarnings.swift diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 3eac9bf078..22c0a4c45e 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -561,7 +561,7 @@ repositoryURL = "http://github.com/pointfreeco/swift-dependencies"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.0.0; + minimumVersion = 1.3.4; }; }; DCE73E032947D063004EE92E /* XCRemoteSwiftPackageReference "swift-tagged" */ = { diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f33c147a90..dec6c15a32 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,106 +1,122 @@ { - "object": { - "pins": [ - { - "package": "combine-schedulers", - "repositoryURL": "https://github.com/pointfreeco/combine-schedulers", - "state": { - "branch": null, - "revision": "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", - "version": "1.0.0" - } - }, - { - "package": "swift-case-paths", - "repositoryURL": "https://github.com/pointfreeco/swift-case-paths", - "state": { - "branch": null, - "revision": "5da6989aae464f324eef5c5b52bdb7974725ab81", - "version": "1.0.0" - } - }, - { - "package": "swift-clocks", - "repositoryURL": "https://github.com/pointfreeco/swift-clocks", - "state": { - "branch": null, - "revision": "d1fd837326aa719bee979bdde1f53cd5797443eb", - "version": "1.0.0" - } - }, - { - "package": "swift-collections", - "repositoryURL": "https://github.com/apple/swift-collections", - "state": { - "branch": null, - "revision": "937e904258d22af6e447a0b72c0bc67583ef64a2", - "version": "1.0.4" - } - }, - { - "package": "swift-concurrency-extras", - "repositoryURL": "https://github.com/pointfreeco/swift-concurrency-extras", - "state": { - "branch": null, - "revision": "ea631ce892687f5432a833312292b80db238186a", - "version": "1.0.0" - } - }, - { - "package": "swift-custom-dump", - "repositoryURL": "https://github.com/pointfreeco/swift-custom-dump", - "state": { - "branch": null, - "revision": "edd66cace818e1b1c6f1b3349bb1d8e00d6f8b01", - "version": "1.0.0" - } - }, - { - "package": "swift-dependencies", - "repositoryURL": "http://github.com/pointfreeco/swift-dependencies", - "state": { - "branch": null, - "revision": "4e1eb6e28afe723286d8cc60611237ffbddba7c5", - "version": "1.0.0" - } - }, - { - "package": "SwiftDocCPlugin", - "repositoryURL": "https://github.com/apple/swift-docc-plugin", - "state": { - "branch": null, - "revision": "3303b164430d9a7055ba484c8ead67a52f7b74f6", - "version": "1.0.0" - } - }, - { - "package": "swift-identified-collections", - "repositoryURL": "https://github.com/pointfreeco/swift-identified-collections.git", - "state": { - "branch": null, - "revision": "d1e45f3e1eee2c9193f5369fa9d70a6ddad635e8", - "version": "1.0.0" - } - }, - { - "package": "swift-tagged", - "repositoryURL": "https://github.com/pointfreeco/swift-tagged.git", - "state": { - "branch": null, - "revision": "af06825aaa6adffd636c10a2570b2010c7c07e6a", - "version": "0.9.0" - } - }, - { - "package": "xctest-dynamic-overlay", - "repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay", - "state": { - "branch": null, - "revision": "23cbf2294e350076ea4dbd7d5d047c1e76b03631", - "version": "1.0.2" - } + "pins" : [ + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "487a4d151e795a5e076a7e7aedcd13c2ebff6c31", + "version" : "1.0.1" } - ] - }, - "version": 1 + }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "031704ba0634b45e02fe875b8ddddc7f30a07f49", + "version" : "1.5.3" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "eb64eacfed55635a771e3410f9c91de46cf5c6a0", + "version" : "1.0.3" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", + "version" : "1.1.2" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "d237304f42af07f22563aa4cc2d7e2cfb25da82e", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "http://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "52018827ce21e482a36e3795bea2666b3898164c", + "version" : "1.3.4" + } + }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "revision" : "26ac5758409154cc448d7ab82389c520fa8a8247", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections.git", + "state" : { + "revision" : "2f5ab6e091dd032b63dacbda052405756010dc3b", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-issue-reporting", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-issue-reporting", + "state" : { + "branch" : "1.2.0", + "revision" : "926f43898706eaa127db79ac42138e1ad7e85a3f" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "4c6cc0a3b9e8f14b3ae2307c5ccae4de6167ac2c", + "version" : "600.0.0-prerelease-2024-06-12" + } + }, + { + "identity" : "swift-tagged", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-tagged.git", + "state" : { + "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", + "version" : "0.10.0" + } + } + ], + "version" : 2 } diff --git a/Package.resolved b/Package.resolved index dc5a3d88df..9a54b0f913 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "b871e5ed11a23e52c2896a92ce2c829982ff8619", - "version" : "1.4.2" + "revision" : "031704ba0634b45e02fe875b8ddddc7f30a07f49", + "version" : "1.5.3" } }, { @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "f01efb26f3a192a0e88dcdb7c3c391ec2fc25d9c", - "version" : "1.3.0" + "revision" : "d237304f42af07f22563aa4cc2d7e2cfb25da82e", + "version" : "1.3.1" } }, { @@ -37,21 +37,21 @@ } }, { - "identity" : "swift-syntax", + "identity" : "swift-issue-reporting", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax", + "location" : "https://github.com/pointfreeco/swift-issue-reporting", "state" : { - "revision" : "303e5c5c36d6a558407d364878df131c3546fad8", - "version" : "510.0.2" + "branch" : "1.2.0", + "revision" : "926f43898706eaa127db79ac42138e1ad7e85a3f" } }, { - "identity" : "xctest-dynamic-overlay", + "identity" : "swift-syntax", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "6f30bdba373bbd7fbfe241dddd732651f2fbd1e2", - "version" : "1.1.2" + "revision" : "4c6cc0a3b9e8f14b3ae2307c5ccae4de6167ac2c", + "version" : "600.0.0-prerelease-2024-06-12" } } ], diff --git a/Package.swift b/Package.swift index 1af8379830..63f72c632f 100644 --- a/Package.swift +++ b/Package.swift @@ -22,9 +22,9 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), - .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.2.2"), - .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.0.0"), - .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.0.0"), + .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.5.3"), + .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.1"), + .package(url: "https://github.com/pointfreeco/swift-issue-reporting", branch: "1.2.0"), ], targets: [ .target( @@ -32,6 +32,7 @@ let package = Package( dependencies: [ "SwiftUINavigationCore", .product(name: "CasePaths", package: "swift-case-paths"), + .product(name: "IssueReporting", package: "swift-issue-reporting"), ] ), .testTarget( @@ -44,7 +45,7 @@ let package = Package( name: "SwiftUINavigationCore", dependencies: [ .product(name: "CustomDump", package: "swift-custom-dump"), - .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), + .product(name: "IssueReporting", package: "swift-issue-reporting"), ] ), ] diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index b3a8b94dc8..b565f5ed65 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -22,9 +22,9 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), - .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.2.2"), - .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.0.0"), - .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.0.0"), + .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.5.3"), + .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.1"), + .package(url: "https://github.com/pointfreeco/swift-issue-reporting", from: "1.2.0"), ], targets: [ .target( @@ -32,6 +32,7 @@ let package = Package( dependencies: [ "SwiftUINavigationCore", .product(name: "CasePaths", package: "swift-case-paths"), + .product(name: "IssueReporting", package: "swift-issue-reporting"), ] ), .testTarget( @@ -44,7 +45,7 @@ let package = Package( name: "SwiftUINavigationCore", dependencies: [ .product(name: "CustomDump", package: "swift-custom-dump"), - .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), + .product(name: "IssueReporting", package: "swift-issue-reporting"), ] ), ], diff --git a/Sources/SwiftUINavigation/Internal/Deprecations.swift b/Sources/SwiftUINavigation/Internal/Deprecations.swift index 641171d0a0..c9ddd73d30 100644 --- a/Sources/SwiftUINavigation/Internal/Deprecations.swift +++ b/Sources/SwiftUINavigation/Internal/Deprecations.swift @@ -1,6 +1,6 @@ #if canImport(SwiftUI) + import IssueReporting import SwiftUI - @_spi(RuntimeWarn) import SwiftUINavigationCore // NB: Deprecated after 1.3.0 @@ -862,8 +862,10 @@ public init( _ enum: Binding, - file: StaticString = #fileID, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, line: UInt = #line, + column: UInt = #column, @ViewBuilder content: () -> CaseLet ) where @@ -874,7 +876,11 @@ { self.init(`enum`) { content() - Default { _ExhaustivityCheckView(file: file, line: line) } + Default { + _ExhaustivityCheckView( + fileID: fileID, filePath: filePath, line: line, column: column + ) + } } } @@ -911,8 +917,10 @@ public init( _ enum: Binding, - file: StaticString = #fileID, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, line: UInt = #line, + column: UInt = #column, @ViewBuilder content: () -> TupleView< ( CaseLet, @@ -933,7 +941,11 @@ self.init(`enum`) { content.value.0 content.value.1 - Default { _ExhaustivityCheckView(file: file, line: line) } + Default { + _ExhaustivityCheckView( + fileID: fileID, filePath: filePath, line: line, column: column + ) + } } } @@ -981,8 +993,10 @@ public init( _ enum: Binding, - file: StaticString = #fileID, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, line: UInt = #line, + column: UInt = #column, @ViewBuilder content: () -> TupleView< ( CaseLet, @@ -1008,7 +1022,11 @@ content.value.0 content.value.1 content.value.2 - Default { _ExhaustivityCheckView(file: file, line: line) } + Default { + _ExhaustivityCheckView( + fileID: fileID, filePath: filePath, line: line, column: column + ) + } } } @@ -1068,8 +1086,10 @@ Case4: Sendable, Content4 >( _ enum: Binding, - file: StaticString = #fileID, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, line: UInt = #line, + column: UInt = #column, @ViewBuilder content: () -> TupleView< ( CaseLet, @@ -1100,7 +1120,11 @@ content.value.1 content.value.2 content.value.3 - Default { _ExhaustivityCheckView(file: file, line: line) } + Default { + _ExhaustivityCheckView( + fileID: fileID, filePath: filePath, line: line, column: column + ) + } } } @@ -1168,8 +1192,10 @@ Case5: Sendable, Content5 >( _ enum: Binding, - file: StaticString = #fileID, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, line: UInt = #line, + column: UInt = #column, @ViewBuilder content: () -> TupleView< ( CaseLet, @@ -1205,7 +1231,11 @@ content.value.2 content.value.3 content.value.4 - Default { _ExhaustivityCheckView(file: file, line: line) } + Default { + _ExhaustivityCheckView( + fileID: fileID, filePath: filePath, line: line, column: column + ) + } } } @@ -1281,8 +1311,10 @@ Case6: Sendable, Content6 >( _ enum: Binding, - file: StaticString = #fileID, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, line: UInt = #line, + column: UInt = #column, @ViewBuilder content: () -> TupleView< ( CaseLet, @@ -1323,7 +1355,11 @@ content.value.3 content.value.4 content.value.5 - Default { _ExhaustivityCheckView(file: file, line: line) } + Default { + _ExhaustivityCheckView( + fileID: fileID, filePath: filePath, line: line, column: column + ) + } } } @@ -1407,8 +1443,10 @@ Case7: Sendable, Content7 >( _ enum: Binding, - file: StaticString = #fileID, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, line: UInt = #line, + column: UInt = #column, @ViewBuilder content: () -> TupleView< ( CaseLet, @@ -1454,7 +1492,11 @@ content.value.4 content.value.5 content.value.6 - Default { _ExhaustivityCheckView(file: file, line: line) } + Default { + _ExhaustivityCheckView( + fileID: fileID, filePath: filePath, line: line, column: column + ) + } } } @@ -1546,8 +1588,10 @@ Case8: Sendable, Content8 >( _ enum: Binding, - file: StaticString = #fileID, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, line: UInt = #line, + column: UInt = #column, @ViewBuilder content: () -> TupleView< ( CaseLet, @@ -1598,7 +1642,11 @@ content.value.5 content.value.6 content.value.7 - Default { _ExhaustivityCheckView(file: file, line: line) } + Default { + _ExhaustivityCheckView( + fileID: fileID, filePath: filePath, line: line, column: column + ) + } } } @@ -1698,8 +1746,10 @@ Case9: Sendable, Content9 >( _ enum: Binding, - file: StaticString = #fileID, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, line: UInt = #line, + column: UInt = #column, @ViewBuilder content: () -> TupleView< ( CaseLet, @@ -1755,25 +1805,32 @@ content.value.6 content.value.7 content.value.8 - Default { _ExhaustivityCheckView(file: file, line: line) } + Default { + _ExhaustivityCheckView( + fileID: fileID, filePath: filePath, line: line, column: column + ) + } } } } public struct _ExhaustivityCheckView: View { @EnvironmentObject private var `enum`: BindingObject - let file: StaticString + let fileID: StaticString + let filePath: StaticString let line: UInt + let column: UInt public var body: some View { #if DEBUG let message = """ - Warning: Switch.body@\(self.file):\(self.line) + Warning: Switch.body@\(fileID):\(line) "Switch" did not handle "\(describeCase(self.enum.wrappedValue.wrappedValue))" - Make sure that you exhaustively provide a "CaseLet" view for each case in "\(Enum.self)", \ - provide a "Default" view at the end of the "Switch", or use an "IfCaseLet" view instead. + Make sure that you exhaustively provide a "CaseLet" view for each case in \ + "\(Enum.self)", provide a "Default" view at the end of the "Switch", or use an \ + "IfCaseLet" view instead. """ VStack(spacing: 17) { self.exclamation() @@ -1785,7 +1842,15 @@ .foregroundColor(.white) .padding() .background(Color.red.edgesIgnoringSafeArea(.all)) - .onAppear { runtimeWarn(message, file: self.file, line: self.line) } + .onAppear { + reportIssue( + message, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } #else EmptyView() #endif diff --git a/Sources/SwiftUINavigationCore/ButtonState.swift b/Sources/SwiftUINavigationCore/ButtonState.swift index efe8acbcf2..35494883aa 100644 --- a/Sources/SwiftUINavigationCore/ButtonState.swift +++ b/Sources/SwiftUINavigationCore/ButtonState.swift @@ -1,5 +1,6 @@ #if canImport(SwiftUI) import CustomDump + import IssueReporting import SwiftUI public struct ButtonState: Identifiable { @@ -79,7 +80,7 @@ case let .animatedSend(action, _): var output = "" customDump(self.action, to: &output, indent: 4) - runtimeWarn( + reportIssue( """ An animated action was performed asynchronously: … diff --git a/Sources/SwiftUINavigationCore/Internal/RuntimeWarnings.swift b/Sources/SwiftUINavigationCore/Internal/RuntimeWarnings.swift deleted file mode 100644 index 6212de6f29..0000000000 --- a/Sources/SwiftUINavigationCore/Internal/RuntimeWarnings.swift +++ /dev/null @@ -1,84 +0,0 @@ -#if canImport(SwiftUI) - @_spi(RuntimeWarn) - @_transparent - @inline(__always) - public func runtimeWarn( - _ message: @autoclosure () -> String, - category: String? = "SwiftUINavigation", - file: StaticString? = nil, - line: UInt? = nil - ) { - #if DEBUG - let message = message() - let category = category ?? "Runtime Warning" - if _XCTIsTesting { - if let file, let line { - XCTFail(message, file: file, line: line) - } else { - XCTFail(message) - } - } else { - #if canImport(os) - os_log( - .fault, - dso: dso.wrappedValue, - log: OSLog(subsystem: "com.apple.runtime-issues", category: category), - "%@", - message - ) - #else - fputs("\(formatter.string(from: Date())) [\(category)] \(message)\n", stderr) - #endif - } - #endif - } - - #if DEBUG - import XCTestDynamicOverlay - - #if canImport(os) - import os - import Foundation - - // NB: Xcode runtime warnings offer a much better experience than traditional assertions and - // breakpoints, but Apple provides no means of creating custom runtime warnings ourselves. - // To work around this, we hook into SwiftUI's runtime issue delivery mechanism, instead. - // - // Feedback filed: https://gist.github.com/stephencelis/a8d06383ed6ccde3e5ef5d1b3ad52bbc - @usableFromInline - let dso = UncheckedSendable( - { - let count = _dyld_image_count() - for i in 0..: @unchecked Sendable { - @usableFromInline - var wrappedValue: Value - init(_ value: Value) { - self.wrappedValue = value - } - } - #else - import Foundation - - @usableFromInline - let formatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd HH:MM:SS.sssZ" - return formatter - }() - #endif - #endif -#endif // canImport(SwiftUI) diff --git a/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7416d722b7..d4778940bf 100644 --- a/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "b871e5ed11a23e52c2896a92ce2c829982ff8619", - "version" : "1.4.2" + "revision" : "12bc5b9191b62ee62cafecbfed953fbb1e1554cd", + "version" : "1.5.2" } }, { @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "d029d9d39c87bed85b1c50adee7c41795261a192", - "version" : "1.0.6" + "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", + "version" : "1.1.2" } }, { @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "aedcf6f4cd486ccef5b312ccac85d4b3f6e58605", - "version" : "1.1.2" + "revision" : "f01efb26f3a192a0e88dcdb7c3c391ec2fc25d9c", + "version" : "1.3.0" } }, { @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "http://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "c31b1445c4fae49e6fdb75496b895a3653f6aefc", - "version" : "1.1.5" + "revision" : "d80613633e76d1ef86f41926e72fbef6a2f77d9c", + "version" : "1.3.3" } }, { @@ -86,17 +86,26 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-identified-collections.git", "state" : { - "revision" : "d1e45f3e1eee2c9193f5369fa9d70a6ddad635e8", - "version" : "1.0.0" + "revision" : "2f5ab6e091dd032b63dacbda052405756010dc3b", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-issue-reporting", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-issue-reporting", + "state" : { + "branch" : "1.2.0", + "revision" : "926f43898706eaa127db79ac42138e1ad7e85a3f" } }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax.git", + "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "6ad4ea24b01559dde0773e3d091f1b9e36175036", - "version" : "509.0.2" + "revision" : "4c6cc0a3b9e8f14b3ae2307c5ccae4de6167ac2c", + "version" : "600.0.0-prerelease-2024-06-12" } }, { @@ -113,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "23cbf2294e350076ea4dbd7d5d047c1e76b03631", - "version" : "1.0.2" + "revision" : "926f43898706eaa127db79ac42138e1ad7e85a3f", + "version" : "1.2.0" } } ], diff --git a/Tests/SwiftUINavigationTests/BindingTests.swift b/Tests/SwiftUINavigationTests/BindingTests.swift index ba85a092e2..8d45a3888c 100644 --- a/Tests/SwiftUINavigationTests/BindingTests.swift +++ b/Tests/SwiftUINavigationTests/BindingTests.swift @@ -12,6 +12,7 @@ case outOfStock(isOnBackOrder: Bool) } + @MainActor func testCaseLookup() throws { @Binding var status: Status _status = Binding(initialValue: .inStock(quantity: 1)) @@ -22,6 +23,7 @@ XCTAssertEqual(status, .inStock(quantity: 2)) } + @MainActor func testCaseCannotReplaceOtherCase() throws { @Binding var status: Status _status = Binding(initialValue: .inStock(quantity: 1)) @@ -34,6 +36,7 @@ XCTAssertEqual(status, .outOfStock(isOnBackOrder: true)) } + @MainActor func testDestinationCannotReplaceOtherDestination() throws { #if os(iOS) || os(macOS) try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] != nil) @@ -52,6 +55,7 @@ } extension Binding { + @MainActor fileprivate init(initialValue: Value) { var value = initialValue self.init( diff --git a/Tests/SwiftUINavigationTests/ButtonStateTests.swift b/Tests/SwiftUINavigationTests/ButtonStateTests.swift index 857d268063..9cf8c2f247 100644 --- a/Tests/SwiftUINavigationTests/ButtonStateTests.swift +++ b/Tests/SwiftUINavigationTests/ButtonStateTests.swift @@ -5,11 +5,10 @@ import XCTest final class ButtonStateTests: XCTestCase { - @MainActor func testAsyncAnimationWarning() async { XCTExpectFailure { $0.compactDescription == """ - An animated action was performed asynchronously: … + failed - An animated action was performed asynchronously: … Action: ButtonStateAction.send( diff --git a/Tests/SwiftUINavigationTests/SwiftUINavigationTests.swift b/Tests/SwiftUINavigationTests/SwiftUINavigationTests.swift index abd3bda8ea..57718e2cad 100644 --- a/Tests/SwiftUINavigationTests/SwiftUINavigationTests.swift +++ b/Tests/SwiftUINavigationTests/SwiftUINavigationTests.swift @@ -5,6 +5,7 @@ @testable import SwiftUINavigation final class SwiftUINavigationTests: XCTestCase { + @MainActor func testBindingUnwrap() throws { var value: Int? let binding = Binding(get: { value }, set: { value = $0 }) @@ -29,6 +30,7 @@ XCTAssertEqual(unwrapped.wrappedValue, 1729) } + @MainActor func testBindingCase() throws { struct MyError: Error, Equatable {} var value: Result? = nil From f1d9d6a2e817cbed681c54b9975d78bf8d66ba38 Mon Sep 17 00:00:00 2001 From: stephencelis Date: Mon, 22 Jul 2024 23:30:03 +0000 Subject: [PATCH 28/97] Run swift-format --- .../xcshareddata/swiftpm/Package.resolved | 29 +++++++------------ 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved index d4778940bf..dec6c15a32 100644 --- a/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", - "version" : "1.0.0" + "revision" : "487a4d151e795a5e076a7e7aedcd13c2ebff6c31", + "version" : "1.0.1" } }, { @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "12bc5b9191b62ee62cafecbfed953fbb1e1554cd", - "version" : "1.5.2" + "revision" : "031704ba0634b45e02fe875b8ddddc7f30a07f49", + "version" : "1.5.3" } }, { @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "a8421d68068d8f45fbceb418fbf22c5dad4afd33", - "version" : "1.0.2" + "revision" : "eb64eacfed55635a771e3410f9c91de46cf5c6a0", + "version" : "1.0.3" } }, { @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "f01efb26f3a192a0e88dcdb7c3c391ec2fc25d9c", - "version" : "1.3.0" + "revision" : "d237304f42af07f22563aa4cc2d7e2cfb25da82e", + "version" : "1.3.1" } }, { @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "http://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "d80613633e76d1ef86f41926e72fbef6a2f77d9c", - "version" : "1.3.3" + "revision" : "52018827ce21e482a36e3795bea2666b3898164c", + "version" : "1.3.4" } }, { @@ -116,15 +116,6 @@ "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", "version" : "0.10.0" } - }, - { - "identity" : "xctest-dynamic-overlay", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", - "state" : { - "revision" : "926f43898706eaa127db79ac42138e1ad7e85a3f", - "version" : "1.2.0" - } } ], "version" : 2 From 97f854044356ac082e7e698f39264cc035544d77 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 22 Jul 2024 16:37:42 -0700 Subject: [PATCH 29/97] wip --- Package.resolved | 4 ++-- Package.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.resolved b/Package.resolved index 9a54b0f913..7c00374097 100644 --- a/Package.resolved +++ b/Package.resolved @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-issue-reporting", "state" : { - "branch" : "1.2.0", - "revision" : "926f43898706eaa127db79ac42138e1ad7e85a3f" + "revision" : "926f43898706eaa127db79ac42138e1ad7e85a3f", + "version" : "1.2.0" } }, { diff --git a/Package.swift b/Package.swift index 63f72c632f..e68111d395 100644 --- a/Package.swift +++ b/Package.swift @@ -24,7 +24,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.5.3"), .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.1"), - .package(url: "https://github.com/pointfreeco/swift-issue-reporting", branch: "1.2.0"), + .package(url: "https://github.com/pointfreeco/swift-issue-reporting", from: "1.2.0"), ], targets: [ .target( From e4f6000387262e51d599f310468a4bef4e637651 Mon Sep 17 00:00:00 2001 From: stephencelis Date: Mon, 22 Jul 2024 23:39:39 +0000 Subject: [PATCH 30/97] Run swift-format --- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved index dec6c15a32..a7fbf94317 100644 --- a/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -95,8 +95,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-issue-reporting", "state" : { - "branch" : "1.2.0", - "revision" : "926f43898706eaa127db79ac42138e1ad7e85a3f" + "revision" : "926f43898706eaa127db79ac42138e1ad7e85a3f", + "version" : "1.2.0" } }, { From 0adf7ccdd5c7906502d29076b6a6973439a18028 Mon Sep 17 00:00:00 2001 From: David Furman Date: Tue, 23 Jul 2024 12:14:21 -0700 Subject: [PATCH 31/97] Fix article links (#180) * Fix article links Fulfills https://github.com/pointfreeco/swiftui-navigation/issues/178 * Update references instead * revert brackets to parentheses --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index bba0b2d03d..871eed17cd 100644 --- a/README.md +++ b/README.md @@ -124,9 +124,9 @@ This library is released under the MIT license. See [LICENSE](LICENSE) for detai [NavigationLink.init]: https://developer.apple.com/documentation/swiftui/navigationlink/init(destination:label:)-27n7s [TabView.init]: https://developer.apple.com/documentation/swiftui/tabview/init(content:) [case-paths-gh]: https://github.com/pointfreeco/swift-case-paths -[what-is-article]: https://pointfreeco.github.io/swiftui-navigation/main/documentation/swiftuinavigation/whatisnavigation -[nav-links-dests-article]: https://pointfreeco.github.io/swiftui-navigation/main/documentation/swiftuinavigation/navigation -[sheets-popovers-covers-article]: https://pointfreeco.github.io/swiftui-navigation/main/documentation/swiftuinavigation/sheetspopoverscovers -[alerts-dialogs-article]: https://pointfreeco.github.io/swiftui-navigation/main/documentation/swiftuinavigation/alertsdialogs -[bindings]: https://pointfreeco.github.io/swiftui-navigation/main/documentation/swiftuinavigation/bindings -[docs]: https://pointfreeco.github.io/swiftui-navigation/main/documentation/swiftuinavigation/ +[what-is-article]: https://swiftpackageindex.com/pointfreeco/swiftui-navigation/main/documentation/swiftuinavigation/whatisnavigation +[nav-links-dests-article]: https://swiftpackageindex.com/pointfreeco/swiftui-navigation/main/documentation/swiftuinavigation/navigation +[sheets-popovers-covers-article]: https://swiftpackageindex.com/pointfreeco/swiftui-navigation/main/documentation/swiftuinavigation/sheetspopoverscovers +[alerts-dialogs-article]: https://swiftpackageindex.com/pointfreeco/swiftui-navigation/main/documentation/swiftuinavigation/alertsdialogs +[bindings]: https://swiftpackageindex.com/pointfreeco/swiftui-navigation/main/documentation/swiftuinavigation/bindings +[docs]: https://swiftpackageindex.com/pointfreeco/swiftui-navigation/main/documentation/swiftuinavigation From b05be6ee6b26eec3c030148c444ae43358131b4d Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 24 Jul 2024 12:02:20 -0700 Subject: [PATCH 32/97] Point Issue Reporting to xctest-dynamic-overlay repo (#185) * Point Issue Reporting to xctest-dynamic-overlay repo Swift Package Manager honors redirects, but it appears to associate the path suffix with the package name, and this conflicts with package resolution in certain (but not all) cases. I think we have no choice but to roll back everything to point to the original xctest-dynamic-overlay URL and extract Issue Reporting to a dedicated repo. * wip --- Examples/Examples.xcodeproj/project.pbxproj | 2 +- Package.resolved | 24 +++++------ Package.swift | 10 ++--- .../xcshareddata/swiftpm/Package.resolved | 40 +++++++++---------- 4 files changed, 38 insertions(+), 38 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 22c0a4c45e..acc6de1430 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -561,7 +561,7 @@ repositoryURL = "http://github.com/pointfreeco/swift-dependencies"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.3.4; + minimumVersion = 1.3.5; }; }; DCE73E032947D063004EE92E /* XCRemoteSwiftPackageReference "swift-tagged" */ = { diff --git a/Package.resolved b/Package.resolved index 7c00374097..9b70b8a699 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "031704ba0634b45e02fe875b8ddddc7f30a07f49", - "version" : "1.5.3" + "branch" : "xct-name", + "revision" : "593151ec13a564a79dc930cf57c82a67355b76c2" } }, { @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "d237304f42af07f22563aa4cc2d7e2cfb25da82e", - "version" : "1.3.1" + "branch" : "xct-name", + "revision" : "3af72bba805d9d91aecdc5c0df15d5f8b89de8de" } }, { @@ -37,21 +37,21 @@ } }, { - "identity" : "swift-issue-reporting", + "identity" : "swift-syntax", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-issue-reporting", + "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "926f43898706eaa127db79ac42138e1ad7e85a3f", - "version" : "1.2.0" + "revision" : "4c6cc0a3b9e8f14b3ae2307c5ccae4de6167ac2c", + "version" : "600.0.0-prerelease-2024-06-12" } }, { - "identity" : "swift-syntax", + "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-syntax", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "4c6cc0a3b9e8f14b3ae2307c5ccae4de6167ac2c", - "version" : "600.0.0-prerelease-2024-06-12" + "revision" : "357ca1e5dd31f613a1d43320870ebc219386a495", + "version" : "1.2.2" } } ], diff --git a/Package.swift b/Package.swift index e68111d395..63d94d4ae8 100644 --- a/Package.swift +++ b/Package.swift @@ -22,9 +22,9 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), - .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.5.3"), - .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.1"), - .package(url: "https://github.com/pointfreeco/swift-issue-reporting", from: "1.2.0"), + .package(url: "https://github.com/pointfreeco/swift-case-paths", branch: "xct-name"), + .package(url: "https://github.com/pointfreeco/swift-custom-dump", branch: "xct-name"), + .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.2.2"), ], targets: [ .target( @@ -32,7 +32,7 @@ let package = Package( dependencies: [ "SwiftUINavigationCore", .product(name: "CasePaths", package: "swift-case-paths"), - .product(name: "IssueReporting", package: "swift-issue-reporting"), + .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), ] ), .testTarget( @@ -45,7 +45,7 @@ let package = Package( name: "SwiftUINavigationCore", dependencies: [ .product(name: "CustomDump", package: "swift-custom-dump"), - .product(name: "IssueReporting", package: "swift-issue-reporting"), + .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), ] ), ] diff --git a/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved index a7fbf94317..115ca181e5 100644 --- a/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "487a4d151e795a5e076a7e7aedcd13c2ebff6c31", - "version" : "1.0.1" + "revision" : "9fa31f4403da54855f1e2aeaeff478f4f0e40b13", + "version" : "1.0.2" } }, { @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "031704ba0634b45e02fe875b8ddddc7f30a07f49", - "version" : "1.5.3" + "branch" : "xct-name", + "revision" : "593151ec13a564a79dc930cf57c82a67355b76c2" } }, { @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "eb64eacfed55635a771e3410f9c91de46cf5c6a0", - "version" : "1.0.3" + "revision" : "3581e280bf0d90c3fb9236fb23e75a5d8c46b533", + "version" : "1.0.4" } }, { @@ -39,7 +39,7 @@ { "identity" : "swift-concurrency-extras", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras.git", "state" : { "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", "version" : "1.1.0" @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "d237304f42af07f22563aa4cc2d7e2cfb25da82e", - "version" : "1.3.1" + "branch" : "xct-name", + "revision" : "3af72bba805d9d91aecdc5c0df15d5f8b89de8de" } }, { @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "http://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "52018827ce21e482a36e3795bea2666b3898164c", - "version" : "1.3.4" + "revision" : "cc26d06125dbc913c6d9e8a905a5db0b994509e0", + "version" : "1.3.5" } }, { @@ -90,15 +90,6 @@ "version" : "1.1.0" } }, - { - "identity" : "swift-issue-reporting", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-issue-reporting", - "state" : { - "revision" : "926f43898706eaa127db79ac42138e1ad7e85a3f", - "version" : "1.2.0" - } - }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", @@ -116,6 +107,15 @@ "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", "version" : "0.10.0" } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "357ca1e5dd31f613a1d43320870ebc219386a495", + "version" : "1.2.2" + } } ], "version" : 2 From 1b18b77a2e32250b45d3429f9928222981983826 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 24 Jul 2024 12:07:05 -0700 Subject: [PATCH 33/97] Bump versions --- Package.resolved | 8 ++++---- Package.swift | 4 ++-- Package@swift-6.0.swift | 8 ++++---- .../xcshareddata/swiftpm/Package.resolved | 8 ++++---- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Package.resolved b/Package.resolved index 9b70b8a699..031478ea7b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "branch" : "xct-name", - "revision" : "593151ec13a564a79dc930cf57c82a67355b76c2" + "revision" : "71344dd930fde41e8f3adafe260adcbb2fc2a3dc", + "version" : "1.5.4" } }, { @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "branch" : "xct-name", - "revision" : "3af72bba805d9d91aecdc5c0df15d5f8b89de8de" + "revision" : "aec6a73f5c1dc1f1be4f61888094b95cf995d973", + "version" : "1.3.2" } }, { diff --git a/Package.swift b/Package.swift index 63d94d4ae8..6d602663b5 100644 --- a/Package.swift +++ b/Package.swift @@ -22,8 +22,8 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), - .package(url: "https://github.com/pointfreeco/swift-case-paths", branch: "xct-name"), - .package(url: "https://github.com/pointfreeco/swift-custom-dump", branch: "xct-name"), + .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.5.4"), + .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.2"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.2.2"), ], targets: [ diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index b565f5ed65..c05de82e27 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -22,9 +22,9 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), - .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.5.3"), - .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.1"), - .package(url: "https://github.com/pointfreeco/swift-issue-reporting", from: "1.2.0"), + .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.5.4"), + .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.2"), + .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.2.2"), ], targets: [ .target( @@ -32,7 +32,7 @@ let package = Package( dependencies: [ "SwiftUINavigationCore", .product(name: "CasePaths", package: "swift-case-paths"), - .product(name: "IssueReporting", package: "swift-issue-reporting"), + .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), ] ), .testTarget( diff --git a/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved index 115ca181e5..ba1a2dd163 100644 --- a/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "branch" : "xct-name", - "revision" : "593151ec13a564a79dc930cf57c82a67355b76c2" + "revision" : "71344dd930fde41e8f3adafe260adcbb2fc2a3dc", + "version" : "1.5.4" } }, { @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "branch" : "xct-name", - "revision" : "3af72bba805d9d91aecdc5c0df15d5f8b89de8de" + "revision" : "aec6a73f5c1dc1f1be4f61888094b95cf995d973", + "version" : "1.3.2" } }, { From fc91d591ebba1f90d65028ccb65c861e5979e898 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 24 Jul 2024 15:02:22 -0700 Subject: [PATCH 34/97] Update Package@swift-6.0.swift (#186) --- Package@swift-6.0.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index c05de82e27..80252b013c 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -45,7 +45,7 @@ let package = Package( name: "SwiftUINavigationCore", dependencies: [ .product(name: "CustomDump", package: "swift-custom-dump"), - .product(name: "IssueReporting", package: "swift-issue-reporting"), + .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), ] ), ], From ef8a5223764d93f8d72481c290d1570edc2bca60 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 1 Aug 2024 14:02:06 -0700 Subject: [PATCH 35/97] UIKitNavigation (#167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * Run swift-format * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * fix * wip * wip * wip * wip * modernize wifi settings demo * wip * Run swift-format * wip * wip * added some tests * more tests * more assertions * Run swift-format * wip * uwip * Run swift-format * wip * Run swift-format * test clean up * Run swift-format * clean up tests * Run swift-format * added some tests that dont pass but should * Run swift-format * fix tests * clean up tests * Run swift-format * wip * fix some tests * wip * wip * wip * fix * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * fix * wip * wip * wip * wip * wip * wip * wip * wip * wip * fix * wip * wip * wip * wip * wip * wip * wip * wip * push trait * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * moved wifi demo into case studies * wip * wip * wip * wip * wip * case studies * wip * wip * wip * fixes * update test withUITransaction * wip * fixed some tests * fix more tests * wip * wip * wip * wip * wip * wip * remove UIKitCaseStudies target we bundle them directly into the CaseStudies target now * tvos * wip * runtime warning tests * wrote some tests, fixed some tests * fix for testDecodePath and some other tests * Update UIBindable.swift * tests for rebind and unbind * clean up and tests * wip * wip * wip * animation case study * wip * more tests and beginnings of focus case study * finish focus case study * Add accessibilityLabel property to ButtonState (#171) * Add accessibilityLabel property to ButtonState * Revert "Add accessibilityLabel property to ButtonState" This reverts commit de7a369f002793d945527447b56c4e75f7f88e39. * Add accessibilityLabel support to UIAlertController à la Stephen Celis * Make UIAlertAction convenience initializers public * Apply suggestions from code review --------- Co-authored-by: Stephen Celis * docs * wip * finish focus case study * Dismiss correct view controller when doing presentation. (#176) * Dismiss correct view controller when doing presentation. * wip * new test * Fix * wip * wip * fix project * wip * wip * wip * Fix UIKit navigation issues (#183) * Fix dismissal in `present` method * Remove unneeded params from closures * Fix push behavior when item is updated * Revert "Fix push behavior when item is updated" This reverts commit c3021c42b5e0ea0944773069fd47c352a572e219. * Add test --------- Co-authored-by: Stephen Celis * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * lots of docs * playing around with isolation * swift 6 * wip * wip * fix memory leak * let alert state compile on other platforms * wip * wip * temp * wip * Revert "temp" This reverts commit 036c93305096191f0f55ddc0d118e464fae62c1a. * wip * fixes * wip * wip * wip * fix * wip * wip * wip * fix tests * small fixes * wip; --------- Co-authored-by: stephencelis Co-authored-by: Brandon Williams Co-authored-by: mbrandonw Co-authored-by: Cosmic Flamingo <67525430+acosmicflamingo@users.noreply.github.com> Co-authored-by: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Co-authored-by: Oskar Ek --- .spi.yml | 5 +- Examples/CaseStudies/01-Alerts.swift | 56 - .../CaseStudies/02-ConfirmationDialogs.swift | 53 - Examples/CaseStudies/03-Sheets.swift | 119 - Examples/CaseStudies/04-Popovers.swift | 115 - .../CaseStudies/05-FullScreenCovers.swift | 115 - .../06-NavigationDestinations.swift | 132 -- Examples/CaseStudies/07-NavigationLinks.swift | 122 - Examples/CaseStudies/08-Routing.swift | 125 - .../CaseStudies/09-CustomComponents.swift | 110 - .../CaseStudies/10-SynchronizedBindings.swift | 64 - Examples/CaseStudies/11-IfLet.swift | 48 - Examples/CaseStudies/12-IfCaseLet.swift | 55 - Examples/CaseStudies/Internal/CaseStudy.swift | 222 ++ .../CaseStudies/Internal/DetentsHelper.swift | 12 + .../{ => Internal}/FactClient.swift | 4 +- .../CaseStudies/Internal/Text+Template.swift | 59 + Examples/CaseStudies/RootView.swift | 60 +- .../SwiftUI/AlertDialogState.swift | 85 + .../CaseStudies/SwiftUI/EnumControls.swift | 61 + .../CaseStudies/SwiftUI/EnumNavigation.swift | 135 ++ .../SwiftUI/OptionalNavigation.swift | 122 + .../SwiftUI/SwiftUICaseStudies.swift | 24 + .../SwiftUI/SynchronizedBindings.swift | 62 + .../UIKit/AnimationsViewController.swift | 89 + .../BasicsNavigationViewController.swift | 115 + .../ConciseEnumNavigationViewController.swift | 131 ++ .../UIKit/EnumControlsViewController.swift | 109 + .../ErasedNavigationStackController.swift | 234 ++ .../UIKit/FocusViewController.swift | 122 + .../MinimalObservationViewController.swift | 76 + .../StaticNavigationStackController.swift | 157 ++ .../UIControlBindingsViewController.swift | 130 ++ .../CaseStudies/UIKit/UIKitCaseStudies.swift | 40 + .../WiFiFeature/ConnectToNetworkFeature.swift | 111 + .../UIKit/WiFiFeature/Network.swift | 53 + .../WiFiFeature/NetworkDetailFeature.swift | 99 + .../WiFiFeature/WiFiSettingsFeature.swift | 311 +++ .../CaseStudiesTests/CaseStudies.xctestplan | 42 + .../Internal/AssertEventually.swift | 160 ++ .../CaseStudiesTests/Internal/SetUp.swift | 18 + .../CaseStudiesTests/Internal/XCTTODO.swift | 11 + .../NavigationPathTests.swift | 646 ++++++ .../NavigationStackTests.swift | 318 +++ .../CaseStudiesTests/PresentationTests.swift | 482 ++++ .../RuntimeWarningTests.swift | 144 ++ Examples/Examples.xcodeproj/project.pbxproj | 414 +++- .../xcshareddata/swiftpm/Package.resolved | 42 +- .../xcschemes/CaseStudies.xcscheme | 96 + Examples/Inventory/Item.swift | 2 +- Makefile | 31 +- Package.resolved | 25 +- Package.swift | 50 +- Package@swift-6.0.swift | 45 +- README.md | 335 ++- Sources/SwiftNavigation/AlertState.swift | 217 ++ .../Bind.swift | 0 .../Binding.swift | 0 Sources/SwiftNavigation/ButtonState.swift | 402 ++++ .../SwiftNavigation/ButtonStateBuilder.swift | 35 + .../ConfirmationDialogState.swift | 258 +++ .../Articles/CrossPlatform.md | 34 + .../Articles/WhatIsNavigation.md | 54 + .../Extensions/AlertState.md | 6 +- .../Extensions/ButtonState.md | 14 +- .../Extensions/ConfirmationDialogState.md | 6 +- .../Extensions/TextState.md | 2 +- .../Extensions/UIBindable.md | 17 + .../Extensions/UIBinding.md | 27 + .../Documentation.docc/SwiftNavigation.md | 64 + .../HashableObject.swift | 0 .../SwiftNavigation/Internal/Exports.swift | 2 + Sources/SwiftNavigation/Observe.swift | 188 ++ .../TextState.swift | 280 ++- Sources/SwiftNavigation/UIBindable.swift | 191 ++ Sources/SwiftNavigation/UIBinding.swift | 739 ++++++ .../SwiftNavigation/UINavigationPath.swift | 205 ++ Sources/SwiftNavigation/UITransaction.swift | 94 + Sources/SwiftUINavigation/Alert.swift | 196 ++ Sources/SwiftUINavigation/Binding.swift | 57 +- .../ConfirmationDialog.swift | 158 +- .../Articles/AlertsDialogs.md | 10 +- .../Documentation.docc/Articles/Bindings.md | 3 +- .../Documentation.docc/Articles/Navigation.md | 5 +- .../Articles/SwiftUINavigationTools.md | 5 + .../Articles/WhatIsNavigation.md | 293 --- .../Extensions/Deprecations.md | 54 - .../Documentation.docc/Extensions/Switch.md | 8 - .../Documentation.docc/SwiftUINavigation.md | 105 +- .../SwiftUINavigation/FullScreenCover.swift | 4 +- .../Internal/Deprecations.swift | 2026 ----------------- .../SwiftUINavigation/Internal/Exports.swift | 3 +- .../Internal/Identified.swift | 7 +- .../Internal/LockIsolated.swift | 18 - .../NavigationDestination.swift | 19 + Sources/SwiftUINavigation/Popover.swift | 4 +- Sources/SwiftUINavigation/Sheet.swift | 4 +- Sources/SwiftUINavigation/WithState.swift | 5 + Sources/SwiftUINavigationCore/Alert.swift | 141 -- .../SwiftUINavigationCore/AlertState.swift | 284 --- .../SwiftUINavigationCore/ButtonState.swift | 377 --- .../ButtonStateBuilder.swift | 36 - .../ConfirmationDialog.swift | 150 -- .../ConfirmationDialogState.swift | 294 --- .../Extensions/AlertStateDeprecations.md | 22 - .../Extensions/ButtonStateDeprecations.md | 24 - .../ConfirmationDialogStateDeprecations.md | 21 - .../Extensions/Deprecations.md | 19 - .../SwiftUINavigationCore.md | 32 - .../Internal/Deprecations.swift | 331 --- .../NavigationDestination.swift | 25 - .../Bindings/UIColorWell.swift | 30 + .../UIKitNavigation/Bindings/UIControl.swift | 105 + .../Bindings/UIDatePicker.swift | 30 + .../Bindings/UIPageControl.swift | 28 + .../Bindings/UISegmentedControl.swift | 44 + .../UIKitNavigation/Bindings/UISlider.swift | 29 + .../UIKitNavigation/Bindings/UIStepper.swift | 29 + .../UIKitNavigation/Bindings/UISwitch.swift | 32 + .../Bindings/UITabBarController.swift | 34 + .../Bindings/UITextField.swift | 310 +++ .../Extensions/UIColorWell.md | 8 + .../Extensions/UIControlProtocol.md | 8 + .../Extensions/UIDatePicker.md | 8 + .../Extensions/UIKitAnimation.md | 35 + .../Extensions/UIPageControl.md | 8 + .../Documentation.docc/Extensions/UISlider.md | 8 + .../Extensions/UIStepper.md | 8 + .../Documentation.docc/Extensions/UISwitch.md | 8 + .../Extensions/UITextField.md | 22 + .../Extensions/UIViewController.md | 26 + .../Documentation.docc/Extensions/observe.md | 7 + .../Documentation.docc/UIKitNavigation.md | 165 ++ .../Internal/ErrorMechanism.swift | 20 + .../UIKitNavigation/Internal/Exports.swift | 3 + .../Internal/PopFromViewController.swift | 13 + .../Internal/ToOptionalUnit.swift | 12 + .../UIKitNavigation/Navigation/Dismiss.swift | 45 + .../NavigationStackController.swift | 443 ++++ .../Navigation/Presentation.swift | 452 ++++ Sources/UIKitNavigation/Navigation/Push.swift | 43 + .../Navigation/UIAlertController.swift | 87 + Sources/UIKitNavigation/Observe.swift | 177 ++ .../SwiftUI/Representable.swift | 24 + Sources/UIKitNavigation/UIBinding.swift | 13 + Sources/UIKitNavigation/UIKitAnimation.swift | 408 ++++ Sources/UIKitNavigation/UITransaction.swift | 51 + Sources/UIKitNavigationShim/include/shim.h | 19 + Sources/UIKitNavigationShim/shim.m | 88 + .../contents.xcworkspacedata | 0 .../xcshareddata/IDEWorkspaceChecks.plist | 0 .../xcschemes/SwiftNavigation.xcscheme | 78 + .../xcschemes/SwiftUINavigation.xcscheme | 25 +- .../xcschemes/SwiftUINavigationCore.xcscheme | 67 + .../xcschemes/UIKitNavigation.xcscheme | 72 + .../xcshareddata/swiftpm/Package.resolved | 122 - Tests/SwiftNavigation.xctestplan | 24 + .../SwiftNavigationTests/IsolationTests.swift | 50 + .../SwiftNavigationTests/LifetimeTests.swift | 33 + Tests/SwiftUINavigation.xctestplan | 24 + Tests/SwiftUINavigationTests/AlertTests.swift | 123 +- .../SwiftUINavigationTests/BindingTests.swift | 3 +- .../ButtonStateTests.swift | 33 +- .../SwiftUINavigationTests.swift | 16 +- .../TextStateTests.swift | 2 +- Tests/UIKitNavigation.xctestplan | 24 + .../Internal/XCTTODO.swift | 11 + .../MemoryManagementTests.swift | 69 + .../UIBindableTests.swift | 49 + .../UIKitNavigationTests/UIBindingTests.swift | 187 ++ .../UIKitNavigationTests/UIControlTests.swift | 176 ++ .../UINavigationPathTests.swift | 32 + 172 files changed, 12345 insertions(+), 5933 deletions(-) delete mode 100644 Examples/CaseStudies/01-Alerts.swift delete mode 100644 Examples/CaseStudies/02-ConfirmationDialogs.swift delete mode 100644 Examples/CaseStudies/03-Sheets.swift delete mode 100644 Examples/CaseStudies/04-Popovers.swift delete mode 100644 Examples/CaseStudies/05-FullScreenCovers.swift delete mode 100644 Examples/CaseStudies/06-NavigationDestinations.swift delete mode 100644 Examples/CaseStudies/07-NavigationLinks.swift delete mode 100644 Examples/CaseStudies/08-Routing.swift delete mode 100644 Examples/CaseStudies/09-CustomComponents.swift delete mode 100644 Examples/CaseStudies/10-SynchronizedBindings.swift delete mode 100644 Examples/CaseStudies/11-IfLet.swift delete mode 100644 Examples/CaseStudies/12-IfCaseLet.swift create mode 100644 Examples/CaseStudies/Internal/CaseStudy.swift create mode 100644 Examples/CaseStudies/Internal/DetentsHelper.swift rename Examples/CaseStudies/{ => Internal}/FactClient.swift (90%) create mode 100644 Examples/CaseStudies/Internal/Text+Template.swift create mode 100644 Examples/CaseStudies/SwiftUI/AlertDialogState.swift create mode 100644 Examples/CaseStudies/SwiftUI/EnumControls.swift create mode 100644 Examples/CaseStudies/SwiftUI/EnumNavigation.swift create mode 100644 Examples/CaseStudies/SwiftUI/OptionalNavigation.swift create mode 100644 Examples/CaseStudies/SwiftUI/SwiftUICaseStudies.swift create mode 100644 Examples/CaseStudies/SwiftUI/SynchronizedBindings.swift create mode 100644 Examples/CaseStudies/UIKit/AnimationsViewController.swift create mode 100644 Examples/CaseStudies/UIKit/BasicsNavigationViewController.swift create mode 100644 Examples/CaseStudies/UIKit/ConciseEnumNavigationViewController.swift create mode 100644 Examples/CaseStudies/UIKit/EnumControlsViewController.swift create mode 100644 Examples/CaseStudies/UIKit/ErasedNavigationStackController.swift create mode 100644 Examples/CaseStudies/UIKit/FocusViewController.swift create mode 100644 Examples/CaseStudies/UIKit/MinimalObservationViewController.swift create mode 100644 Examples/CaseStudies/UIKit/StaticNavigationStackController.swift create mode 100644 Examples/CaseStudies/UIKit/UIControlBindingsViewController.swift create mode 100644 Examples/CaseStudies/UIKit/UIKitCaseStudies.swift create mode 100644 Examples/CaseStudies/UIKit/WiFiFeature/ConnectToNetworkFeature.swift create mode 100644 Examples/CaseStudies/UIKit/WiFiFeature/Network.swift create mode 100644 Examples/CaseStudies/UIKit/WiFiFeature/NetworkDetailFeature.swift create mode 100644 Examples/CaseStudies/UIKit/WiFiFeature/WiFiSettingsFeature.swift create mode 100644 Examples/CaseStudiesTests/CaseStudies.xctestplan create mode 100644 Examples/CaseStudiesTests/Internal/AssertEventually.swift create mode 100644 Examples/CaseStudiesTests/Internal/SetUp.swift create mode 100644 Examples/CaseStudiesTests/Internal/XCTTODO.swift create mode 100644 Examples/CaseStudiesTests/NavigationPathTests.swift create mode 100644 Examples/CaseStudiesTests/NavigationStackTests.swift create mode 100644 Examples/CaseStudiesTests/PresentationTests.swift create mode 100644 Examples/CaseStudiesTests/RuntimeWarningTests.swift create mode 100644 Examples/Examples.xcodeproj/xcshareddata/xcschemes/CaseStudies.xcscheme create mode 100644 Sources/SwiftNavigation/AlertState.swift rename Sources/{SwiftUINavigationCore => SwiftNavigation}/Bind.swift (100%) rename Sources/{SwiftUINavigationCore => SwiftNavigation}/Binding.swift (100%) create mode 100644 Sources/SwiftNavigation/ButtonState.swift create mode 100644 Sources/SwiftNavigation/ButtonStateBuilder.swift create mode 100644 Sources/SwiftNavigation/ConfirmationDialogState.swift create mode 100644 Sources/SwiftNavigation/Documentation.docc/Articles/CrossPlatform.md create mode 100644 Sources/SwiftNavigation/Documentation.docc/Articles/WhatIsNavigation.md rename Sources/{SwiftUINavigationCore => SwiftNavigation}/Documentation.docc/Extensions/AlertState.md (67%) rename Sources/{SwiftUINavigationCore => SwiftNavigation}/Documentation.docc/Extensions/ButtonState.md (59%) rename Sources/{SwiftUINavigationCore => SwiftNavigation}/Documentation.docc/Extensions/ConfirmationDialogState.md (74%) rename Sources/{SwiftUINavigationCore => SwiftNavigation}/Documentation.docc/Extensions/TextState.md (82%) create mode 100644 Sources/SwiftNavigation/Documentation.docc/Extensions/UIBindable.md create mode 100644 Sources/SwiftNavigation/Documentation.docc/Extensions/UIBinding.md create mode 100644 Sources/SwiftNavigation/Documentation.docc/SwiftNavigation.md rename Sources/{SwiftUINavigation => SwiftNavigation}/HashableObject.swift (100%) create mode 100644 Sources/SwiftNavigation/Internal/Exports.swift create mode 100644 Sources/SwiftNavigation/Observe.swift rename Sources/{SwiftUINavigationCore => SwiftNavigation}/TextState.swift (80%) create mode 100644 Sources/SwiftNavigation/UIBindable.swift create mode 100644 Sources/SwiftNavigation/UIBinding.swift create mode 100644 Sources/SwiftNavigation/UINavigationPath.swift create mode 100644 Sources/SwiftNavigation/UITransaction.swift create mode 100644 Sources/SwiftUINavigation/Documentation.docc/Articles/SwiftUINavigationTools.md delete mode 100644 Sources/SwiftUINavigation/Documentation.docc/Articles/WhatIsNavigation.md delete mode 100644 Sources/SwiftUINavigation/Documentation.docc/Extensions/Deprecations.md delete mode 100644 Sources/SwiftUINavigation/Documentation.docc/Extensions/Switch.md delete mode 100644 Sources/SwiftUINavigation/Internal/Deprecations.swift delete mode 100644 Sources/SwiftUINavigation/Internal/LockIsolated.swift delete mode 100644 Sources/SwiftUINavigationCore/Alert.swift delete mode 100644 Sources/SwiftUINavigationCore/AlertState.swift delete mode 100644 Sources/SwiftUINavigationCore/ButtonState.swift delete mode 100644 Sources/SwiftUINavigationCore/ButtonStateBuilder.swift delete mode 100644 Sources/SwiftUINavigationCore/ConfirmationDialog.swift delete mode 100644 Sources/SwiftUINavigationCore/ConfirmationDialogState.swift delete mode 100644 Sources/SwiftUINavigationCore/Documentation.docc/Extensions/AlertStateDeprecations.md delete mode 100644 Sources/SwiftUINavigationCore/Documentation.docc/Extensions/ButtonStateDeprecations.md delete mode 100644 Sources/SwiftUINavigationCore/Documentation.docc/Extensions/ConfirmationDialogStateDeprecations.md delete mode 100644 Sources/SwiftUINavigationCore/Documentation.docc/Extensions/Deprecations.md delete mode 100644 Sources/SwiftUINavigationCore/Documentation.docc/SwiftUINavigationCore.md delete mode 100644 Sources/SwiftUINavigationCore/Internal/Deprecations.swift delete mode 100644 Sources/SwiftUINavigationCore/NavigationDestination.swift create mode 100644 Sources/UIKitNavigation/Bindings/UIColorWell.swift create mode 100644 Sources/UIKitNavigation/Bindings/UIControl.swift create mode 100644 Sources/UIKitNavigation/Bindings/UIDatePicker.swift create mode 100644 Sources/UIKitNavigation/Bindings/UIPageControl.swift create mode 100644 Sources/UIKitNavigation/Bindings/UISegmentedControl.swift create mode 100644 Sources/UIKitNavigation/Bindings/UISlider.swift create mode 100644 Sources/UIKitNavigation/Bindings/UIStepper.swift create mode 100644 Sources/UIKitNavigation/Bindings/UISwitch.swift create mode 100644 Sources/UIKitNavigation/Bindings/UITabBarController.swift create mode 100644 Sources/UIKitNavigation/Bindings/UITextField.swift create mode 100644 Sources/UIKitNavigation/Documentation.docc/Extensions/UIColorWell.md create mode 100644 Sources/UIKitNavigation/Documentation.docc/Extensions/UIControlProtocol.md create mode 100644 Sources/UIKitNavigation/Documentation.docc/Extensions/UIDatePicker.md create mode 100644 Sources/UIKitNavigation/Documentation.docc/Extensions/UIKitAnimation.md create mode 100644 Sources/UIKitNavigation/Documentation.docc/Extensions/UIPageControl.md create mode 100644 Sources/UIKitNavigation/Documentation.docc/Extensions/UISlider.md create mode 100644 Sources/UIKitNavigation/Documentation.docc/Extensions/UIStepper.md create mode 100644 Sources/UIKitNavigation/Documentation.docc/Extensions/UISwitch.md create mode 100644 Sources/UIKitNavigation/Documentation.docc/Extensions/UITextField.md create mode 100644 Sources/UIKitNavigation/Documentation.docc/Extensions/UIViewController.md create mode 100644 Sources/UIKitNavigation/Documentation.docc/Extensions/observe.md create mode 100644 Sources/UIKitNavigation/Documentation.docc/UIKitNavigation.md create mode 100644 Sources/UIKitNavigation/Internal/ErrorMechanism.swift create mode 100644 Sources/UIKitNavigation/Internal/Exports.swift create mode 100644 Sources/UIKitNavigation/Internal/PopFromViewController.swift create mode 100644 Sources/UIKitNavigation/Internal/ToOptionalUnit.swift create mode 100644 Sources/UIKitNavigation/Navigation/Dismiss.swift create mode 100644 Sources/UIKitNavigation/Navigation/NavigationStackController.swift create mode 100644 Sources/UIKitNavigation/Navigation/Presentation.swift create mode 100644 Sources/UIKitNavigation/Navigation/Push.swift create mode 100644 Sources/UIKitNavigation/Navigation/UIAlertController.swift create mode 100644 Sources/UIKitNavigation/Observe.swift create mode 100644 Sources/UIKitNavigation/SwiftUI/Representable.swift create mode 100644 Sources/UIKitNavigation/UIBinding.swift create mode 100644 Sources/UIKitNavigation/UIKitAnimation.swift create mode 100644 Sources/UIKitNavigation/UITransaction.swift create mode 100644 Sources/UIKitNavigationShim/include/shim.h create mode 100644 Sources/UIKitNavigationShim/shim.m rename {SwiftUINavigation.xcworkspace => SwiftNavigation.xcworkspace}/contents.xcworkspacedata (100%) rename {SwiftUINavigation.xcworkspace => SwiftNavigation.xcworkspace}/xcshareddata/IDEWorkspaceChecks.plist (100%) create mode 100644 SwiftNavigation.xcworkspace/xcshareddata/xcschemes/SwiftNavigation.xcscheme rename {SwiftUINavigation.xcworkspace => SwiftNavigation.xcworkspace}/xcshareddata/xcschemes/SwiftUINavigation.xcscheme (80%) create mode 100644 SwiftNavigation.xcworkspace/xcshareddata/xcschemes/SwiftUINavigationCore.xcscheme create mode 100644 SwiftNavigation.xcworkspace/xcshareddata/xcschemes/UIKitNavigation.xcscheme delete mode 100644 SwiftUINavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Tests/SwiftNavigation.xctestplan create mode 100644 Tests/SwiftNavigationTests/IsolationTests.swift create mode 100644 Tests/SwiftNavigationTests/LifetimeTests.swift create mode 100644 Tests/SwiftUINavigation.xctestplan create mode 100644 Tests/UIKitNavigation.xctestplan create mode 100644 Tests/UIKitNavigationTests/Internal/XCTTODO.swift create mode 100644 Tests/UIKitNavigationTests/MemoryManagementTests.swift create mode 100644 Tests/UIKitNavigationTests/UIBindableTests.swift create mode 100644 Tests/UIKitNavigationTests/UIBindingTests.swift create mode 100644 Tests/UIKitNavigationTests/UIControlTests.swift create mode 100644 Tests/UIKitNavigationTests/UINavigationPathTests.swift diff --git a/.spi.yml b/.spi.yml index eeeb1f4d06..11f61019ab 100644 --- a/.spi.yml +++ b/.spi.yml @@ -1,4 +1,7 @@ version: 1 builder: configs: - - documentation_targets: [SwiftUINavigation, SwiftUINavigationCore] + - documentation_targets: + - SwiftNavigation + - SwiftUINavigation + - UIKitNavigation 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..9012fb85ea --- /dev/null +++ b/Examples/CaseStudies/Internal/CaseStudy.swift @@ -0,0 +1,222 @@ +import SwiftUI +import UIKitNavigation + +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