diff --git a/Package.resolved b/Package.resolved index 16f3f5aee6ec..f09441354d70 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "1313e97b3134138f8ca61f73141726a7e426c0097a38fadeba8e33be243b0d2e", + "originHash" : "372ba44815bad1dc45c295eaa0194eb2f24433da53e2c0e97e52f6f95f0fe64c", "pins" : [ { "identity" : "combine-schedulers", @@ -10,6 +10,15 @@ "version" : "1.0.3" } }, + { + "identity" : "opencombine", + "kind" : "remoteSourceControl", + "location" : "https://github.com/OpenCombine/OpenCombine.git", + "state" : { + "revision" : "8576f0d579b27020beccbccc3ea6844f3ddfc2c2", + "version" : "0.14.0" + } + }, { "identity" : "swift-case-paths", "kind" : "remoteSourceControl", @@ -61,7 +70,7 @@ "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { "revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc", - "version" : "1.9.5" + "version" : "1.10.0" } }, { diff --git a/Package.swift b/Package.swift index 6eb71066a01c..f90a2f170676 100644 --- a/Package.swift +++ b/Package.swift @@ -32,6 +32,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.3.0"), .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.0.0"), .package(url: "https://github.com/swiftlang/swift-syntax", "509.0.0"..<"603.0.0"), + .package(url: "https://github.com/OpenCombine/OpenCombine.git", from: "0.14.0"), ], targets: [ .target( @@ -51,6 +52,13 @@ let package = Package( .product(name: "Sharing", package: "swift-sharing"), .product(name: "SwiftUINavigation", package: "swift-navigation"), .product(name: "UIKitNavigation", package: "swift-navigation"), + .product( + name: "OpenCombine", package: "OpenCombine", + condition: .when(platforms: [ + .android, + .linux, + ]) + ), ], resources: [ .process("Resources/PrivacyInfo.xcprivacy") diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index 0374b060b64b..bf7487fc8fe9 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -32,6 +32,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.3.0"), .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.0.0"), .package(url: "https://github.com/swiftlang/swift-syntax", "509.0.0"..<"603.0.0"), + .package(url: "https://github.com/OpenCombine/OpenCombine.git", from: "0.14.0"), ], targets: [ .target( @@ -51,6 +52,13 @@ let package = Package( .product(name: "Sharing", package: "swift-sharing"), .product(name: "SwiftUINavigation", package: "swift-navigation"), .product(name: "UIKitNavigation", package: "swift-navigation"), + .product( + name: "OpenCombine", package: "OpenCombine", + condition: .when(platforms: [ + .android, + .linux, + ]) + ), ], resources: [ .process("Resources/PrivacyInfo.xcprivacy") diff --git a/Sources/ComposableArchitecture/Core.swift b/Sources/ComposableArchitecture/Core.swift index 3c753fe5971d..410e7f3a6fe1 100644 --- a/Sources/ComposableArchitecture/Core.swift +++ b/Sources/ComposableArchitecture/Core.swift @@ -1,6 +1,11 @@ -import Combine import Foundation +#if canImport(Combine) + import Combine +#else + import OpenCombine +#endif + @MainActor protocol Core: AnyObject, Sendable { associatedtype State @@ -101,12 +106,14 @@ final class RootCore: Core { switch effect.operation { case .none: break - case let .publisher(publisher): + case .publisher(let publisher): var didComplete = false let boxedTask = Box?>(wrappedValue: nil) let effectCancellable = withEscapedDependencies { continuation in publisher - .receive(on: UIScheduler.shared) + #if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + .receive(on: UIScheduler.shared) + #endif .handleEvents(receiveCancel: { [weak self] in self?.effectCancellables[uuid] = nil }) .sink( receiveCompletion: { [weak self] _ in @@ -136,7 +143,7 @@ final class RootCore: Core { task.cancel() } } - case let .run(name, priority, operation): + case .run(let name, let priority, let operation): withEscapedDependencies { continuation in let task = Task(name: name, priority: priority) { @MainActor [weak self] in let isCompleted = LockIsolated(false) diff --git a/Sources/ComposableArchitecture/Dependencies/Dismiss.swift b/Sources/ComposableArchitecture/Dependencies/Dismiss.swift index 55e51e61a764..b01d16bbbdfe 100644 --- a/Sources/ComposableArchitecture/Dependencies/Dismiss.swift +++ b/Sources/ComposableArchitecture/Dependencies/Dismiss.swift @@ -1,5 +1,3 @@ -import SwiftUI - extension DependencyValues { /// An effect that dismisses the current presentation. /// @@ -78,70 +76,102 @@ extension DependencyValues { public struct DismissEffect: Sendable { var dismiss: (@MainActor @Sendable () -> Void)? - @MainActor - public func callAsFunction( - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) async { - await self.callAsFunction( - animation: nil, - fileID: fileID, - filePath: filePath, - line: line, - column: column - ) - } - - @MainActor - public func callAsFunction( - animation: Animation?, - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) async { - await callAsFunction( - transaction: Transaction(animation: animation), - fileID: fileID, - filePath: filePath, - line: line, - column: column - ) - } - - @MainActor - public func callAsFunction( - transaction: Transaction, - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) async { - guard let dismiss = self.dismiss - else { - reportIssue( - """ - A reducer requested dismissal at "\(fileID):\(line)", but couldn't be dismissed. + #if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + @MainActor + public func callAsFunction( + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async { + await self.callAsFunction( + animation: nil, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } - This is generally considered an application logic error, and can happen when a reducer \ - assumes it runs in a presentation context. If a reducer can run at both the root level \ - of an application, as well as in a presentation destination, use \ - @Dependency(\\.isPresented) to determine if the reducer is being presented before calling \ - @Dependency(\\.dismiss). - """, + @MainActor + public func callAsFunction( + animation: Animation?, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async { + await callAsFunction( + transaction: Transaction(animation: animation), fileID: fileID, filePath: filePath, line: line, column: column ) - return } - withTransaction(transaction) { + + @MainActor + public func callAsFunction( + transaction: Transaction, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async { + guard let dismiss = self.dismiss + else { + reportIssue( + """ + A reducer requested dismissal at "\(fileID):\(line)", but couldn't be dismissed. + + This is generally considered an application logic error, and can happen when a reducer \ + assumes it runs in a presentation context. If a reducer can run at both the root level \ + of an application, as well as in a presentation destination, use \ + @Dependency(\\.isPresented) to determine if the reducer is being presented before calling \ + @Dependency(\\.dismiss). + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + return + } + withTransaction(transaction) { + dismiss() + } + } + #else + @MainActor + public func callAsFunction( + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async { + guard let dismiss = self.dismiss + else { + reportIssue( + """ + A reducer requested dismissal at "\(fileID):\(line)", but couldn't be dismissed. + + This is generally considered an application logic error, and can happen when a reducer \ + assumes it runs in a presentation context. If a reducer can run at both the root level \ + of an application, as well as in a presentation destination, use \ + @Dependency(\\.isPresented) to determine if the reducer is being presented before calling \ + @Dependency(\\.dismiss). + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + return + } + dismiss() } - } + #endif } extension DismissEffect { diff --git a/Sources/ComposableArchitecture/Effect.swift b/Sources/ComposableArchitecture/Effect.swift index b71f9e56b410..474f1ffec7d0 100644 --- a/Sources/ComposableArchitecture/Effect.swift +++ b/Sources/ComposableArchitecture/Effect.swift @@ -1,6 +1,10 @@ -@preconcurrency import Combine import Foundation -import SwiftUI + +#if canImport(Combine) + @preconcurrency import Combine +#else + @preconcurrency import OpenCombine +#endif public struct Effect: Sendable { @usableFromInline @@ -148,20 +152,22 @@ extension Effect { Self(operation: .publisher(Just(action).eraseToAnyPublisher())) } - /// Initializes an effect that immediately emits the action passed in. - /// - /// > Note: We do not recommend using `Effect.send` to share logic. Instead, limit usage to - /// > child-parent communication, where a child may want to emit a "delegate" action for a parent - /// > to listen to. - /// > - /// > For more information, see . - /// - /// - Parameters: - /// - action: The action that is immediately emitted by the effect. - /// - animation: An animation. - public static func send(_ action: Action, animation: Animation? = nil) -> Self { - .send(action).animation(animation) - } + #if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + /// Initializes an effect that immediately emits the action passed in. + /// + /// > Note: We do not recommend using `Effect.send` to share logic. Instead, limit usage to + /// > child-parent communication, where a child may want to emit a "delegate" action for a parent + /// > to listen to. + /// > + /// > For more information, see . + /// + /// - Parameters: + /// - action: The action that is immediately emitted by the effect. + /// - animation: An animation. + public static func send(_ action: Action, animation: Animation? = nil) -> Self { + .send(action).animation(animation) + } + #endif } /// A type that can send actions back into the system when used from @@ -208,26 +214,28 @@ public struct Send: Sendable { self.send(action) } - /// Sends an action back into the system from an effect with animation. - /// - /// - Parameters: - /// - action: An action. - /// - animation: An animation. - public func callAsFunction(_ action: Action, animation: Animation?) { - callAsFunction(action, transaction: Transaction(animation: animation)) - } + #if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + /// Sends an action back into the system from an effect with animation. + /// + /// - Parameters: + /// - action: An action. + /// - animation: An animation. + public func callAsFunction(_ action: Action, animation: Animation?) { + callAsFunction(action, transaction: Transaction(animation: animation)) + } - /// Sends an action back into the system from an effect with transaction. - /// - /// - Parameters: - /// - action: An action. - /// - transaction: A transaction. - public func callAsFunction(_ action: Action, transaction: Transaction) { - guard !Task.isCancelled else { return } - withTransaction(transaction) { - self(action) + /// Sends an action back into the system from an effect with transaction. + /// + /// - Parameters: + /// - action: An action. + /// - transaction: A transaction. + public func callAsFunction(_ action: Action, transaction: Transaction) { + guard !Task.isCancelled else { return } + withTransaction(transaction) { + self(action) + } } - } + #endif } // MARK: - Composing Effects diff --git a/Sources/ComposableArchitecture/Effects/Animation.swift b/Sources/ComposableArchitecture/Effects/Animation.swift index 6bb4c21b6ebf..adfbe1400c11 100644 --- a/Sources/ComposableArchitecture/Effects/Animation.swift +++ b/Sources/ComposableArchitecture/Effects/Animation.swift @@ -1,100 +1,108 @@ -import Combine -import SwiftUI +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + import SwiftUI -extension Effect { - /// Wraps the emission of each element with SwiftUI's `withAnimation`. - /// - /// ```swift - /// case .buttonTapped: - /// return .run { send in - /// await send(.activityResponse(self.apiClient.fetchActivity())) - /// } - /// .animation() - /// ``` - /// - /// - Parameter animation: An animation. - /// - Returns: A publisher. - public func animation(_ animation: Animation? = .default) -> Self { - self.transaction(Transaction(animation: animation)) - } + #if canImport(Combine) + import Combine + #else + import OpenCombine + #endif + + extension Effect { + /// Wraps the emission of each element with SwiftUI's `withAnimation`. + /// + /// ```swift + /// case .buttonTapped: + /// return .run { send in + /// await send(.activityResponse(self.apiClient.fetchActivity())) + /// } + /// .animation() + /// ``` + /// + /// - Parameter animation: An animation. + /// - Returns: A publisher. + public func animation(_ animation: Animation? = .default) -> Self { + self.transaction(Transaction(animation: animation)) + } - /// Wraps the emission of each element with SwiftUI's `withTransaction`. - /// - /// ```swift - /// case .buttonTapped: - /// var transaction = Transaction(animation: .default) - /// transaction.disablesAnimations = true - /// return .run { send in - /// await send(.activityResponse(self.apiClient.fetchActivity())) - /// } - /// .transaction(transaction) - /// ``` - /// - /// - Parameter transaction: A transaction. - /// - Returns: A publisher. - public func transaction(_ transaction: Transaction) -> Self { - switch self.operation { - case .none: - return .none - case let .publisher(publisher): - return Self( - operation: .publisher( - TransactionPublisher(upstream: publisher, transaction: transaction).eraseToAnyPublisher() + /// Wraps the emission of each element with SwiftUI's `withTransaction`. + /// + /// ```swift + /// case .buttonTapped: + /// var transaction = Transaction(animation: .default) + /// transaction.disablesAnimations = true + /// return .run { send in + /// await send(.activityResponse(self.apiClient.fetchActivity())) + /// } + /// .transaction(transaction) + /// ``` + /// + /// - Parameter transaction: A transaction. + /// - Returns: A publisher. + public func transaction(_ transaction: Transaction) -> Self { + switch self.operation { + case .none: + return .none + case .publisher(let publisher): + return Self( + operation: .publisher( + TransactionPublisher(upstream: publisher, transaction: transaction) + .eraseToAnyPublisher() + ) ) - ) - case let .run(name, priority, operation): - let uncheckedTransaction = UncheckedSendable(transaction) - return Self( - operation: .run(name: name, priority: priority) { send in - await operation( - Send { value in - withTransaction(uncheckedTransaction.value) { - send(value) + case .run(let name, let priority, let operation): + let uncheckedTransaction = UncheckedSendable(transaction) + return Self( + operation: .run(name: name, priority: priority) { send in + await operation( + Send { value in + withTransaction(uncheckedTransaction.value) { + send(value) + } } - } - ) - } - ) + ) + } + ) + } } } -} -private struct TransactionPublisher: Publisher { - typealias Output = Upstream.Output - typealias Failure = Upstream.Failure + private struct TransactionPublisher: Publisher { + typealias Output = Upstream.Output + typealias Failure = Upstream.Failure - var upstream: Upstream - var transaction: Transaction + var upstream: Upstream + var transaction: Transaction - func receive(subscriber: some Combine.Subscriber) { - let conduit = Subscriber(downstream: subscriber, transaction: self.transaction) - self.upstream.receive(subscriber: conduit) - } + func receive(subscriber: some Combine.Subscriber) { + let conduit = Subscriber(downstream: subscriber, transaction: self.transaction) + self.upstream.receive(subscriber: conduit) + } - private final class Subscriber: Combine.Subscriber { - typealias Input = Downstream.Input - typealias Failure = Downstream.Failure + private final class Subscriber: Combine.Subscriber { + typealias Input = Downstream.Input + typealias Failure = Downstream.Failure - let downstream: Downstream - let transaction: Transaction + let downstream: Downstream + let transaction: Transaction - init(downstream: Downstream, transaction: Transaction) { - self.downstream = downstream - self.transaction = transaction - } + init(downstream: Downstream, transaction: Transaction) { + self.downstream = downstream + self.transaction = transaction + } - func receive(subscription: any Subscription) { - self.downstream.receive(subscription: subscription) - } + func receive(subscription: any Subscription) { + self.downstream.receive(subscription: subscription) + } - func receive(_ input: Input) -> Subscribers.Demand { - withTransaction(self.transaction) { - self.downstream.receive(input) + func receive(_ input: Input) -> Subscribers.Demand { + withTransaction(self.transaction) { + self.downstream.receive(input) + } } - } - func receive(completion: Subscribers.Completion) { - self.downstream.receive(completion: completion) + func receive(completion: Subscribers.Completion) { + self.downstream.receive(completion: completion) + } } } -} +#endif diff --git a/Sources/ComposableArchitecture/Effects/Cancellation.swift b/Sources/ComposableArchitecture/Effects/Cancellation.swift index fa00efc09416..10a86491e93e 100644 --- a/Sources/ComposableArchitecture/Effects/Cancellation.swift +++ b/Sources/ComposableArchitecture/Effects/Cancellation.swift @@ -1,6 +1,11 @@ -@preconcurrency import Combine import Foundation +#if canImport(Combine) + @preconcurrency import Combine +#else + @preconcurrency import OpenCombine +#endif + extension Effect { /// Turns an effect into one that is capable of being canceled. /// @@ -39,7 +44,7 @@ extension Effect { switch self.operation { case .none: return .none - case let .publisher(publisher): + case .publisher(let publisher): return Self( operation: .publisher( Deferred { @@ -83,7 +88,7 @@ extension Effect { .eraseToAnyPublisher() ) ) - case let .run(name, priority, operation): + case .run(let name, let priority, let operation): return withEscapedDependencies { continuation in return Self( operation: .run(name: name, priority: priority) { send in @@ -252,7 +257,7 @@ extension Task { self.id = id self.navigationIDPath = navigationIDPath switch TestContext.current { - case let .swiftTesting(.some(testing)): + case .swiftTesting(.some(let testing)): self.testIdentifier = testing.test.id default: self.testIdentifier = nil diff --git a/Sources/ComposableArchitecture/Effects/Debounce.swift b/Sources/ComposableArchitecture/Effects/Debounce.swift index e5d1ab71881a..765aa4121619 100644 --- a/Sources/ComposableArchitecture/Effects/Debounce.swift +++ b/Sources/ComposableArchitecture/Effects/Debounce.swift @@ -1,4 +1,8 @@ -import Combine +#if canImport(Combine) + import Combine +#else + import OpenCombine +#endif extension Effect { /// Turns an effect into one that can be debounced. diff --git a/Sources/ComposableArchitecture/Effects/Publisher.swift b/Sources/ComposableArchitecture/Effects/Publisher.swift index 8fb9b7a661e7..9e8e2a56f8e2 100644 --- a/Sources/ComposableArchitecture/Effects/Publisher.swift +++ b/Sources/ComposableArchitecture/Effects/Publisher.swift @@ -1,4 +1,8 @@ -import Combine +#if canImport(Combine) + import Combine +#else + import OpenCombine +#endif extension Effect { /// Creates an effect from a Combine publisher. @@ -20,17 +24,24 @@ public struct _EffectPublisher: Publisher { self.effect = effect } - public func receive(subscriber: some Combine.Subscriber) { - publisher.subscribe(subscriber) - } + #if canImport(Combine) + public func receive(subscriber: some Combine.Subscriber) { + publisher.subscribe(subscriber) + } + #else + public func receive(subscriber: Downstream) + where Downstream.Failure == Failure, Downstream.Input == Output { + publisher.subscribe(subscriber) + } + #endif private var publisher: AnyPublisher { switch effect.operation { case .none: return Empty().eraseToAnyPublisher() - case let .publisher(publisher): + case .publisher(let publisher): return publisher - case let .run(name, priority, operation): + case .run(let name, let priority, let operation): return .create { subscriber in let task = Task(name: name, priority: priority) { @MainActor in defer { subscriber.send(completion: .finished) } diff --git a/Sources/ComposableArchitecture/Effects/Throttle.swift b/Sources/ComposableArchitecture/Effects/Throttle.swift index e4170fedd4c9..406648841f99 100644 --- a/Sources/ComposableArchitecture/Effects/Throttle.swift +++ b/Sources/ComposableArchitecture/Effects/Throttle.swift @@ -1,7 +1,12 @@ -@preconcurrency import Combine import Dispatch import Foundation +#if canImport(Combine) + @preconcurrency import Combine +#else + @preconcurrency import OpenCombine +#endif + extension Effect where Action: Sendable { /// Throttles an effect so that it only publishes one output per given interval. /// @@ -38,7 +43,7 @@ extension Effect where Action: Sendable { return .publisher { _EffectPublisher(self) } .throttle(id: id, for: interval, scheduler: scheduler, latest: latest) - case let .publisher(publisher): + case .publisher(let publisher): return .publisher { publisher .receive(on: scheduler) diff --git a/Sources/ComposableArchitecture/Internal/Create.swift b/Sources/ComposableArchitecture/Internal/Create.swift index 4b47b677f866..95305f9ac7f8 100644 --- a/Sources/ComposableArchitecture/Internal/Create.swift +++ b/Sources/ComposableArchitecture/Internal/Create.swift @@ -20,9 +20,14 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -@preconcurrency import Combine import Foundation +#if canImport(Combine) + @preconcurrency import Combine +#else + @preconcurrency import OpenCombine +#endif + final class DemandBuffer: @unchecked Sendable { private var buffer = [S.Input]() private let subscriber: S @@ -127,13 +132,14 @@ extension Publishers { } func receive(subscriber: S) where S.Input == Output, S.Failure == Failure { - subscriber.receive(subscription: Subscription(callback: callback, downstream: subscriber)) + subscriber.receive( + subscription: Subscription(callback: callback, downstream: subscriber)) } } } extension Publishers.Create { - fileprivate final class Subscription: Combine.Subscription, Sendable + fileprivate final class Subscription: CombineSubscription, Sendable where Downstream.Input == Output, Downstream.Failure == Never { private let buffer: DemandBuffer private let cancellable = LockIsolated<(any Cancellable)?>(nil) diff --git a/Sources/ComposableArchitecture/Internal/CurrentValueRelay.swift b/Sources/ComposableArchitecture/Internal/CurrentValueRelay.swift index 071049c4e51b..e5e85cdffd4a 100644 --- a/Sources/ComposableArchitecture/Internal/CurrentValueRelay.swift +++ b/Sources/ComposableArchitecture/Internal/CurrentValueRelay.swift @@ -1,11 +1,22 @@ -import Combine import Foundation +#if canImport(Combine) + import Combine + typealias CombineSubscription = Combine.Subscription +#else + import OpenCombine + typealias CombineSubscription = OpenCombine.Subscription +#endif + final class CurrentValueRelay: Publisher, @unchecked Sendable { typealias Failure = Never private var currentValue: Output - private let lock: os_unfair_lock_t + #if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + private let lock: os_unfair_lock_t + #else + private let lock = NSLock() + #endif private var subscriptions = ContiguousArray() var value: Output { @@ -15,14 +26,18 @@ final class CurrentValueRelay: Publisher, @unchecked Sendable { init(_ value: Output) { self.currentValue = value - self.lock = os_unfair_lock_t.allocate(capacity: 1) - self.lock.initialize(to: os_unfair_lock()) + #if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + self.lock = os_unfair_lock_t.allocate(capacity: 1) + self.lock.initialize(to: os_unfair_lock()) + #endif } - deinit { - self.lock.deinitialize(count: 1) - self.lock.deallocate() - } + #if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + deinit { + self.lock.deinitialize(count: 1) + self.lock.deallocate() + } + #endif func receive(subscriber: some Subscriber) { let subscription = Subscription(upstream: self, downstream: subscriber) @@ -52,24 +67,32 @@ final class CurrentValueRelay: Publisher, @unchecked Sendable { } extension CurrentValueRelay { - fileprivate final class Subscription: Combine.Subscription, Equatable { + fileprivate final class Subscription: CombineSubscription, Equatable { private var demand = Subscribers.Demand.none private var downstream: (any Subscriber)? - private let lock: os_unfair_lock_t + #if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + private let lock: os_unfair_lock_t + #else + private let lock = NSLock() + #endif private var receivedLastValue = false private var upstream: CurrentValueRelay? init(upstream: CurrentValueRelay, downstream: any Subscriber) { self.upstream = upstream self.downstream = downstream - self.lock = os_unfair_lock_t.allocate(capacity: 1) - self.lock.initialize(to: os_unfair_lock()) + #if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + self.lock = os_unfair_lock_t.allocate(capacity: 1) + self.lock.initialize(to: os_unfair_lock()) + #endif } - deinit { - self.lock.deinitialize(count: 1) - self.lock.deallocate() - } + #if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + deinit { + self.lock.deinitialize(count: 1) + self.lock.deallocate() + } + #endif func cancel() { self.lock.sync { diff --git a/Sources/ComposableArchitecture/Internal/Deprecations.swift b/Sources/ComposableArchitecture/Internal/Deprecations.swift index e4e30355ed4a..04e11da7ded3 100644 --- a/Sources/ComposableArchitecture/Internal/Deprecations.swift +++ b/Sources/ComposableArchitecture/Internal/Deprecations.swift @@ -1,4 +1,4 @@ -#if canImport(SwiftUI) +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) import SwiftUI #endif #if canImport(UIKit) @@ -120,7 +120,7 @@ } #endif -#if canImport(SwiftUI) +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) extension Binding { @available( *, deprecated, @@ -135,11 +135,13 @@ // NB: Deprecated with 1.10.0: -@available(*, deprecated, message: "Use '.fileSystem' ('FileStorage.fileSystem') instead") -public func LiveFileStorage() -> FileStorage { .fileSystem } +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + @available(*, deprecated, message: "Use '.fileSystem' ('FileStorage.fileSystem') instead") + public func LiveFileStorage() -> FileStorage { .fileSystem } -@available(*, deprecated, message: "Use '.inMemory' ('FileStorage.inMemory') instead") -public func InMemoryFileStorage() -> FileStorage { .inMemory } + @available(*, deprecated, message: "Use '.inMemory' ('FileStorage.inMemory') instead") + public func InMemoryFileStorage() -> FileStorage { .inMemory } +#endif // NB: Deprecated with 1.0.0: diff --git a/Sources/ComposableArchitecture/Internal/EffectActions.swift b/Sources/ComposableArchitecture/Internal/EffectActions.swift index 338be97c11e6..f4c2561e76c2 100644 --- a/Sources/ComposableArchitecture/Internal/EffectActions.swift +++ b/Sources/ComposableArchitecture/Internal/EffectActions.swift @@ -1,11 +1,15 @@ -@preconcurrency import Combine +#if canImport(Combine) + @preconcurrency import Combine +#else + @preconcurrency import OpenCombine +#endif extension Effect where Action: Sendable { @_spi(Internals) public var actions: AsyncStream { switch self.operation { case .none: return .finished - case let .publisher(publisher): + case .publisher(let publisher): return AsyncStream { continuation in let cancellable = publisher.sink( receiveCompletion: { _ in continuation.finish() }, @@ -15,7 +19,7 @@ extension Effect where Action: Sendable { cancellable.cancel() } } - case let .run(name, priority, operation): + case .run(let name, let priority, let operation): return AsyncStream { continuation in let task = Task(name: name, priority: priority) { await operation(Send { action in continuation.yield(action) }) diff --git a/Sources/ComposableArchitecture/Internal/EphemeralState.swift b/Sources/ComposableArchitecture/Internal/EphemeralState.swift index a6c8c21190f7..eb2b1686f42c 100644 --- a/Sources/ComposableArchitecture/Internal/EphemeralState.swift +++ b/Sources/ComposableArchitecture/Internal/EphemeralState.swift @@ -14,12 +14,14 @@ extension _EphemeralState { public static var actionType: Any.Type { Action.self } } -@_documentation(visibility: private) -extension AlertState: _EphemeralState {} +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + @_documentation(visibility: private) + extension AlertState: _EphemeralState {} -@_documentation(visibility: private) -@available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) -extension ConfirmationDialogState: _EphemeralState {} + @_documentation(visibility: private) + @available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) + extension ConfirmationDialogState: _EphemeralState {} +#endif @usableFromInline func ephemeralType(of state: State) -> (any _EphemeralState.Type)? { diff --git a/Sources/ComposableArchitecture/Internal/Exports.swift b/Sources/ComposableArchitecture/Internal/Exports.swift index e171ce657694..8fc3daf877b0 100644 --- a/Sources/ComposableArchitecture/Internal/Exports.swift +++ b/Sources/ComposableArchitecture/Internal/Exports.swift @@ -1,6 +1,5 @@ @_exported import CasePaths @_exported import Clocks -@_exported import CombineSchedulers @_exported import ConcurrencyExtras @_exported import CustomDump @_exported import Dependencies @@ -9,5 +8,13 @@ @_exported import Observation @_exported import Perception @_exported import Sharing -@_exported import SwiftUINavigation -@_exported import UIKitNavigation + +#if canImport(Combine) + @_exported import CombineSchedulers +#endif +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + @_exported import SwiftUINavigation +#endif +#if canImport(UIKit) + @_exported import UIKitNavigation +#endif diff --git a/Sources/ComposableArchitecture/Internal/Locking.swift b/Sources/ComposableArchitecture/Internal/Locking.swift index c61b341a5ea9..a03d6a3bbada 100644 --- a/Sources/ComposableArchitecture/Internal/Locking.swift +++ b/Sources/ComposableArchitecture/Internal/Locking.swift @@ -1,19 +1,30 @@ import Foundation -extension UnsafeMutablePointer { - @inlinable @discardableResult - func sync(_ work: () -> R) -> R { - os_unfair_lock_lock(self) - defer { os_unfair_lock_unlock(self) } - return work() - } +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + extension UnsafeMutablePointer { + @inlinable @discardableResult + func sync(_ work: () -> R) -> R { + os_unfair_lock_lock(self) + defer { os_unfair_lock_unlock(self) } + return work() + } - func lock() { - os_unfair_lock_lock(self) + func lock() { + os_unfair_lock_lock(self) + } + + func unlock() { + os_unfair_lock_unlock(self) + } } +#endif - func unlock() { - os_unfair_lock_unlock(self) +extension NSLock { + @inlinable @discardableResult + func sync(_ work: () -> R) -> R { + lock() + defer { unlock() } + return work() } } diff --git a/Sources/ComposableArchitecture/Internal/Logger.swift b/Sources/ComposableArchitecture/Internal/Logger.swift index af0ae06a14a4..416153f497f2 100644 --- a/Sources/ComposableArchitecture/Internal/Logger.swift +++ b/Sources/ComposableArchitecture/Internal/Logger.swift @@ -1,44 +1,46 @@ -import OSLog +#if canImport(OSLog) + import OSLog -@_spi(Logging) -#if swift(<5.10) - @MainActor(unsafe) -#else - @preconcurrency@MainActor -#endif -public final class Logger { - public static let shared = Logger() - public var isEnabled = false - @Published public var logs: [String] = [] - #if DEBUG - @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) - var logger: os.Logger { - os.Logger(subsystem: "composable-architecture", category: "store-events") - } - public func log(level: OSLogType = .default, _ string: @autoclosure () -> String) { - guard self.isEnabled else { return } - let string = string() - if isRunningForPreviews { - print("\(string)") - } else { - if #available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) { - self.logger.log(level: level, "\(string)") - } - } - self.logs.append(string) - } - public func clear() { - self.logs = [] - } + @_spi(Logging) + #if swift(<5.10) + @MainActor(unsafe) #else - @inlinable @inline(__always) - public func log(level: OSLogType = .default, _ string: @autoclosure () -> String) { - } - @inlinable @inline(__always) - public func clear() { - } + @preconcurrency@MainActor #endif -} + public final class Logger { + public static let shared = Logger() + public var isEnabled = false + @Published public var logs: [String] = [] + #if DEBUG + @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) + var logger: os.Logger { + os.Logger(subsystem: "composable-architecture", category: "store-events") + } + public func log(level: OSLogType = .default, _ string: @autoclosure () -> String) { + guard self.isEnabled else { return } + let string = string() + if isRunningForPreviews { + print("\(string)") + } else { + if #available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) { + self.logger.log(level: level, "\(string)") + } + } + self.logs.append(string) + } + public func clear() { + self.logs = [] + } + #else + @inlinable @inline(__always) + public func log(level: OSLogType = .default, _ string: @autoclosure () -> String) { + } + @inlinable @inline(__always) + public func clear() { + } + #endif + } -private let isRunningForPreviews = - ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" + private let isRunningForPreviews = + ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" +#endif diff --git a/Sources/ComposableArchitecture/Internal/NotificationName.swift b/Sources/ComposableArchitecture/Internal/NotificationName.swift index 4b7f2d977518..4c5d6af98be9 100644 --- a/Sources/ComposableArchitecture/Internal/NotificationName.swift +++ b/Sources/ComposableArchitecture/Internal/NotificationName.swift @@ -1,56 +1,58 @@ -import Foundation +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + import Foundation -#if canImport(AppKit) - import AppKit -#endif -#if canImport(UIKit) - import UIKit -#endif -#if canImport(WatchKit) - import WatchKit -#endif - -@_spi(Internals) -public var willResignNotificationName: Notification.Name? { - #if os(iOS) || os(tvOS) || os(visionOS) - return UIApplication.willResignActiveNotification - #elseif os(macOS) - return NSApplication.willResignActiveNotification - #else - if #available(watchOS 7, *) { - return WKExtension.applicationWillResignActiveNotification - } else { - return nil - } + #if canImport(AppKit) + import AppKit #endif -} - -@_spi(Internals) -public let willEnterForegroundNotificationName: Notification.Name? = { - #if os(iOS) || os(tvOS) || os(visionOS) - return UIApplication.willEnterForegroundNotification - #elseif os(macOS) - return NSApplication.willBecomeActiveNotification - #else - if #available(watchOS 7, *) { - return WKExtension.applicationWillEnterForegroundNotification - } else { - return nil - } + #if canImport(UIKit) + import UIKit #endif -}() - -@_spi(Internals) -public let willTerminateNotificationName: Notification.Name? = { - #if os(iOS) || os(tvOS) || os(visionOS) - return UIApplication.willTerminateNotification - #elseif os(macOS) - return NSApplication.willTerminateNotification - #else - return nil + #if canImport(WatchKit) + import WatchKit #endif -}() -var canListenForResignActive: Bool { - willResignNotificationName != nil -} + @_spi(Internals) + public var willResignNotificationName: Notification.Name? { + #if os(iOS) || os(tvOS) || os(visionOS) + return UIApplication.willResignActiveNotification + #elseif os(macOS) + return NSApplication.willResignActiveNotification + #else + if #available(watchOS 7, *) { + return WKExtension.applicationWillResignActiveNotification + } else { + return nil + } + #endif + } + + @_spi(Internals) + public let willEnterForegroundNotificationName: Notification.Name? = { + #if os(iOS) || os(tvOS) || os(visionOS) + return UIApplication.willEnterForegroundNotification + #elseif os(macOS) + return NSApplication.willBecomeActiveNotification + #else + if #available(watchOS 7, *) { + return WKExtension.applicationWillEnterForegroundNotification + } else { + return nil + } + #endif + }() + + @_spi(Internals) + public let willTerminateNotificationName: Notification.Name? = { + #if os(iOS) || os(tvOS) || os(visionOS) + return UIApplication.willTerminateNotification + #elseif os(macOS) + return NSApplication.willTerminateNotification + #else + return nil + #endif + }() + + var canListenForResignActive: Bool { + willResignNotificationName != nil + } +#endif diff --git a/Sources/ComposableArchitecture/Internal/OpenCombine+Merge.swift b/Sources/ComposableArchitecture/Internal/OpenCombine+Merge.swift new file mode 100644 index 000000000000..625c80e5064c --- /dev/null +++ b/Sources/ComposableArchitecture/Internal/OpenCombine+Merge.swift @@ -0,0 +1,913 @@ +#if !canImport(Combine) + import OpenCombine + + // Verbatim copy of + // https://github.com/cx-org/CombineX/blob/299bc0f8861f7aa6708780457aeeafab1c51eaa7/Sources/CombineX/Publishers/B/Merge.swift and + // https://github.com/cx-org/CombineX/blob/299bc0f8861f7aa6708780457aeeafab1c51eaa7/Sources/CombineX/Publishers/B/Combined/Merge%2B.swift#L5 + // to make Effect's `merge` work on Windows using OpenCombine. + + // MIT License + + // Copyright (c) 2019 Quentin Jin + + // Permission is hereby granted, free of charge, to any person obtaining a copy + // of this software and associated documentation files (the "Software"), to deal + // in the Software without restriction, including without limitation the rights + // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + // copies of the Software, and to permit persons to whom the Software is + // furnished to do so, subject to the following conditions: + + // The above copyright notice and this permission notice shall be included in all + // copies or substantial portions of the Software. + + // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + // SOFTWARE. + + extension Publisher { + + /// Combines elements from this publisher with those from another publisher, delivering an interleaved sequence of elements. + /// + /// The merged publisher continues to emit elements until all upstream publishers finish. If an upstream publisher produces an error, the merged publisher fails with that error. + /// - Parameter other: Another publisher. + /// - Returns: A publisher that emits an event when either upstream publisher emits an event. + public func merge(with other: P) -> Publishers.Merge + where Failure == P.Failure, Output == P.Output { + return .init(self, other) + } + } + + extension Publishers.Merge: Equatable where A: Equatable, B: Equatable { + + /// Returns a Boolean value that indicates whether two publishers are equivalent. + /// + /// - Parameters: + /// - lhs: A merging publisher to compare for equality. + /// - rhs: Another merging publisher to compare for equality.. + /// - Returns: `true` if the two merging - rhs: Another merging publisher to compare for equality. + public static func == (lhs: Publishers.Merge, rhs: Publishers.Merge) -> Bool { + return lhs.a == rhs.a && rhs.b == rhs.b + } + } + + extension Publishers { + + /// A publisher created by applying the merge function to two upstream publishers. + public struct Merge: Publisher + where A: Publisher, B: Publisher, A.Failure == B.Failure, A.Output == B.Output { + + public typealias Output = A.Output + + public typealias Failure = A.Failure + + public let a: A + + public let b: B + + let pub: AnyPublisher + + public init(_ a: A, _ b: B) { + self.a = a + self.b = b + + self.pub = + Publishers + .Sequence(sequence: [a.eraseToAnyPublisher(), b.eraseToAnyPublisher()]) + .flatMap { $0 } + .eraseToAnyPublisher() + } + + public func receive(subscriber: S) + where B.Failure == S.Failure, B.Output == S.Input { + self.pub.subscribe(subscriber) + } + + public func merge(with other: P) -> Publishers.Merge3 + where B.Failure == P.Failure, B.Output == P.Output { + return .init(self.a, self.b, other) + } + + public func merge(with z: Z, _ y: Y) -> Publishers.Merge4 + where + Z: Publisher, Y: Publisher, B.Failure == Z.Failure, B.Output == Z.Output, + Z.Failure == Y.Failure, Z.Output == Y.Output + { + return .init(self.a, self.b, z, y) + } + + public func merge(with z: Z, _ y: Y, _ x: X) + -> Publishers.Merge5 + where + Z: Publisher, Y: Publisher, X: Publisher, B.Failure == Z.Failure, + B.Output == Z.Output, Z.Failure == Y.Failure, Z.Output == Y.Output, + Y.Failure == X.Failure, Y.Output == X.Output + { + return .init(self.a, self.b, z, y, x) + } + + public func merge(with z: Z, _ y: Y, _ x: X, _ w: W) + -> Publishers.Merge6 + where + Z: Publisher, Y: Publisher, X: Publisher, W: Publisher, B.Failure == Z.Failure, + B.Output == Z.Output, Z.Failure == Y.Failure, Z.Output == Y.Output, + Y.Failure == X.Failure, Y.Output == X.Output, X.Failure == W.Failure, + X.Output == W.Output + { + return .init(self.a, self.b, z, y, x, w) + } + + public func merge(with z: Z, _ y: Y, _ x: X, _ w: W, _ v: V) + -> Publishers.Merge7 + where + Z: Publisher, Y: Publisher, X: Publisher, W: Publisher, V: Publisher, + B.Failure == Z.Failure, B.Output == Z.Output, Z.Failure == Y.Failure, + Z.Output == Y.Output, Y.Failure == X.Failure, Y.Output == X.Output, + X.Failure == W.Failure, X.Output == W.Output, W.Failure == V.Failure, + W.Output == V.Output + { + return .init(self.a, self.b, z, y, x, w, v) + } + + public func merge(with z: Z, _ y: Y, _ x: X, _ w: W, _ v: V, _ u: U) + -> Publishers.Merge8 + where + Z: Publisher, Y: Publisher, X: Publisher, W: Publisher, V: Publisher, U: Publisher, + B.Failure == Z.Failure, B.Output == Z.Output, Z.Failure == Y.Failure, + Z.Output == Y.Output, Y.Failure == X.Failure, Y.Output == X.Output, + X.Failure == W.Failure, X.Output == W.Output, W.Failure == V.Failure, + W.Output == V.Output, V.Failure == U.Failure, V.Output == U.Output + { + return .init(self.a, self.b, z, y, x, w, v, u) + } + } + } + + extension Publisher { + + /// Combines elements from this publisher with those from two other publishers, delivering an interleaved sequence of elements. + /// + /// The merged publisher continues to emit elements until all upstream publishers finish. If an upstream publisher produces an error, the merged publisher fails with that error. + /// + /// - Parameters: + /// - b: A second publisher. + /// - c: A third publisher. + /// - Returns: A publisher that emits an event when any upstream publisher emits + /// an event. + public func merge(with b: B, _ c: C) -> Publishers.Merge3 + where + B: Publisher, C: Publisher, Failure == B.Failure, Output == B.Output, + B.Failure == C.Failure, B.Output == C.Output + { + return .init(self, b, c) + } + + /// Combines elements from this publisher with those from three other publishers, delivering + /// an interleaved sequence of elements. + /// + /// The merged publisher continues to emit elements until all upstream publishers finish. If an upstream publisher produces an error, the merged publisher fails with that error. + /// + /// - Parameters: + /// - b: A second publisher. + /// - c: A third publisher. + /// - d: A fourth publisher. + /// - Returns: A publisher that emits an event when any upstream publisher emits an event. + public func merge(with b: B, _ c: C, _ d: D) -> Publishers.Merge4 + where + B: Publisher, C: Publisher, D: Publisher, Failure == B.Failure, Output == B.Output, + B.Failure == C.Failure, B.Output == C.Output, C.Failure == D.Failure, + C.Output == D.Output + { + return .init(self, b, c, d) + } + + /// Combines elements from this publisher with those from four other publishers, delivering an interleaved sequence of elements. + /// + /// The merged publisher continues to emit elements until all upstream publishers finish. If an upstream publisher produces an error, the merged publisher fails with that error. + /// + /// - Parameters: + /// - b: A second publisher. + /// - c: A third publisher. + /// - d: A fourth publisher. + /// - e: A fifth publisher. + /// - Returns: A publisher that emits an event when any upstream publisher emits an event. + public func merge(with b: B, _ c: C, _ d: D, _ e: E) + -> Publishers.Merge5 + where + B: Publisher, C: Publisher, D: Publisher, E: Publisher, Failure == B.Failure, + Output == B.Output, B.Failure == C.Failure, B.Output == C.Output, + C.Failure == D.Failure, C.Output == D.Output, D.Failure == E.Failure, + D.Output == E.Output + { + return .init(self, b, c, d, e) + } + + /// Combines elements from this publisher with those from five other publishers, delivering an interleaved sequence of elements. + /// + /// The merged publisher continues to emit elements until all upstream publishers finish. If an upstream publisher produces an error, the merged publisher fails with that error. + /// + /// - Parameters: + /// - b: A second publisher. + /// - c: A third publisher. + /// - d: A fourth publisher. + /// - e: A fifth publisher. + /// - f: A sixth publisher. + /// - Returns: A publisher that emits an event when any upstream publisher emits an event. + public func merge(with b: B, _ c: C, _ d: D, _ e: E, _ f: F) + -> Publishers.Merge6 + where + B: Publisher, C: Publisher, D: Publisher, E: Publisher, F: Publisher, + Failure == B.Failure, Output == B.Output, B.Failure == C.Failure, B.Output == C.Output, + C.Failure == D.Failure, C.Output == D.Output, D.Failure == E.Failure, + D.Output == E.Output, E.Failure == F.Failure, E.Output == F.Output + { + return .init(self, b, c, d, e, f) + } + + /// Combines elements from this publisher with those from six other publishers, delivering an interleaved sequence of elements. + /// + /// The merged publisher continues to emit elements until all upstream publishers finish. If an upstream publisher produces an error, the merged publisher fails with that error. + /// + /// - Parameters: + /// - b: A second publisher. + /// - c: A third publisher. + /// - d: A fourth publisher. + /// - e: A fifth publisher. + /// - f: A sixth publisher. + /// - g: A seventh publisher. + /// - Returns: A publisher that emits an event when any upstream publisher emits an event. + public func merge(with b: B, _ c: C, _ d: D, _ e: E, _ f: F, _ g: G) + -> Publishers.Merge7 + where + B: Publisher, C: Publisher, D: Publisher, E: Publisher, F: Publisher, G: Publisher, + Failure == B.Failure, Output == B.Output, B.Failure == C.Failure, B.Output == C.Output, + C.Failure == D.Failure, C.Output == D.Output, D.Failure == E.Failure, + D.Output == E.Output, E.Failure == F.Failure, E.Output == F.Output, + F.Failure == G.Failure, F.Output == G.Output + { + return .init(self, b, c, d, e, f, g) + } + + /// Combines elements from this publisher with those from seven other publishers, delivering an interleaved sequence of elements. + /// + /// The merged publisher continues to emit elements until all upstream publishers finish. If an upstream publisher produces an error, the merged publisher fails with that error. + /// + /// - Parameters: + /// - b: A second publisher. + /// - c: A third publisher. + /// - d: A fourth publisher. + /// - e: A fifth publisher. + /// - f: A sixth publisher. + /// - g: A seventh publisher. + /// - h: An eighth publisher. + /// - Returns: A publisher that emits an event when any upstream publisher emits an event. + public func merge( + with b: B, _ c: C, _ d: D, _ e: E, _ f: F, _ g: G, _ h: H + ) -> Publishers.Merge8 + where + B: Publisher, C: Publisher, D: Publisher, E: Publisher, F: Publisher, G: Publisher, + H: Publisher, Failure == B.Failure, Output == B.Output, B.Failure == C.Failure, + B.Output == C.Output, C.Failure == D.Failure, C.Output == D.Output, + D.Failure == E.Failure, D.Output == E.Output, E.Failure == F.Failure, + E.Output == F.Output, F.Failure == G.Failure, F.Output == G.Output, + G.Failure == H.Failure, G.Output == H.Output + { + return .init(self, b, c, d, e, f, g, h) + } + + /// Combines elements from this publisher with those from another publisher of the same type, delivering an interleaved sequence of elements. + /// + /// - Parameter other: Another publisher of this publisher's type. + /// - Returns: A publisher that emits an event when either upstream publisher emits + /// an event. + public func merge(with other: Self) -> Publishers.MergeMany { + return .init(self, other) + } + } + + extension Publishers.Merge3: Equatable where A: Equatable, B: Equatable, C: Equatable { + + /// Returns a Boolean value that indicates whether two publishers are equivalent. + /// + /// - Parameters: + /// - lhs: A merging publisher to compare for equality. + /// - rhs: Another merging publisher to compare for equality. + /// - Returns: `true` if the two merging publishers have equal source publishers, `false` otherwise. + public static func == (lhs: Publishers.Merge3, rhs: Publishers.Merge3) + -> Bool + { + return lhs.a == rhs.a + && lhs.b == rhs.b + && lhs.c == rhs.c + } + } + + extension Publishers.Merge4: Equatable + where A: Equatable, B: Equatable, C: Equatable, D: Equatable { + + /// Returns a Boolean value that indicates whether two publishers are equivalent. + /// + /// - Parameters: + /// - lhs: A merging publisher to compare for equality. + /// - rhs: Another merging publisher to compare for equality. + /// - Returns: `true` if the two merging publishers have equal source publishers, `false` otherwise. + public static func == ( + lhs: Publishers.Merge4, rhs: Publishers.Merge4 + ) -> Bool { + return lhs.a == rhs.a + && lhs.b == rhs.b + && lhs.c == rhs.c + && lhs.d == rhs.d + } + } + + extension Publishers.Merge5: Equatable + where A: Equatable, B: Equatable, C: Equatable, D: Equatable, E: Equatable { + + /// Returns a Boolean value that indicates whether two publishers are equivalent. + /// + /// - Parameters: + /// - lhs: A merging publisher to compare for equality. + /// - rhs: Another merging publisher to compare for equality. + /// - Returns: `true` if the two merging publishers have equal source publishers, `false` otherwise. + public static func == ( + lhs: Publishers.Merge5, rhs: Publishers.Merge5 + ) -> Bool { + return lhs.a == rhs.a + && lhs.b == rhs.b + && lhs.c == rhs.c + && lhs.d == rhs.d + && lhs.e == rhs.e + } + } + + extension Publishers.Merge6: Equatable + where A: Equatable, B: Equatable, C: Equatable, D: Equatable, E: Equatable, F: Equatable { + + /// Returns a Boolean value that indicates whether two publishers are equivalent. + /// + /// - Parameters: + /// - lhs: A merging publisher to compare for equality. + /// - rhs: Another merging publisher to compare for equality. + /// - Returns: `true` if the two merging publishers have equal source publishers, `false` otherwise. + public static func == ( + lhs: Publishers.Merge6, rhs: Publishers.Merge6 + ) -> Bool { + return lhs.a == rhs.a + && lhs.b == rhs.b + && lhs.c == rhs.c + && lhs.d == rhs.d + && lhs.e == rhs.e + && lhs.f == rhs.f + } + } + + extension Publishers.Merge7: Equatable + where + A: Equatable, B: Equatable, C: Equatable, D: Equatable, E: Equatable, F: Equatable, + G: Equatable + { + + /// Returns a Boolean value that indicates whether two publishers are equivalent. + /// + /// - Parameters: + /// - lhs: A merging publisher to compare for equality. + /// - rhs: Another merging publisher to compare for equality. + /// - Returns: `true` if the two merging publishers have equal source publishers, `false` otherwise. + public static func == ( + lhs: Publishers.Merge7, rhs: Publishers.Merge7 + ) -> Bool { + return lhs.a == rhs.a + && lhs.b == rhs.b + && lhs.c == rhs.c + && lhs.d == rhs.d + && lhs.e == rhs.e + && lhs.f == rhs.f + && lhs.g == rhs.g + } + } + + extension Publishers.Merge8: Equatable + where + A: Equatable, B: Equatable, C: Equatable, D: Equatable, E: Equatable, F: Equatable, + G: Equatable, H: Equatable + { + + /// Returns a Boolean value that indicates whether two publishers are equivalent. + /// + /// - Parameters: + /// - lhs: A merging publisher to compare for equality. + /// - rhs: Another merging publisher to compare for equality. + /// - Returns: `true` if the two merging publishers have equal source publishers, `false` otherwise. + public static func == ( + lhs: Publishers.Merge8, + rhs: Publishers.Merge8 + ) -> Bool { + return lhs.a == rhs.a + && lhs.b == rhs.b + && lhs.c == rhs.c + && lhs.d == rhs.d + && lhs.e == rhs.e + && lhs.f == rhs.f + && lhs.g == rhs.g + && lhs.h == rhs.h + } + } + + extension Publishers.MergeMany: Equatable where Upstream: Equatable { + + public static func == ( + lhs: Publishers.MergeMany, rhs: Publishers.MergeMany + ) -> Bool { + return lhs.publishers == rhs.publishers + } + } + + extension Publishers { + + /// A publisher created by applying the merge function to three upstream publishers. + public struct Merge3: Publisher + where + A: Publisher, B: Publisher, C: Publisher, A.Failure == B.Failure, A.Output == B.Output, + B.Failure == C.Failure, B.Output == C.Output + { + + public typealias Output = A.Output + + public typealias Failure = A.Failure + + public let a: A + + public let b: B + + public let c: C + + let pub: AnyPublisher + + public init(_ a: A, _ b: B, _ c: C) { + self.a = a + self.b = b + self.c = c + + self.pub = + Publishers + .Sequence(sequence: [ + a.eraseToAnyPublisher(), + b.eraseToAnyPublisher(), + c.eraseToAnyPublisher(), + ]) + .flatMap { $0 } + .eraseToAnyPublisher() + } + + public func receive(subscriber: S) + where C.Failure == S.Failure, C.Output == S.Input { + self.pub.subscribe(subscriber) + } + + public func merge(with other: P) -> Publishers.Merge4 + where C.Failure == P.Failure, C.Output == P.Output { + return .init(a, b, c, other) + } + + public func merge(with z: Z, _ y: Y) -> Publishers.Merge5 + where + Z: Publisher, Y: Publisher, C.Failure == Z.Failure, C.Output == Z.Output, + Z.Failure == Y.Failure, Z.Output == Y.Output + { + return .init(a, b, c, z, y) + } + + public func merge(with z: Z, _ y: Y, _ x: X) + -> Publishers.Merge6 + where + Z: Publisher, Y: Publisher, X: Publisher, C.Failure == Z.Failure, + C.Output == Z.Output, Z.Failure == Y.Failure, Z.Output == Y.Output, + Y.Failure == X.Failure, Y.Output == X.Output + { + return .init(a, b, c, z, y, x) + } + + public func merge(with z: Z, _ y: Y, _ x: X, _ w: W) + -> Publishers.Merge7 + where + Z: Publisher, Y: Publisher, X: Publisher, W: Publisher, C.Failure == Z.Failure, + C.Output == Z.Output, Z.Failure == Y.Failure, Z.Output == Y.Output, + Y.Failure == X.Failure, Y.Output == X.Output, X.Failure == W.Failure, + X.Output == W.Output + { + return .init(a, b, c, z, y, x, w) + } + + public func merge(with z: Z, _ y: Y, _ x: X, _ w: W, _ v: V) + -> Publishers.Merge8 + where + Z: Publisher, Y: Publisher, X: Publisher, W: Publisher, V: Publisher, + C.Failure == Z.Failure, C.Output == Z.Output, Z.Failure == Y.Failure, + Z.Output == Y.Output, Y.Failure == X.Failure, Y.Output == X.Output, + X.Failure == W.Failure, X.Output == W.Output, W.Failure == V.Failure, + W.Output == V.Output + { + return .init(a, b, c, z, y, x, w, v) + } + } + + /// A publisher created by applying the merge function to four upstream publishers. + public struct Merge4: Publisher + where + A: Publisher, B: Publisher, C: Publisher, D: Publisher, A.Failure == B.Failure, + A.Output == B.Output, B.Failure == C.Failure, B.Output == C.Output, + C.Failure == D.Failure, C.Output == D.Output + { + + public typealias Output = A.Output + + public typealias Failure = A.Failure + + public let a: A + + public let b: B + + public let c: C + + public let d: D + + let pub: AnyPublisher + + public init(_ a: A, _ b: B, _ c: C, _ d: D) { + self.a = a + self.b = b + self.c = c + self.d = d + + self.pub = + Publishers + .Sequence(sequence: [ + a.eraseToAnyPublisher(), + b.eraseToAnyPublisher(), + c.eraseToAnyPublisher(), + d.eraseToAnyPublisher(), + ]) + .flatMap { $0 } + .eraseToAnyPublisher() + } + + public func receive(subscriber: S) + where D.Failure == S.Failure, D.Output == S.Input { + self.pub.subscribe(subscriber) + } + + public func merge(with other: P) -> Publishers.Merge5 + where D.Failure == P.Failure, D.Output == P.Output { + return .init(a, b, c, d, other) + } + + public func merge(with z: Z, _ y: Y) -> Publishers.Merge6 + where + Z: Publisher, Y: Publisher, D.Failure == Z.Failure, D.Output == Z.Output, + Z.Failure == Y.Failure, Z.Output == Y.Output + { + return .init(a, b, c, d, z, y) + } + + public func merge(with z: Z, _ y: Y, _ x: X) + -> Publishers.Merge7 + where + Z: Publisher, Y: Publisher, X: Publisher, D.Failure == Z.Failure, + D.Output == Z.Output, Z.Failure == Y.Failure, Z.Output == Y.Output, + Y.Failure == X.Failure, Y.Output == X.Output + { + return .init(a, b, c, d, z, y, x) + } + + public func merge(with z: Z, _ y: Y, _ x: X, _ w: W) + -> Publishers.Merge8 + where + Z: Publisher, Y: Publisher, X: Publisher, W: Publisher, D.Failure == Z.Failure, + D.Output == Z.Output, Z.Failure == Y.Failure, Z.Output == Y.Output, + Y.Failure == X.Failure, Y.Output == X.Output, X.Failure == W.Failure, + X.Output == W.Output + { + return .init(a, b, c, d, z, y, x, w) + } + } + + /// A publisher created by applying the merge function to five upstream publishers. + public struct Merge5: Publisher + where + A: Publisher, B: Publisher, C: Publisher, D: Publisher, E: Publisher, + A.Failure == B.Failure, A.Output == B.Output, B.Failure == C.Failure, + B.Output == C.Output, C.Failure == D.Failure, C.Output == D.Output, + D.Failure == E.Failure, D.Output == E.Output + { + + public typealias Output = A.Output + + public typealias Failure = A.Failure + + public let a: A + + public let b: B + + public let c: C + + public let d: D + + public let e: E + + let pub: AnyPublisher + + public init(_ a: A, _ b: B, _ c: C, _ d: D, _ e: E) { + self.a = a + self.b = b + self.c = c + self.d = d + self.e = e + + self.pub = + Publishers + .Sequence(sequence: [ + a.eraseToAnyPublisher(), + b.eraseToAnyPublisher(), + c.eraseToAnyPublisher(), + d.eraseToAnyPublisher(), + e.eraseToAnyPublisher(), + ]) + .flatMap { $0 } + .eraseToAnyPublisher() + } + + public func receive(subscriber: S) + where E.Failure == S.Failure, E.Output == S.Input { + self.pub.subscribe(subscriber) + } + + public func merge(with other: P) -> Publishers.Merge6 + where E.Failure == P.Failure, E.Output == P.Output { + return .init(a, b, c, d, e, other) + } + + public func merge(with z: Z, _ y: Y) -> Publishers.Merge7 + where + Z: Publisher, Y: Publisher, E.Failure == Z.Failure, E.Output == Z.Output, + Z.Failure == Y.Failure, Z.Output == Y.Output + { + return .init(a, b, c, d, e, z, y) + } + + public func merge(with z: Z, _ y: Y, _ x: X) + -> Publishers.Merge8 + where + Z: Publisher, Y: Publisher, X: Publisher, E.Failure == Z.Failure, + E.Output == Z.Output, Z.Failure == Y.Failure, Z.Output == Y.Output, + Y.Failure == X.Failure, Y.Output == X.Output + { + return .init(a, b, c, d, e, z, y, x) + } + } + + /// A publisher created by applying the merge function to six upstream publishers. + public struct Merge6: Publisher + where + A: Publisher, B: Publisher, C: Publisher, D: Publisher, E: Publisher, F: Publisher, + A.Failure == B.Failure, A.Output == B.Output, B.Failure == C.Failure, + B.Output == C.Output, C.Failure == D.Failure, C.Output == D.Output, + D.Failure == E.Failure, D.Output == E.Output, E.Failure == F.Failure, + E.Output == F.Output + { + + public typealias Output = A.Output + + public typealias Failure = A.Failure + + public let a: A + + public let b: B + + public let c: C + + public let d: D + + public let e: E + + public let f: F + + let pub: AnyPublisher + + public init(_ a: A, _ b: B, _ c: C, _ d: D, _ e: E, _ f: F) { + self.a = a + self.b = b + self.c = c + self.d = d + self.e = e + self.f = f + + self.pub = + Publishers + .Sequence(sequence: [ + a.eraseToAnyPublisher(), + b.eraseToAnyPublisher(), + c.eraseToAnyPublisher(), + d.eraseToAnyPublisher(), + e.eraseToAnyPublisher(), + f.eraseToAnyPublisher(), + ]) + .flatMap { $0 } + .eraseToAnyPublisher() + } + + public func receive(subscriber: S) + where F.Failure == S.Failure, F.Output == S.Input { + self.pub.subscribe(subscriber) + } + + public func merge(with other: P) -> Publishers.Merge7 + where F.Failure == P.Failure, F.Output == P.Output { + return .init(a, b, c, d, e, f, other) + } + + public func merge(with z: Z, _ y: Y) -> Publishers.Merge8 + where + Z: Publisher, Y: Publisher, F.Failure == Z.Failure, F.Output == Z.Output, + Z.Failure == Y.Failure, Z.Output == Y.Output + { + return .init(a, b, c, d, e, f, z, y) + } + } + + /// A publisher created by applying the merge function to seven upstream publishers. + public struct Merge7: Publisher + where + A: Publisher, B: Publisher, C: Publisher, D: Publisher, E: Publisher, F: Publisher, + G: Publisher, A.Failure == B.Failure, A.Output == B.Output, B.Failure == C.Failure, + B.Output == C.Output, C.Failure == D.Failure, C.Output == D.Output, + D.Failure == E.Failure, D.Output == E.Output, E.Failure == F.Failure, + E.Output == F.Output, F.Failure == G.Failure, F.Output == G.Output + { + + public typealias Output = A.Output + + public typealias Failure = A.Failure + + public let a: A + + public let b: B + + public let c: C + + public let d: D + + public let e: E + + public let f: F + + public let g: G + + let pub: AnyPublisher + + public init(_ a: A, _ b: B, _ c: C, _ d: D, _ e: E, _ f: F, _ g: G) { + self.a = a + self.b = b + self.c = c + self.d = d + self.e = e + self.f = f + self.g = g + + self.pub = + Publishers + .Sequence(sequence: [ + a.eraseToAnyPublisher(), + b.eraseToAnyPublisher(), + c.eraseToAnyPublisher(), + d.eraseToAnyPublisher(), + e.eraseToAnyPublisher(), + f.eraseToAnyPublisher(), + g.eraseToAnyPublisher(), + ]) + .flatMap { $0 } + .eraseToAnyPublisher() + } + + public func receive(subscriber: S) + where G.Failure == S.Failure, G.Output == S.Input { + self.pub.subscribe(subscriber) + } + + public func merge(with other: P) + -> Publishers.Merge8 + where G.Failure == P.Failure, G.Output == P.Output { + return .init(a, b, c, d, e, f, g, other) + } + } + + /// A publisher created by applying the merge function to eight upstream publishers. + public struct Merge8: Publisher + where + A: Publisher, B: Publisher, C: Publisher, D: Publisher, E: Publisher, F: Publisher, + G: Publisher, H: Publisher, A.Failure == B.Failure, A.Output == B.Output, + B.Failure == C.Failure, B.Output == C.Output, C.Failure == D.Failure, + C.Output == D.Output, D.Failure == E.Failure, D.Output == E.Output, + E.Failure == F.Failure, E.Output == F.Output, F.Failure == G.Failure, + F.Output == G.Output, G.Failure == H.Failure, G.Output == H.Output + { + + public typealias Output = A.Output + + public typealias Failure = A.Failure + + public let a: A + + public let b: B + + public let c: C + + public let d: D + + public let e: E + + public let f: F + + public let g: G + + public let h: H + + let pub: AnyPublisher + + public init(_ a: A, _ b: B, _ c: C, _ d: D, _ e: E, _ f: F, _ g: G, _ h: H) { + self.a = a + self.b = b + self.c = c + self.d = d + self.e = e + self.f = f + self.g = g + self.h = h + + self.pub = + Publishers + .Sequence(sequence: [ + a.eraseToAnyPublisher(), + b.eraseToAnyPublisher(), + c.eraseToAnyPublisher(), + d.eraseToAnyPublisher(), + e.eraseToAnyPublisher(), + f.eraseToAnyPublisher(), + g.eraseToAnyPublisher(), + h.eraseToAnyPublisher(), + ]) + .flatMap { $0 } + .eraseToAnyPublisher() + } + + public func receive(subscriber: S) + where H.Failure == S.Failure, H.Output == S.Input { + self.pub.subscribe(subscriber) + } + } + + public struct MergeMany: Publisher { + + public typealias Output = Upstream.Output + + public typealias Failure = Upstream.Failure + + public let publishers: [Upstream] + + let pub: AnyPublisher + + public init(_ upstream: Upstream...) { + self.publishers = upstream + + self.pub = + Publishers + .Sequence(sequence: upstream) + .flatMap { $0 } + .eraseToAnyPublisher() + } + + public init(_ upstream: S) where Upstream == S.Element { + self.publishers = Array(upstream) + + self.pub = + Publishers + .Sequence(sequence: upstream) + .flatMap { $0 } + .eraseToAnyPublisher() + } + + public func receive(subscriber: S) + where Upstream.Failure == S.Failure, Upstream.Output == S.Input { + + self.pub.subscribe(subscriber) + } + + public func merge(with other: Upstream) -> Publishers.MergeMany { + return .init(Array(self.publishers) + [other]) + } + } + } +#endif diff --git a/Sources/ComposableArchitecture/Observation/Alert+Observation.swift b/Sources/ComposableArchitecture/Observation/Alert+Observation.swift index eca61ede1039..52f42e986565 100644 --- a/Sources/ComposableArchitecture/Observation/Alert+Observation.swift +++ b/Sources/ComposableArchitecture/Observation/Alert+Observation.swift @@ -1,82 +1,84 @@ -import SwiftUI +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + import SwiftUI -@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) -extension View { - /// Presents an alert when a piece of optional state held in a store becomes non-`nil`. - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public func alert(_ item: Binding, Action>?>) -> some View { - let store = item.wrappedValue - let alertState = store?.withState { $0 } - return self.alert( - (alertState?.title).map(Text.init) ?? Text(verbatim: ""), - isPresented: Binding(item), - presenting: alertState, - actions: { alertState in - ForEach(alertState.buttons) { button in - Button(role: button.role.map(ButtonRole.init)) { - switch button.action.type { - case let .send(action): - if let action { - store?.send(action) - } - case let .animatedSend(action, animation): - if let action { - store?.send(action, animation: animation) + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + extension View { + /// Presents an alert when a piece of optional state held in a store becomes non-`nil`. + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency @MainActor + #endif + public func alert(_ item: Binding, Action>?>) -> some View { + let store = item.wrappedValue + let alertState = store?.withState { $0 } + return self.alert( + (alertState?.title).map(Text.init) ?? Text(verbatim: ""), + isPresented: Binding(item), + presenting: alertState, + actions: { alertState in + ForEach(alertState.buttons) { button in + Button(role: button.role.map(ButtonRole.init)) { + switch button.action.type { + case .send(let action): + if let action { + store?.send(action) + } + case .animatedSend(let action, let animation): + if let action { + store?.send(action, animation: animation) + } } + } label: { + Text(button.label) } - } label: { - Text(button.label) } + }, + message: { + $0.message.map(Text.init) } - }, - message: { - $0.message.map(Text.init) - } - ) - } + ) + } - /// Presents an alert when a piece of optional state held in a store becomes non-`nil`. - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public func confirmationDialog( - _ item: Binding, Action>?> - ) -> some View { - let store = item.wrappedValue - let confirmationDialogState = store?.withState { $0 } - return self.confirmationDialog( - (confirmationDialogState?.title).map(Text.init) ?? Text(verbatim: ""), - isPresented: Binding(item), - titleVisibility: (confirmationDialogState?.titleVisibility).map(Visibility.init) - ?? .automatic, - presenting: confirmationDialogState, - actions: { confirmationDialogState in - ForEach(confirmationDialogState.buttons) { button in - Button(role: button.role.map(ButtonRole.init)) { - switch button.action.type { - case let .send(action): - if let action { - store?.send(action) - } - case let .animatedSend(action, animation): - if let action { - store?.send(action, animation: animation) + /// Presents an alert when a piece of optional state held in a store becomes non-`nil`. + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency @MainActor + #endif + public func confirmationDialog( + _ item: Binding, Action>?> + ) -> some View { + let store = item.wrappedValue + let confirmationDialogState = store?.withState { $0 } + return self.confirmationDialog( + (confirmationDialogState?.title).map(Text.init) ?? Text(verbatim: ""), + isPresented: Binding(item), + titleVisibility: (confirmationDialogState?.titleVisibility).map(Visibility.init) + ?? .automatic, + presenting: confirmationDialogState, + actions: { confirmationDialogState in + ForEach(confirmationDialogState.buttons) { button in + Button(role: button.role.map(ButtonRole.init)) { + switch button.action.type { + case .send(let action): + if let action { + store?.send(action) + } + case .animatedSend(let action, let animation): + if let action { + store?.send(action, animation: animation) + } } + } label: { + Text(button.label) } - } label: { - Text(button.label) } + }, + message: { + $0.message.map(Text.init) } - }, - message: { - $0.message.map(Text.init) - } - ) + ) + } } -} +#endif diff --git a/Sources/ComposableArchitecture/Observation/Binding+Observation.swift b/Sources/ComposableArchitecture/Observation/Binding+Observation.swift index d462a50cf0bf..7a4421a622c1 100644 --- a/Sources/ComposableArchitecture/Observation/Binding+Observation.swift +++ b/Sources/ComposableArchitecture/Observation/Binding+Observation.swift @@ -1,434 +1,436 @@ -import SwiftUI - -extension Binding { - @_disfavoredOverload - public subscript( - dynamicMember keyPath: KeyPath - ) -> _StoreBinding - where Value == Store { - _StoreBinding(binding: self, keyPath: keyPath) - } -} - -extension ObservedObject.Wrapper { - @_disfavoredOverload - public subscript( - dynamicMember keyPath: KeyPath - ) -> _StoreObservedObject - where ObjectType == Store { - _StoreObservedObject(wrapper: self, keyPath: keyPath) - } -} - -extension UIBinding { - @_disfavoredOverload - public subscript( - dynamicMember keyPath: KeyPath - ) -> _StoreUIBinding - where Value == Store { - _StoreUIBinding(binding: self, keyPath: keyPath) - } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SwiftUI.Bindable { - @_disfavoredOverload - public subscript( - dynamicMember keyPath: KeyPath - ) -> _StoreBindable_SwiftUI - where Value == Store { - _StoreBindable_SwiftUI(bindable: self, keyPath: keyPath) - } -} - -@available(iOS, introduced: 13, obsoleted: 17) -@available(macOS, introduced: 10.15, obsoleted: 14) -@available(tvOS, introduced: 13, obsoleted: 17) -@available(watchOS, introduced: 6, obsoleted: 10) -@available(visionOS, unavailable) -extension Perception.Bindable { - @_disfavoredOverload - public subscript( - dynamicMember keyPath: KeyPath - ) -> _StoreBindable_Perception - where Value == Store { - _StoreBindable_Perception(bindable: self, keyPath: keyPath) - } -} - -extension UIBindable { - @_disfavoredOverload - public subscript( - dynamicMember keyPath: KeyPath - ) -> _StoreUIBindable - where Value == Store { - _StoreUIBindable(bindable: self, keyPath: keyPath) +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + import SwiftUI + + extension Binding { + @_disfavoredOverload + public subscript( + dynamicMember keyPath: KeyPath + ) -> _StoreBinding + where Value == Store { + _StoreBinding(binding: self, keyPath: keyPath) + } } -} - -extension BindingAction { - public static func set( - _ keyPath: _SendableWritableKeyPath, - _ value: Value - ) -> Self where Root: ObservableState { - .init( - keyPath: keyPath, - set: { $0[keyPath: keyPath] = value }, - value: value, - valueIsEqualTo: { $0 as? Value == value } - ) + + extension ObservedObject.Wrapper { + @_disfavoredOverload + public subscript( + dynamicMember keyPath: KeyPath + ) -> _StoreObservedObject + where ObjectType == Store { + _StoreObservedObject(wrapper: self, keyPath: keyPath) + } } - public static func ~= ( - keyPath: WritableKeyPath, - bindingAction: Self - ) -> Bool where Root: ObservableState { - keyPath == bindingAction.keyPath + extension UIBinding { + @_disfavoredOverload + public subscript( + dynamicMember keyPath: KeyPath + ) -> _StoreUIBinding + where Value == Store { + _StoreUIBinding(binding: self, keyPath: keyPath) + } } -} - -#if DEBUG - private final class BindableActionDebugger: Sendable { - let isInvalidated: @MainActor @Sendable () -> Bool - let value: any Sendable - let wasCalled = LockIsolated(false) - init( - value: some Sendable, - isInvalidated: @escaping @MainActor @Sendable () -> Bool - ) { - self.value = value - self.isInvalidated = isInvalidated + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SwiftUI.Bindable { + @_disfavoredOverload + public subscript( + dynamicMember keyPath: KeyPath + ) -> _StoreBindable_SwiftUI + where Value == Store { + _StoreBindable_SwiftUI(bindable: self, keyPath: keyPath) } - deinit { - guard !wasCalled.value - else { return } - Task { @MainActor [value, isInvalidated] in - guard !isInvalidated() else { return } - var valueDump: String { - var valueDump = "" - customDump(value, to: &valueDump, maxDepth: 0) - return valueDump - } - reportIssue( - """ - A binding action sent from a store was not handled. + } - Action: - \(typeName(Action.self)).binding(.set(_, \(valueDump))) + @available(iOS, introduced: 13, obsoleted: 17) + @available(macOS, introduced: 10.15, obsoleted: 14) + @available(tvOS, introduced: 13, obsoleted: 17) + @available(watchOS, introduced: 6, obsoleted: 10) + @available(visionOS, unavailable) + extension Perception.Bindable { + @_disfavoredOverload + public subscript( + dynamicMember keyPath: KeyPath + ) -> _StoreBindable_Perception + where Value == Store { + _StoreBindable_Perception(bindable: self, keyPath: keyPath) + } + } - To fix this, invoke "BindingReducer()" from your feature reducer's "body". - """ - ) - } + extension UIBindable { + @_disfavoredOverload + public subscript( + dynamicMember keyPath: KeyPath + ) -> _StoreUIBindable + where Value == Store { + _StoreUIBindable(bindable: self, keyPath: keyPath) } } -#endif -extension BindableAction where State: ObservableState { - fileprivate static func set( - _ keyPath: _SendableWritableKeyPath, - _ value: Value, - isInvalidated: (@MainActor @Sendable () -> Bool)? - ) -> Self { - #if DEBUG - if let isInvalidated { - let debugger = BindableActionDebugger( - value: value, - isInvalidated: isInvalidated - ) - return Self.binding( - .init( - keyPath: keyPath, - set: { - debugger.wasCalled.setValue(true) - $0[keyPath: keyPath] = value - }, - value: value, - valueIsEqualTo: { $0 as? Value == value } - ) - ) - } - #endif - return Self.binding( + extension BindingAction { + public static func set( + _ keyPath: _SendableWritableKeyPath, + _ value: Value + ) -> Self where Root: ObservableState { .init( keyPath: keyPath, set: { $0[keyPath: keyPath] = value }, value: value, valueIsEqualTo: { $0 as? Value == value } ) - ) - } + } - public static func set( - _ keyPath: _SendableWritableKeyPath, - _ value: Value - ) -> Self { - self.set(keyPath, value, isInvalidated: nil) + public static func ~= ( + keyPath: WritableKeyPath, + bindingAction: Self + ) -> Bool where Root: ObservableState { + keyPath == bindingAction.keyPath + } } -} - -extension Store where State: ObservableState, Action: BindableAction, Action.State == State { - public subscript( - dynamicMember keyPath: WritableKeyPath - ) -> Value { - get { self.state[keyPath: keyPath] } - set { - BindingLocal.$isActive.withValue(true) { - self.send( - .set( - keyPath.unsafeSendable(), - newValue, - isInvalidated: { [weak self] in self?.core.isInvalid ?? true } + + #if DEBUG + private final class BindableActionDebugger: Sendable { + let isInvalidated: @MainActor @Sendable () -> Bool + let value: any Sendable + let wasCalled = LockIsolated(false) + init( + value: some Sendable, + isInvalidated: @escaping @MainActor @Sendable () -> Bool + ) { + self.value = value + self.isInvalidated = isInvalidated + } + deinit { + guard !wasCalled.value + else { return } + Task { @MainActor [value, isInvalidated] in + guard !isInvalidated() else { return } + var valueDump: String { + var valueDump = "" + customDump(value, to: &valueDump, maxDepth: 0) + return valueDump + } + reportIssue( + """ + A binding action sent from a store was not handled. + + Action: + \(typeName(Action.self)).binding(.set(_, \(valueDump))) + + To fix this, invoke "BindingReducer()" from your feature reducer's "body". + """ ) - ) + } } } - } -} - -extension Store -where - State: Equatable & Sendable, - State: ObservableState, - Action: BindableAction, - Action.State == State -{ - public var state: State { - get { self.observableState } - set { - BindingLocal.$isActive.withValue(true) { - self.send( - .set(\.self, newValue, isInvalidated: { [weak self] in self?.core.isInvalid ?? true }) + #endif + + extension BindableAction where State: ObservableState { + fileprivate static func set( + _ keyPath: _SendableWritableKeyPath, + _ value: Value, + isInvalidated: (@MainActor @Sendable () -> Bool)? + ) -> Self { + #if DEBUG + if let isInvalidated { + let debugger = BindableActionDebugger( + value: value, + isInvalidated: isInvalidated + ) + return Self.binding( + .init( + keyPath: keyPath, + set: { + debugger.wasCalled.setValue(true) + $0[keyPath: keyPath] = value + }, + value: value, + valueIsEqualTo: { $0 as? Value == value } + ) + ) + } + #endif + return Self.binding( + .init( + keyPath: keyPath, + set: { $0[keyPath: keyPath] = value }, + value: value, + valueIsEqualTo: { $0 as? Value == value } ) - } + ) + } + + public static func set( + _ keyPath: _SendableWritableKeyPath, + _ value: Value + ) -> Self { + self.set(keyPath, value, isInvalidated: nil) } } -} - -extension Store -where - State: ObservableState, - Action: ViewAction, - Action.ViewAction: BindableAction, - Action.ViewAction.State == State -{ - public subscript( - dynamicMember keyPath: WritableKeyPath - ) -> Value { - get { self.state[keyPath: keyPath] } - set { - BindingLocal.$isActive.withValue(true) { - self.send( - .view( + + extension Store where State: ObservableState, Action: BindableAction, Action.State == State { + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> Value { + get { self.state[keyPath: keyPath] } + set { + BindingLocal.$isActive.withValue(true) { + self.send( .set( keyPath.unsafeSendable(), newValue, isInvalidated: { [weak self] in self?.core.isInvalid ?? true } ) ) - ) + } } } } -} - -extension Store -where - State: Equatable & Sendable, - State: ObservableState, - Action: ViewAction, - Action.ViewAction: BindableAction, - Action.ViewAction.State == State -{ - public var state: State { - get { self.observableState } - set { - BindingLocal.$isActive.withValue(true) { - self.send( - .view( + + extension Store + where + State: Equatable & Sendable, + State: ObservableState, + Action: BindableAction, + Action.State == State + { + public var state: State { + get { self.observableState } + set { + BindingLocal.$isActive.withValue(true) { + self.send( .set(\.self, newValue, isInvalidated: { [weak self] in self?.core.isInvalid ?? true }) ) - ) + } } } } -} - -@dynamicMemberLookup -public struct _StoreBinding { - fileprivate let binding: Binding> - fileprivate let keyPath: KeyPath - - public subscript( - dynamicMember keyPath: KeyPath - ) -> _StoreBinding { - _StoreBinding( - binding: self.binding, - keyPath: self.keyPath.appending(path: keyPath) - ) - } - /// Creates a binding to the value by sending new values through the given action. - /// - /// - Parameter action: An action for the binding to send values through. - /// - Returns: A binding. - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public func sending(_ action: CaseKeyPath) -> Binding { - self.binding[state: self.keyPath, action: action] - } -} - -@dynamicMemberLookup -public struct _StoreObservedObject { - fileprivate let wrapper: ObservedObject>.Wrapper - fileprivate let keyPath: KeyPath - - public subscript( - dynamicMember keyPath: KeyPath - ) -> _StoreObservedObject { - _StoreObservedObject( - wrapper: wrapper, - keyPath: self.keyPath.appending(path: keyPath) - ) + extension Store + where + State: ObservableState, + Action: ViewAction, + Action.ViewAction: BindableAction, + Action.ViewAction.State == State + { + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> Value { + get { self.state[keyPath: keyPath] } + set { + BindingLocal.$isActive.withValue(true) { + self.send( + .view( + .set( + keyPath.unsafeSendable(), + newValue, + isInvalidated: { [weak self] in self?.core.isInvalid ?? true } + ) + ) + ) + } + } + } } - /// Creates a binding to the value by sending new values through the given action. - /// - /// - Parameter action: An action for the binding to send values through. - /// - Returns: A binding. - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public func sending(_ action: CaseKeyPath) -> Binding { - self.wrapper[state: self.keyPath, action: action] - } -} - -@dynamicMemberLookup -public struct _StoreUIBinding { - fileprivate let binding: UIBinding> - fileprivate let keyPath: KeyPath - - public subscript( - dynamicMember keyPath: KeyPath - ) -> _StoreUIBinding { - _StoreUIBinding( - binding: self.binding, - keyPath: self.keyPath.appending(path: keyPath) - ) + extension Store + where + State: Equatable & Sendable, + State: ObservableState, + Action: ViewAction, + Action.ViewAction: BindableAction, + Action.ViewAction.State == State + { + public var state: State { + get { self.observableState } + set { + BindingLocal.$isActive.withValue(true) { + self.send( + .view( + .set(\.self, newValue, isInvalidated: { [weak self] in self?.core.isInvalid ?? true }) + ) + ) + } + } + } } - /// Creates a binding to the value by sending new values through the given action. - /// - /// - Parameter action: An action for the binding to send values through. - /// - Returns: A binding. - @MainActor - public func sending(_ action: CaseKeyPath) -> UIBinding { - self.binding[state: self.keyPath, action: action] - } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -@dynamicMemberLookup -public struct _StoreBindable_SwiftUI { - fileprivate let bindable: SwiftUI.Bindable> - fileprivate let keyPath: KeyPath - - public subscript( - dynamicMember keyPath: KeyPath - ) -> _StoreBindable_SwiftUI { - _StoreBindable_SwiftUI( - bindable: self.bindable, - keyPath: self.keyPath.appending(path: keyPath) - ) + @dynamicMemberLookup + public struct _StoreBinding { + fileprivate let binding: Binding> + fileprivate let keyPath: KeyPath + + public subscript( + dynamicMember keyPath: KeyPath + ) -> _StoreBinding { + _StoreBinding( + binding: self.binding, + keyPath: self.keyPath.appending(path: keyPath) + ) + } + + /// Creates a binding to the value by sending new values through the given action. + /// + /// - Parameter action: An action for the binding to send values through. + /// - Returns: A binding. + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency @MainActor + #endif + public func sending(_ action: CaseKeyPath) -> Binding { + self.binding[state: self.keyPath, action: action] + } } - /// Creates a binding to the value by sending new values through the given action. - /// - /// - Parameter action: An action for the binding to send values through. - /// - Returns: A binding. - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public func sending(_ action: CaseKeyPath) -> Binding { - self.bindable[state: self.keyPath, action: action] + @dynamicMemberLookup + public struct _StoreObservedObject { + fileprivate let wrapper: ObservedObject>.Wrapper + fileprivate let keyPath: KeyPath + + public subscript( + dynamicMember keyPath: KeyPath + ) -> _StoreObservedObject { + _StoreObservedObject( + wrapper: wrapper, + keyPath: self.keyPath.appending(path: keyPath) + ) + } + + /// Creates a binding to the value by sending new values through the given action. + /// + /// - Parameter action: An action for the binding to send values through. + /// - Returns: A binding. + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency @MainActor + #endif + public func sending(_ action: CaseKeyPath) -> Binding { + self.wrapper[state: self.keyPath, action: action] + } } -} - -@available(iOS, introduced: 13, obsoleted: 17) -@available(macOS, introduced: 10.15, obsoleted: 14) -@available(tvOS, introduced: 13, obsoleted: 17) -@available(watchOS, introduced: 6, obsoleted: 10) -@available(visionOS, unavailable) -@dynamicMemberLookup -public struct _StoreBindable_Perception { - fileprivate let bindable: Perception.Bindable> - fileprivate let keyPath: KeyPath - - public subscript( - dynamicMember keyPath: KeyPath - ) -> _StoreBindable_Perception { - _StoreBindable_Perception( - bindable: self.bindable, - keyPath: self.keyPath.appending(path: keyPath) - ) + + @dynamicMemberLookup + public struct _StoreUIBinding { + fileprivate let binding: UIBinding> + fileprivate let keyPath: KeyPath + + public subscript( + dynamicMember keyPath: KeyPath + ) -> _StoreUIBinding { + _StoreUIBinding( + binding: self.binding, + keyPath: self.keyPath.appending(path: keyPath) + ) + } + + /// Creates a binding to the value by sending new values through the given action. + /// + /// - Parameter action: An action for the binding to send values through. + /// - Returns: A binding. + @MainActor + public func sending(_ action: CaseKeyPath) -> UIBinding { + self.binding[state: self.keyPath, action: action] + } } - /// Creates a binding to the value by sending new values through the given action. - /// - /// - Parameter action: An action for the binding to send values through. - /// - Returns: A binding. - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public func sending(_ action: CaseKeyPath) -> Binding { - self.bindable[state: self.keyPath, action: action] + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @dynamicMemberLookup + public struct _StoreBindable_SwiftUI { + fileprivate let bindable: SwiftUI.Bindable> + fileprivate let keyPath: KeyPath + + public subscript( + dynamicMember keyPath: KeyPath + ) -> _StoreBindable_SwiftUI { + _StoreBindable_SwiftUI( + bindable: self.bindable, + keyPath: self.keyPath.appending(path: keyPath) + ) + } + + /// Creates a binding to the value by sending new values through the given action. + /// + /// - Parameter action: An action for the binding to send values through. + /// - Returns: A binding. + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency @MainActor + #endif + public func sending(_ action: CaseKeyPath) -> Binding { + self.bindable[state: self.keyPath, action: action] + } } -} - -public struct _StoreUIBindable { - fileprivate let bindable: UIBindable> - fileprivate let keyPath: KeyPath - - public subscript( - dynamicMember keyPath: KeyPath - ) -> _StoreUIBindable { - _StoreUIBindable( - bindable: self.bindable, - keyPath: self.keyPath.appending(path: keyPath) - ) + + @available(iOS, introduced: 13, obsoleted: 17) + @available(macOS, introduced: 10.15, obsoleted: 14) + @available(tvOS, introduced: 13, obsoleted: 17) + @available(watchOS, introduced: 6, obsoleted: 10) + @available(visionOS, unavailable) + @dynamicMemberLookup + public struct _StoreBindable_Perception { + fileprivate let bindable: Perception.Bindable> + fileprivate let keyPath: KeyPath + + public subscript( + dynamicMember keyPath: KeyPath + ) -> _StoreBindable_Perception { + _StoreBindable_Perception( + bindable: self.bindable, + keyPath: self.keyPath.appending(path: keyPath) + ) + } + + /// Creates a binding to the value by sending new values through the given action. + /// + /// - Parameter action: An action for the binding to send values through. + /// - Returns: A binding. + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency @MainActor + #endif + public func sending(_ action: CaseKeyPath) -> Binding { + self.bindable[state: self.keyPath, action: action] + } } - /// Creates a binding to the value by sending new values through the given action. - /// - /// - Parameter action: An action for the binding to send values through. - /// - Returns: A binding. - @MainActor - public func sending(_ action: CaseKeyPath) -> UIBinding { - self.bindable[state: self.keyPath, action: action] + public struct _StoreUIBindable { + fileprivate let bindable: UIBindable> + fileprivate let keyPath: KeyPath + + public subscript( + dynamicMember keyPath: KeyPath + ) -> _StoreUIBindable { + _StoreUIBindable( + bindable: self.bindable, + keyPath: self.keyPath.appending(path: keyPath) + ) + } + + /// Creates a binding to the value by sending new values through the given action. + /// + /// - Parameter action: An action for the binding to send values through. + /// - Returns: A binding. + @MainActor + public func sending(_ action: CaseKeyPath) -> UIBinding { + self.bindable[state: self.keyPath, action: action] + } } -} - -extension Store where State: ObservableState { - fileprivate subscript( - state state: KeyPath, - action action: CaseKeyPath - ) -> Value { - get { self.state[keyPath: state] } - set { - BindingLocal.$isActive.withValue(true) { - self.send(action(newValue)) + + extension Store where State: ObservableState { + fileprivate subscript( + state state: KeyPath, + action action: CaseKeyPath + ) -> Value { + get { self.state[keyPath: state] } + set { + BindingLocal.$isActive.withValue(true) { + self.send(action(newValue)) + } } } } -} +#endif diff --git a/Sources/ComposableArchitecture/Observation/IdentifiedArray+Observation.swift b/Sources/ComposableArchitecture/Observation/IdentifiedArray+Observation.swift index 70a39c3baa24..15768cd3e4e8 100644 --- a/Sources/ComposableArchitecture/Observation/IdentifiedArray+Observation.swift +++ b/Sources/ComposableArchitecture/Observation/IdentifiedArray+Observation.swift @@ -1,5 +1,5 @@ +import Foundation import OrderedCollections -import SwiftUI extension Store where State: ObservableState { /// Scopes the store of an identified collection to a collection of stores. @@ -97,7 +97,7 @@ public struct _StoreCollection: RandomAc #if swift(<5.10) @MainActor(unsafe) #else - @preconcurrency@MainActor + @preconcurrency @MainActor #endif fileprivate init(_ store: Store, IdentifiedAction>) { self.store = store diff --git a/Sources/ComposableArchitecture/Observation/NavigationStack+Observation.swift b/Sources/ComposableArchitecture/Observation/NavigationStack+Observation.swift index e72b200471b1..5b777a456fa0 100644 --- a/Sources/ComposableArchitecture/Observation/NavigationStack+Observation.swift +++ b/Sources/ComposableArchitecture/Observation/NavigationStack+Observation.swift @@ -1,245 +1,229 @@ -import SwiftUI - -extension Binding { - /// Derives a binding to a store focused on ``StackState`` and ``StackAction``. - /// - /// This operator is most used in conjunction with `NavigationStack`, and in particular - /// the initializer ``SwiftUI/NavigationStack/init(path:root:destination:fileID:filePath:line:column:)`` that - /// ships with this library. - /// - /// For example, suppose you have a feature that holds onto ``StackState`` in its state in order - /// to represent all the screens that can be pushed onto a navigation stack: - /// - /// ```swift - /// @Reducer - /// struct Feature { - /// @ObservableState - /// struct State { - /// var path: StackState = [] - /// } - /// enum Action { - /// case path(StackActionOf) - /// } - /// var body: some ReducerOf { - /// Reduce { state, action in - /// // Core feature logic - /// } - /// .forEach(\.rows, action: \.rows) { - /// Child() - /// } - /// } - /// @Reducer - /// enum Path { - /// // ... - /// } - /// } - /// ``` - /// - /// > Note: We are using the ``Reducer()`` macro on an enum to compose together all the features - /// that can be pushed onto the stack. See for - /// more information. - /// - /// Then in the view you can use this operator, with - /// `NavigationStack` ``SwiftUI/NavigationStack/init(path:root:destination:fileID:filePath:line:column:)``, to - /// derive a store for each element in the stack: - /// - /// ```swift - /// struct FeatureView: View { - /// @Bindable var store: StoreOf - /// - /// var body: some View { - /// NavigationStack(path: $store.scope(state: \.path, action: \.path)) { - /// // Root view - /// } destination: { - /// // Destinations - /// } - /// } - /// } - /// ``` - #if swift(>=5.10) - @preconcurrency@MainActor - #else - @MainActor(unsafe) - #endif - public func scope( - state: KeyPath>, - action: CaseKeyPath> - ) -> Binding, StackAction>> - where Value == Store { - self[state: state, action: action] - } -} - -extension ObservedObject.Wrapper { - #if swift(>=5.10) - @preconcurrency@MainActor - #else - @MainActor(unsafe) - #endif - public func scope( - state: KeyPath>, - action: CaseKeyPath> - ) -> Binding, StackAction>> - where ObjectType == Store { - self[state: state, action: action] - } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SwiftUI.Bindable { - /// Derives a binding to a store focused on ``StackState`` and ``StackAction``. - /// - /// See ``SwiftUI/Binding/scope(state:action:fileID:filePath:line:column:)`` defined on `Binding` for more - /// information. - #if swift(>=5.10) - @preconcurrency@MainActor - #else - @MainActor(unsafe) - #endif - public func scope( - state: KeyPath>, - action: CaseKeyPath> - ) -> Binding, StackAction>> - where Value == Store { - self[state: state, action: action] +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + import SwiftUI + + extension Binding { + /// Derives a binding to a store focused on ``StackState`` and ``StackAction``. + /// + /// This operator is most used in conjunction with `NavigationStack`, and in particular + /// the initializer ``SwiftUI/NavigationStack/init(path:root:destination:fileID:filePath:line:column:)`` that + /// ships with this library. + /// + /// For example, suppose you have a feature that holds onto ``StackState`` in its state in order + /// to represent all the screens that can be pushed onto a navigation stack: + /// + /// ```swift + /// @Reducer + /// struct Feature { + /// @ObservableState + /// struct State { + /// var path: StackState = [] + /// } + /// enum Action { + /// case path(StackActionOf) + /// } + /// var body: some ReducerOf { + /// Reduce { state, action in + /// // Core feature logic + /// } + /// .forEach(\.rows, action: \.rows) { + /// Child() + /// } + /// } + /// @Reducer + /// enum Path { + /// // ... + /// } + /// } + /// ``` + /// + /// > Note: We are using the ``Reducer()`` macro on an enum to compose together all the features + /// that can be pushed onto the stack. See for + /// more information. + /// + /// Then in the view you can use this operator, with + /// `NavigationStack` ``SwiftUI/NavigationStack/init(path:root:destination:fileID:filePath:line:column:)``, to + /// derive a store for each element in the stack: + /// + /// ```swift + /// struct FeatureView: View { + /// @Bindable var store: StoreOf + /// + /// var body: some View { + /// NavigationStack(path: $store.scope(state: \.path, action: \.path)) { + /// // Root view + /// } destination: { + /// // Destinations + /// } + /// } + /// } + /// ``` + #if swift(>=5.10) + @preconcurrency @MainActor + #else + @MainActor(unsafe) + #endif + public func scope( + state: KeyPath>, + action: CaseKeyPath> + ) -> Binding, StackAction>> + where Value == Store { + self[state: state, action: action] + } } -} - -@available(iOS, introduced: 13, obsoleted: 17) -@available(macOS, introduced: 10.15, obsoleted: 14) -@available(tvOS, introduced: 13, obsoleted: 17) -@available(watchOS, introduced: 6, obsoleted: 10) -extension Perception.Bindable { - /// Derives a binding to a store focused on ``StackState`` and ``StackAction``. - /// - /// See ``SwiftUI/Binding/scope(state:action:fileID:filePath:line:column:)`` defined on `Binding` for more - /// information. - public func scope( - state: KeyPath>, - action: CaseKeyPath> - ) -> Binding, StackAction>> - where Value == Store { - self[state: state, action: action] + + extension ObservedObject.Wrapper { + #if swift(>=5.10) + @preconcurrency @MainActor + #else + @MainActor(unsafe) + #endif + public func scope( + state: KeyPath>, + action: CaseKeyPath> + ) -> Binding, StackAction>> + where ObjectType == Store { + self[state: state, action: action] + } } -} - -extension UIBindable { - /// Derives a binding to a store focused on ``StackState`` and ``StackAction``. - /// - /// See ``SwiftUI/Binding/scope(state:action:fileID:filePath:line:column:)`` defined on `Binding` for more - /// information. - #if swift(>=5.10) - @preconcurrency@MainActor - #else - @MainActor(unsafe) - #endif - public func scope( - state: KeyPath>, - action: CaseKeyPath> - ) -> UIBinding, StackAction>> - where Value == Store { - self[state: state, action: action] + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SwiftUI.Bindable { + /// Derives a binding to a store focused on ``StackState`` and ``StackAction``. + /// + /// See ``SwiftUI/Binding/scope(state:action:fileID:filePath:line:column:)`` defined on `Binding` for more + /// information. + #if swift(>=5.10) + @preconcurrency @MainActor + #else + @MainActor(unsafe) + #endif + public func scope( + state: KeyPath>, + action: CaseKeyPath> + ) -> Binding, StackAction>> + where Value == Store { + self[state: state, action: action] + } } -} - -@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) -extension NavigationStack { - /// Drives a navigation stack with a store. - /// - /// See the dedicated article on for more information on the library's - /// navigation tools, and in particular see for information on using - /// this view. - public init( - path: Binding, StackAction>>, - @ViewBuilder root: () -> R, - @ViewBuilder destination: @escaping (Store) -> Destination, - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) - where - Data == StackState.PathView, - Root == ModifiedContent> - { - self.init( - path: path[ - fileID: _HashableStaticString(rawValue: fileID), - filePath: _HashableStaticString(rawValue: filePath), - line: line, - column: column - ] - ) { - root() - .modifier( - _NavigationDestinationViewModifier( - store: path.wrappedValue, - destination: destination, - fileID: fileID, - filePath: filePath, - line: line, - column: column - ) - ) + + @available(iOS, introduced: 13, obsoleted: 17) + @available(macOS, introduced: 10.15, obsoleted: 14) + @available(tvOS, introduced: 13, obsoleted: 17) + @available(watchOS, introduced: 6, obsoleted: 10) + extension Perception.Bindable { + /// Derives a binding to a store focused on ``StackState`` and ``StackAction``. + /// + /// See ``SwiftUI/Binding/scope(state:action:fileID:filePath:line:column:)`` defined on `Binding` for more + /// information. + public func scope( + state: KeyPath>, + action: CaseKeyPath> + ) -> Binding, StackAction>> + where Value == Store { + self[state: state, action: action] } } -} - -@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) -public struct _NavigationDestinationViewModifier< - State: ObservableState, Action, Destination: View ->: - ViewModifier -{ - @SwiftUI.State var store: Store, StackAction> - fileprivate let destination: (Store) -> Destination - fileprivate let fileID: StaticString - fileprivate let filePath: StaticString - fileprivate let line: UInt - fileprivate let column: UInt - - public func body(content: Content) -> some View { - content - .environment(\.navigationDestinationType, State.self) - .navigationDestination(for: StackState.Component.self) { component in - destination( - store.scope( - component: component, fileID: fileID, filePath: filePath, line: line, column: column) - ) - .environment(\.navigationDestinationType, State.self) - } + + extension UIBindable { + /// Derives a binding to a store focused on ``StackState`` and ``StackAction``. + /// + /// See ``SwiftUI/Binding/scope(state:action:fileID:filePath:line:column:)`` defined on `Binding` for more + /// information. + #if swift(>=5.10) + @preconcurrency @MainActor + #else + @MainActor(unsafe) + #endif + public func scope( + state: KeyPath>, + action: CaseKeyPath> + ) -> UIBinding, StackAction>> + where Value == Store { + self[state: state, action: action] + } } -} - -@_spi(Internals) -extension Store { - public func scope( - component: StackState.Component, - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) -> Store - where State == StackState, Action == StackAction { - let id = self.id( - state: - \.[ - id: component.id, + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension NavigationStack { + /// Drives a navigation stack with a store. + /// + /// See the dedicated article on for more information on the library's + /// navigation tools, and in particular see for information on using + /// this view. + public init( + path: Binding, StackAction>>, + @ViewBuilder root: () -> R, + @ViewBuilder destination: @escaping (Store) -> Destination, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) + where + Data == StackState.PathView, + Root == ModifiedContent> + { + self.init( + path: path[ fileID: _HashableStaticString(rawValue: fileID), filePath: _HashableStaticString(rawValue: filePath), line: line, column: column - ], - action: \.[id: component.id] - ) - @MainActor - func open( - _ core: some Core, StackAction> - ) -> any Core { - IfLetCore( - base: core, - cachedState: component.element, - stateKeyPath: + ] + ) { + root() + .modifier( + _NavigationDestinationViewModifier( + store: path.wrappedValue, + destination: destination, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + ) + } + } + } + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public struct _NavigationDestinationViewModifier< + State: ObservableState, Action, Destination: View + >: + ViewModifier + { + @SwiftUI.State var store: Store, StackAction> + fileprivate let destination: (Store) -> Destination + fileprivate let fileID: StaticString + fileprivate let filePath: StaticString + fileprivate let line: UInt + fileprivate let column: UInt + + public func body(content: Content) -> some View { + content + .environment(\.navigationDestinationType, State.self) + .navigationDestination(for: StackState.Component.self) { component in + destination( + store.scope( + component: component, fileID: fileID, filePath: filePath, line: line, column: column) + ) + .environment(\.navigationDestinationType, State.self) + } + } + } + + @_spi(Internals) + extension Store { + public func scope( + component: StackState.Component, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) -> Store + where State == StackState, Action == StackAction { + let id = self.id( + state: \.[ id: component.id, fileID: _HashableStaticString(rawValue: fileID), @@ -247,336 +231,345 @@ extension Store { line: line, column: column ], - actionKeyPath: \.[id: component.id] + action: \.[id: component.id] ) + @MainActor + func open( + _ core: some Core, StackAction> + ) -> any Core { + IfLetCore( + base: core, + cachedState: component.element, + stateKeyPath: + \.[ + id: component.id, + fileID: _HashableStaticString(rawValue: fileID), + filePath: _HashableStaticString(rawValue: filePath), + line: line, + column: column + ], + actionKeyPath: \.[id: component.id] + ) + } + return self.scope(id: id, childCore: open(self.core)) } - return self.scope(id: id, childCore: open(self.core)) } -} - -@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) -extension NavigationLink where Destination == Never { - /// Creates a navigation link that presents the view corresponding to an element of - /// ``StackState``. - /// - /// When someone activates the navigation link that this initializer creates, SwiftUI looks for - /// a parent `NavigationStack` view with a store of ``StackState`` containing elements that - /// matches the type of this initializer's `state` input. - /// - /// See SwiftUI's documentation for `NavigationLink.init(value:label:)` for more. - /// - /// - Parameters: - /// - state: An optional value to present. When the user selects the link, SwiftUI stores a - /// copy of the value. Pass a `nil` value to disable the link. - /// - label: A label that describes the view that this link presents. - /// - fileID: The fileID. - /// - filePath: The filePath. - /// - line: The line. - /// - column: The column. - #if compiler(>=6) - @MainActor - #endif - public init( - state: P?, - @ViewBuilder label: () -> L, - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) - where Label == _NavigationLinkStoreContent { - @Dependency(\.stackElementID) var stackElementID - self.init(value: state.map { StackState.Component(id: stackElementID(), element: $0) }) { - _NavigationLinkStoreContent( - state: state, - label: label, - fileID: fileID, - filePath: filePath, - line: line, - column: column - ) + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension NavigationLink where Destination == Never { + /// Creates a navigation link that presents the view corresponding to an element of + /// ``StackState``. + /// + /// When someone activates the navigation link that this initializer creates, SwiftUI looks for + /// a parent `NavigationStack` view with a store of ``StackState`` containing elements that + /// matches the type of this initializer's `state` input. + /// + /// See SwiftUI's documentation for `NavigationLink.init(value:label:)` for more. + /// + /// - Parameters: + /// - state: An optional value to present. When the user selects the link, SwiftUI stores a + /// copy of the value. Pass a `nil` value to disable the link. + /// - label: A label that describes the view that this link presents. + /// - fileID: The fileID. + /// - filePath: The filePath. + /// - line: The line. + /// - column: The column. + #if compiler(>=6) + @MainActor + #endif + public init( + state: P?, + @ViewBuilder label: () -> L, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) + where Label == _NavigationLinkStoreContent { + @Dependency(\.stackElementID) var stackElementID + self.init(value: state.map { StackState.Component(id: stackElementID(), element: $0) }) { + _NavigationLinkStoreContent( + state: state, + label: label, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } } - } - /// Creates a navigation link that presents the view corresponding to an element of - /// ``StackState``, with a text label that the link generates from a localized string key. - /// - /// When someone activates the navigation link that this initializer creates, SwiftUI looks for - /// a parent ``NavigationStackStore`` view with a store of ``StackState`` containing elements - /// that matches the type of this initializer's `state` input. - /// - /// See SwiftUI's documentation for `NavigationLink.init(_:value:)` for more. - /// - /// - Parameters: - /// - titleKey: A localized string that describes the view that this link - /// presents. - /// - state: An optional value to present. When the user selects the link, SwiftUI stores a - /// copy of the value. Pass a `nil` value to disable the link. - /// - fileID: The fileID. - /// - line: The line. - #if compiler(>=6) - @MainActor - #endif - public init

( - _ titleKey: LocalizedStringKey, state: P?, fileID: StaticString = #fileID, line: UInt = #line - ) - where Label == _NavigationLinkStoreContent { - self.init(state: state, label: { Text(titleKey) }, fileID: fileID, line: line) - } + /// Creates a navigation link that presents the view corresponding to an element of + /// ``StackState``, with a text label that the link generates from a localized string key. + /// + /// When someone activates the navigation link that this initializer creates, SwiftUI looks for + /// a parent ``NavigationStackStore`` view with a store of ``StackState`` containing elements + /// that matches the type of this initializer's `state` input. + /// + /// See SwiftUI's documentation for `NavigationLink.init(_:value:)` for more. + /// + /// - Parameters: + /// - titleKey: A localized string that describes the view that this link + /// presents. + /// - state: An optional value to present. When the user selects the link, SwiftUI stores a + /// copy of the value. Pass a `nil` value to disable the link. + /// - fileID: The fileID. + /// - line: The line. + #if compiler(>=6) + @MainActor + #endif + public init

( + _ titleKey: LocalizedStringKey, state: P?, fileID: StaticString = #fileID, line: UInt = #line + ) + where Label == _NavigationLinkStoreContent { + self.init(state: state, label: { Text(titleKey) }, fileID: fileID, line: line) + } - /// Creates a navigation link that presents the view corresponding to an element of - /// ``StackState``, with a text label that the link generates from a title string. - /// - /// When someone activates the navigation link that this initializer creates, SwiftUI looks for - /// a parent ``NavigationStackStore`` view with a store of ``StackState`` containing elements - /// that matches the type of this initializer's `state` input. - /// - /// See SwiftUI's documentation for `NavigationLink.init(_:value:)` for more. - /// - /// - Parameters: - /// - title: A string that describes the view that this link presents. - /// - state: An optional value to present. When the user selects the link, SwiftUI stores a - /// copy of the value. Pass a `nil` value to disable the link. - /// - fileID: The fileID. - /// - line: The line. - #if compiler(>=6) - @MainActor - #endif - @_disfavoredOverload - public init( - _ title: S, state: P?, fileID: StaticString = #fileID, line: UInt = #line - ) - where Label == _NavigationLinkStoreContent { - self.init(state: state, label: { Text(title) }, fileID: fileID, line: line) + /// Creates a navigation link that presents the view corresponding to an element of + /// ``StackState``, with a text label that the link generates from a title string. + /// + /// When someone activates the navigation link that this initializer creates, SwiftUI looks for + /// a parent ``NavigationStackStore`` view with a store of ``StackState`` containing elements + /// that matches the type of this initializer's `state` input. + /// + /// See SwiftUI's documentation for `NavigationLink.init(_:value:)` for more. + /// + /// - Parameters: + /// - title: A string that describes the view that this link presents. + /// - state: An optional value to present. When the user selects the link, SwiftUI stores a + /// copy of the value. Pass a `nil` value to disable the link. + /// - fileID: The fileID. + /// - line: The line. + #if compiler(>=6) + @MainActor + #endif + @_disfavoredOverload + public init( + _ title: S, state: P?, fileID: StaticString = #fileID, line: UInt = #line + ) + where Label == _NavigationLinkStoreContent { + self.init(state: state, label: { Text(title) }, fileID: fileID, line: line) + } } -} -public struct _NavigationLinkStoreContent: View { - let state: State? - @ViewBuilder let label: Label - let fileID: StaticString - let filePath: StaticString - let line: UInt - let column: UInt - @Environment(\.navigationDestinationType) var navigationDestinationType + public struct _NavigationLinkStoreContent: View { + let state: State? + @ViewBuilder let label: Label + let fileID: StaticString + let filePath: StaticString + let line: UInt + let column: UInt + @Environment(\.navigationDestinationType) var navigationDestinationType - @_spi(Internals) - public init( - state: State?, - @ViewBuilder label: () -> Label, - fileID: StaticString, - filePath: StaticString, - line: UInt, - column: UInt - ) { - self.state = state - self.label = label() - self.fileID = fileID - self.filePath = filePath - self.line = line - self.column = column - } + @_spi(Internals) + public init( + state: State?, + @ViewBuilder label: () -> Label, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) { + self.state = state + self.label = label() + self.fileID = fileID + self.filePath = filePath + self.line = line + self.column = column + } - public var body: some View { - #if DEBUG - label.onAppear { - if navigationDestinationType != State.self { - let elementType = - navigationDestinationType.map { typeName($0) } - ?? """ - (None found in view hierarchy. Is this link inside a store-powered \ - 'NavigationStack'?) + public var body: some View { + #if DEBUG + label.onAppear { + if navigationDestinationType != State.self { + let elementType = + navigationDestinationType.map { typeName($0) } + ?? """ + (None found in view hierarchy. Is this link inside a store-powered \ + 'NavigationStack'?) + """ + reportIssue( """ - reportIssue( - """ - A navigation link at "\(fileID):\(line)" is not presentable. - - NavigationStack state element type: - \(elementType) - NavigationLink state type: - \(typeName(State.self)) - NavigationLink state value: - \(String(customDumping: state).indent(by: 2)) - """, - fileID: fileID, - filePath: filePath, - line: line, - column: column - ) - } - } - #else - label - #endif - } -} - -extension Store where State: ObservableState { - fileprivate subscript( - state state: KeyPath>, - action action: CaseKeyPath>, - isInViewBody isInViewBody: Bool = _isInPerceptionTracking - ) -> Store, StackAction> { - get { - #if DEBUG && !os(visionOS) - _PerceptionLocals.$isInPerceptionTracking.withValue(isInViewBody) { - self.scope(state: state, action: action) + A navigation link at "\(fileID):\(line)" is not presentable. + + NavigationStack state element type: + \(elementType) + NavigationLink state type: + \(typeName(State.self)) + NavigationLink state value: + \(String(customDumping: state).indent(by: 2)) + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } } #else - self.scope(state: state, action: action) + label #endif } - set {} } -} -extension Store { - @_spi(Internals) - public subscript( - fileID fileID: _HashableStaticString, - filePath filePath: _HashableStaticString, - line line: UInt, - column column: UInt - ) -> StackState.PathView - where State == StackState, Action == StackAction { - get { self.currentState.path } - set { - let newCount = newValue.count - guard newCount != self.currentState.count else { - reportIssue( - """ - A navigation stack binding at "\(fileID.rawValue):\(line)" was written to with a \ - path that has the same number of elements that already exist in the store. A view \ - should only write to this binding with a path that has pushed a new element onto the \ - stack, or popped one or more elements from the stack. - - This usually means the "forEach" has not been integrated with the reducer powering the \ - store, and this reducer is responsible for handling stack actions. - - To fix this, ensure that "forEach" is invoked from the reducer's "body": - - Reduce { state, action in - // ... - } - .forEach(\\.path, action: \\.path) { - Path() - } - - And ensure that every parent reducer is integrated into the root reducer that powers \ - the store. - """, - fileID: fileID.rawValue, - filePath: filePath.rawValue, - line: line, - column: column - ) - return - } - if newCount > self.currentState.count, let component = newValue.last { - self.send(.push(id: component.id, state: component.element)) - } else { - self.send(.popFrom(id: self.currentState.ids[newCount])) + extension Store where State: ObservableState { + fileprivate subscript( + state state: KeyPath>, + action action: CaseKeyPath>, + isInViewBody isInViewBody: Bool = _isInPerceptionTracking + ) -> Store, StackAction> { + get { + #if DEBUG && !os(visionOS) + _PerceptionLocals.$isInPerceptionTracking.withValue(isInViewBody) { + self.scope(state: state, action: action) + } + #else + self.scope(state: state, action: action) + #endif } + set {} } } -} - -@_spi(Internals) -public var _isInPerceptionTracking: Bool { - #if DEBUG && !os(visionOS) - return _PerceptionLocals.isInPerceptionTracking || _PerceptionLocals.skipPerceptionChecking - #else - return false - #endif -} - -extension StackState { - var path: PathView { - _read { yield PathView(base: self) } - _modify { - var path = PathView(base: self) - yield &path - self = path.base - } - set { self = newValue.base } - } - public struct Component: Hashable { - @_spi(Internals) - public let id: StackElementID + extension Store { @_spi(Internals) - public var element: Element + public subscript( + fileID fileID: _HashableStaticString, + filePath filePath: _HashableStaticString, + line line: UInt, + column column: UInt + ) -> StackState.PathView + where State == StackState, Action == StackAction { + get { self.currentState.path } + set { + let newCount = newValue.count + guard newCount != self.currentState.count else { + reportIssue( + """ + A navigation stack binding at "\(fileID.rawValue):\(line)" was written to with a \ + path that has the same number of elements that already exist in the store. A view \ + should only write to this binding with a path that has pushed a new element onto the \ + stack, or popped one or more elements from the stack. - @_spi(Internals) - public init(id: StackElementID, element: Element) { - self.id = id - self.element = element - } + This usually means the "forEach" has not been integrated with the reducer powering the \ + store, and this reducer is responsible for handling stack actions. - public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.id == rhs.id - } + To fix this, ensure that "forEach" is invoked from the reducer's "body": - public func hash(into hasher: inout Hasher) { - hasher.combine(self.id) + Reduce { state, action in + // ... + } + .forEach(\\.path, action: \\.path) { + Path() + } + + And ensure that every parent reducer is integrated into the root reducer that powers \ + the store. + """, + fileID: fileID.rawValue, + filePath: filePath.rawValue, + line: line, + column: column + ) + return + } + if newCount > self.currentState.count, let component = newValue.last { + self.send(.push(id: component.id, state: component.element)) + } else { + self.send(.popFrom(id: self.currentState.ids[newCount])) + } + } } } - public struct PathView: MutableCollection, RandomAccessCollection, - RangeReplaceableCollection - { - var base: StackState + extension StackState { + var path: PathView { + _read { yield PathView(base: self) } + _modify { + var path = PathView(base: self) + yield &path + self = path.base + } + set { self = newValue.base } + } - public var startIndex: Int { self.base.startIndex } - public var endIndex: Int { self.base.endIndex } - public func index(after i: Int) -> Int { self.base.index(after: i) } - public func index(before i: Int) -> Int { self.base.index(before: i) } + public struct Component: Hashable { + @_spi(Internals) + public let id: StackElementID + @_spi(Internals) + public var element: Element - public subscript(position: Int) -> Component { - _read { - yield Component(id: self.base.ids[position], element: self.base[position]) + @_spi(Internals) + public init(id: StackElementID, element: Element) { + self.id = id + self.element = element } - _modify { - let id = self.base.ids[position] - var component = Component(id: id, element: self.base[position]) - yield &component - self.base[id: id] = component.element + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id } - set { - self.base[id: newValue.id] = newValue.element + + public func hash(into hasher: inout Hasher) { + hasher.combine(self.id) } } - init(base: StackState) { - self.base = base - } + public struct PathView: MutableCollection, RandomAccessCollection, + RangeReplaceableCollection + { + var base: StackState - public init() { - self.init(base: StackState()) - } + public var startIndex: Int { self.base.startIndex } + public var endIndex: Int { self.base.endIndex } + public func index(after i: Int) -> Int { self.base.index(after: i) } + public func index(before i: Int) -> Int { self.base.index(before: i) } - public mutating func replaceSubrange( - _ subrange: Range, with newElements: some Collection - ) { - for id in self.base.ids[subrange] { - self.base[id: id] = nil + public subscript(position: Int) -> Component { + _read { + yield Component(id: self.base.ids[position], element: self.base[position]) + } + _modify { + let id = self.base.ids[position] + var component = Component(id: id, element: self.base[position]) + yield &component + self.base[id: id] = component.element + } + set { + self.base[id: newValue.id] = newValue.element + } } - for component in newElements.reversed() { - self.base._dictionary - .updateValue(component.element, forKey: component.id, insertingAt: subrange.lowerBound) + + init(base: StackState) { + self.base = base + } + + public init() { + self.init(base: StackState()) + } + + public mutating func replaceSubrange( + _ subrange: Range, with newElements: some Collection + ) { + for id in self.base.ids[subrange] { + self.base[id: id] = nil + } + for component in newElements.reversed() { + self.base._dictionary + .updateValue(component.element, forKey: component.id, insertingAt: subrange.lowerBound) + } } } } -} -private struct NavigationDestinationTypeKey: EnvironmentKey { - static var defaultValue: Any.Type? { nil } -} + private struct NavigationDestinationTypeKey: EnvironmentKey { + static var defaultValue: Any.Type? { nil } + } -extension EnvironmentValues { - @_spi(Internals) - public var navigationDestinationType: Any.Type? { - get { self[NavigationDestinationTypeKey.self] } - set { self[NavigationDestinationTypeKey.self] = newValue } + extension EnvironmentValues { + @_spi(Internals) + public var navigationDestinationType: Any.Type? { + get { self[NavigationDestinationTypeKey.self] } + set { self[NavigationDestinationTypeKey.self] = newValue } + } } -} +#endif diff --git a/Sources/ComposableArchitecture/Observation/Store+Observation.swift b/Sources/ComposableArchitecture/Observation/Store+Observation.swift index 3d923136c0fa..550b55dfacf6 100644 --- a/Sources/ComposableArchitecture/Observation/Store+Observation.swift +++ b/Sources/ComposableArchitecture/Observation/Store+Observation.swift @@ -1,5 +1,3 @@ -import SwiftUI - #if canImport(Observation) import Observation #endif @@ -115,115 +113,39 @@ extension Store where State: ObservableState { } } -extension Binding { - /// Scopes the binding of a store to a binding of an optional presentation store. - /// - /// Use this operator to derive a binding that can be handed to SwiftUI's various navigation - /// view modifiers, such as `sheet(item:)`, `popover(item:)`, etc. - /// - /// - /// For example, suppose your feature can present a child feature in a sheet. Then your feature's - /// domain would hold onto the child's domain using the library's presentation tools (see - /// for more information on these tools): - /// - /// ```swift - /// @Reducer - /// struct Feature { - /// @ObservableState - /// struct State { - /// @Presents var child: Child.State? - /// // ... - /// } - /// enum Action { - /// case child(PresentationActionOf) - /// // ... - /// } - /// // ... - /// } - /// ``` - /// - /// Then you can derive a binding to the child domain that can be handed to the `sheet(item:)` - /// view modifier: - /// - /// ```swift - /// struct FeatureView: View { - /// @Bindable var store: StoreOf - /// - /// var body: some View { - /// // ... - /// .sheet(item: $store.scope(state: \.child, action: \.child)) { store in - /// ChildView(store: store) - /// } - /// } - /// } - /// ``` - /// - /// - Parameters: - /// - state: A key path to optional child state. - /// - action: A case key path to presentation child actions. - /// - fileID: The fileID. - /// - filePath: The filePath. - /// - line: The line. - /// - column: The column. - /// - Returns: A binding of an optional child store. - #if swift(>=5.10) - @preconcurrency@MainActor - #else - @MainActor(unsafe) - #endif - public func scope( - state: KeyPath, - action: CaseKeyPath>, - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) -> Binding?> - where Value == Store { - self[ - id: wrappedValue.currentState[keyPath: state].flatMap(_identifiableID), - state: state, - action: action, - isInViewBody: _isInPerceptionTracking, - fileID: _HashableStaticString(rawValue: fileID), - filePath: _HashableStaticString(rawValue: filePath), - line: line, - column: column - ] - } -} - -extension ObservedObject.Wrapper { - #if swift(>=5.10) - @preconcurrency@MainActor - #else - @MainActor(unsafe) - #endif - public func scope( - state: KeyPath, - action: CaseKeyPath>, - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) -> Binding?> - where ObjectType == Store { - self[ - dynamicMember: - \.[ - id: self[dynamicMember: \._currentState].wrappedValue[keyPath: state] - .flatMap(_identifiableID), - state: state, - action: action, - isInViewBody: _isInPerceptionTracking, - fileID: _HashableStaticString(rawValue: fileID), - filePath: _HashableStaticString(rawValue: filePath), - line: line, - column: column - ] - ] +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + extension ObservedObject.Wrapper { + #if swift(>=5.10) + @preconcurrency @MainActor + #else + @MainActor(unsafe) + #endif + public func scope( + state: KeyPath, + action: CaseKeyPath>, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) -> Binding?> + where ObjectType == Store { + self[ + dynamicMember: + \.[ + id: self[dynamicMember: \._currentState].wrappedValue[keyPath: state] + .flatMap(_identifiableID), + state: state, + action: action, + isInViewBody: _isInPerceptionTracking, + fileID: _HashableStaticString(rawValue: fileID), + filePath: _HashableStaticString(rawValue: filePath), + line: line, + column: column + ] + ] + } } -} +#endif extension Store { fileprivate var _currentState: State { @@ -232,197 +154,6 @@ extension Store { } } -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SwiftUI.Bindable { - /// Scopes the binding of a store to a binding of an optional presentation store. - /// - /// Use this operator to derive a binding that can be handed to SwiftUI's various navigation - /// view modifiers, such as `sheet(item:)`, `popover(item:)`, etc. - /// - /// - /// For example, suppose your feature can present a child feature in a sheet. Then your - /// feature's domain would hold onto the child's domain using the library's presentation tools - /// (see for more information on these tools): - /// - /// ```swift - /// @Reducer - /// struct Feature { - /// @ObservableState - /// struct State { - /// @Presents var child: Child.State? - /// // ... - /// } - /// enum Action { - /// case child(PresentationActionOf) - /// // ... - /// } - /// // ... - /// } - /// ``` - /// - /// Then you can derive a binding to the child domain that can be handed to the `sheet(item:)` - /// view modifier: - /// - /// ```swift - /// struct FeatureView: View { - /// @Bindable var store: StoreOf - /// - /// var body: some View { - /// // ... - /// .sheet(item: $store.scope(state: \.child, action: \.child)) { store in - /// ChildView(store: store) - /// } - /// } - /// } - /// ``` - /// - /// - Parameters: - /// - state: A key path to optional child state. - /// - action: A case key path to presentation child actions. - /// - fileID: The fileID. - /// - filePath: The filePath. - /// - line: The line. - /// - column: The column. - /// - Returns: A binding of an optional child store. - #if swift(>=5.10) - @preconcurrency@MainActor - #else - @MainActor(unsafe) - #endif - public func scope( - state: KeyPath, - action: CaseKeyPath>, - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) -> Binding?> - where Value == Store { - self[ - id: wrappedValue.currentState[keyPath: state].flatMap(_identifiableID), - state: state, - action: action, - isInViewBody: _isInPerceptionTracking, - fileID: _HashableStaticString(rawValue: fileID), - filePath: _HashableStaticString(rawValue: filePath), - line: line, - column: column - ] - } -} - -@available(iOS, introduced: 13, obsoleted: 17) -@available(macOS, introduced: 10.15, obsoleted: 14) -@available(tvOS, introduced: 13, obsoleted: 17) -@available(watchOS, introduced: 6, obsoleted: 10) -extension Perception.Bindable { - /// Scopes the binding of a store to a binding of an optional presentation store. - /// - /// Use this operator to derive a binding that can be handed to SwiftUI's various navigation - /// view modifiers, such as `sheet(item:)`, `popover(item:)`, etc. - /// - /// - /// For example, suppose your feature can present a child feature in a sheet. Then your - /// feature's domain would hold onto the child's domain using the library's presentation tools - /// (see for more information on these tools): - /// - /// ```swift - /// @Reducer - /// struct Feature { - /// @ObservableState - /// struct State { - /// @Presents var child: Child.State? - /// // ... - /// } - /// enum Action { - /// case child(PresentationActionOf) - /// // ... - /// } - /// // ... - /// } - /// ``` - /// - /// Then you can derive a binding to the child domain that can be handed to the `sheet(item:)` - /// view modifier: - /// - /// ```swift - /// struct FeatureView: View { - /// @Bindable var store: StoreOf - /// - /// var body: some View { - /// // ... - /// .sheet(item: $store.scope(state: \.child, action: \.child)) { store in - /// ChildView(store: store) - /// } - /// } - /// } - /// ``` - /// - /// - Parameters: - /// - state: A key path to optional child state. - /// - action: A case key path to presentation child actions. - /// - fileID: The fileID. - /// - filePath: The filePath. - /// - line: The line. - /// - column: The column. - /// - Returns: A binding of an optional child store. - public func scope( - state: KeyPath, - action: CaseKeyPath>, - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) -> Binding?> - where Value == Store { - self[ - id: wrappedValue.currentState[keyPath: state].flatMap(_identifiableID), - state: state, - action: action, - isInViewBody: _isInPerceptionTracking, - fileID: _HashableStaticString(rawValue: fileID), - filePath: _HashableStaticString(rawValue: filePath), - line: line, - column: column - ] - } -} - -extension UIBindable { - #if swift(>=5.10) - @preconcurrency@MainActor - #else - @MainActor(unsafe) - #endif - public func scope( - state: KeyPath, - action: CaseKeyPath>, - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) -> UIBinding?> - where Value == Store { - #if DEBUG && canImport(SwiftUI) - let id = _PerceptionLocals.$skipPerceptionChecking.withValue(true) { - wrappedValue.currentState[keyPath: state].flatMap(_identifiableID) - } - #else - let id = wrappedValue.currentState[keyPath: state].flatMap(_identifiableID) - #endif - return self[ - id: id, - state: state, - action: action, - isInViewBody: true, - fileID: _HashableStaticString(rawValue: fileID), - filePath: _HashableStaticString(rawValue: filePath), - line: line, - column: column - ] - } -} - extension Store where State: ObservableState { @_spi(Internals) public subscript( @@ -515,3 +246,276 @@ func uncachedStoreWarning(_ store: Store) -> Strin https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7 """ } + +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + import SwiftUI + + extension Binding { + /// Scopes the binding of a store to a binding of an optional presentation store. + /// + /// Use this operator to derive a binding that can be handed to SwiftUI's various navigation + /// view modifiers, such as `sheet(item:)`, `popover(item:)`, etc. + /// + /// + /// For example, suppose your feature can present a child feature in a sheet. Then your feature's + /// domain would hold onto the child's domain using the library's presentation tools (see + /// for more information on these tools): + /// + /// ```swift + /// @Reducer + /// struct Feature { + /// @ObservableState + /// struct State { + /// @Presents var child: Child.State? + /// // ... + /// } + /// enum Action { + /// case child(PresentationActionOf) + /// // ... + /// } + /// // ... + /// } + /// ``` + /// + /// Then you can derive a binding to the child domain that can be handed to the `sheet(item:)` + /// view modifier: + /// + /// ```swift + /// struct FeatureView: View { + /// @Bindable var store: StoreOf + /// + /// var body: some View { + /// // ... + /// .sheet(item: $store.scope(state: \.child, action: \.child)) { store in + /// ChildView(store: store) + /// } + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - state: A key path to optional child state. + /// - action: A case key path to presentation child actions. + /// - fileID: The fileID. + /// - filePath: The filePath. + /// - line: The line. + /// - column: The column. + /// - Returns: A binding of an optional child store. + #if swift(>=5.10) + @preconcurrency @MainActor + #else + @MainActor(unsafe) + #endif + public func scope( + state: KeyPath, + action: CaseKeyPath>, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) -> Binding?> + where Value == Store { + self[ + id: wrappedValue.currentState[keyPath: state].flatMap(_identifiableID), + state: state, + action: action, + isInViewBody: _isInPerceptionTracking, + fileID: _HashableStaticString(rawValue: fileID), + filePath: _HashableStaticString(rawValue: filePath), + line: line, + column: column + ] + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SwiftUI.Bindable { + /// Scopes the binding of a store to a binding of an optional presentation store. + /// + /// Use this operator to derive a binding that can be handed to SwiftUI's various navigation + /// view modifiers, such as `sheet(item:)`, `popover(item:)`, etc. + /// + /// + /// For example, suppose your feature can present a child feature in a sheet. Then your + /// feature's domain would hold onto the child's domain using the library's presentation tools + /// (see for more information on these tools): + /// + /// ```swift + /// @Reducer + /// struct Feature { + /// @ObservableState + /// struct State { + /// @Presents var child: Child.State? + /// // ... + /// } + /// enum Action { + /// case child(PresentationActionOf) + /// // ... + /// } + /// // ... + /// } + /// ``` + /// + /// Then you can derive a binding to the child domain that can be handed to the `sheet(item:)` + /// view modifier: + /// + /// ```swift + /// struct FeatureView: View { + /// @Bindable var store: StoreOf + /// + /// var body: some View { + /// // ... + /// .sheet(item: $store.scope(state: \.child, action: \.child)) { store in + /// ChildView(store: store) + /// } + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - state: A key path to optional child state. + /// - action: A case key path to presentation child actions. + /// - fileID: The fileID. + /// - filePath: The filePath. + /// - line: The line. + /// - column: The column. + /// - Returns: A binding of an optional child store. + #if swift(>=5.10) + @preconcurrency @MainActor + #else + @MainActor(unsafe) + #endif + public func scope( + state: KeyPath, + action: CaseKeyPath>, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) -> Binding?> + where Value == Store { + self[ + id: wrappedValue.currentState[keyPath: state].flatMap(_identifiableID), + state: state, + action: action, + isInViewBody: _isInPerceptionTracking, + fileID: _HashableStaticString(rawValue: fileID), + filePath: _HashableStaticString(rawValue: filePath), + line: line, + column: column + ] + } + } + + @available(iOS, introduced: 13, obsoleted: 17) + @available(macOS, introduced: 10.15, obsoleted: 14) + @available(tvOS, introduced: 13, obsoleted: 17) + @available(watchOS, introduced: 6, obsoleted: 10) + extension Perception.Bindable { + /// Scopes the binding of a store to a binding of an optional presentation store. + /// + /// Use this operator to derive a binding that can be handed to SwiftUI's various navigation + /// view modifiers, such as `sheet(item:)`, `popover(item:)`, etc. + /// + /// + /// For example, suppose your feature can present a child feature in a sheet. Then your + /// feature's domain would hold onto the child's domain using the library's presentation tools + /// (see for more information on these tools): + /// + /// ```swift + /// @Reducer + /// struct Feature { + /// @ObservableState + /// struct State { + /// @Presents var child: Child.State? + /// // ... + /// } + /// enum Action { + /// case child(PresentationActionOf) + /// // ... + /// } + /// // ... + /// } + /// ``` + /// + /// Then you can derive a binding to the child domain that can be handed to the `sheet(item:)` + /// view modifier: + /// + /// ```swift + /// struct FeatureView: View { + /// @Bindable var store: StoreOf + /// + /// var body: some View { + /// // ... + /// .sheet(item: $store.scope(state: \.child, action: \.child)) { store in + /// ChildView(store: store) + /// } + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - state: A key path to optional child state. + /// - action: A case key path to presentation child actions. + /// - fileID: The fileID. + /// - filePath: The filePath. + /// - line: The line. + /// - column: The column. + /// - Returns: A binding of an optional child store. + public func scope( + state: KeyPath, + action: CaseKeyPath>, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) -> Binding?> + where Value == Store { + self[ + id: wrappedValue.currentState[keyPath: state].flatMap(_identifiableID), + state: state, + action: action, + isInViewBody: _isInPerceptionTracking, + fileID: _HashableStaticString(rawValue: fileID), + filePath: _HashableStaticString(rawValue: filePath), + line: line, + column: column + ] + } + } + + extension UIBindable { + #if swift(>=5.10) + @preconcurrency @MainActor + #else + @MainActor(unsafe) + #endif + public func scope( + state: KeyPath, + action: CaseKeyPath>, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) -> UIBinding?> + where Value == Store { + #if DEBUG && canImport(SwiftUI) + let id = _PerceptionLocals.$skipPerceptionChecking.withValue(true) { + wrappedValue.currentState[keyPath: state].flatMap(_identifiableID) + } + #else + let id = wrappedValue.currentState[keyPath: state].flatMap(_identifiableID) + #endif + return self[ + id: id, + state: state, + action: action, + isInViewBody: true, + fileID: _HashableStaticString(rawValue: fileID), + filePath: _HashableStaticString(rawValue: filePath), + line: line, + column: column + ] + } + } +#endif diff --git a/Sources/ComposableArchitecture/Observation/ViewAction.swift b/Sources/ComposableArchitecture/Observation/ViewAction.swift index 2bc258c7f6ea..aff3c9612b77 100644 --- a/Sources/ComposableArchitecture/Observation/ViewAction.swift +++ b/Sources/ComposableArchitecture/Observation/ViewAction.swift @@ -1,5 +1,3 @@ -import SwiftUI - /// Defines the actions that can be sent from a view. /// /// See the ``ViewAction(for:)`` macro for more information on how to use this. @@ -12,7 +10,7 @@ public protocol ViewAction { #if swift(<5.10) @MainActor(unsafe) #else - @preconcurrency@MainActor + @preconcurrency @MainActor #endif public protocol ViewActionSending { associatedtype StoreState @@ -27,15 +25,17 @@ extension ViewActionSending { self.store.send(.view(action)) } - /// Send a view action to the store with animation. - @discardableResult - public func send(_ action: StoreAction.ViewAction, animation: Animation?) -> StoreTask { - self.store.send(.view(action), animation: animation) - } + #if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + /// Send a view action to the store with animation. + @discardableResult + public func send(_ action: StoreAction.ViewAction, animation: Animation?) -> StoreTask { + self.store.send(.view(action), animation: animation) + } - /// Send a view action to the store with a transaction. - @discardableResult - public func send(_ action: StoreAction.ViewAction, transaction: Transaction) -> StoreTask { - self.store.send(.view(action), transaction: transaction) - } + /// Send a view action to the store with a transaction. + @discardableResult + public func send(_ action: StoreAction.ViewAction, transaction: Transaction) -> StoreTask { + self.store.send(.view(action), transaction: transaction) + } + #endif } diff --git a/Sources/ComposableArchitecture/Reducer/Reducers/BindingReducer.swift b/Sources/ComposableArchitecture/Reducer/Reducers/BindingReducer.swift index 23b60a75a377..6728fcb1e848 100644 --- a/Sources/ComposableArchitecture/Reducer/Reducers/BindingReducer.swift +++ b/Sources/ComposableArchitecture/Reducer/Reducers/BindingReducer.swift @@ -1,81 +1,82 @@ -import SwiftUI +// TODO: This can probably be ported to Android/Linux +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + /// A reducer that updates bindable state when it receives binding actions. + /// + /// This reducer should typically be composed into the ``Reducer/body-swift.property`` of your + /// feature's reducer: + /// + /// ```swift + /// @Reducer + /// struct Feature { + /// struct State { + /// @BindingState var isOn = false + /// // More properties... + /// } + /// enum Action: BindableAction { + /// case binding(BindingAction) + /// // More actions + /// } + /// + /// var body: some ReducerOf { + /// BindingReducer() + /// Reduce { state, action in + /// // Your feature's logic... + /// } + /// } + /// } + /// ``` + /// + /// This makes it so that the binding's logic is run before the feature's logic, _i.e._ you will + /// only see the state after the binding was written. If you want to react to the state _before_ the + /// binding was written, you can flip the order of the composition: + /// + /// ```swift + /// var body: some ReducerOf { + /// Reduce { state, action in + /// // Your feature's logic... + /// } + /// BindingReducer() + /// } + /// ``` + /// + /// If you forget to compose the ``BindingReducer`` into your feature's reducer, then when a binding + /// is written to it will cause a runtime purple Xcode warning letting you know what needs to be + /// fixed. + public struct BindingReducer: Reducer + where State == ViewAction.State { + @usableFromInline + let toViewAction: (Action) -> ViewAction? -/// A reducer that updates bindable state when it receives binding actions. -/// -/// This reducer should typically be composed into the ``Reducer/body-swift.property`` of your -/// feature's reducer: -/// -/// ```swift -/// @Reducer -/// struct Feature { -/// struct State { -/// @BindingState var isOn = false -/// // More properties... -/// } -/// enum Action: BindableAction { -/// case binding(BindingAction) -/// // More actions -/// } -/// -/// var body: some ReducerOf { -/// BindingReducer() -/// Reduce { state, action in -/// // Your feature's logic... -/// } -/// } -/// } -/// ``` -/// -/// This makes it so that the binding's logic is run before the feature's logic, _i.e._ you will -/// only see the state after the binding was written. If you want to react to the state _before_ the -/// binding was written, you can flip the order of the composition: -/// -/// ```swift -/// var body: some ReducerOf { -/// Reduce { state, action in -/// // Your feature's logic... -/// } -/// BindingReducer() -/// } -/// ``` -/// -/// If you forget to compose the ``BindingReducer`` into your feature's reducer, then when a binding -/// is written to it will cause a runtime purple Xcode warning letting you know what needs to be -/// fixed. -public struct BindingReducer: Reducer -where State == ViewAction.State { - @usableFromInline - let toViewAction: (Action) -> ViewAction? + /// Initializes a reducer that updates bindable state when it receives binding actions. + @inlinable + public init() where Action == ViewAction { + self.init(internal: { $0 }) + } - /// Initializes a reducer that updates bindable state when it receives binding actions. - @inlinable - public init() where Action == ViewAction { - self.init(internal: { $0 }) - } + @inlinable + public init(action toViewAction: CaseKeyPath) where Action: CasePathable { + self.init(internal: { $0[case: toViewAction] }) + } - @inlinable - public init(action toViewAction: CaseKeyPath) where Action: CasePathable { - self.init(internal: { $0[case: toViewAction] }) - } + @inlinable + public init(action toViewAction: @escaping (_ action: Action) -> ViewAction?) { + self.init(internal: toViewAction) + } - @inlinable - public init(action toViewAction: @escaping (_ action: Action) -> ViewAction?) { - self.init(internal: toViewAction) - } - - @usableFromInline - init(internal toViewAction: @escaping (_ action: Action) -> ViewAction?) { - self.toViewAction = toViewAction - } + @usableFromInline + init(internal toViewAction: @escaping (_ action: Action) -> ViewAction?) { + self.toViewAction = toViewAction + } - @inlinable - public func reduce(into state: inout State, action: Action) -> Effect { - // NB: Using a closure and not a `\.binding` key path literal to avoid a bug with archives: - // https://github.com/pointfreeco/swift-composable-architecture/pull/2641 - guard let bindingAction = self.toViewAction(action).flatMap({ $0.binding }) - else { return .none } + @inlinable + public func reduce(into state: inout State, action: Action) -> Effect { + // NB: Using a closure and not a `\.binding` key path literal to avoid a bug with archives: + // https://github.com/pointfreeco/swift-composable-architecture/pull/2641 + guard let bindingAction = self.toViewAction(action).flatMap({ $0.binding }) + else { return .none } - bindingAction.set(&state) - return .none + bindingAction.set(&state) + return .none + } } -} +#endif diff --git a/Sources/ComposableArchitecture/Reducer/Reducers/DebugReducer.swift b/Sources/ComposableArchitecture/Reducer/Reducers/DebugReducer.swift index bcd83be89d0f..767eb1951680 100644 --- a/Sources/ComposableArchitecture/Reducer/Reducers/DebugReducer.swift +++ b/Sources/ComposableArchitecture/Reducer/Reducers/DebugReducer.swift @@ -1,7 +1,12 @@ -import Combine import Dispatch @_spi(SharedChangeTracking) import Sharing +#if canImport(Combine) + import Combine +#else + import OpenCombine +#endif + extension Reducer { /// Enhances a reducer with debug logging of received actions and state mutations for the given /// printer. @@ -33,11 +38,12 @@ public struct _ReducerPrinter: Sendable { let queue: DispatchQueue public init( - printChange: @escaping @Sendable ( - _ receivedAction: Action, - _ oldState: State, - _ newState: State - ) -> Void, + printChange: + @escaping @Sendable ( + _ receivedAction: Action, + _ oldState: State, + _ newState: State + ) -> Void, queue: DispatchQueue? = nil ) { self._printChange = printChange diff --git a/Sources/ComposableArchitecture/Reducer/Reducers/PresentationReducer.swift b/Sources/ComposableArchitecture/Reducer/Reducers/PresentationReducer.swift index b8b65a3a8864..dc9d1eb4ac28 100644 --- a/Sources/ComposableArchitecture/Reducer/Reducers/PresentationReducer.swift +++ b/Sources/ComposableArchitecture/Reducer/Reducers/PresentationReducer.swift @@ -1,5 +1,10 @@ @_spi(Reflection) import CasePaths -import Combine + +#if canImport(Combine) + import Combine +#else + import OpenCombine +#endif /// A property wrapper for state that can be presented. /// @@ -286,7 +291,7 @@ extension PresentationAction: CasePathable { AnyCasePath( embed: { .presented($0) }, extract: { - guard case let .presented(value) = $0 else { return nil } + guard case .presented(let value) = $0 else { return nil } return value } ) @@ -300,7 +305,7 @@ extension PresentationAction: CasePathable { return AnyCasePath( embed: { .presented(keyPath($0)) }, extract: { - guard case let .presented(action) = $0 else { return nil } + guard case .presented(let action) = $0 else { return nil } return action[case: keyPath] } ) @@ -317,7 +322,7 @@ extension PresentationAction: CasePathable { switch $0 { case .dismiss: return .dismiss - case let .presented(action): + case .presented(let action): return .presented(keyPath(action)) } }, @@ -325,7 +330,7 @@ extension PresentationAction: CasePathable { switch $0 { case .dismiss: return .dismiss - case let .presented(action): + case .presented(let action): return action[case: keyPath].map { .presented($0) } } } @@ -591,7 +596,7 @@ public struct _PresentationReducer: Reducer let baseEffects: Effect switch (initialPresentationState.wrappedValue, presentationAction) { - case let (.some(destinationState), .some(.dismiss)): + case (.some(let destinationState), .some(.dismiss)): destinationEffects = .none baseEffects = self.base.reduce(into: &state, action: action) if self.navigationIDPath(for: destinationState) @@ -600,7 +605,7 @@ public struct _PresentationReducer: Reducer state[keyPath: self.toPresentationState].wrappedValue = nil } - case let (.some(destinationState), .some(.presented(destinationAction))): + case (.some(let destinationState), .some(.presented(let destinationAction))): let destinationNavigationIDPath = self.navigationIDPath(for: destinationState) destinationEffects = self.destination .dependency( diff --git a/Sources/ComposableArchitecture/Reducer/Reducers/SignpostReducer.swift b/Sources/ComposableArchitecture/Reducer/Reducers/SignpostReducer.swift index 01769f025e30..c62a39d05d4c 100644 --- a/Sources/ComposableArchitecture/Reducer/Reducers/SignpostReducer.swift +++ b/Sources/ComposableArchitecture/Reducer/Reducers/SignpostReducer.swift @@ -1,143 +1,145 @@ -import OSLog +#if canImport(OSLog) + import OSLog -extension Reducer { - /// Instruments a reducer with - /// [signposts](https://developer.apple.com/documentation/os/logging/recording_performance_data). - /// - /// Each invocation of the reducer will be measured by an interval, and the lifecycle of its - /// effects will be measured with interval and event signposts. - /// - /// To use, build your app for profiling, create a blank instrument, and add the signpost - /// instrument. Start recording your app you will see timing information for every action sent to - /// the store, as well as every effect executed. - /// - /// Effect instrumentation can be particularly useful for inspecting the lifecycle of long-living - /// effects. For example, if you start an effect (_e.g._, a location manager) in `onAppear` and - /// forget to tear down the effect in `onDisappear`, the instrument will show that the effect - /// never completed. - /// - /// - Parameters: - /// - prefix: A string to print at the beginning of the formatted message for the signpost. - /// - log: An `OSLog` to use for signposts. - /// - Returns: A reducer that has been enhanced with instrumentation. - @inlinable - @warn_unqualified_access - public func signpost( - _ prefix: String = "", - log: OSLog = OSLog( - subsystem: "co.pointfree.ComposableArchitecture", - category: "Reducer Instrumentation" - ) - ) -> _SignpostReducer { - _SignpostReducer(base: self, prefix: prefix, log: log) + extension Reducer { + /// Instruments a reducer with + /// [signposts](https://developer.apple.com/documentation/os/logging/recording_performance_data). + /// + /// Each invocation of the reducer will be measured by an interval, and the lifecycle of its + /// effects will be measured with interval and event signposts. + /// + /// To use, build your app for profiling, create a blank instrument, and add the signpost + /// instrument. Start recording your app you will see timing information for every action sent to + /// the store, as well as every effect executed. + /// + /// Effect instrumentation can be particularly useful for inspecting the lifecycle of long-living + /// effects. For example, if you start an effect (_e.g._, a location manager) in `onAppear` and + /// forget to tear down the effect in `onDisappear`, the instrument will show that the effect + /// never completed. + /// + /// - Parameters: + /// - prefix: A string to print at the beginning of the formatted message for the signpost. + /// - log: An `OSLog` to use for signposts. + /// - Returns: A reducer that has been enhanced with instrumentation. + @inlinable + @warn_unqualified_access + public func signpost( + _ prefix: String = "", + log: OSLog = OSLog( + subsystem: "co.pointfree.ComposableArchitecture", + category: "Reducer Instrumentation" + ) + ) -> _SignpostReducer { + _SignpostReducer(base: self, prefix: prefix, log: log) + } } -} -public struct _SignpostReducer: Reducer { - @usableFromInline - let base: Base + public struct _SignpostReducer: Reducer { + @usableFromInline + let base: Base - @usableFromInline - let prefix: String + @usableFromInline + let prefix: String - @usableFromInline - let log: OSLog + @usableFromInline + let log: OSLog - @usableFromInline - init( - base: Base, - prefix: String, - log: OSLog - ) { - self.base = base - // NB: Prevent rendering as "N/A" in Instruments - let zeroWidthSpace = "\u{200B}" - self.prefix = prefix.isEmpty ? zeroWidthSpace : "[\(prefix)] " - self.log = log - } - - @inlinable - public func reduce( - into state: inout Base.State, action: Base.Action - ) -> Effect { - var actionOutput: String! - if self.log.signpostsEnabled { - actionOutput = debugCaseOutput(action) - os_signpost(.begin, log: log, name: "Action", "%s%s", self.prefix, actionOutput) + @usableFromInline + init( + base: Base, + prefix: String, + log: OSLog + ) { + self.base = base + // NB: Prevent rendering as "N/A" in Instruments + let zeroWidthSpace = "\u{200B}" + self.prefix = prefix.isEmpty ? zeroWidthSpace : "[\(prefix)] " + self.log = log } - let effects = self.base.reduce(into: &state, action: action) - if self.log.signpostsEnabled { - os_signpost(.end, log: self.log, name: "Action") - return - effects - .effectSignpost(self.prefix, log: self.log, actionOutput: actionOutput) + + @inlinable + public func reduce( + into state: inout Base.State, action: Base.Action + ) -> Effect { + var actionOutput: String! + if self.log.signpostsEnabled { + actionOutput = debugCaseOutput(action) + os_signpost(.begin, log: log, name: "Action", "%s%s", self.prefix, actionOutput) + } + let effects = self.base.reduce(into: &state, action: action) + if self.log.signpostsEnabled { + os_signpost(.end, log: self.log, name: "Action") + return + effects + .effectSignpost(self.prefix, log: self.log, actionOutput: actionOutput) + } + return effects } - return effects } -} -extension Effect { - @usableFromInline - func effectSignpost( - _ prefix: String, - log: OSLog, - actionOutput: String - ) -> Self { - let sid = OSSignpostID(log: log) + extension Effect { + @usableFromInline + func effectSignpost( + _ prefix: String, + log: OSLog, + actionOutput: String + ) -> Self { + let sid = OSSignpostID(log: log) - switch self.operation { - case .none: - return self - case let .publisher(publisher): - return .init( - operation: .publisher( - publisher.handleEvents( - receiveSubscription: { _ in - os_signpost( - .begin, log: log, name: "Effect", signpostID: sid, "%sStarted from %s", prefix, - actionOutput) - }, - receiveOutput: { value in - os_signpost( - .event, log: log, name: "Effect Output", "%sOutput from %s", prefix, actionOutput) - }, - receiveCompletion: { completion in - switch completion { - case .finished: - os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sFinished", prefix) + switch self.operation { + case .none: + return self + case .publisher(let publisher): + return .init( + operation: .publisher( + publisher.handleEvents( + receiveSubscription: { _ in + os_signpost( + .begin, log: log, name: "Effect", signpostID: sid, "%sStarted from %s", prefix, + actionOutput) + }, + receiveOutput: { value in + os_signpost( + .event, log: log, name: "Effect Output", "%sOutput from %s", prefix, actionOutput) + }, + receiveCompletion: { completion in + switch completion { + case .finished: + os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sFinished", prefix) + } + }, + receiveCancel: { + os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sCancelled", prefix) } - }, - receiveCancel: { - os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sCancelled", prefix) - } + ) + .eraseToAnyPublisher() ) - .eraseToAnyPublisher() ) - ) - case let .run(name, priority, operation): - return .init( - operation: .run(name: name, priority: priority) { send in - os_signpost( - .begin, log: log, name: "Effect", signpostID: sid, "%sStarted from %s", prefix, - actionOutput - ) - await operation( - Send { action in - os_signpost( - .event, log: log, name: "Effect Output", "%sOutput from %s", prefix, actionOutput - ) - send(action) + case .run(let name, let priority, let operation): + return .init( + operation: .run(name: name, priority: priority) { send in + os_signpost( + .begin, log: log, name: "Effect", signpostID: sid, "%sStarted from %s", prefix, + actionOutput + ) + await operation( + Send { action in + os_signpost( + .event, log: log, name: "Effect Output", "%sOutput from %s", prefix, actionOutput + ) + send(action) + } + ) + if Task.isCancelled { + os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sCancelled", prefix) } - ) - if Task.isCancelled { - os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sCancelled", prefix) + os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sFinished", prefix) } - os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sFinished", prefix) - } - ) + ) + } } } -} +#endif @usableFromInline func debugCaseOutput( diff --git a/Sources/ComposableArchitecture/Reducer/Reducers/StackReducer.swift b/Sources/ComposableArchitecture/Reducer/Reducers/StackReducer.swift index ec6982d9008d..cccdffbb0be4 100644 --- a/Sources/ComposableArchitecture/Reducer/Reducers/StackReducer.swift +++ b/Sources/ComposableArchitecture/Reducer/Reducers/StackReducer.swift @@ -1,8 +1,13 @@ @_spi(Reflection) import CasePaths -import Combine import Foundation import OrderedCollections +#if canImport(Combine) + import Combine +#else + import OpenCombine +#endif + /// A list of data representing the content of a navigation stack. /// /// Use this type for modeling a feature's domain that needs to present child features using @@ -268,7 +273,7 @@ public enum StackAction: CasePathable { AnyCasePath( embed: { .element(id: $0, action: $1) }, extract: { - guard case let .element(id, action) = $0 else { return nil } + guard case .element(let id, let action) = $0 else { return nil } return (id: id, action: action) } ) @@ -278,7 +283,7 @@ public enum StackAction: CasePathable { AnyCasePath( embed: { .popFrom(id: $0) }, extract: { - guard case let .popFrom(id) = $0 else { return nil } + guard case .popFrom(let id) = $0 else { return nil } return id } ) @@ -288,7 +293,7 @@ public enum StackAction: CasePathable { AnyCasePath( embed: { .push(id: $0, state: $1) }, extract: { - guard case let .push(id, state) = $0 else { return nil } + guard case .push(let id, let state) = $0 else { return nil } return (id: id, state: state) } ) @@ -503,7 +508,7 @@ public struct _StackReducer: Reducer { let baseEffects: Effect switch self.toStackAction.extract(from: action) { - case let .element(elementID, destinationAction): + case .element(let elementID, let destinationAction): if state[keyPath: self.toStackState][id: elementID] != nil { let elementNavigationIDPath = self.navigationIDPath(for: elementID) destinationEffects = self.destination @@ -556,7 +561,7 @@ public struct _StackReducer: Reducer { baseEffects = self.base.reduce(into: &state, action: action) - case let .popFrom(id): + case .popFrom(let id): destinationEffects = .none let canPop = state[keyPath: self.toStackState].ids.contains(id) baseEffects = self.base.reduce(into: &state, action: action) @@ -580,7 +585,7 @@ public struct _StackReducer: Reducer { ) } - case let .push(id, element): + case .push(let id, let element): destinationEffects = .none if state[keyPath: self.toStackState].ids.contains(id) { reportIssue( diff --git a/Sources/ComposableArchitecture/Sharing/AppStorageKeyPathKey.swift b/Sources/ComposableArchitecture/Sharing/AppStorageKeyPathKey.swift index 001435cacf2b..6d961b8e59d7 100644 --- a/Sources/ComposableArchitecture/Sharing/AppStorageKeyPathKey.swift +++ b/Sources/ComposableArchitecture/Sharing/AppStorageKeyPathKey.swift @@ -1,96 +1,98 @@ import Dependencies import Foundation -extension SharedReaderKey { - /// Creates a persistence key for sharing data in user defaults given a key path. - /// - /// For example, one could initialize a key with the date and time at which the application was - /// most recently launched, and access this date from anywhere using the `@Shared` property - /// wrapper: - /// - /// ```swift - /// @Shared(.appStorage(\.appLaunchedAt)) var appLaunchedAt = Date() - /// ``` - /// - /// - Parameter keyPath: A string key identifying a value to share in memory. - /// - Returns: A persistence key. - @available(*, deprecated, message: "Use 'appStorage' with a supported data type, instead") - public static func appStorage( - _ keyPath: _SendableReferenceWritableKeyPath - ) -> Self where Self == AppStorageKeyPathKey { - AppStorageKeyPathKey(keyPath) +#if !os(Linux) && !os(Android) + extension SharedReaderKey { + /// Creates a persistence key for sharing data in user defaults given a key path. + /// + /// For example, one could initialize a key with the date and time at which the application was + /// most recently launched, and access this date from anywhere using the `@Shared` property + /// wrapper: + /// + /// ```swift + /// @Shared(.appStorage(\.appLaunchedAt)) var appLaunchedAt = Date() + /// ``` + /// + /// - Parameter keyPath: A string key identifying a value to share in memory. + /// - Returns: A persistence key. + @available(*, deprecated, message: "Use 'appStorage' with a supported data type, instead") + public static func appStorage( + _ keyPath: _SendableReferenceWritableKeyPath + ) -> Self where Self == AppStorageKeyPathKey { + AppStorageKeyPathKey(keyPath) + } } -} -/// A type defining a user defaults persistence strategy via key path. -/// -/// See ``Sharing/SharedReaderKey/appStorage(_:)`` to create values of this type. -@available(*, deprecated, message: "Use an 'AppStorageKey', instead") -public struct AppStorageKeyPathKey: Sendable { - private let keyPath: _SendableReferenceWritableKeyPath - private let store: UncheckedSendable - - public init(_ keyPath: _SendableReferenceWritableKeyPath) { - @Dependency(\.defaultAppStorage) var store - self.keyPath = keyPath - self.store = UncheckedSendable(store) - } -} + /// A type defining a user defaults persistence strategy via key path. + /// + /// See ``Sharing/SharedReaderKey/appStorage(_:)`` to create values of this type. + @available(*, deprecated, message: "Use an 'AppStorageKey', instead") + public struct AppStorageKeyPathKey: Sendable { + private let keyPath: _SendableReferenceWritableKeyPath + private let store: UncheckedSendable -@available(*, deprecated, message: "Use an 'AppStorageKey', instead") -extension AppStorageKeyPathKey: SharedKey, Hashable { - #if canImport(Sharing2) - public func load(context: LoadContext, continuation: LoadContinuation) { - continuation.resume(returning: self.store.wrappedValue[keyPath: self.keyPath]) + public init(_ keyPath: _SendableReferenceWritableKeyPath) { + @Dependency(\.defaultAppStorage) var store + self.keyPath = keyPath + self.store = UncheckedSendable(store) } + } - public func subscribe(context: LoadContext, subscriber: SharedSubscriber) - -> SharedSubscription - { - let observer = self.store.wrappedValue.observe(self.keyPath, options: .new) { _, change in - guard - !SharedAppStorageLocals.isSetting - else { return } - subscriber.yield(with: Result { change.newValue }) + @available(*, deprecated, message: "Use an 'AppStorageKey', instead") + extension AppStorageKeyPathKey: SharedKey, Hashable { + #if canImport(Sharing2) + public func load(context: LoadContext, continuation: LoadContinuation) { + continuation.resume(returning: self.store.wrappedValue[keyPath: self.keyPath]) } - return SharedSubscription { - observer.invalidate() - } - } - public func save(_ value: Value, context: SaveContext, continuation: SaveContinuation) { - SharedAppStorageLocals.$isSetting.withValue(true) { - self.store.wrappedValue[keyPath: self.keyPath] = value + public func subscribe(context: LoadContext, subscriber: SharedSubscriber) + -> SharedSubscription + { + let observer = self.store.wrappedValue.observe(self.keyPath, options: .new) { _, change in + guard + !SharedAppStorageLocals.isSetting + else { return } + subscriber.yield(with: Result { change.newValue }) + } + return SharedSubscription { + observer.invalidate() + } } - continuation.resume() - } - #else - public func load(initialValue _: Value?) -> Value? { - self.store.wrappedValue[keyPath: self.keyPath] - } - public func subscribe( - initialValue: Value?, - didSet receiveValue: @escaping @Sendable (_ newValue: Value?) -> Void - ) -> SharedSubscription { - let observer = self.store.wrappedValue.observe(self.keyPath, options: .new) { _, change in - guard - !SharedAppStorageLocals.isSetting - else { return } - receiveValue(change.newValue ?? initialValue) + public func save(_ value: Value, context: SaveContext, continuation: SaveContinuation) { + SharedAppStorageLocals.$isSetting.withValue(true) { + self.store.wrappedValue[keyPath: self.keyPath] = value + } + continuation.resume() } - return SharedSubscription { - observer.invalidate() + #else + public func load(initialValue _: Value?) -> Value? { + self.store.wrappedValue[keyPath: self.keyPath] } - } - public func save(_ newValue: Value, immediately: Bool) { - SharedAppStorageLocals.$isSetting.withValue(true) { - self.store.wrappedValue[keyPath: self.keyPath] = newValue + public func subscribe( + initialValue: Value?, + didSet receiveValue: @escaping @Sendable (_ newValue: Value?) -> Void + ) -> SharedSubscription { + let observer = self.store.wrappedValue.observe(self.keyPath, options: .new) { _, change in + guard + !SharedAppStorageLocals.isSetting + else { return } + receiveValue(change.newValue ?? initialValue) + } + return SharedSubscription { + observer.invalidate() + } } - } - #endif -} + + public func save(_ newValue: Value, immediately: Bool) { + SharedAppStorageLocals.$isSetting.withValue(true) { + self.store.wrappedValue[keyPath: self.keyPath] = newValue + } + } + #endif + } +#endif // NB: This is mainly used for tests, where observer notifications can bleed across cases. private enum SharedAppStorageLocals { diff --git a/Sources/ComposableArchitecture/Store.swift b/Sources/ComposableArchitecture/Store.swift index 183d9d716b6d..cb6a7302a945 100644 --- a/Sources/ComposableArchitecture/Store.swift +++ b/Sources/ComposableArchitecture/Store.swift @@ -1,7 +1,20 @@ -import Combine -import CombineSchedulers import Foundation -import SwiftUI + +#if canImport(Combine) + import Combine + import CombineSchedulers +#else + import OpenCombine +#endif + +@_spi(Internals) +public var _isInPerceptionTracking: Bool { + #if DEBUG && !os(visionOS) && !os(Linux) && !os(Android) + return _PerceptionLocals.isInPerceptionTracking || _PerceptionLocals.skipPerceptionChecking + #else + return false + #endif +} /// A store represents the runtime that powers the application. It is the object that you will pass /// around to views that need to interact with the application. @@ -149,12 +162,14 @@ public final class Store: _Store { self.scopeID = nil } - deinit { - guard Thread.isMainThread else { return } - MainActor._assumeIsolated { - Logger.shared.log("\(storeTypeName(of: self)).deinit") + #if !os(Linux) && !os(Android) + deinit { + guard Thread.isMainThread else { return } + MainActor._assumeIsolated { + Logger.shared.log("\(storeTypeName(of: self)).deinit") + } } - } + #endif /// Calls the given closure with a snapshot of the current state of the store. /// @@ -195,31 +210,33 @@ public final class Store: _Store { .init(rawValue: self.send(action)) } - /// Sends an action to the store with a given animation. - /// - /// See ``Store/send(_:)`` for more info. - /// - /// - Parameters: - /// - action: An action. - /// - animation: An animation. - @discardableResult - public func send(_ action: Action, animation: Animation?) -> StoreTask { - send(action, transaction: Transaction(animation: animation)) - } + #if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + /// Sends an action to the store with a given animation. + /// + /// See ``Store/send(_:)`` for more info. + /// + /// - Parameters: + /// - action: An action. + /// - animation: An animation. + @discardableResult + public func send(_ action: Action, animation: Animation?) -> StoreTask { + send(action, transaction: Transaction(animation: animation)) + } - /// Sends an action to the store with a given transaction. - /// - /// See ``Store/send(_:)`` for more info. - /// - /// - Parameters: - /// - action: An action. - /// - transaction: A transaction. - @discardableResult - public func send(_ action: Action, transaction: Transaction) -> StoreTask { - withTransaction(transaction) { - .init(rawValue: self.send(action)) + /// Sends an action to the store with a given transaction. + /// + /// See ``Store/send(_:)`` for more info. + /// + /// - Parameters: + /// - action: An action. + /// - transaction: A transaction. + @discardableResult + public func send(_ action: Action, transaction: Transaction) -> StoreTask { + withTransaction(transaction) { + .init(rawValue: self.send(action)) + } } - } + #endif /// Scopes the store to one that exposes child state and actions. /// @@ -328,7 +345,9 @@ public final class Store: _Store { } private init(core: some Core, scopeID: AnyHashable?, parent: (any _Store)?) { - defer { Logger.shared.log("\(storeTypeName(of: self)).init") } + #if !os(Linux) && !os(Android) + defer { Logger.shared.log("\(storeTypeName(of: self)).init") } + #endif self.core = core self.parent = parent self.scopeID = scopeID @@ -378,7 +397,11 @@ public final class Store: _Store { public var publisher: StorePublisher { StorePublisher( store: self, - upstream: self.core.didSet.receive(on: UIScheduler.shared).map { self.withState(\.self) } + upstream: self.core.didSet + #if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + .receive(on: UIScheduler.shared) + #endif + .map { self.withState(\.self) } ) } diff --git a/Sources/ComposableArchitecture/SwiftUI/Alert.swift b/Sources/ComposableArchitecture/SwiftUI/Alert.swift index a45ae5934333..8f2da1cc358a 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Alert.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Alert.swift @@ -1,106 +1,108 @@ -import SwiftUI +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + import SwiftUI -@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) -extension View { - /// Displays an alert when the store's state becomes non-`nil`, and dismisses it when it becomes - /// `nil`. - /// - /// - Parameters: - /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for an - /// alert. - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public func alert( - store: Store>, PresentationAction> - ) -> some View { - self._alert(store: store, state: { $0 }, action: { $0 }) - } + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + extension View { + /// Displays an alert when the store's state becomes non-`nil`, and dismisses it when it becomes + /// `nil`. + /// + /// - Parameters: + /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for an + /// alert. + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency @MainActor + #endif + public func alert( + store: Store>, PresentationAction> + ) -> some View { + self._alert(store: store, state: { $0 }, action: { $0 }) + } - /// Displays an alert when the store's state becomes non-`nil`, and dismisses it when it becomes - /// `nil`. - /// - /// - Parameters: - /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for an - /// alert. - /// - toDestinationState: A transformation to extract alert state from the presentation state. - /// - fromDestinationAction: A transformation to embed alert actions into the presentation - /// action. - @available( - iOS, deprecated: 9999, - message: - "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - @available( - macOS, deprecated: 9999, - message: - "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - @available( - tvOS, deprecated: 9999, - message: - "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - @available( - watchOS, deprecated: 9999, - message: - "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public func alert( - store: Store, PresentationAction>, - state toDestinationState: @escaping (_ state: State) -> AlertState?, - action fromDestinationAction: @escaping (_ alertAction: ButtonAction) -> Action - ) -> some View { - self._alert(store: store, state: toDestinationState, action: fromDestinationAction) - } + /// Displays an alert when the store's state becomes non-`nil`, and dismisses it when it becomes + /// `nil`. + /// + /// - Parameters: + /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for an + /// alert. + /// - toDestinationState: A transformation to extract alert state from the presentation state. + /// - fromDestinationAction: A transformation to embed alert actions into the presentation + /// action. + @available( + iOS, deprecated: 9999, + message: + "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + @available( + macOS, deprecated: 9999, + message: + "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + @available( + tvOS, deprecated: 9999, + message: + "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + @available( + watchOS, deprecated: 9999, + message: + "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency@MainActor + #endif + public func alert( + store: Store, PresentationAction>, + state toDestinationState: @escaping (_ state: State) -> AlertState?, + action fromDestinationAction: @escaping (_ alertAction: ButtonAction) -> Action + ) -> some View { + self._alert(store: store, state: toDestinationState, action: fromDestinationAction) + } - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - private func _alert( - store: Store, PresentationAction>, - state toDestinationState: @escaping (_ state: State) -> AlertState?, - action fromDestinationAction: @escaping (_ alertAction: ButtonAction) -> Action - ) -> some View { - self.presentation( - store: store, state: toDestinationState, action: fromDestinationAction - ) { `self`, $isPresented, destination in - let alertState = store.withState { $0.wrappedValue.flatMap(toDestinationState) } - self.alert( - (alertState?.title).map(Text.init) ?? Text(verbatim: ""), - isPresented: $isPresented, - presenting: alertState, - actions: { alertState in - ForEach(alertState.buttons) { button in - Button(role: button.role.map(ButtonRole.init)) { - switch button.action.type { - case let .send(action): - if let action { - store.send(.presented(fromDestinationAction(action))) - } - case let .animatedSend(action, animation): - if let action { - store.send(.presented(fromDestinationAction(action)), animation: animation) + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency @MainActor + #endif + private func _alert( + store: Store, PresentationAction>, + state toDestinationState: @escaping (_ state: State) -> AlertState?, + action fromDestinationAction: @escaping (_ alertAction: ButtonAction) -> Action + ) -> some View { + self.presentation( + store: store, state: toDestinationState, action: fromDestinationAction + ) { `self`, $isPresented, destination in + let alertState = store.withState { $0.wrappedValue.flatMap(toDestinationState) } + self.alert( + (alertState?.title).map(Text.init) ?? Text(verbatim: ""), + isPresented: $isPresented, + presenting: alertState, + actions: { alertState in + ForEach(alertState.buttons) { button in + Button(role: button.role.map(ButtonRole.init)) { + switch button.action.type { + case .send(let action): + if let action { + store.send(.presented(fromDestinationAction(action))) + } + case .animatedSend(let action, let animation): + if let action { + store.send(.presented(fromDestinationAction(action)), animation: animation) + } } + } label: { + Text(button.label) } - } label: { - Text(button.label) } + }, + message: { + $0.message.map(Text.init) } - }, - message: { - $0.message.map(Text.init) - } - ) + ) + } } } -} +#endif diff --git a/Sources/ComposableArchitecture/SwiftUI/Binding.swift b/Sources/ComposableArchitecture/SwiftUI/Binding.swift index a34509064cbc..26823db34431 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Binding.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Binding.swift @@ -1,806 +1,808 @@ -import CustomDump -import SwiftUI - -/// A property wrapper type that can designate properties of app state that can be directly bindable -/// in SwiftUI views. -/// -/// Along with an action type that conforms to the ``BindableAction`` protocol, this type can be -/// used to safely eliminate the boilerplate that is typically incurred when working with multiple -/// mutable fields on state. -/// -/// > Note: It is not necessary to annotate _every_ field with `@BindingState`, and in fact it is -/// > not recommended. Marking a field with the property wrapper makes it instantly mutable from the -/// > outside, which may hurt the encapsulation of your feature. It is best to limit the usage of -/// > the property wrapper to only those fields that need to have bindings derived for handing to -/// > SwiftUI components. -/// -/// Read for more information. -@available( - iOS, - deprecated: 9999, - message: - "Deriving bindings directly from stores using '@ObservableState'. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#BindingState" -) -@available( - macOS, - deprecated: 9999, - message: - "Deriving bindings directly from stores using '@ObservableState'. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#BindingState" -) -@available( - tvOS, - deprecated: 9999, - message: - "Deriving bindings directly from stores using '@ObservableState'. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#BindingState" -) -@available( - watchOS, - deprecated: 9999, - message: - "Deriving bindings directly from stores using '@ObservableState'. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#BindingState" -) -@propertyWrapper -public struct BindingState { - /// The underlying value wrapped by the binding state. - public var wrappedValue: Value - #if DEBUG - let fileID: StaticString - let filePath: StaticString - let line: UInt - let column: UInt - #endif - - /// Creates bindable state from the value of another bindable state. - public init( - wrappedValue: Value, - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) { - self.wrappedValue = wrappedValue - #if DEBUG - self.fileID = fileID - self.filePath = filePath - self.line = line - self.column = column - #endif - } +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + import CustomDump + import SwiftUI - /// A projection that can be used to derive bindings from a view store. + /// A property wrapper type that can designate properties of app state that can be directly bindable + /// in SwiftUI views. /// - /// Use the projected value to derive bindings from a view store with properties annotated with - /// `@BindingState`. To get the `projectedValue`, prefix the property with `$`: + /// Along with an action type that conforms to the ``BindableAction`` protocol, this type can be + /// used to safely eliminate the boilerplate that is typically incurred when working with multiple + /// mutable fields on state. /// - /// ```swift - /// TextField("Display name", text: viewStore.$displayName) - /// ``` + /// > Note: It is not necessary to annotate _every_ field with `@BindingState`, and in fact it is + /// > not recommended. Marking a field with the property wrapper makes it instantly mutable from the + /// > outside, which may hurt the encapsulation of your feature. It is best to limit the usage of + /// > the property wrapper to only those fields that need to have bindings derived for handing to + /// > SwiftUI components. /// - /// See ``BindingState`` for more details. - public var projectedValue: Self { - get { self } - set { self = newValue } - } -} + /// Read for more information. + @available( + iOS, + deprecated: 9999, + message: + "Deriving bindings directly from stores using '@ObservableState'. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#BindingState" + ) + @available( + macOS, + deprecated: 9999, + message: + "Deriving bindings directly from stores using '@ObservableState'. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#BindingState" + ) + @available( + tvOS, + deprecated: 9999, + message: + "Deriving bindings directly from stores using '@ObservableState'. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#BindingState" + ) + @available( + watchOS, + deprecated: 9999, + message: + "Deriving bindings directly from stores using '@ObservableState'. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#BindingState" + ) + @propertyWrapper + public struct BindingState { + /// The underlying value wrapped by the binding state. + public var wrappedValue: Value + #if DEBUG + let fileID: StaticString + let filePath: StaticString + let line: UInt + let column: UInt + #endif -extension BindingState: Equatable where Value: Equatable { - public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.wrappedValue == rhs.wrappedValue - } -} + /// Creates bindable state from the value of another bindable state. + public init( + wrappedValue: Value, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + self.wrappedValue = wrappedValue + #if DEBUG + self.fileID = fileID + self.filePath = filePath + self.line = line + self.column = column + #endif + } -extension BindingState: Hashable where Value: Hashable { - public func hash(into hasher: inout Hasher) { - self.wrappedValue.hash(into: &hasher) + /// A projection that can be used to derive bindings from a view store. + /// + /// Use the projected value to derive bindings from a view store with properties annotated with + /// `@BindingState`. To get the `projectedValue`, prefix the property with `$`: + /// + /// ```swift + /// TextField("Display name", text: viewStore.$displayName) + /// ``` + /// + /// See ``BindingState`` for more details. + public var projectedValue: Self { + get { self } + set { self = newValue } + } } -} -extension BindingState: Decodable where Value: Decodable { - public init(from decoder: any Decoder) throws { - do { - let container = try decoder.singleValueContainer() - self.init(wrappedValue: try container.decode(Value.self)) - } catch { - self.init(wrappedValue: try Value(from: decoder)) + extension BindingState: Equatable where Value: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.wrappedValue == rhs.wrappedValue } } -} -extension BindingState: Encodable where Value: Encodable { - public func encode(to encoder: any Encoder) throws { - do { - var container = encoder.singleValueContainer() - try container.encode(self.wrappedValue) - } catch { - try self.wrappedValue.encode(to: encoder) + extension BindingState: Hashable where Value: Hashable { + public func hash(into hasher: inout Hasher) { + self.wrappedValue.hash(into: &hasher) } } -} -extension BindingState: CustomReflectable { - public var customMirror: Mirror { - Mirror(reflecting: self.wrappedValue) + extension BindingState: Decodable where Value: Decodable { + public init(from decoder: any Decoder) throws { + do { + let container = try decoder.singleValueContainer() + self.init(wrappedValue: try container.decode(Value.self)) + } catch { + self.init(wrappedValue: try Value(from: decoder)) + } + } } -} -extension BindingState: CustomDumpRepresentable { - public var customDumpValue: Any { - self.wrappedValue + extension BindingState: Encodable where Value: Encodable { + public func encode(to encoder: any Encoder) throws { + do { + var container = encoder.singleValueContainer() + try container.encode(self.wrappedValue) + } catch { + try self.wrappedValue.encode(to: encoder) + } + } } -} -extension BindingState: CustomDebugStringConvertible where Value: CustomDebugStringConvertible { - public var debugDescription: String { - self.wrappedValue.debugDescription + extension BindingState: CustomReflectable { + public var customMirror: Mirror { + Mirror(reflecting: self.wrappedValue) + } } -} - -extension BindingState: Sendable where Value: Sendable {} - -/// An action that describes simple mutations to some root state at a writable key path. -/// -/// Used in conjunction with ``BindingState`` and ``BindableAction`` to safely eliminate the -/// boilerplate typically associated with mutating multiple fields in state. -/// -/// Read for more information. -public struct BindingAction: CasePathable, Equatable, Sendable { - public let keyPath: _SendablePartialKeyPath - - @usableFromInline - let set: @Sendable (inout Root) -> Void - let value: any Sendable - let valueIsEqualTo: @Sendable (Any) -> Bool - - init( - keyPath: _SendablePartialKeyPath, - set: @escaping @Sendable (inout Root) -> Void, - value: any Sendable, - valueIsEqualTo: @escaping @Sendable (Any) -> Bool - ) { - self.keyPath = keyPath - self.set = set - self.value = value - self.valueIsEqualTo = valueIsEqualTo + + extension BindingState: CustomDumpRepresentable { + public var customDumpValue: Any { + self.wrappedValue + } } - public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.keyPath == rhs.keyPath && lhs.valueIsEqualTo(rhs.value) + extension BindingState: CustomDebugStringConvertible where Value: CustomDebugStringConvertible { + public var debugDescription: String { + self.wrappedValue.debugDescription + } } - public static var allCasePaths: AllCasePaths { - AllCasePaths() + extension BindingState: Sendable where Value: Sendable {} + + /// An action that describes simple mutations to some root state at a writable key path. + /// + /// Used in conjunction with ``BindingState`` and ``BindableAction`` to safely eliminate the + /// boilerplate typically associated with mutating multiple fields in state. + /// + /// Read for more information. + public struct BindingAction: CasePathable, Equatable, Sendable { + public let keyPath: _SendablePartialKeyPath + + @usableFromInline + let set: @Sendable (inout Root) -> Void + let value: any Sendable + let valueIsEqualTo: @Sendable (Any) -> Bool + + init( + keyPath: _SendablePartialKeyPath, + set: @escaping @Sendable (inout Root) -> Void, + value: any Sendable, + valueIsEqualTo: @escaping @Sendable (Any) -> Bool + ) { + self.keyPath = keyPath + self.set = set + self.value = value + self.valueIsEqualTo = valueIsEqualTo + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.keyPath == rhs.keyPath && lhs.valueIsEqualTo(rhs.value) + } + + public static var allCasePaths: AllCasePaths { + AllCasePaths() + } + + @dynamicMemberLookup + public struct AllCasePaths { + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> AnyCasePath where Root: ObservableState { + let keyPath = keyPath.unsafeSendable() + return AnyCasePath( + embed: { .set(keyPath, $0) }, + extract: { $0.keyPath == keyPath ? $0.value as? Value : nil } + ) + } + + public subscript( + dynamicMember keyPath: WritableKeyPath> + ) -> AnyCasePath { + let keyPath = keyPath.unsafeSendable() + return AnyCasePath( + embed: { .set(keyPath, $0) }, + extract: { $0.keyPath == keyPath ? $0.value as? Value : nil } + ) + } + } } - @dynamicMemberLookup - public struct AllCasePaths { - public subscript( - dynamicMember keyPath: WritableKeyPath - ) -> AnyCasePath where Root: ObservableState { - let keyPath = keyPath.unsafeSendable() - return AnyCasePath( - embed: { .set(keyPath, $0) }, - extract: { $0.keyPath == keyPath ? $0.value as? Value : nil } + extension BindingAction { + /// Returns an action that describes simple mutations to some root state at a writable key path + /// to binding state. + /// + /// - Parameters: + /// - keyPath: A key path to the property that should be mutated. This property must be + /// annotated with the ``BindingState`` property wrapper. + /// - value: A value to assign at the given key path. + /// - Returns: An action that describes simple mutations to some root state at a writable key + /// path. + public static func set( + _ keyPath: _SendableWritableKeyPath>, + _ value: Value + ) -> Self { + return .init( + keyPath: keyPath, + set: { $0[keyPath: keyPath].wrappedValue = value }, + value: value ) } - public subscript( - dynamicMember keyPath: WritableKeyPath> - ) -> AnyCasePath { - let keyPath = keyPath.unsafeSendable() - return AnyCasePath( - embed: { .set(keyPath, $0) }, - extract: { $0.keyPath == keyPath ? $0.value as? Value : nil } + /// Matches a binding action by its key path. + /// + /// Implicitly invoked when switching on a reducer's action and pattern matching on a binding + /// action directly to do further work: + /// + /// ```swift + /// case .binding(\.displayName): // Invokes the `~=` operator. + /// // Validate display name + /// + /// case .binding(\.enableNotifications): + /// // Return an authorization request effect + /// ``` + public static func ~= ( + keyPath: WritableKeyPath>, + bindingAction: Self + ) -> Bool { + keyPath == bindingAction.keyPath + } + + init( + keyPath: _SendableWritableKeyPath>, + set: @escaping @Sendable (_ state: inout Root) -> Void, + value: Value + ) { + self.init( + keyPath: keyPath, + set: set, + value: value, + valueIsEqualTo: { $0 as? Value == value } ) } } -} -extension BindingAction { - /// Returns an action that describes simple mutations to some root state at a writable key path - /// to binding state. - /// - /// - Parameters: - /// - keyPath: A key path to the property that should be mutated. This property must be - /// annotated with the ``BindingState`` property wrapper. - /// - value: A value to assign at the given key path. - /// - Returns: An action that describes simple mutations to some root state at a writable key - /// path. - public static func set( - _ keyPath: _SendableWritableKeyPath>, - _ value: Value - ) -> Self { - return .init( - keyPath: keyPath, - set: { $0[keyPath: keyPath].wrappedValue = value }, - value: value - ) + extension BindingAction: CustomDumpStringConvertible { + public var customDumpDescription: String { + var description = ".set(" + customDump(self.keyPath, to: &description, maxDepth: 0) + description.append(", ") + customDump(self.value, to: &description, maxDepth: 0) + description.append(")") + return description + } } - /// Matches a binding action by its key path. - /// - /// Implicitly invoked when switching on a reducer's action and pattern matching on a binding - /// action directly to do further work: + /// An action type that exposes a `binding` case that holds a ``BindingAction``. /// - /// ```swift - /// case .binding(\.displayName): // Invokes the `~=` operator. - /// // Validate display name + /// Used in conjunction with ``BindingState`` to safely eliminate the boilerplate typically + /// associated with mutating multiple fields in state. /// - /// case .binding(\.enableNotifications): - /// // Return an authorization request effect - /// ``` - public static func ~= ( - keyPath: WritableKeyPath>, - bindingAction: Self - ) -> Bool { - keyPath == bindingAction.keyPath - } + /// Read for more information. + public protocol BindableAction { + /// The root state type that contains bindable fields. + associatedtype State + + /// Embeds a binding action in this action type. + /// + /// - Returns: A binding action. + static func binding(_ action: BindingAction) -> Self - init( - keyPath: _SendableWritableKeyPath>, - set: @escaping @Sendable (_ state: inout Root) -> Void, - value: Value - ) { - self.init( - keyPath: keyPath, - set: set, - value: value, - valueIsEqualTo: { $0 as? Value == value } - ) + /// Extracts a binding action from this action type. + var binding: BindingAction? { get } } -} - -extension BindingAction: CustomDumpStringConvertible { - public var customDumpDescription: String { - var description = ".set(" - customDump(self.keyPath, to: &description, maxDepth: 0) - description.append(", ") - customDump(self.value, to: &description, maxDepth: 0) - description.append(")") - return description + + extension BindableAction { + public var binding: BindingAction? { + AnyCasePath(unsafe: { .binding($0) }).extract(from: self) + } } -} - -/// An action type that exposes a `binding` case that holds a ``BindingAction``. -/// -/// Used in conjunction with ``BindingState`` to safely eliminate the boilerplate typically -/// associated with mutating multiple fields in state. -/// -/// Read for more information. -public protocol BindableAction { - /// The root state type that contains bindable fields. - associatedtype State - - /// Embeds a binding action in this action type. - /// - /// - Returns: A binding action. - static func binding(_ action: BindingAction) -> Self - /// Extracts a binding action from this action type. - var binding: BindingAction? { get } -} + extension BindableAction { + /// Constructs a binding action for the given key path and bindable value. + /// + /// Shorthand for `.binding(.set(\.$keyPath, value))`. + /// + /// - Returns: A binding action. + public static func set( + _ keyPath: _SendableWritableKeyPath>, + _ value: Value + ) -> Self { + self.binding(.set(keyPath, value)) + } + } -extension BindableAction { - public var binding: BindingAction? { - AnyCasePath(unsafe: { .binding($0) }).extract(from: self) + extension ViewStore where ViewAction: BindableAction, ViewAction.State == ViewState { + public subscript( + dynamicMember keyPath: WritableKeyPath> + ) -> Binding { + let keyPath = keyPath.unsafeSendable() + return self.binding( + get: { $0[keyPath: keyPath].wrappedValue }, + send: { value in + #if DEBUG + let bindingState = self.state[keyPath: keyPath] + let debugger = BindableActionViewStoreDebugger( + value: value, + bindableActionType: ViewAction.self, + context: .bindingState, + isInvalidated: { [weak self] in self?.store.core.isInvalid ?? true }, + fileID: bindingState.fileID, + filePath: bindingState.filePath, + line: bindingState.line, + column: bindingState.column + ) + let set: @Sendable (inout ViewState) -> Void = { + $0[keyPath: keyPath].wrappedValue = value + debugger.wasCalled.setValue(true) + } + #else + let set: @Sendable (inout ViewState) -> Void = { + $0[keyPath: keyPath].wrappedValue = value + } + #endif + return .binding(.init(keyPath: keyPath, set: set, value: value)) + } + ) + } } -} -extension BindableAction { - /// Constructs a binding action for the given key path and bindable value. - /// - /// Shorthand for `.binding(.set(\.$keyPath, value))`. + /// A property wrapper type that can designate properties of view state that can be directly + /// bindable in SwiftUI views. /// - /// - Returns: A binding action. - public static func set( - _ keyPath: _SendableWritableKeyPath>, - _ value: Value - ) -> Self { - self.binding(.set(keyPath, value)) - } -} - -extension ViewStore where ViewAction: BindableAction, ViewAction.State == ViewState { - public subscript( - dynamicMember keyPath: WritableKeyPath> - ) -> Binding { - let keyPath = keyPath.unsafeSendable() - return self.binding( - get: { $0[keyPath: keyPath].wrappedValue }, - send: { value in - #if DEBUG - let bindingState = self.state[keyPath: keyPath] - let debugger = BindableActionViewStoreDebugger( - value: value, - bindableActionType: ViewAction.self, - context: .bindingState, - isInvalidated: { [weak self] in self?.store.core.isInvalid ?? true }, - fileID: bindingState.fileID, - filePath: bindingState.filePath, - line: bindingState.line, - column: bindingState.column - ) - let set: @Sendable (inout ViewState) -> Void = { - $0[keyPath: keyPath].wrappedValue = value - debugger.wasCalled.setValue(true) - } - #else - let set: @Sendable (inout ViewState) -> Void = { - $0[keyPath: keyPath].wrappedValue = value - } - #endif - return .binding(.init(keyPath: keyPath, set: set, value: value)) - } - ) - } -} - -/// A property wrapper type that can designate properties of view state that can be directly -/// bindable in SwiftUI views. -/// -/// Read for more information. -@dynamicMemberLookup -@propertyWrapper -public struct BindingViewState { - let binding: Binding - let initialValue: Value - - init(binding: Binding) { - self.binding = binding - self.initialValue = binding.wrappedValue - } + /// Read for more information. + @dynamicMemberLookup + @propertyWrapper + public struct BindingViewState { + let binding: Binding + let initialValue: Value + + init(binding: Binding) { + self.binding = binding + self.initialValue = binding.wrappedValue + } - public var wrappedValue: Value { - get { self.binding.wrappedValue } - set { self.binding.wrappedValue = newValue } - } + public var wrappedValue: Value { + get { self.binding.wrappedValue } + set { self.binding.wrappedValue = newValue } + } - public var projectedValue: Binding { - self.binding - } + public var projectedValue: Binding { + self.binding + } - public subscript( - dynamicMember keyPath: WritableKeyPath - ) -> BindingViewState { - BindingViewState(binding: self.binding[dynamicMember: keyPath]) + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> BindingViewState { + BindingViewState(binding: self.binding[dynamicMember: keyPath]) + } } -} -extension BindingViewState: Equatable where Value: Equatable { - public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.initialValue == rhs.initialValue && lhs.wrappedValue == rhs.wrappedValue + extension BindingViewState: Equatable where Value: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.initialValue == rhs.initialValue && lhs.wrappedValue == rhs.wrappedValue + } } -} -extension BindingViewState: Hashable where Value: Hashable { - public func hash(into hasher: inout Hasher) { - hasher.combine(self.initialValue) - hasher.combine(self.wrappedValue) + extension BindingViewState: Hashable where Value: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(self.initialValue) + hasher.combine(self.wrappedValue) + } } -} -extension BindingViewState: CustomReflectable { - public var customMirror: Mirror { - Mirror(reflecting: self.wrappedValue) + extension BindingViewState: CustomReflectable { + public var customMirror: Mirror { + Mirror(reflecting: self.wrappedValue) + } } -} -extension BindingViewState: CustomDumpRepresentable { - public var customDumpValue: Any { - self.wrappedValue + extension BindingViewState: CustomDumpRepresentable { + public var customDumpValue: Any { + self.wrappedValue + } } -} -extension BindingViewState: CustomDebugStringConvertible -where Value: CustomDebugStringConvertible { - public var debugDescription: String { - self.wrappedValue.debugDescription + extension BindingViewState: CustomDebugStringConvertible + where Value: CustomDebugStringConvertible { + public var debugDescription: String { + self.wrappedValue.debugDescription + } } -} - -/// A property wrapper type that can derive ``BindingViewState`` values for a ``ViewStore``. -/// -/// Read for more information. -@dynamicMemberLookup -@propertyWrapper -#if swift(<5.10) - @MainActor(unsafe) -#else - @preconcurrency@MainActor -#endif -public struct BindingViewStore { - let store: Store> - #if DEBUG - let bindableActionType: Any.Type - let fileID: StaticString - let filePath: StaticString - let line: UInt - let column: UInt - #endif - init>( - store: Store, - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) { - self.store = store._scope(state: { $0 }, action: { .binding($0) }) + /// A property wrapper type that can derive ``BindingViewState`` values for a ``ViewStore``. + /// + /// Read for more information. + @dynamicMemberLookup + @propertyWrapper + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency@MainActor + #endif + public struct BindingViewStore { + let store: Store> #if DEBUG - self.bindableActionType = type(of: Action.self) - self.fileID = fileID - self.filePath = filePath - self.line = line - self.column = column + let bindableActionType: Any.Type + let fileID: StaticString + let filePath: StaticString + let line: UInt + let column: UInt #endif - } - public init(projectedValue: Self) { - self = projectedValue - } + init>( + store: Store, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + self.store = store._scope(state: { $0 }, action: { .binding($0) }) + #if DEBUG + self.bindableActionType = type(of: Action.self) + self.fileID = fileID + self.filePath = filePath + self.line = line + self.column = column + #endif + } - public var wrappedValue: State { - self.store.withState { $0 } - } + public init(projectedValue: Self) { + self = projectedValue + } - public var projectedValue: Self { - get { self } - set { self = newValue } - } + public var wrappedValue: State { + self.store.withState { $0 } + } - public subscript(dynamicMember keyPath: KeyPath) -> Value { - self.wrappedValue[keyPath: keyPath] - } + public var projectedValue: Self { + get { self } + set { self = newValue } + } - public subscript( - dynamicMember keyPath: WritableKeyPath> - ) -> BindingViewState { - let keyPath = keyPath.unsafeSendable() - return BindingViewState( - binding: ViewStore(self.store, observe: { $0[keyPath: keyPath].wrappedValue }) - .binding( - send: { value in - #if DEBUG - let debugger = BindableActionViewStoreDebugger( - value: value, - bindableActionType: self.bindableActionType, - context: .bindingStore, - isInvalidated: { [weak store] in store?.core.isInvalid ?? true }, - fileID: self.fileID, - filePath: self.filePath, - line: self.line, - column: self.column - ) - let set: @Sendable (inout State) -> Void = { - $0[keyPath: keyPath].wrappedValue = value - debugger.wasCalled.setValue(true) - } - #else - let set: @Sendable (inout State) -> Void = { - $0[keyPath: keyPath].wrappedValue = value - } - #endif - return .init(keyPath: keyPath, set: set, value: value) - } - ) - ) - } -} + public subscript(dynamicMember keyPath: KeyPath) -> Value { + self.wrappedValue[keyPath: keyPath] + } -extension ViewStore { - /// Initializes a structure that transforms a ``Store`` into an observable ``ViewStore`` in order - /// to compute bindings from state. - /// - /// Read for more information. - /// - /// - Parameters: - /// - store: A store. - /// - toViewState: A function that transforms binding store state into observable view state. - /// All changes to the view state will cause the `WithViewStore` to re-compute its view. - /// - fromViewAction: A function that transforms view actions into store action. - /// - isDuplicate: A function to determine when two `ViewState` values are equal. When values - /// are equal, repeat view computations are removed. - public convenience init( - _ store: Store, - observe toViewState: @escaping (_ state: BindingViewStore) -> ViewState, - send fromViewAction: @escaping (_ viewAction: ViewAction) -> Action, - removeDuplicates isDuplicate: @escaping (_ lhs: ViewState, _ rhs: ViewState) -> Bool - ) where ViewAction: BindableAction { - self.init( - store, - observe: { (_: State) in - toViewState( - BindingViewStore( - store: store._scope(state: { $0 }, action: fromViewAction) + public subscript( + dynamicMember keyPath: WritableKeyPath> + ) -> BindingViewState { + let keyPath = keyPath.unsafeSendable() + return BindingViewState( + binding: ViewStore(self.store, observe: { $0[keyPath: keyPath].wrappedValue }) + .binding( + send: { value in + #if DEBUG + let debugger = BindableActionViewStoreDebugger( + value: value, + bindableActionType: self.bindableActionType, + context: .bindingStore, + isInvalidated: { [weak store] in store?.core.isInvalid ?? true }, + fileID: self.fileID, + filePath: self.filePath, + line: self.line, + column: self.column + ) + let set: @Sendable (inout State) -> Void = { + $0[keyPath: keyPath].wrappedValue = value + debugger.wasCalled.setValue(true) + } + #else + let set: @Sendable (inout State) -> Void = { + $0[keyPath: keyPath].wrappedValue = value + } + #endif + return .init(keyPath: keyPath, set: set, value: value) + } ) - ) - }, - send: fromViewAction, - removeDuplicates: isDuplicate - ) + ) + } } - /// Initializes a structure that transforms a ``Store`` into an observable ``ViewStore`` in order - /// to compute bindings from state. - /// - /// Read for more information. - /// - /// - Parameters: - /// - store: A store. - /// - toViewState: A function that transforms binding store state into observable view state. - /// All changes to the view state will cause the `WithViewStore` to re-compute its view. - /// - isDuplicate: A function to determine when two `ViewState` values are equal. When values - /// are equal, repeat view computations are removed. - @_disfavoredOverload - public convenience init( - _ store: Store, - observe toViewState: @escaping (_ state: BindingViewStore) -> ViewState, - removeDuplicates isDuplicate: @escaping (_ lhs: ViewState, _ rhs: ViewState) -> Bool - ) where ViewAction: BindableAction { - self.init( - store, - observe: toViewState, - send: { $0 }, - removeDuplicates: isDuplicate - ) - } -} + extension ViewStore { + /// Initializes a structure that transforms a ``Store`` into an observable ``ViewStore`` in order + /// to compute bindings from state. + /// + /// Read for more information. + /// + /// - Parameters: + /// - store: A store. + /// - toViewState: A function that transforms binding store state into observable view state. + /// All changes to the view state will cause the `WithViewStore` to re-compute its view. + /// - fromViewAction: A function that transforms view actions into store action. + /// - isDuplicate: A function to determine when two `ViewState` values are equal. When values + /// are equal, repeat view computations are removed. + public convenience init( + _ store: Store, + observe toViewState: @escaping (_ state: BindingViewStore) -> ViewState, + send fromViewAction: @escaping (_ viewAction: ViewAction) -> Action, + removeDuplicates isDuplicate: @escaping (_ lhs: ViewState, _ rhs: ViewState) -> Bool + ) where ViewAction: BindableAction { + self.init( + store, + observe: { (_: State) in + toViewState( + BindingViewStore( + store: store._scope(state: { $0 }, action: fromViewAction) + ) + ) + }, + send: fromViewAction, + removeDuplicates: isDuplicate + ) + } -extension ViewStore where ViewState: Equatable { - /// Initializes a structure that transforms a ``Store`` into an observable ``ViewStore`` in order - /// to compute bindings from state. - /// - /// Read for more information. - /// - /// - Parameters: - /// - store: A store. - /// - toViewState: A function that transforms binding store state into observable view state. - /// All changes to the view state will cause the `WithViewStore` to re-compute its view. - /// - fromViewAction: A function that transforms view actions into store action. - @_disfavoredOverload - public convenience init( - _ store: Store, - observe toViewState: @escaping (_ state: BindingViewStore) -> ViewState, - send fromViewAction: @escaping (_ viewAction: ViewAction) -> Action - ) where ViewAction: BindableAction { - self.init( - store, - observe: toViewState, - send: fromViewAction, - removeDuplicates: == - ) + /// Initializes a structure that transforms a ``Store`` into an observable ``ViewStore`` in order + /// to compute bindings from state. + /// + /// Read for more information. + /// + /// - Parameters: + /// - store: A store. + /// - toViewState: A function that transforms binding store state into observable view state. + /// All changes to the view state will cause the `WithViewStore` to re-compute its view. + /// - isDuplicate: A function to determine when two `ViewState` values are equal. When values + /// are equal, repeat view computations are removed. + @_disfavoredOverload + public convenience init( + _ store: Store, + observe toViewState: @escaping (_ state: BindingViewStore) -> ViewState, + removeDuplicates isDuplicate: @escaping (_ lhs: ViewState, _ rhs: ViewState) -> Bool + ) where ViewAction: BindableAction { + self.init( + store, + observe: toViewState, + send: { $0 }, + removeDuplicates: isDuplicate + ) + } } - /// Initializes a structure that transforms a ``Store`` into an observable ``ViewStore`` in order - /// to compute bindings from state. - /// - /// Read for more information. - /// - /// - Parameters: - /// - store: A store. - /// - toViewState: A function that transforms binding store state into observable view state. - /// All changes to the view state will cause the `WithViewStore` to re-compute its view. - @_disfavoredOverload - public convenience init( - _ store: Store, - observe toViewState: @escaping (_ state: BindingViewStore) -> ViewState - ) where ViewAction: BindableAction { - self.init( - store, - observe: toViewState, - removeDuplicates: == - ) - } -} + extension ViewStore where ViewState: Equatable { + /// Initializes a structure that transforms a ``Store`` into an observable ``ViewStore`` in order + /// to compute bindings from state. + /// + /// Read for more information. + /// + /// - Parameters: + /// - store: A store. + /// - toViewState: A function that transforms binding store state into observable view state. + /// All changes to the view state will cause the `WithViewStore` to re-compute its view. + /// - fromViewAction: A function that transforms view actions into store action. + @_disfavoredOverload + public convenience init( + _ store: Store, + observe toViewState: @escaping (_ state: BindingViewStore) -> ViewState, + send fromViewAction: @escaping (_ viewAction: ViewAction) -> Action + ) where ViewAction: BindableAction { + self.init( + store, + observe: toViewState, + send: fromViewAction, + removeDuplicates: == + ) + } -extension WithViewStore where Content: View { - /// Initializes a structure that transforms a ``Store`` into an observable ``ViewStore`` in order - /// to compute bindings and views from state. - /// - /// Read for more information. - /// - /// - Parameters: - /// - store: A store. - /// - toViewState: A function that transforms binding store state into observable view state. - /// All changes to the view state will cause the `WithViewStore` to re-compute its view. - /// - fromViewAction: A function that transforms view actions into store action. - /// - isDuplicate: A function to determine when two `ViewState` values are equal. When values - /// are equal, repeat view computations are removed. - /// - content: A function that can generate content from a view store. - /// - file: The file. - /// - line: The line. - @_disfavoredOverload - public init( - _ store: Store, - observe toViewState: @escaping (_ state: BindingViewStore) -> ViewState, - send fromViewAction: @escaping (_ viewAction: ViewAction) -> Action, - removeDuplicates isDuplicate: @escaping (_ lhs: ViewState, _ rhs: ViewState) -> Bool, - @ViewBuilder content: @escaping (_ viewStore: ViewStore) -> Content, - file: StaticString = #fileID, - line: UInt = #line - ) where ViewAction: BindableAction { - self.init( - store, - observe: { (_: State) in - toViewState(BindingViewStore(store: store._scope(state: { $0 }, action: fromViewAction))) - }, - send: fromViewAction, - removeDuplicates: isDuplicate, - content: content, - file: file, - line: line - ) + /// Initializes a structure that transforms a ``Store`` into an observable ``ViewStore`` in order + /// to compute bindings from state. + /// + /// Read for more information. + /// + /// - Parameters: + /// - store: A store. + /// - toViewState: A function that transforms binding store state into observable view state. + /// All changes to the view state will cause the `WithViewStore` to re-compute its view. + @_disfavoredOverload + public convenience init( + _ store: Store, + observe toViewState: @escaping (_ state: BindingViewStore) -> ViewState + ) where ViewAction: BindableAction { + self.init( + store, + observe: toViewState, + removeDuplicates: == + ) + } } - /// Initializes a structure that transforms a ``Store`` into an observable ``ViewStore`` in order - /// to compute bindings and views from state. - /// - /// Read for more information. - /// - /// - Parameters: - /// - store: A store. - /// - toViewState: A function that transforms binding store state into observable view state. - /// All changes to the view state will cause the `WithViewStore` to re-compute its view. - /// - isDuplicate: A function to determine when two `ViewState` values are equal. When values - /// are equal, repeat view computations are removed. - /// - content: A function that can generate content from a view store. - /// - file: The file. - /// - line: The line. - @_disfavoredOverload - public init( - _ store: Store, - observe toViewState: @escaping (_ state: BindingViewStore) -> ViewState, - removeDuplicates isDuplicate: @escaping (_ lhs: ViewState, _ rhs: ViewState) -> Bool, - @ViewBuilder content: @escaping (_ viewStore: ViewStore) -> Content, - file: StaticString = #fileID, - line: UInt = #line - ) where ViewAction: BindableAction { - self.init( - store, - observe: toViewState, - send: { $0 }, - removeDuplicates: isDuplicate, - content: content, - file: file, - line: line - ) - } -} + extension WithViewStore where Content: View { + /// Initializes a structure that transforms a ``Store`` into an observable ``ViewStore`` in order + /// to compute bindings and views from state. + /// + /// Read for more information. + /// + /// - Parameters: + /// - store: A store. + /// - toViewState: A function that transforms binding store state into observable view state. + /// All changes to the view state will cause the `WithViewStore` to re-compute its view. + /// - fromViewAction: A function that transforms view actions into store action. + /// - isDuplicate: A function to determine when two `ViewState` values are equal. When values + /// are equal, repeat view computations are removed. + /// - content: A function that can generate content from a view store. + /// - file: The file. + /// - line: The line. + @_disfavoredOverload + public init( + _ store: Store, + observe toViewState: @escaping (_ state: BindingViewStore) -> ViewState, + send fromViewAction: @escaping (_ viewAction: ViewAction) -> Action, + removeDuplicates isDuplicate: @escaping (_ lhs: ViewState, _ rhs: ViewState) -> Bool, + @ViewBuilder content: @escaping (_ viewStore: ViewStore) -> Content, + file: StaticString = #fileID, + line: UInt = #line + ) where ViewAction: BindableAction { + self.init( + store, + observe: { (_: State) in + toViewState(BindingViewStore(store: store._scope(state: { $0 }, action: fromViewAction))) + }, + send: fromViewAction, + removeDuplicates: isDuplicate, + content: content, + file: file, + line: line + ) + } -extension WithViewStore where ViewState: Equatable, Content: View { - /// Initializes a structure that transforms a ``Store`` into an observable ``ViewStore`` in order - /// to compute bindings and views from state. - /// - /// Read for more information. - /// - /// - Parameters: - /// - store: A store. - /// - toViewState: A function that transforms binding store state into observable view state. - /// All changes to the view state will cause the `WithViewStore` to re-compute its view. - /// - fromViewAction: A function that transforms view actions into store action. - /// - content: A function that can generate content from a view store. - /// - file: The file. - /// - line: The line. - @_disfavoredOverload - public init( - _ store: Store, - observe toViewState: @escaping (_ state: BindingViewStore) -> ViewState, - send fromViewAction: @escaping (_ viewAction: ViewAction) -> Action, - @ViewBuilder content: @escaping (_ viewStore: ViewStore) -> Content, - file: StaticString = #fileID, - line: UInt = #line - ) where ViewAction: BindableAction { - self.init( - store, - observe: toViewState, - send: fromViewAction, - removeDuplicates: ==, - content: content, - file: file, - line: line - ) + /// Initializes a structure that transforms a ``Store`` into an observable ``ViewStore`` in order + /// to compute bindings and views from state. + /// + /// Read for more information. + /// + /// - Parameters: + /// - store: A store. + /// - toViewState: A function that transforms binding store state into observable view state. + /// All changes to the view state will cause the `WithViewStore` to re-compute its view. + /// - isDuplicate: A function to determine when two `ViewState` values are equal. When values + /// are equal, repeat view computations are removed. + /// - content: A function that can generate content from a view store. + /// - file: The file. + /// - line: The line. + @_disfavoredOverload + public init( + _ store: Store, + observe toViewState: @escaping (_ state: BindingViewStore) -> ViewState, + removeDuplicates isDuplicate: @escaping (_ lhs: ViewState, _ rhs: ViewState) -> Bool, + @ViewBuilder content: @escaping (_ viewStore: ViewStore) -> Content, + file: StaticString = #fileID, + line: UInt = #line + ) where ViewAction: BindableAction { + self.init( + store, + observe: toViewState, + send: { $0 }, + removeDuplicates: isDuplicate, + content: content, + file: file, + line: line + ) + } } - /// Initializes a structure that transforms a ``Store`` into an observable ``ViewStore`` in order - /// to compute bindings and views from state. - /// - /// Read for more information. - /// - /// - Parameters: - /// - store: A store. - /// - toViewState: A function that transforms binding store state into observable view state. - /// All changes to the view state will cause the `WithViewStore` to re-compute its view. - /// - content: A function that can generate content from a view store. - /// - file: The file. - /// - line: The line. - @_disfavoredOverload - public init( - _ store: Store, - observe toViewState: @escaping (_ state: BindingViewStore) -> ViewState, - @ViewBuilder content: @escaping (_ viewStore: ViewStore) -> Content, - file: StaticString = #fileID, - line: UInt = #line - ) where ViewAction: BindableAction { - self.init( - store, - observe: toViewState, - removeDuplicates: ==, - content: content, - file: file, - line: line - ) + extension WithViewStore where ViewState: Equatable, Content: View { + /// Initializes a structure that transforms a ``Store`` into an observable ``ViewStore`` in order + /// to compute bindings and views from state. + /// + /// Read for more information. + /// + /// - Parameters: + /// - store: A store. + /// - toViewState: A function that transforms binding store state into observable view state. + /// All changes to the view state will cause the `WithViewStore` to re-compute its view. + /// - fromViewAction: A function that transforms view actions into store action. + /// - content: A function that can generate content from a view store. + /// - file: The file. + /// - line: The line. + @_disfavoredOverload + public init( + _ store: Store, + observe toViewState: @escaping (_ state: BindingViewStore) -> ViewState, + send fromViewAction: @escaping (_ viewAction: ViewAction) -> Action, + @ViewBuilder content: @escaping (_ viewStore: ViewStore) -> Content, + file: StaticString = #fileID, + line: UInt = #line + ) where ViewAction: BindableAction { + self.init( + store, + observe: toViewState, + send: fromViewAction, + removeDuplicates: ==, + content: content, + file: file, + line: line + ) + } + + /// Initializes a structure that transforms a ``Store`` into an observable ``ViewStore`` in order + /// to compute bindings and views from state. + /// + /// Read for more information. + /// + /// - Parameters: + /// - store: A store. + /// - toViewState: A function that transforms binding store state into observable view state. + /// All changes to the view state will cause the `WithViewStore` to re-compute its view. + /// - content: A function that can generate content from a view store. + /// - file: The file. + /// - line: The line. + @_disfavoredOverload + public init( + _ store: Store, + observe toViewState: @escaping (_ state: BindingViewStore) -> ViewState, + @ViewBuilder content: @escaping (_ viewStore: ViewStore) -> Content, + file: StaticString = #fileID, + line: UInt = #line + ) where ViewAction: BindableAction { + self.init( + store, + observe: toViewState, + removeDuplicates: ==, + content: content, + file: file, + line: line + ) + } } -} - -#if DEBUG - private final class BindableActionViewStoreDebugger: Sendable { - enum Context { - case bindingState - case bindingStore - case viewStore - } - - let value: Value - let bindableActionType: Any.Type - let context: Context - let isInvalidated: @MainActor @Sendable () -> Bool - let fileID: StaticString - let filePath: StaticString - let line: UInt - let column: UInt - let wasCalled = LockIsolated(false) - init( - value: Value, - bindableActionType: Any.Type, - context: Context, - isInvalidated: @escaping @MainActor @Sendable () -> Bool, - fileID: StaticString, - filePath: StaticString, - line: UInt, - column: UInt - ) { - self.value = value - self.bindableActionType = bindableActionType - self.context = context - self.isInvalidated = isInvalidated - self.fileID = fileID - self.filePath = filePath - self.line = line - self.column = column - } - - deinit { - guard !self.wasCalled.value - else { return } - - Task { - @MainActor [ - context, fileID, filePath, line, column, value, bindableActionType, isInvalidated - ] in - let tmp = isInvalidated() - guard !tmp else { return } - var valueDump: String { - var valueDump = "" - customDump(value, to: &valueDump, maxDepth: 0) - return valueDump + #if DEBUG + private final class BindableActionViewStoreDebugger: Sendable { + enum Context { + case bindingState + case bindingStore + case viewStore + } + + let value: Value + let bindableActionType: Any.Type + let context: Context + let isInvalidated: @MainActor @Sendable () -> Bool + let fileID: StaticString + let filePath: StaticString + let line: UInt + let column: UInt + let wasCalled = LockIsolated(false) + + init( + value: Value, + bindableActionType: Any.Type, + context: Context, + isInvalidated: @escaping @MainActor @Sendable () -> Bool, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) { + self.value = value + self.bindableActionType = bindableActionType + self.context = context + self.isInvalidated = isInvalidated + self.fileID = fileID + self.filePath = filePath + self.line = line + self.column = column + } + + deinit { + guard !self.wasCalled.value + else { return } + + Task { + @MainActor [ + context, fileID, filePath, line, column, value, bindableActionType, isInvalidated + ] in + let tmp = isInvalidated() + guard !tmp else { return } + var valueDump: String { + var valueDump = "" + customDump(value, to: &valueDump, maxDepth: 0) + return valueDump + } + reportIssue( + """ + A binding action sent from a store \ + \(context == .bindingState ? "for binding state defined " : "")at \ + "\(fileID):\(line)" was not handled. + + Action: + \(typeName(bindableActionType)).binding(.set(_, \(valueDump))) + + To fix this, invoke "BindingReducer()" from your feature reducer's "body". + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) } - reportIssue( - """ - A binding action sent from a store \ - \(context == .bindingState ? "for binding state defined " : "")at \ - "\(fileID):\(line)" was not handled. - - Action: - \(typeName(bindableActionType)).binding(.set(_, \(valueDump))) - - To fix this, invoke "BindingReducer()" from your feature reducer's "body". - """, - fileID: fileID, - filePath: filePath, - line: line, - column: column - ) } } - } + #endif #endif diff --git a/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift b/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift index f2bc676ffe41..0efba9a34477 100644 --- a/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift +++ b/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift @@ -1,111 +1,118 @@ -import SwiftUI +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + import SwiftUI -@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) -extension View { - /// Displays a dialog when the store's state becomes non-`nil`, and dismisses it when it becomes - /// `nil`. - /// - /// - Parameters: - /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for a - /// dialog. - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public func confirmationDialog( - store: Store< - PresentationState>, - PresentationAction - > - ) -> some View { - self._confirmationDialog(store: store, state: { $0 }, action: { $0 }) - } + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + extension View { + /// Displays a dialog when the store's state becomes non-`nil`, and dismisses it when it becomes + /// `nil`. + /// + /// - Parameters: + /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for a + /// dialog. + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency @MainActor + #endif + public func confirmationDialog( + store: Store< + PresentationState>, + PresentationAction + > + ) -> some View { + self._confirmationDialog(store: store, state: { $0 }, action: { $0 }) + } - /// Displays a dialog when then store's state becomes non-`nil`, and dismisses it when it becomes - /// `nil`. - /// - /// - Parameters: - /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for a - /// dialog. - /// - toDestinationState: A transformation to extract dialog state from the presentation state. - /// - fromDestinationAction: A transformation to embed dialog actions into the presentation - /// action. - @available( - iOS, deprecated: 9999, - message: - "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - @available( - macOS, deprecated: 9999, - message: - "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - @available( - tvOS, deprecated: 9999, - message: - "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - @available( - watchOS, deprecated: 9999, - message: - "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public func confirmationDialog( - store: Store, PresentationAction>, - state toDestinationState: @escaping (_ state: State) -> ConfirmationDialogState?, - action fromDestinationAction: @escaping (_ confirmationDialogAction: ButtonAction) -> Action - ) -> some View { - self._confirmationDialog(store: store, state: toDestinationState, action: fromDestinationAction) - } + /// Displays a dialog when then store's state becomes non-`nil`, and dismisses it when it becomes + /// `nil`. + /// + /// - Parameters: + /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for a + /// dialog. + /// - toDestinationState: A transformation to extract dialog state from the presentation state. + /// - fromDestinationAction: A transformation to embed dialog actions into the presentation + /// action. + @available( + iOS, deprecated: 9999, + message: + "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + @available( + macOS, deprecated: 9999, + message: + "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + @available( + tvOS, deprecated: 9999, + message: + "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + @available( + watchOS, deprecated: 9999, + message: + "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency@MainActor + #endif + public func confirmationDialog( + store: Store, PresentationAction>, + state toDestinationState: + @escaping (_ state: State) -> ConfirmationDialogState?, + action fromDestinationAction: @escaping (_ confirmationDialogAction: ButtonAction) -> Action + ) -> some View { + self._confirmationDialog( + store: store, state: toDestinationState, action: fromDestinationAction) + } - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - private func _confirmationDialog( - store: Store, PresentationAction>, - state toDestinationState: @escaping (_ state: State) -> ConfirmationDialogState?, - action fromDestinationAction: @escaping (_ confirmationDialogAction: ButtonAction) -> Action - ) -> some View { - self.presentation( - store: store, state: toDestinationState, action: fromDestinationAction - ) { `self`, $isPresented, destination in - let confirmationDialogState = store.withState { $0.wrappedValue.flatMap(toDestinationState) } - self.confirmationDialog( - (confirmationDialogState?.title).map(Text.init) ?? Text(verbatim: ""), - isPresented: $isPresented, - titleVisibility: (confirmationDialogState?.titleVisibility).map(Visibility.init) - ?? .automatic, - presenting: confirmationDialogState, - actions: { confirmationDialogState in - ForEach(confirmationDialogState.buttons) { button in - Button(role: button.role.map(ButtonRole.init)) { - switch button.action.type { - case let .send(action): - if let action { - store.send(.presented(fromDestinationAction(action))) - } - case let .animatedSend(action, animation): - if let action { - store.send(.presented(fromDestinationAction(action)), animation: animation) + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency @MainActor + #endif + private func _confirmationDialog( + store: Store, PresentationAction>, + state toDestinationState: + @escaping (_ state: State) -> ConfirmationDialogState?, + action fromDestinationAction: @escaping (_ confirmationDialogAction: ButtonAction) -> Action + ) -> some View { + self.presentation( + store: store, state: toDestinationState, action: fromDestinationAction + ) { `self`, $isPresented, destination in + let confirmationDialogState = store.withState { + $0.wrappedValue.flatMap(toDestinationState) + } + self.confirmationDialog( + (confirmationDialogState?.title).map(Text.init) ?? Text(verbatim: ""), + isPresented: $isPresented, + titleVisibility: (confirmationDialogState?.titleVisibility).map(Visibility.init) + ?? .automatic, + presenting: confirmationDialogState, + actions: { confirmationDialogState in + ForEach(confirmationDialogState.buttons) { button in + Button(role: button.role.map(ButtonRole.init)) { + switch button.action.type { + case .send(let action): + if let action { + store.send(.presented(fromDestinationAction(action))) + } + case .animatedSend(let action, let animation): + if let action { + store.send(.presented(fromDestinationAction(action)), animation: animation) + } } + } label: { + Text(button.label) } - } label: { - Text(button.label) } + }, + message: { + $0.message.map(Text.init) } - }, - message: { - $0.message.map(Text.init) - } - ) + ) + } } } -} +#endif diff --git a/Sources/ComposableArchitecture/SwiftUI/Deprecated/ActionSheet.swift b/Sources/ComposableArchitecture/SwiftUI/Deprecated/ActionSheet.swift index bacb3608bed8..0e1461c85b49 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Deprecated/ActionSheet.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Deprecated/ActionSheet.swift @@ -1,98 +1,101 @@ -import SwiftUI +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + import SwiftUI -extension View { - /// Displays an action sheet when the store's state becomes non-`nil`, and dismisses it when it - /// becomes `nil`. - /// - /// - Parameters: - /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for an - /// alert. - /// - toDestinationState: A transformation to extract alert state from the presentation state. - /// - fromDestinationAction: A transformation to embed alert actions into the presentation - /// action. - @available( - iOS, - introduced: 13, - deprecated: 100000, - message: "use 'View.confirmationDialog(store:)' instead." - ) - @available(macOS, unavailable) - @available( - tvOS, - introduced: 13, - deprecated: 100000, - message: "use 'View.confirmationDialog(store:)' instead." - ) - @available( - watchOS, - introduced: 6, - deprecated: 100000, - message: "use 'View.confirmationDialog(store:)' instead." - ) - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public func actionSheet( - store: Store< - PresentationState>, PresentationAction - > - ) -> some View { - self.actionSheet(store: store, state: { $0 }, action: { $0 }) - } + extension View { + /// Displays an action sheet when the store's state becomes non-`nil`, and dismisses it when it + /// becomes `nil`. + /// + /// - Parameters: + /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for an + /// alert. + /// - toDestinationState: A transformation to extract alert state from the presentation state. + /// - fromDestinationAction: A transformation to embed alert actions into the presentation + /// action. + @available( + iOS, + introduced: 13, + deprecated: 100000, + message: "use 'View.confirmationDialog(store:)' instead." + ) + @available(macOS, unavailable) + @available( + tvOS, + introduced: 13, + deprecated: 100000, + message: "use 'View.confirmationDialog(store:)' instead." + ) + @available( + watchOS, + introduced: 6, + deprecated: 100000, + message: "use 'View.confirmationDialog(store:)' instead." + ) + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency@MainActor + #endif + public func actionSheet( + store: Store< + PresentationState>, PresentationAction + > + ) -> some View { + self.actionSheet(store: store, state: { $0 }, action: { $0 }) + } - /// Displays an alert when the store's state becomes non-`nil`, and dismisses it when it becomes - /// `nil`. - /// - /// - Parameters: - /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for an - /// alert. - /// - toDestinationState: A transformation to extract alert state from the presentation state. - /// - fromDestinationAction: A transformation to embed alert actions into the presentation - /// action. - @available( - iOS, - introduced: 13, - deprecated: 100000, - message: "use 'View.confirmationDialog(store:state:action:)' instead." - ) - @available(macOS, unavailable) - @available( - tvOS, - introduced: 13, - deprecated: 100000, - message: "use 'View.confirmationDialog(store:state:action:)' instead." - ) - @available( - watchOS, - introduced: 6, - deprecated: 100000, - message: "use 'View.confirmationDialog(store:state:action:)' instead." - ) - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public func actionSheet( - store: Store, PresentationAction>, - state toDestinationState: @escaping (_ state: State) -> ConfirmationDialogState?, - action fromDestinationAction: @escaping (_ alertAction: ButtonAction) -> Action - ) -> some View { - self.presentation( - store: store, state: toDestinationState, action: fromDestinationAction - ) { `self`, $item, _ in - let actionSheetState = store.withState { $0.wrappedValue.flatMap(toDestinationState) } - self.actionSheet(item: $item) { _ in - ActionSheet(actionSheetState!) { action in - if let action { - store.send(.presented(fromDestinationAction(action))) - } else { - store.send(.dismiss) + /// Displays an alert when the store's state becomes non-`nil`, and dismisses it when it becomes + /// `nil`. + /// + /// - Parameters: + /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for an + /// alert. + /// - toDestinationState: A transformation to extract alert state from the presentation state. + /// - fromDestinationAction: A transformation to embed alert actions into the presentation + /// action. + @available( + iOS, + introduced: 13, + deprecated: 100000, + message: "use 'View.confirmationDialog(store:state:action:)' instead." + ) + @available(macOS, unavailable) + @available( + tvOS, + introduced: 13, + deprecated: 100000, + message: "use 'View.confirmationDialog(store:state:action:)' instead." + ) + @available( + watchOS, + introduced: 6, + deprecated: 100000, + message: "use 'View.confirmationDialog(store:state:action:)' instead." + ) + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency@MainActor + #endif + public func actionSheet( + store: Store, PresentationAction>, + state toDestinationState: + @escaping (_ state: State) -> ConfirmationDialogState?, + action fromDestinationAction: @escaping (_ alertAction: ButtonAction) -> Action + ) -> some View { + self.presentation( + store: store, state: toDestinationState, action: fromDestinationAction + ) { `self`, $item, _ in + let actionSheetState = store.withState { $0.wrappedValue.flatMap(toDestinationState) } + self.actionSheet(item: $item) { _ in + ActionSheet(actionSheetState!) { action in + if let action { + store.send(.presented(fromDestinationAction(action))) + } else { + store.send(.dismiss) + } } } } } } -} +#endif diff --git a/Sources/ComposableArchitecture/SwiftUI/Deprecated/LegacyAlert.swift b/Sources/ComposableArchitecture/SwiftUI/Deprecated/LegacyAlert.swift index 21ee708eb5f8..9a7d22d549c4 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Deprecated/LegacyAlert.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Deprecated/LegacyAlert.swift @@ -1,87 +1,91 @@ -import SwiftUI +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + import SwiftUI -extension View { - /// Displays a legacy alert when the store's state becomes non-`nil`, and dismisses it when it - /// becomes `nil`. - /// - /// - Parameters: - /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for an - /// alert. - @available(iOS, introduced: 13, deprecated: 100000, message: "use `View.alert(store:) instead.") - @available( - macOS, introduced: 10.15, deprecated: 100000, message: "use `View.alert(store:) instead." - ) - @available(tvOS, introduced: 13, deprecated: 100000, message: "use `View.alert(store:) instead.") - @available( - watchOS, introduced: 6, deprecated: 100000, message: "use `View.alert(store:) instead." - ) - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public func legacyAlert( - store: Store>, PresentationAction> - ) -> some View { - self.legacyAlert(store: store, state: { $0 }, action: { $0 }) - } + extension View { + /// Displays a legacy alert when the store's state becomes non-`nil`, and dismisses it when it + /// becomes `nil`. + /// + /// - Parameters: + /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for an + /// alert. + @available(iOS, introduced: 13, deprecated: 100000, message: "use `View.alert(store:) instead.") + @available( + macOS, introduced: 10.15, deprecated: 100000, message: "use `View.alert(store:) instead." + ) + @available( + tvOS, introduced: 13, deprecated: 100000, message: "use `View.alert(store:) instead." + ) + @available( + watchOS, introduced: 6, deprecated: 100000, message: "use `View.alert(store:) instead." + ) + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency@MainActor + #endif + public func legacyAlert( + store: Store>, PresentationAction> + ) -> some View { + self.legacyAlert(store: store, state: { $0 }, action: { $0 }) + } - /// Displays a legacy alert when the store's state becomes non-`nil`, and dismisses it when it - /// becomes `nil`. - /// - /// - Parameters: - /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for an - /// alert. - /// - toDestinationState: A transformation to extract alert state from the presentation state. - /// - fromDestinationAction: A transformation to embed alert actions into the presentation - /// action. - @available( - iOS, - introduced: 13, - deprecated: 100000, - message: "use `View.alert(store:state:action:) instead." - ) - @available( - macOS, - introduced: 10.15, - deprecated: 100000, - message: "use `View.alert(store:state:action:) instead." - ) - @available( - tvOS, - introduced: 13, - deprecated: 100000, - message: "use `View.alert(store:state:action:) instead." - ) - @available( - watchOS, - introduced: 6, - deprecated: 100000, - message: "use `View.alert(store:state:action:) instead." - ) - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public func legacyAlert( - store: Store, PresentationAction>, - state toDestinationState: @escaping (_ state: State) -> AlertState?, - action fromDestinationAction: @escaping (_ alertAction: ButtonAction) -> Action - ) -> some View { - self.presentation( - store: store, state: toDestinationState, action: fromDestinationAction - ) { `self`, $item, _ in - let alertState = store.withState { $0.wrappedValue.flatMap(toDestinationState) } - self.alert(item: $item) { _ in - Alert(alertState!) { action in - if let action { - store.send(.presented(fromDestinationAction(action))) - } else { - store.send(.dismiss) + /// Displays a legacy alert when the store's state becomes non-`nil`, and dismisses it when it + /// becomes `nil`. + /// + /// - Parameters: + /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for an + /// alert. + /// - toDestinationState: A transformation to extract alert state from the presentation state. + /// - fromDestinationAction: A transformation to embed alert actions into the presentation + /// action. + @available( + iOS, + introduced: 13, + deprecated: 100000, + message: "use `View.alert(store:state:action:) instead." + ) + @available( + macOS, + introduced: 10.15, + deprecated: 100000, + message: "use `View.alert(store:state:action:) instead." + ) + @available( + tvOS, + introduced: 13, + deprecated: 100000, + message: "use `View.alert(store:state:action:) instead." + ) + @available( + watchOS, + introduced: 6, + deprecated: 100000, + message: "use `View.alert(store:state:action:) instead." + ) + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency@MainActor + #endif + public func legacyAlert( + store: Store, PresentationAction>, + state toDestinationState: @escaping (_ state: State) -> AlertState?, + action fromDestinationAction: @escaping (_ alertAction: ButtonAction) -> Action + ) -> some View { + self.presentation( + store: store, state: toDestinationState, action: fromDestinationAction + ) { `self`, $item, _ in + let alertState = store.withState { $0.wrappedValue.flatMap(toDestinationState) } + self.alert(item: $item) { _ in + Alert(alertState!) { action in + if let action { + store.send(.presented(fromDestinationAction(action))) + } else { + store.send(.dismiss) + } } } } } } -} +#endif diff --git a/Sources/ComposableArchitecture/SwiftUI/Deprecated/NavigationLinkStore.swift b/Sources/ComposableArchitecture/SwiftUI/Deprecated/NavigationLinkStore.swift index 283b7165d8b3..8f497bda7597 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Deprecated/NavigationLinkStore.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Deprecated/NavigationLinkStore.swift @@ -1,217 +1,228 @@ -import Combine -import SwiftUI +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + import SwiftUI -/// A view that controls a navigation presentation. -/// -/// This view is similar to SwiftUI's `NavigationLink`, but it allows driving navigation from an -/// optional or enum instead of just a boolean. -/// -/// Typically you use this view by first modeling your features as having a parent feature that -/// holds onto an optional piece of child state using the ``PresentationState``, -/// ``PresentationAction`` and ``Reducer/ifLet(_:action:destination:fileID:filePath:line:column:)-4ub6q`` tools (see -/// for more information). Then in the view you can construct a -/// `NavigationLinkStore` by passing a ``Store`` that is focused on the presentation domain: -/// -/// ```swift -/// NavigationLinkStore( -/// self.store.scope(state: \.$child, action: \.child) -/// ) { -/// viewStore.send(.linkTapped) -/// } destination: { store in -/// ChildView(store: store) -/// } label: { -/// Text("Go to child") -/// } -/// ``` -/// -/// Then when the `child` state flips from `nil` to non-`nil` a drill-down animation will occur to -/// the child domain. -@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 struct NavigationLinkStore< - State, - Action, - DestinationState, - DestinationAction, - Destination: View, - Label: View ->: View { - let store: Store, PresentationAction> - @ObservedObject var viewStore: ViewStore> - let toDestinationState: (State) -> DestinationState? - let fromDestinationAction: (DestinationAction) -> Action - let onTap: () -> Void - let destination: (Store) -> Destination - let label: Label - var isDetailLink = true + #if canImport(Combine) + import Combine + #else + import OpenCombine + #endif - public init( - _ store: Store, PresentationAction>, - onTap: @escaping () -> Void, - @ViewBuilder destination: @escaping (_ store: Store) -> Destination, - @ViewBuilder label: () -> Label - ) where State == DestinationState, Action == DestinationAction { - self.init( - store, - state: { $0 }, - action: { $0 }, - onTap: onTap, - destination: destination, - label: label - ) - } + /// A view that controls a navigation presentation. + /// + /// This view is similar to SwiftUI's `NavigationLink`, but it allows driving navigation from an + /// optional or enum instead of just a boolean. + /// + /// Typically you use this view by first modeling your features as having a parent feature that + /// holds onto an optional piece of child state using the ``PresentationState``, + /// ``PresentationAction`` and ``Reducer/ifLet(_:action:destination:fileID:filePath:line:column:)-4ub6q`` tools (see + /// for more information). Then in the view you can construct a + /// `NavigationLinkStore` by passing a ``Store`` that is focused on the presentation domain: + /// + /// ```swift + /// NavigationLinkStore( + /// self.store.scope(state: \.$child, action: \.child) + /// ) { + /// viewStore.send(.linkTapped) + /// } destination: { store in + /// ChildView(store: store) + /// } label: { + /// Text("Go to child") + /// } + /// ``` + /// + /// Then when the `child` state flips from `nil` to non-`nil` a drill-down animation will occur to + /// the child domain. + @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 struct NavigationLinkStore< + State, + Action, + DestinationState, + DestinationAction, + Destination: View, + Label: View + >: View { + let store: Store, PresentationAction> + @ObservedObject var viewStore: ViewStore> + let toDestinationState: (State) -> DestinationState? + let fromDestinationAction: (DestinationAction) -> Action + let onTap: () -> Void + let destination: (Store) -> Destination + let label: Label + var isDetailLink = true - public init( - _ store: Store, PresentationAction>, - state toDestinationState: @escaping (_ state: State) -> DestinationState?, - action fromDestinationAction: @escaping (_ destinationAction: DestinationAction) -> Action, - onTap: @escaping () -> Void, - @ViewBuilder destination: @escaping (_ store: Store) -> - Destination, - @ViewBuilder label: () -> Label - ) { - func open( - _ core: some Core, PresentationAction> - ) -> any Core, PresentationAction> { - PresentationCore(base: core, toDestinationState: toDestinationState) + public init( + _ store: Store, PresentationAction>, + onTap: @escaping () -> Void, + @ViewBuilder destination: @escaping (_ store: Store) -> Destination, + @ViewBuilder label: () -> Label + ) where State == DestinationState, Action == DestinationAction { + self.init( + store, + state: { $0 }, + action: { $0 }, + onTap: onTap, + destination: destination, + label: label + ) } - let store = store.scope( - id: store.id(state: \.self, action: \.self), - childCore: open(store.core) - ) - self.store = store - self.viewStore = ViewStore( - store._scope( - state: { $0.wrappedValue.flatMap(toDestinationState) != nil }, - action: { $0 } - ), - observe: { $0 } - ) - self.toDestinationState = toDestinationState - self.fromDestinationAction = fromDestinationAction - self.onTap = onTap - self.destination = destination - self.label = label() - } - public init( - _ store: Store, PresentationAction>, - id: State.ID, - onTap: @escaping () -> Void, - @ViewBuilder destination: @escaping (_ store: Store) -> Destination, - @ViewBuilder label: () -> Label - ) where State == DestinationState, Action == DestinationAction, State: Identifiable { - self.init( - store, - state: { $0 }, - action: { $0 }, - id: id, - onTap: onTap, - destination: destination, - label: label - ) - } + public init( + _ store: Store, PresentationAction>, + state toDestinationState: @escaping (_ state: State) -> DestinationState?, + action fromDestinationAction: @escaping (_ destinationAction: DestinationAction) -> Action, + onTap: @escaping () -> Void, + @ViewBuilder destination: + @escaping (_ store: Store) -> + Destination, + @ViewBuilder label: () -> Label + ) { + func open( + _ core: some Core, PresentationAction> + ) -> any Core, PresentationAction> { + PresentationCore(base: core, toDestinationState: toDestinationState) + } + let store = store.scope( + id: store.id(state: \.self, action: \.self), + childCore: open(store.core) + ) + self.store = store + self.viewStore = ViewStore( + store._scope( + state: { $0.wrappedValue.flatMap(toDestinationState) != nil }, + action: { $0 } + ), + observe: { $0 } + ) + self.toDestinationState = toDestinationState + self.fromDestinationAction = fromDestinationAction + self.onTap = onTap + self.destination = destination + self.label = label() + } - public init( - _ store: Store, PresentationAction>, - state toDestinationState: @escaping (_ state: State) -> DestinationState?, - action fromDestinationAction: @escaping (_ destinationAction: DestinationAction) -> Action, - id: DestinationState.ID, - onTap: @escaping () -> Void, - @ViewBuilder destination: @escaping (_ store: Store) -> - Destination, - @ViewBuilder label: () -> Label - ) where DestinationState: Identifiable { - func open( - _ core: some Core, PresentationAction> - ) -> any Core, PresentationAction> { - NavigationLinkCore(base: core, id: id, toDestinationState: toDestinationState) + public init( + _ store: Store, PresentationAction>, + id: State.ID, + onTap: @escaping () -> Void, + @ViewBuilder destination: @escaping (_ store: Store) -> Destination, + @ViewBuilder label: () -> Label + ) where State == DestinationState, Action == DestinationAction, State: Identifiable { + self.init( + store, + state: { $0 }, + action: { $0 }, + id: id, + onTap: onTap, + destination: destination, + label: label + ) } - let store = store.scope( - id: store.id(state: \.self, action: \.self), - childCore: open(store.core) - ) - self.store = store - self.viewStore = ViewStore( - store._scope( - state: { $0.wrappedValue.flatMap(toDestinationState)?.id == id }, - action: { $0 } - ), - observe: { $0 } - ) - self.toDestinationState = toDestinationState - self.fromDestinationAction = fromDestinationAction - self.onTap = onTap - self.destination = destination - self.label = label() - } - public var body: some View { - NavigationLink( - isActive: Binding( - get: { self.viewStore.state }, - set: { - if $0 { - withTransaction($1, self.onTap) - } else if self.viewStore.state { - self.viewStore.send(.dismiss, transaction: $1) - } - } + public init( + _ store: Store, PresentationAction>, + state toDestinationState: @escaping (_ state: State) -> DestinationState?, + action fromDestinationAction: @escaping (_ destinationAction: DestinationAction) -> Action, + id: DestinationState.ID, + onTap: @escaping () -> Void, + @ViewBuilder destination: + @escaping (_ store: Store) -> + Destination, + @ViewBuilder label: () -> Label + ) where DestinationState: Identifiable { + func open( + _ core: some Core, PresentationAction> + ) -> any Core, PresentationAction> { + NavigationLinkCore(base: core, id: id, toDestinationState: toDestinationState) + } + let store = store.scope( + id: store.id(state: \.self, action: \.self), + childCore: open(store.core) ) - ) { - IfLetStore( - self.store._scope( - state: returningLastNonNilValue { $0.wrappedValue.flatMap(self.toDestinationState) }, - action: { .presented(self.fromDestinationAction($0)) } + self.store = store + self.viewStore = ViewStore( + store._scope( + state: { $0.wrappedValue.flatMap(toDestinationState)?.id == id }, + action: { $0 } ), - then: self.destination + observe: { $0 } ) - } label: { - self.label + self.toDestinationState = toDestinationState + self.fromDestinationAction = fromDestinationAction + self.onTap = onTap + self.destination = destination + self.label = label() } - #if os(iOS) - .isDetailLink(self.isDetailLink) - #endif - } - @available(macOS, unavailable) - @available(tvOS, unavailable) - @available(watchOS, unavailable) - public func isDetailLink(_ isDetailLink: Bool) -> Self { - var link = self - link.isDetailLink = isDetailLink - return link - } -} + public var body: some View { + NavigationLink( + isActive: Binding( + get: { self.viewStore.state }, + set: { + if $0 { + withTransaction($1, self.onTap) + } else if self.viewStore.state { + self.viewStore.send(.dismiss, transaction: $1) + } + } + ) + ) { + IfLetStore( + self.store._scope( + state: returningLastNonNilValue { $0.wrappedValue.flatMap(self.toDestinationState) }, + action: { .presented(self.fromDestinationAction($0)) } + ), + then: self.destination + ) + } label: { + self.label + } + #if os(iOS) + .isDetailLink(self.isDetailLink) + #endif + } -private final class NavigationLinkCore< - Base: Core, PresentationAction>, - State, - Action, - DestinationState: Identifiable ->: Core { - let base: Base - let id: DestinationState.ID - let toDestinationState: (State) -> DestinationState? - init( - base: Base, - id: DestinationState.ID, - toDestinationState: @escaping (State) -> DestinationState? - ) { - self.base = base - self.id = id - self.toDestinationState = toDestinationState - } - var state: Base.State { - base.state + @available(macOS, unavailable) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + public func isDetailLink(_ isDetailLink: Bool) -> Self { + var link = self + link.isDetailLink = isDetailLink + return link + } } - func send(_ action: Base.Action) -> Task? { - base.send(action) + + private final class NavigationLinkCore< + Base: Core, PresentationAction>, + State, + Action, + DestinationState: Identifiable + >: Core { + let base: Base + let id: DestinationState.ID + let toDestinationState: (State) -> DestinationState? + init( + base: Base, + id: DestinationState.ID, + toDestinationState: @escaping (State) -> DestinationState? + ) { + self.base = base + self.id = id + self.toDestinationState = toDestinationState + } + var state: Base.State { + base.state + } + func send(_ action: Base.Action) -> Task? { + base.send(action) + } + var canStoreCacheChildren: Bool { base.canStoreCacheChildren } + var didSet: CurrentValueRelay { base.didSet } + var isInvalid: Bool { + state.wrappedValue.flatMap(toDestinationState)?.id != id || base.isInvalid + } + var effectCancellables: [UUID: AnyCancellable] { base.effectCancellables } } - var canStoreCacheChildren: Bool { base.canStoreCacheChildren } - var didSet: CurrentValueRelay { base.didSet } - var isInvalid: Bool { state.wrappedValue.flatMap(toDestinationState)?.id != id || base.isInvalid } - var effectCancellables: [UUID: AnyCancellable] { base.effectCancellables } -} +#endif diff --git a/Sources/ComposableArchitecture/SwiftUI/ForEachStore.swift b/Sources/ComposableArchitecture/SwiftUI/ForEachStore.swift index e0533d53f796..1db17f0dec2f 100644 --- a/Sources/ComposableArchitecture/SwiftUI/ForEachStore.swift +++ b/Sources/ComposableArchitecture/SwiftUI/ForEachStore.swift @@ -1,258 +1,260 @@ -import OrderedCollections -import SwiftUI +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + import OrderedCollections + import SwiftUI -/// A Composable Architecture-friendly wrapper around `ForEach` that simplifies working with -/// collections of state. -/// -/// ``ForEachStore`` loops over a store's collection with a store scoped to the domain of each -/// element. This allows you to extract and modularize an element's view and avoid concerns around -/// collection index math and parent-child store communication. -/// -/// For example, a todos app may define the domain and logic associated with an individual todo: -/// -/// ```swift -/// @Reducer -/// struct Todo { -/// struct State: Equatable, Identifiable { -/// let id: UUID -/// var description = "" -/// var isComplete = false -/// } -/// -/// enum Action { -/// case isCompleteToggled(Bool) -/// case descriptionChanged(String) -/// } -/// -/// var body: some Reducer { -/// // ... -/// } -/// } -/// ``` -/// -/// As well as a view with a domain-specific store: -/// -/// ```swift -/// struct TodoView: View { -/// let store: StoreOf -/// var body: some View { /* ... */ } -/// } -/// ``` -/// -/// For a parent domain to work with a collection of todos, it can hold onto this collection in -/// state: -/// -/// ```swift -/// @Reducer -/// struct Todos { -/// struct State: Equatable { -/// var todos: IdentifiedArrayOf = [] -/// } -/// // ... -/// } -/// ``` -/// -/// Define a case to handle actions sent to the child domain: -/// -/// ```swift -/// enum Action { -/// case todos(IdentifiedActionOf) -/// } -/// ``` -/// -/// Enhance its core reducer using -/// ``Reducer/forEach(_:action:element:fileID:filePath:line:column:)-6zye8``: -/// -/// ```swift -/// var body: some Reducer { -/// Reduce { state, action in -/// // ... -/// } -/// .forEach(\.todos, action: \.todos) { -/// Todo() -/// } -/// } -/// ``` -/// -/// And finally render a list of `TodoView`s using ``ForEachStore``: -/// -/// ```swift -/// ForEachStore( -/// self.store.scope(state: \.todos, action: \.todos) -/// ) { todoStore in -/// TodoView(store: todoStore) -/// } -/// ``` -/// -@available( - iOS, deprecated: 9999, - message: - "Pass 'ForEach' a store scoped to an identified array and identified action, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-ForEachStore-with-ForEach]" -) -@available( - macOS, deprecated: 9999, - message: - "Pass 'ForEach' a store scoped to an identified array and identified action, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-ForEachStore-with-ForEach]" -) -@available( - tvOS, deprecated: 9999, - message: - "Pass 'ForEach' a store scoped to an identified array and identified action, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-ForEachStore-with-ForEach]" -) -@available( - watchOS, deprecated: 9999, - message: - "Pass 'ForEach' a store scoped to an identified array and identified action, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-ForEachStore-with-ForEach]" -) -public struct ForEachStore< - EachState, EachAction, Data: Collection, ID: Hashable & Sendable, Content: View ->: View { - public let data: Data - let content: Content - - /// Initializes a structure that computes views on demand from a store on a collection of data and - /// an identified action. + /// A Composable Architecture-friendly wrapper around `ForEach` that simplifies working with + /// collections of state. + /// + /// ``ForEachStore`` loops over a store's collection with a store scoped to the domain of each + /// element. This allows you to extract and modularize an element's view and avoid concerns around + /// collection index math and parent-child store communication. + /// + /// For example, a todos app may define the domain and logic associated with an individual todo: + /// + /// ```swift + /// @Reducer + /// struct Todo { + /// struct State: Equatable, Identifiable { + /// let id: UUID + /// var description = "" + /// var isComplete = false + /// } + /// + /// enum Action { + /// case isCompleteToggled(Bool) + /// case descriptionChanged(String) + /// } + /// + /// var body: some Reducer { + /// // ... + /// } + /// } + /// ``` + /// + /// As well as a view with a domain-specific store: + /// + /// ```swift + /// struct TodoView: View { + /// let store: StoreOf + /// var body: some View { /* ... */ } + /// } + /// ``` + /// + /// For a parent domain to work with a collection of todos, it can hold onto this collection in + /// state: + /// + /// ```swift + /// @Reducer + /// struct Todos { + /// struct State: Equatable { + /// var todos: IdentifiedArrayOf = [] + /// } + /// // ... + /// } + /// ``` + /// + /// Define a case to handle actions sent to the child domain: + /// + /// ```swift + /// enum Action { + /// case todos(IdentifiedActionOf) + /// } + /// ``` + /// + /// Enhance its core reducer using + /// ``Reducer/forEach(_:action:element:fileID:filePath:line:column:)-6zye8``: + /// + /// ```swift + /// var body: some Reducer { + /// Reduce { state, action in + /// // ... + /// } + /// .forEach(\.todos, action: \.todos) { + /// Todo() + /// } + /// } + /// ``` + /// + /// And finally render a list of `TodoView`s using ``ForEachStore``: + /// + /// ```swift + /// ForEachStore( + /// self.store.scope(state: \.todos, action: \.todos) + /// ) { todoStore in + /// TodoView(store: todoStore) + /// } + /// ``` /// - /// - Parameters: - /// - store: A store on an identified array of data and an identified action. - /// - content: A function that can generate content given a store of an element. - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public init( - _ store: Store, IdentifiedAction>, - @ViewBuilder content: @escaping (_ store: Store) -> EachContent - ) - where - Data == IdentifiedArray, - Content == WithViewStore< - IdentifiedArray, IdentifiedAction, - ForEach, ID, EachContent> - > - { - self.data = store.withState { $0 } - - func open( - _ core: some Core, IdentifiedAction>, - element: EachState, - id: ID - ) -> any Core { - IfLetCore( - base: core, - cachedState: element, - stateKeyPath: \.[id: id], - actionKeyPath: \.[id: id] - ) - } - - self.content = WithViewStore( - store, - observe: { $0 }, - removeDuplicates: { areOrderedSetsDuplicates($0.ids, $1.ids) } - ) { viewStore in - ForEach(viewStore.state, id: viewStore.state.id) { element in - let id = element[keyPath: viewStore.state.id] - content( - store.scope( - id: store.id(state: \.[id: id]!, action: \.[id: id]), - childCore: open(store.core, element: element, id: id) - ) - ) - } - } - } - @available( - iOS, - deprecated: 9999, + iOS, deprecated: 9999, message: - "Use an 'IdentifiedAction', instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Identified-actions" + "Pass 'ForEach' a store scoped to an identified array and identified action, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-ForEachStore-with-ForEach]" ) @available( - macOS, - deprecated: 9999, + macOS, deprecated: 9999, message: - "Use an 'IdentifiedAction', instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Identified-actions" + "Pass 'ForEach' a store scoped to an identified array and identified action, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-ForEachStore-with-ForEach]" ) @available( - tvOS, - deprecated: 9999, + tvOS, deprecated: 9999, message: - "Use an 'IdentifiedAction', instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Identified-actions" + "Pass 'ForEach' a store scoped to an identified array and identified action, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-ForEachStore-with-ForEach]" ) @available( - watchOS, - deprecated: 9999, + watchOS, deprecated: 9999, message: - "Use an 'IdentifiedAction', instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Identified-actions" + "Pass 'ForEach' a store scoped to an identified array and identified action, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-ForEachStore-with-ForEach]" ) - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public init( - _ store: Store, (id: ID, action: EachAction)>, - @ViewBuilder content: @escaping (_ store: Store) -> EachContent - ) - where - Data == IdentifiedArray, - Content == WithViewStore< - IdentifiedArray, (id: ID, action: EachAction), - ForEach, ID, EachContent> - > - { - self.data = store.withState { $0 } + public struct ForEachStore< + EachState, EachAction, Data: Collection, ID: Hashable & Sendable, Content: View + >: View { + public let data: Data + let content: Content - func open( - _ core: some Core, (id: ID, action: EachAction)>, - element: EachState, - id: ID - ) -> any Core { - IfLetCore( - base: core, - cachedState: element, - stateKeyPath: \.[id: id], - actionKeyPath: \.[id: id] - ) - } + /// Initializes a structure that computes views on demand from a store on a collection of data and + /// an identified action. + /// + /// - Parameters: + /// - store: A store on an identified array of data and an identified action. + /// - content: A function that can generate content given a store of an element. + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency @MainActor + #endif + public init( + _ store: Store, IdentifiedAction>, + @ViewBuilder content: @escaping (_ store: Store) -> EachContent + ) + where + Data == IdentifiedArray, + Content == WithViewStore< + IdentifiedArray, IdentifiedAction, + ForEach, ID, EachContent> + > + { + self.data = store.withState { $0 } + + func open( + _ core: some Core, IdentifiedAction>, + element: EachState, + id: ID + ) -> any Core { + IfLetCore( + base: core, + cachedState: element, + stateKeyPath: \.[id: id], + actionKeyPath: \.[id: id] + ) + } - self.content = WithViewStore( - store, - observe: { $0 }, - removeDuplicates: { areOrderedSetsDuplicates($0.ids, $1.ids) } - ) { viewStore in - ForEach(viewStore.state, id: viewStore.state.id) { element in - let id = element[keyPath: viewStore.state.id] - content( - store.scope( - id: store.id(state: \.[id: id]!, action: \.[id: id]), - childCore: open(store.core, element: element, id: id) + self.content = WithViewStore( + store, + observe: { $0 }, + removeDuplicates: { areOrderedSetsDuplicates($0.ids, $1.ids) } + ) { viewStore in + ForEach(viewStore.state, id: viewStore.state.id) { element in + let id = element[keyPath: viewStore.state.id] + content( + store.scope( + id: store.id(state: \.[id: id]!, action: \.[id: id]), + childCore: open(store.core, element: element, id: id) + ) ) + } + } + } + + @available( + iOS, + deprecated: 9999, + message: + "Use an 'IdentifiedAction', instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Identified-actions" + ) + @available( + macOS, + deprecated: 9999, + message: + "Use an 'IdentifiedAction', instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Identified-actions" + ) + @available( + tvOS, + deprecated: 9999, + message: + "Use an 'IdentifiedAction', instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Identified-actions" + ) + @available( + watchOS, + deprecated: 9999, + message: + "Use an 'IdentifiedAction', instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Identified-actions" + ) + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency@MainActor + #endif + public init( + _ store: Store, (id: ID, action: EachAction)>, + @ViewBuilder content: @escaping (_ store: Store) -> EachContent + ) + where + Data == IdentifiedArray, + Content == WithViewStore< + IdentifiedArray, (id: ID, action: EachAction), + ForEach, ID, EachContent> + > + { + self.data = store.withState { $0 } + + func open( + _ core: some Core, (id: ID, action: EachAction)>, + element: EachState, + id: ID + ) -> any Core { + IfLetCore( + base: core, + cachedState: element, + stateKeyPath: \.[id: id], + actionKeyPath: \.[id: id] ) } + + self.content = WithViewStore( + store, + observe: { $0 }, + removeDuplicates: { areOrderedSetsDuplicates($0.ids, $1.ids) } + ) { viewStore in + ForEach(viewStore.state, id: viewStore.state.id) { element in + let id = element[keyPath: viewStore.state.id] + content( + store.scope( + id: store.id(state: \.[id: id]!, action: \.[id: id]), + childCore: open(store.core, element: element, id: id) + ) + ) + } + } } - } - public var body: some View { - self.content + public var body: some View { + self.content + } } -} -#if compiler(>=6) - extension ForEachStore: @preconcurrency DynamicViewContent {} -#else - extension ForEachStore: DynamicViewContent {} -#endif + #if compiler(>=6) + extension ForEachStore: @preconcurrency DynamicViewContent {} + #else + extension ForEachStore: DynamicViewContent {} + #endif -extension Case { - fileprivate subscript(id id: ID) -> Case - where Value == (id: ID, action: Action) { - Case( - embed: { (id: id, action: $0) }, - extract: { $0.id == id ? $0.action : nil } - ) + extension Case { + fileprivate subscript(id id: ID) -> Case + where Value == (id: ID, action: Action) { + Case( + embed: { (id: id, action: $0) }, + extract: { $0.id == id ? $0.action : nil } + ) + } } -} +#endif diff --git a/Sources/ComposableArchitecture/SwiftUI/FullScreenCover.swift b/Sources/ComposableArchitecture/SwiftUI/FullScreenCover.swift index 10744c5b5d57..ed9ace3eb0b1 100644 --- a/Sources/ComposableArchitecture/SwiftUI/FullScreenCover.swift +++ b/Sources/ComposableArchitecture/SwiftUI/FullScreenCover.swift @@ -1,107 +1,112 @@ -import SwiftUI +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + import SwiftUI -#if !os(macOS) - @available(iOS 14, tvOS 14, watchOS 7, *) - @available(macOS, unavailable) - extension View { - /// Presents a modal view that covers as much of the screen as possible using the store you - /// provide as a data source for the sheet's content. - /// - /// > This is a Composable Architecture-friendly version of SwiftUI's `fullScreenCover` view - /// > modifier. - /// - /// - Parameters: - /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for - /// a modal. When `store`'s state is non-`nil`, the system passes a store of unwrapped `State` - /// and `Action` to the modifier's closure. You use this store to power the content in a sheet - /// you create that the system displays to the user. If `store`'s state is `nil`-ed out, the - /// system dismisses the currently displayed sheet. - /// - onDismiss: The closure to execute when dismissing the modal view. - /// - content: A closure returning the content of the modal view. - @available( - iOS, deprecated: 9999, - message: - "Pass a binding of a store to 'fullScreenCover(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" - ) - @available( - macOS, deprecated: 9999, - message: - "Pass a binding of a store to 'fullScreenCover(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" - ) - @available( - tvOS, deprecated: 9999, - message: - "Pass a binding of a store to 'fullScreenCover(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" - ) - @available( - watchOS, deprecated: 9999, - message: - "Pass a binding of a store to 'fullScreenCover(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" - ) - public func fullScreenCover( - store: Store, PresentationAction>, - onDismiss: (() -> Void)? = nil, - @ViewBuilder content: @escaping (_ store: Store) -> Content - ) -> some View { - self.presentation(store: store) { `self`, $item, destination in - self.fullScreenCover(item: $item, onDismiss: onDismiss) { _ in - destination(content) + #if !os(macOS) + @available(iOS 14, tvOS 14, watchOS 7, *) + @available(macOS, unavailable) + extension View { + /// Presents a modal view that covers as much of the screen as possible using the store you + /// provide as a data source for the sheet's content. + /// + /// > This is a Composable Architecture-friendly version of SwiftUI's `fullScreenCover` view + /// > modifier. + /// + /// - Parameters: + /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for + /// a modal. When `store`'s state is non-`nil`, the system passes a store of unwrapped `State` + /// and `Action` to the modifier's closure. You use this store to power the content in a sheet + /// you create that the system displays to the user. If `store`'s state is `nil`-ed out, the + /// system dismisses the currently displayed sheet. + /// - onDismiss: The closure to execute when dismissing the modal view. + /// - content: A closure returning the content of the modal view. + @available( + iOS, deprecated: 9999, + message: + "Pass a binding of a store to 'fullScreenCover(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + @available( + macOS, deprecated: 9999, + message: + "Pass a binding of a store to 'fullScreenCover(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + @available( + tvOS, deprecated: 9999, + message: + "Pass a binding of a store to 'fullScreenCover(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + @available( + watchOS, deprecated: 9999, + message: + "Pass a binding of a store to 'fullScreenCover(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + public func fullScreenCover( + store: Store, PresentationAction>, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping (_ store: Store) -> Content + ) -> some View { + self.presentation(store: store) { `self`, $item, destination in + self.fullScreenCover(item: $item, onDismiss: onDismiss) { _ in + destination(content) + } } } - } - /// Presents a modal view that covers as much of the screen as possible using the store you - /// provide as a data source for the sheet's content. - /// - /// > This is a Composable Architecture-friendly version of SwiftUI's `fullScreenCover` view - /// > modifier. - /// - /// - Parameters: - /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for - /// a modal. When `store`'s state is non-`nil`, the system passes a store of unwrapped `State` - /// and `Action` to the modifier's closure. You use this store to power the content in a sheet - /// you create that the system displays to the user. If `store`'s state is `nil`-ed out, the - /// system dismisses the currently displayed sheet. - /// - toDestinationState: A transformation to extract modal state from the presentation state. - /// - fromDestinationAction: A transformation to embed modal actions into the presentation - /// action. - /// - onDismiss: The closure to execute when dismissing the modal view. - /// - content: A closure returning the content of the modal view. - @available( - iOS, deprecated: 9999, - message: - "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - @available( - macOS, deprecated: 9999, - message: - "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - @available( - tvOS, deprecated: 9999, - message: - "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - @available( - watchOS, deprecated: 9999, - message: - "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - public func fullScreenCover( - store: Store, PresentationAction>, - state toDestinationState: @escaping (_ state: State) -> DestinationState?, - action fromDestinationAction: @escaping (_ destinationAction: DestinationAction) -> Action, - onDismiss: (() -> Void)? = nil, - @ViewBuilder content: @escaping (_ store: Store) -> - Content - ) -> some View { - self.presentation( - store: store, state: toDestinationState, action: fromDestinationAction - ) { `self`, $item, destination in - self.fullScreenCover(item: $item, onDismiss: onDismiss) { _ in - destination(content) + /// Presents a modal view that covers as much of the screen as possible using the store you + /// provide as a data source for the sheet's content. + /// + /// > This is a Composable Architecture-friendly version of SwiftUI's `fullScreenCover` view + /// > modifier. + /// + /// - Parameters: + /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for + /// a modal. When `store`'s state is non-`nil`, the system passes a store of unwrapped `State` + /// and `Action` to the modifier's closure. You use this store to power the content in a sheet + /// you create that the system displays to the user. If `store`'s state is `nil`-ed out, the + /// system dismisses the currently displayed sheet. + /// - toDestinationState: A transformation to extract modal state from the presentation state. + /// - fromDestinationAction: A transformation to embed modal actions into the presentation + /// action. + /// - onDismiss: The closure to execute when dismissing the modal view. + /// - content: A closure returning the content of the modal view. + @available( + iOS, deprecated: 9999, + message: + "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + @available( + macOS, deprecated: 9999, + message: + "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + @available( + tvOS, deprecated: 9999, + message: + "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + @available( + watchOS, deprecated: 9999, + message: + "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + public func fullScreenCover< + State, Action, DestinationState, DestinationAction, Content: View + >( + store: Store, PresentationAction>, + state toDestinationState: @escaping (_ state: State) -> DestinationState?, + action fromDestinationAction: @escaping (_ destinationAction: DestinationAction) -> Action, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: + @escaping (_ store: Store) -> + Content + ) -> some View { + self.presentation( + store: store, state: toDestinationState, action: fromDestinationAction + ) { `self`, $item, destination in + self.fullScreenCover(item: $item, onDismiss: onDismiss) { _ in + destination(content) + } } } } - } + #endif #endif diff --git a/Sources/ComposableArchitecture/SwiftUI/IfLetStore.swift b/Sources/ComposableArchitecture/SwiftUI/IfLetStore.swift index 6065c50776f2..7bed1e9623c2 100644 --- a/Sources/ComposableArchitecture/SwiftUI/IfLetStore.swift +++ b/Sources/ComposableArchitecture/SwiftUI/IfLetStore.swift @@ -1,320 +1,322 @@ -import Combine -import SwiftUI +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + import SwiftUI + import Combine -/// A view that safely unwraps a store of optional state in order to show one of two views. -/// -/// When the underlying state is non-`nil`, the `then` closure will be performed with a ``Store`` -/// that holds onto non-optional state, and otherwise the `else` closure will be performed. -/// -/// This is useful for deciding between two views to show depending on an optional piece of state: -/// -/// ```swift -/// IfLetStore( -/// store.scope(state: \.results, action: { .results($0) }) -/// ) { -/// SearchResultsView(store: $0) -/// } else: { -/// Text("Loading search results...") -/// } -/// ``` -/// -@available( - iOS, deprecated: 9999, - message: - "Use 'if let' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-IfLetStore-with-if-let]" -) -@available( - macOS, deprecated: 9999, - message: - "Use 'if let' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-IfLetStore-with-if-let]" -) -@available( - tvOS, deprecated: 9999, - message: - "Use 'if let' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-IfLetStore-with-if-let]" -) -@available( - watchOS, deprecated: 9999, - message: - "Use 'if let' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-IfLetStore-with-if-let]" -) -public struct IfLetStore: View { - private let content: (ViewStore) -> Content - private let store: Store - - /// Initializes an ``IfLetStore`` view that computes content depending on if a store of optional - /// state is `nil` or non-`nil`. + /// A view that safely unwraps a store of optional state in order to show one of two views. /// - /// - Parameters: - /// - store: A store of optional state. - /// - ifContent: A function that is given a store of non-optional state and returns a view that - /// is visible only when the optional state is non-`nil`. - /// - elseContent: A view that is only visible when the optional state is `nil`. - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public init( - _ store: Store, - @ViewBuilder then ifContent: @escaping (_ store: Store) -> IfContent, - @ViewBuilder else elseContent: () -> ElseContent - ) where Content == _ConditionalContent { - func open(_ core: some Core) -> any Core { - _IfLetCore(base: core) - } - let store = store.scope( - id: store.id(state: \.self, action: \.self), - childCore: open(store.core) - ) - self.store = store - let elseContent = elseContent() - self.content = { viewStore in - if let state = viewStore.state { - @MainActor - func open(_ core: some Core) -> any Core { - IfLetCore(base: core, cachedState: state, stateKeyPath: \.self, actionKeyPath: \.self) - } - return ViewBuilder.buildEither( - first: ifContent( - store.scope( - id: store.id(state: \.!, action: \.self), - childCore: open(store.core) - ) - ) - ) - } else { - return ViewBuilder.buildEither(second: elseContent) - } - } - } - - /// Initializes an ``IfLetStore`` view that computes content depending on if a store of optional - /// state is `nil` or non-`nil`. + /// When the underlying state is non-`nil`, the `then` closure will be performed with a ``Store`` + /// that holds onto non-optional state, and otherwise the `else` closure will be performed. /// - /// - Parameters: - /// - store: A store of optional state. - /// - ifContent: A function that is given a store of non-optional state and returns a view that - /// is visible only when the optional state is non-`nil`. - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public init( - _ store: Store, - @ViewBuilder then ifContent: @escaping (_ store: Store) -> IfContent - ) where Content == IfContent? { - func open(_ core: some Core) -> any Core { - _IfLetCore(base: core) - } - let store = store.scope( - id: store.id(state: \.self, action: \.self), - childCore: open(store.core) - ) - self.store = store - self.content = { viewStore in - if let state = viewStore.state { - @MainActor - func open(_ core: some Core) -> any Core { - IfLetCore(base: core, cachedState: state, stateKeyPath: \.self, actionKeyPath: \.self) - } - return ifContent( - store.scope( - id: store.id(state: \.!, action: \.self), - childCore: open(store.core) - ) - ) - } else { - return nil - } - } - } - - /// Initializes an ``IfLetStore`` view that computes content depending on if a store of - /// ``PresentationState`` and ``PresentationAction`` is `nil` or non-`nil`. + /// This is useful for deciding between two views to show depending on an optional piece of state: /// - /// - Parameters: - /// - store: A store of optional state. - /// - ifContent: A function that is given a store of non-optional state and returns a view that - /// is visible only when the optional state is non-`nil`. - /// - elseContent: A view that is only visible when the optional state is `nil`. - @available( - iOS, deprecated: 9999, - message: - "Scope the store into the destination's wrapped 'state' and presented 'action', instead: 'store.scope(state: \\.destination, action: \\.destination.presented)'. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - @available( - macOS, deprecated: 9999, - message: - "Scope the store into the destination's wrapped 'state' and presented 'action', instead: 'store.scope(state: \\.destination, action: \\.destination.presented)'. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - @available( - tvOS, deprecated: 9999, - message: - "Scope the store into the destination's wrapped 'state' and presented 'action', instead: 'store.scope(state: \\.destination, action: \\.destination.presented)'. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - @available( - watchOS, deprecated: 9999, - message: - "Scope the store into the destination's wrapped 'state' and presented 'action', instead: 'store.scope(state: \\.destination, action: \\.destination.presented)'. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public init( - _ store: Store, PresentationAction>, - @ViewBuilder then ifContent: @escaping (_ store: Store) -> IfContent, - @ViewBuilder else elseContent: @escaping () -> ElseContent - ) where Content == _ConditionalContent { - self.init( - store.scope(state: \.wrappedValue, action: \.presented), - then: ifContent, - else: elseContent - ) - } - - /// Initializes an ``IfLetStore`` view that computes content depending on if a store of - /// ``PresentationState`` and ``PresentationAction`` is `nil` or non-`nil`. + /// ```swift + /// IfLetStore( + /// store.scope(state: \.results, action: { .results($0) }) + /// ) { + /// SearchResultsView(store: $0) + /// } else: { + /// Text("Loading search results...") + /// } + /// ``` /// - /// - Parameters: - /// - store: A store of optional state. - /// - ifContent: A function that is given a store of non-optional state and returns a view that - /// is visible only when the optional state is non-`nil`. @available( iOS, deprecated: 9999, message: - "Scope the store into the destination's wrapped 'state' and presented 'action', instead: 'store.scope(state: \\.destination, action: \\.destination.presented)'. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + "Use 'if let' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-IfLetStore-with-if-let]" ) @available( macOS, deprecated: 9999, message: - "Scope the store into the destination's wrapped 'state' and presented 'action', instead: 'store.scope(state: \\.destination, action: \\.destination.presented)'. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + "Use 'if let' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-IfLetStore-with-if-let]" ) @available( tvOS, deprecated: 9999, message: - "Scope the store into the destination's wrapped 'state' and presented 'action', instead: 'store.scope(state: \\.destination, action: \\.destination.presented)'. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + "Use 'if let' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-IfLetStore-with-if-let]" ) @available( watchOS, deprecated: 9999, message: - "Scope the store into the destination's wrapped 'state' and presented 'action', instead: 'store.scope(state: \\.destination, action: \\.destination.presented)'. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + "Use 'if let' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-IfLetStore-with-if-let]" ) - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public init( - _ store: Store, PresentationAction>, - @ViewBuilder then ifContent: @escaping (_ store: Store) -> IfContent - ) where Content == IfContent? { - self.init( - store.scope(state: \.wrappedValue, action: \.presented), - then: ifContent + public struct IfLetStore: View { + private let content: (ViewStore) -> Content + private let store: Store + + /// Initializes an ``IfLetStore`` view that computes content depending on if a store of optional + /// state is `nil` or non-`nil`. + /// + /// - Parameters: + /// - store: A store of optional state. + /// - ifContent: A function that is given a store of non-optional state and returns a view that + /// is visible only when the optional state is non-`nil`. + /// - elseContent: A view that is only visible when the optional state is `nil`. + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency @MainActor + #endif + public init( + _ store: Store, + @ViewBuilder then ifContent: @escaping (_ store: Store) -> IfContent, + @ViewBuilder else elseContent: () -> ElseContent + ) where Content == _ConditionalContent { + func open(_ core: some Core) -> any Core { + _IfLetCore(base: core) + } + let store = store.scope( + id: store.id(state: \.self, action: \.self), + childCore: open(store.core) + ) + self.store = store + let elseContent = elseContent() + self.content = { viewStore in + if let state = viewStore.state { + @MainActor + func open(_ core: some Core) -> any Core { + IfLetCore(base: core, cachedState: state, stateKeyPath: \.self, actionKeyPath: \.self) + } + return ViewBuilder.buildEither( + first: ifContent( + store.scope( + id: store.id(state: \.!, action: \.self), + childCore: open(store.core) + ) + ) + ) + } else { + return ViewBuilder.buildEither(second: elseContent) + } + } + } + + /// Initializes an ``IfLetStore`` view that computes content depending on if a store of optional + /// state is `nil` or non-`nil`. + /// + /// - Parameters: + /// - store: A store of optional state. + /// - ifContent: A function that is given a store of non-optional state and returns a view that + /// is visible only when the optional state is non-`nil`. + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency @MainActor + #endif + public init( + _ store: Store, + @ViewBuilder then ifContent: @escaping (_ store: Store) -> IfContent + ) where Content == IfContent? { + func open(_ core: some Core) -> any Core { + _IfLetCore(base: core) + } + let store = store.scope( + id: store.id(state: \.self, action: \.self), + childCore: open(store.core) + ) + self.store = store + self.content = { viewStore in + if let state = viewStore.state { + @MainActor + func open(_ core: some Core) -> any Core { + IfLetCore(base: core, cachedState: state, stateKeyPath: \.self, actionKeyPath: \.self) + } + return ifContent( + store.scope( + id: store.id(state: \.!, action: \.self), + childCore: open(store.core) + ) + ) + } else { + return nil + } + } + } + + /// Initializes an ``IfLetStore`` view that computes content depending on if a store of + /// ``PresentationState`` and ``PresentationAction`` is `nil` or non-`nil`. + /// + /// - Parameters: + /// - store: A store of optional state. + /// - ifContent: A function that is given a store of non-optional state and returns a view that + /// is visible only when the optional state is non-`nil`. + /// - elseContent: A view that is only visible when the optional state is `nil`. + @available( + iOS, deprecated: 9999, + message: + "Scope the store into the destination's wrapped 'state' and presented 'action', instead: 'store.scope(state: \\.destination, action: \\.destination.presented)'. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" ) - } + @available( + macOS, deprecated: 9999, + message: + "Scope the store into the destination's wrapped 'state' and presented 'action', instead: 'store.scope(state: \\.destination, action: \\.destination.presented)'. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + @available( + tvOS, deprecated: 9999, + message: + "Scope the store into the destination's wrapped 'state' and presented 'action', instead: 'store.scope(state: \\.destination, action: \\.destination.presented)'. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + @available( + watchOS, deprecated: 9999, + message: + "Scope the store into the destination's wrapped 'state' and presented 'action', instead: 'store.scope(state: \\.destination, action: \\.destination.presented)'. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency@MainActor + #endif + public init( + _ store: Store, PresentationAction>, + @ViewBuilder then ifContent: @escaping (_ store: Store) -> IfContent, + @ViewBuilder else elseContent: @escaping () -> ElseContent + ) where Content == _ConditionalContent { + self.init( + store.scope(state: \.wrappedValue, action: \.presented), + then: ifContent, + else: elseContent + ) + } - /// Initializes an ``IfLetStore`` view that computes content depending on if a store of - /// ``PresentationState`` and ``PresentationAction`` is `nil` or non-`nil` and state can further - /// be extracted from the destination state, _e.g._ it matches a particular case of an enum. - /// - /// - Parameters: - /// - store: A store of optional state. - /// - toState: A closure that attempts to extract state for the "if" branch from the destination - /// state. - /// - fromAction: A closure that embeds actions for the "if" branch in destination actions. - /// - ifContent: A function that is given a store of non-optional state and returns a view that - /// is visible only when the optional state is non-`nil` and state can be extracted from the - /// destination state. - /// - elseContent: A view that is only visible when state cannot be extracted from the - /// destination. - @available( - *, deprecated, - message: - "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public init( - _ store: Store, PresentationAction>, - state toState: @escaping (_ destinationState: DestinationState) -> State?, - action fromAction: @escaping (_ action: Action) -> DestinationAction, - @ViewBuilder then ifContent: @escaping (_ store: Store) -> IfContent, - @ViewBuilder else elseContent: @escaping () -> ElseContent - ) where Content == _ConditionalContent { - self.init( - store.scope( - state: { $0.wrappedValue.flatMap(toState) }, - action: { .presented(fromAction($0)) } - ), - then: ifContent, - else: elseContent + /// Initializes an ``IfLetStore`` view that computes content depending on if a store of + /// ``PresentationState`` and ``PresentationAction`` is `nil` or non-`nil`. + /// + /// - Parameters: + /// - store: A store of optional state. + /// - ifContent: A function that is given a store of non-optional state and returns a view that + /// is visible only when the optional state is non-`nil`. + @available( + iOS, deprecated: 9999, + message: + "Scope the store into the destination's wrapped 'state' and presented 'action', instead: 'store.scope(state: \\.destination, action: \\.destination.presented)'. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" ) - } + @available( + macOS, deprecated: 9999, + message: + "Scope the store into the destination's wrapped 'state' and presented 'action', instead: 'store.scope(state: \\.destination, action: \\.destination.presented)'. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + @available( + tvOS, deprecated: 9999, + message: + "Scope the store into the destination's wrapped 'state' and presented 'action', instead: 'store.scope(state: \\.destination, action: \\.destination.presented)'. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + @available( + watchOS, deprecated: 9999, + message: + "Scope the store into the destination's wrapped 'state' and presented 'action', instead: 'store.scope(state: \\.destination, action: \\.destination.presented)'. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency@MainActor + #endif + public init( + _ store: Store, PresentationAction>, + @ViewBuilder then ifContent: @escaping (_ store: Store) -> IfContent + ) where Content == IfContent? { + self.init( + store.scope(state: \.wrappedValue, action: \.presented), + then: ifContent + ) + } - /// Initializes an ``IfLetStore`` view that computes content depending on if a store of - /// ``PresentationState`` and ``PresentationAction`` is `nil` or non-`nil` and state can further - /// be extracted from the destination state, _e.g._ it matches a particular case of an enum. - /// - /// - Parameters: - /// - store: A store of optional state. - /// - toState: A closure that attempts to extract state for the "if" branch from the destination - /// state. - /// - fromAction: A closure that embeds actions for the "if" branch in destination actions. - /// - ifContent: A function that is given a store of non-optional state and returns a view that - /// is visible only when the optional state is non-`nil` and state can be extracted from the - /// destination state. - @available( - *, deprecated, - message: - "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public init( - _ store: Store, PresentationAction>, - state toState: @escaping (_ destinationState: DestinationState) -> State?, - action fromAction: @escaping (_ action: Action) -> DestinationAction, - @ViewBuilder then ifContent: @escaping (_ store: Store) -> IfContent - ) where Content == IfContent? { - self.init( - store.scope( - state: { $0.wrappedValue.flatMap(toState) }, - action: { .presented(fromAction($0)) } - ), - then: ifContent + /// Initializes an ``IfLetStore`` view that computes content depending on if a store of + /// ``PresentationState`` and ``PresentationAction`` is `nil` or non-`nil` and state can further + /// be extracted from the destination state, _e.g._ it matches a particular case of an enum. + /// + /// - Parameters: + /// - store: A store of optional state. + /// - toState: A closure that attempts to extract state for the "if" branch from the destination + /// state. + /// - fromAction: A closure that embeds actions for the "if" branch in destination actions. + /// - ifContent: A function that is given a store of non-optional state and returns a view that + /// is visible only when the optional state is non-`nil` and state can be extracted from the + /// destination state. + /// - elseContent: A view that is only visible when state cannot be extracted from the + /// destination. + @available( + *, deprecated, + message: + "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" ) - } + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency@MainActor + #endif + public init( + _ store: Store, PresentationAction>, + state toState: @escaping (_ destinationState: DestinationState) -> State?, + action fromAction: @escaping (_ action: Action) -> DestinationAction, + @ViewBuilder then ifContent: @escaping (_ store: Store) -> IfContent, + @ViewBuilder else elseContent: @escaping () -> ElseContent + ) where Content == _ConditionalContent { + self.init( + store.scope( + state: { $0.wrappedValue.flatMap(toState) }, + action: { .presented(fromAction($0)) } + ), + then: ifContent, + else: elseContent + ) + } - public var body: some View { - WithViewStore( - self.store, - observe: { $0 }, - removeDuplicates: { ($0 != nil) == ($1 != nil) }, - content: self.content + /// Initializes an ``IfLetStore`` view that computes content depending on if a store of + /// ``PresentationState`` and ``PresentationAction`` is `nil` or non-`nil` and state can further + /// be extracted from the destination state, _e.g._ it matches a particular case of an enum. + /// + /// - Parameters: + /// - store: A store of optional state. + /// - toState: A closure that attempts to extract state for the "if" branch from the destination + /// state. + /// - fromAction: A closure that embeds actions for the "if" branch in destination actions. + /// - ifContent: A function that is given a store of non-optional state and returns a view that + /// is visible only when the optional state is non-`nil` and state can be extracted from the + /// destination state. + @available( + *, deprecated, + message: + "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" ) + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency@MainActor + #endif + public init( + _ store: Store, PresentationAction>, + state toState: @escaping (_ destinationState: DestinationState) -> State?, + action fromAction: @escaping (_ action: Action) -> DestinationAction, + @ViewBuilder then ifContent: @escaping (_ store: Store) -> IfContent + ) where Content == IfContent? { + self.init( + store.scope( + state: { $0.wrappedValue.flatMap(toState) }, + action: { .presented(fromAction($0)) } + ), + then: ifContent + ) + } + + public var body: some View { + WithViewStore( + self.store, + observe: { $0 }, + removeDuplicates: { ($0 != nil) == ($1 != nil) }, + content: self.content + ) + } } -} -private final class _IfLetCore, Wrapped, Action>: Core { - let base: Base - init(base: Base) { - self.base = base + private final class _IfLetCore, Wrapped, Action>: Core { + let base: Base + init(base: Base) { + self.base = base + } + var state: Base.State { base.state } + func send(_ action: Action) -> Task? { base.send(action) } + var canStoreCacheChildren: Bool { base.canStoreCacheChildren } + var didSet: CurrentValueRelay { base.didSet } + var isInvalid: Bool { state == nil || base.isInvalid } + var effectCancellables: [UUID: AnyCancellable] { base.effectCancellables } } - var state: Base.State { base.state } - func send(_ action: Action) -> Task? { base.send(action) } - var canStoreCacheChildren: Bool { base.canStoreCacheChildren } - var didSet: CurrentValueRelay { base.didSet } - var isInvalid: Bool { state == nil || base.isInvalid } - var effectCancellables: [UUID: AnyCancellable] { base.effectCancellables } -} +#endif diff --git a/Sources/ComposableArchitecture/SwiftUI/NavigationDestination.swift b/Sources/ComposableArchitecture/SwiftUI/NavigationDestination.swift index 71a6626ce1d4..a91a3eea33a1 100644 --- a/Sources/ComposableArchitecture/SwiftUI/NavigationDestination.swift +++ b/Sources/ComposableArchitecture/SwiftUI/NavigationDestination.swift @@ -1,130 +1,133 @@ -@_spi(Reflection) import CasePaths -import SwiftUI +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + @_spi(Reflection) import CasePaths + import SwiftUI -@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) -extension View { - /// Associates a destination view with a store that can be used to push the view onto a - /// `NavigationStack`. - /// - /// > This is a Composable Architecture-friendly version of SwiftUI's - /// > `navigationDestination(isPresented:)` view modifier. - /// - /// - Parameters: - /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for - /// a screen. When `store`'s state is non-`nil`, the system passes a store of unwrapped - /// `State` and `Action` to the modifier's closure. You use this store to power the content - /// in a view that the system pushes onto the navigation stack. If `store`'s state is - /// `nil`-ed out, the system pops the view from the stack. - /// - destination: A closure returning the content of the destination view. - @available( - iOS, deprecated: 9999, - message: - "Pass a binding of a store to 'navigationDestination(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" - ) - @available( - macOS, deprecated: 9999, - message: - "Pass a binding of a store to 'navigationDestination(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" - ) - @available( - tvOS, deprecated: 9999, - message: - "Pass a binding of a store to 'navigationDestination(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" - ) - @available( - watchOS, deprecated: 9999, - message: - "Pass a binding of a store to 'navigationDestination(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" - ) - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public func navigationDestination( - store: Store, PresentationAction>, - @ViewBuilder destination: @escaping (_ store: Store) -> Destination - ) -> some View { - self.presentation( - store: store, - id: { $0.wrappedValue.map(NavigationDestinationID.init) } - ) { `self`, $item, destinationContent in - self.navigationDestination(isPresented: Binding($item)) { - destinationContent(destination) + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension View { + /// Associates a destination view with a store that can be used to push the view onto a + /// `NavigationStack`. + /// + /// > This is a Composable Architecture-friendly version of SwiftUI's + /// > `navigationDestination(isPresented:)` view modifier. + /// + /// - Parameters: + /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for + /// a screen. When `store`'s state is non-`nil`, the system passes a store of unwrapped + /// `State` and `Action` to the modifier's closure. You use this store to power the content + /// in a view that the system pushes onto the navigation stack. If `store`'s state is + /// `nil`-ed out, the system pops the view from the stack. + /// - destination: A closure returning the content of the destination view. + @available( + iOS, deprecated: 9999, + message: + "Pass a binding of a store to 'navigationDestination(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + @available( + macOS, deprecated: 9999, + message: + "Pass a binding of a store to 'navigationDestination(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + @available( + tvOS, deprecated: 9999, + message: + "Pass a binding of a store to 'navigationDestination(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + @available( + watchOS, deprecated: 9999, + message: + "Pass a binding of a store to 'navigationDestination(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency@MainActor + #endif + public func navigationDestination( + store: Store, PresentationAction>, + @ViewBuilder destination: @escaping (_ store: Store) -> Destination + ) -> some View { + self.presentation( + store: store, + id: { $0.wrappedValue.map(NavigationDestinationID.init) } + ) { `self`, $item, destinationContent in + self.navigationDestination(isPresented: Binding($item)) { + destinationContent(destination) + } } } - } - /// Associates a destination view with a store that can be used to push the view onto a - /// `NavigationStack`. - /// - /// > This is a Composable Architecture-friendly version of SwiftUI's - /// > `navigationDestination(isPresented:)` view modifier. - /// - /// - Parameters: - /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for - /// a screen. When `store`'s state is non-`nil`, the system passes a store of unwrapped - /// `State` and `Action` to the modifier's closure. You use this store to power the content - /// in a view that the system pushes onto the navigation stack. If `store`'s state is - /// `nil`-ed out, the system pops the view from the stack. - /// - toDestinationState: A transformation to extract screen state from the presentation - /// state. - /// - fromDestinationAction: A transformation to embed screen actions into the presentation - /// action. - /// - destination: A closure returning the content of the destination view. - @available( - iOS, deprecated: 9999, - message: - "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - @available( - macOS, deprecated: 9999, - message: - "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - @available( - tvOS, deprecated: 9999, - message: - "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - @available( - watchOS, deprecated: 9999, - message: - "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public func navigationDestination< - State, Action, DestinationState, DestinationAction, Destination: View - >( - store: Store, PresentationAction>, - state toDestinationState: @escaping (_ state: State) -> DestinationState?, - action fromDestinationAction: @escaping (_ destinationAction: DestinationAction) -> Action, - @ViewBuilder destination: @escaping (_ store: Store) -> - Destination - ) -> some View { - self.presentation( - store: store, - state: toDestinationState, - id: { $0.wrappedValue.map(NavigationDestinationID.init) }, - action: fromDestinationAction - ) { `self`, $item, destinationContent in - self.navigationDestination(isPresented: Binding($item)) { - destinationContent(destination) + /// Associates a destination view with a store that can be used to push the view onto a + /// `NavigationStack`. + /// + /// > This is a Composable Architecture-friendly version of SwiftUI's + /// > `navigationDestination(isPresented:)` view modifier. + /// + /// - Parameters: + /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for + /// a screen. When `store`'s state is non-`nil`, the system passes a store of unwrapped + /// `State` and `Action` to the modifier's closure. You use this store to power the content + /// in a view that the system pushes onto the navigation stack. If `store`'s state is + /// `nil`-ed out, the system pops the view from the stack. + /// - toDestinationState: A transformation to extract screen state from the presentation + /// state. + /// - fromDestinationAction: A transformation to embed screen actions into the presentation + /// action. + /// - destination: A closure returning the content of the destination view. + @available( + iOS, deprecated: 9999, + message: + "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + @available( + macOS, deprecated: 9999, + message: + "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + @available( + tvOS, deprecated: 9999, + message: + "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + @available( + watchOS, deprecated: 9999, + message: + "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency@MainActor + #endif + public func navigationDestination< + State, Action, DestinationState, DestinationAction, Destination: View + >( + store: Store, PresentationAction>, + state toDestinationState: @escaping (_ state: State) -> DestinationState?, + action fromDestinationAction: @escaping (_ destinationAction: DestinationAction) -> Action, + @ViewBuilder destination: + @escaping (_ store: Store) -> + Destination + ) -> some View { + self.presentation( + store: store, + state: toDestinationState, + id: { $0.wrappedValue.map(NavigationDestinationID.init) }, + action: fromDestinationAction + ) { `self`, $item, destinationContent in + self.navigationDestination(isPresented: Binding($item)) { + destinationContent(destination) + } } } } -} -private struct NavigationDestinationID: Hashable { - let objectIdentifier: ObjectIdentifier - let enumTag: UInt32? + private struct NavigationDestinationID: Hashable { + let objectIdentifier: ObjectIdentifier + let enumTag: UInt32? - init(_ value: Value) { - self.objectIdentifier = ObjectIdentifier(Value.self) - self.enumTag = EnumMetadata(Value.self)?.tag(of: value) + init(_ value: Value) { + self.objectIdentifier = ObjectIdentifier(Value.self) + self.enumTag = EnumMetadata(Value.self)?.tag(of: value) + } } -} +#endif diff --git a/Sources/ComposableArchitecture/SwiftUI/NavigationStackStore.swift b/Sources/ComposableArchitecture/SwiftUI/NavigationStackStore.swift index 85013140825e..b171f5151f28 100644 --- a/Sources/ComposableArchitecture/SwiftUI/NavigationStackStore.swift +++ b/Sources/ComposableArchitecture/SwiftUI/NavigationStackStore.swift @@ -1,82 +1,66 @@ -import OrderedCollections -import SwiftUI +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + import OrderedCollections + import SwiftUI -/// A navigation stack that is driven by a store. -/// -/// This view can be used to drive stack-based navigation in the Composable Architecture when passed -/// a store that is focused on ``StackState`` and ``StackAction``. -/// -/// See the dedicated article on for more information on the library's navigation -/// tools, and in particular see for information on using this view. -@available( - iOS, deprecated: 9999, - message: - "Use 'NavigationStack.init(path:)' with a store scoped from observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-NavigationStackStore-with-NavigationStack]" -) -@available( - macOS, deprecated: 9999, - message: - "Use 'NavigationStack.init(path:)' with a store scoped from observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-NavigationStackStore-with-NavigationStack]" -) -@available( - tvOS, deprecated: 9999, - message: - "Use 'NavigationStack.init(path:)' with a store scoped from observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-NavigationStackStore-with-NavigationStack]" -) -@available( - watchOS, deprecated: 9999, - message: - "Use 'NavigationStack.init(path:)' with a store scoped from observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-NavigationStackStore-with-NavigationStack]" -) -@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) -public struct NavigationStackStore: View { - private let root: Root - private let destination: (StackState.Component) -> Destination - @ObservedObject private var viewStore: ViewStore, StackAction> - - /// Creates a navigation stack with a store of stack state and actions. + /// A navigation stack that is driven by a store. /// - /// - Parameters: - /// - store: A store of stack state and actions to power this stack. - /// - root: The view to display when the stack is empty. - /// - destination: A view builder that defines a view to display when an element is appended to - /// the stack's state. The closure takes one argument, which is a store of the value to - /// present. - /// - fileID: The fileID. - /// - filePath: The filePath. - /// - line: The line. - /// - column: The column. - public init( - _ store: Store, StackAction>, - @ViewBuilder root: () -> Root, - @ViewBuilder destination: @escaping (_ store: Store) -> Destination, - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) { - func navigationDestination( - component: StackState.Component - ) -> Destination { - let id = store.id( - state: - \.[ - id: component.id, - fileID: _HashableStaticString(rawValue: fileID), - filePath: _HashableStaticString(rawValue: filePath), - line: line, - column: column - ], - action: \.[id: component.id] - ) - @MainActor - func open( - _ core: some Core, StackAction> - ) -> any Core { - IfLetCore( - base: core, - cachedState: component.element, - stateKeyPath: + /// This view can be used to drive stack-based navigation in the Composable Architecture when passed + /// a store that is focused on ``StackState`` and ``StackAction``. + /// + /// See the dedicated article on for more information on the library's navigation + /// tools, and in particular see for information on using this view. + @available( + iOS, deprecated: 9999, + message: + "Use 'NavigationStack.init(path:)' with a store scoped from observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-NavigationStackStore-with-NavigationStack]" + ) + @available( + macOS, deprecated: 9999, + message: + "Use 'NavigationStack.init(path:)' with a store scoped from observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-NavigationStackStore-with-NavigationStack]" + ) + @available( + tvOS, deprecated: 9999, + message: + "Use 'NavigationStack.init(path:)' with a store scoped from observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-NavigationStackStore-with-NavigationStack]" + ) + @available( + watchOS, deprecated: 9999, + message: + "Use 'NavigationStack.init(path:)' with a store scoped from observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-NavigationStackStore-with-NavigationStack]" + ) + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public struct NavigationStackStore: View { + private let root: Root + private let destination: (StackState.Component) -> Destination + @ObservedObject private var viewStore: ViewStore, StackAction> + + /// Creates a navigation stack with a store of stack state and actions. + /// + /// - Parameters: + /// - store: A store of stack state and actions to power this stack. + /// - root: The view to display when the stack is empty. + /// - destination: A view builder that defines a view to display when an element is appended to + /// the stack's state. The closure takes one argument, which is a store of the value to + /// present. + /// - fileID: The fileID. + /// - filePath: The filePath. + /// - line: The line. + /// - column: The column. + public init( + _ store: Store, StackAction>, + @ViewBuilder root: () -> Root, + @ViewBuilder destination: @escaping (_ store: Store) -> Destination, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + func navigationDestination( + component: StackState.Component + ) -> Destination { + let id = store.id( + state: \.[ id: component.id, fileID: _HashableStaticString(rawValue: fileID), @@ -84,61 +68,8 @@ public struct NavigationStackStore line: line, column: column ], - actionKeyPath: \.[id: component.id] + action: \.[id: component.id] ) - } - return destination(store.scope(id: id, childCore: open(store.core))) - } - self.root = root() - self.destination = navigationDestination(component:) - self._viewStore = ObservedObject( - wrappedValue: ViewStore( - store, - observe: { $0 }, - removeDuplicates: { areOrderedSetsDuplicates($0.ids, $1.ids) } - ) - ) - } - - /// Creates a navigation stack with a store of stack state and actions. - /// - /// - Parameters: - /// - store: A store of stack state and actions to power this stack. - /// - root: The view to display when the stack is empty. - /// - destination: A view builder that defines a view to display when an element is appended to - /// the stack's state. The closure takes one argument, which is the initial enum state to - /// present. You can switch over this value and use ``CaseLet`` views to handle each case. - /// - fileID: The fileID. - /// - filePath: The filePath. - /// - line: The line. - /// - column: The column. - @_disfavoredOverload - public init( - _ store: Store, StackAction>, - @ViewBuilder root: () -> Root, - @ViewBuilder destination: @escaping (_ initialState: State) -> D, - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) where Destination == SwitchStore { - func navigationDestination( - component: StackState.Component - ) -> Destination { - let id = store.id( - state: - \.[ - id: component.id, - fileID: _HashableStaticString(rawValue: fileID), - filePath: _HashableStaticString(rawValue: filePath), - line: line, - column: column - ], - action: \.[id: component.id] - ) - if let child = store.children[id] as? Store { - return SwitchStore(child, content: destination) - } else { @MainActor func open( _ core: some Core, StackAction> @@ -157,51 +88,122 @@ public struct NavigationStackStore actionKeyPath: \.[id: component.id] ) } - return SwitchStore(store.scope(id: id, childCore: open(store.core)), content: destination) + return destination(store.scope(id: id, childCore: open(store.core))) } - } - - self.root = root() - self.destination = navigationDestination(component:) - self._viewStore = ObservedObject( - wrappedValue: ViewStore( - store, - observe: { $0 }, - removeDuplicates: { areOrderedSetsDuplicates($0.ids, $1.ids) } + self.root = root() + self.destination = navigationDestination(component:) + self._viewStore = ObservedObject( + wrappedValue: ViewStore( + store, + observe: { $0 }, + removeDuplicates: { areOrderedSetsDuplicates($0.ids, $1.ids) } + ) ) - ) - } + } - public var body: some View { - NavigationStack( - path: self.viewStore.binding( - get: { $0.path }, - compactSend: { newPath in - if newPath.count > self.viewStore.path.count, let component = newPath.last { - return .push(id: component.id, state: component.element) - } else if newPath.count < self.viewStore.path.count { - return .popFrom(id: self.viewStore.path[newPath.count].id) - } else { - return nil + /// Creates a navigation stack with a store of stack state and actions. + /// + /// - Parameters: + /// - store: A store of stack state and actions to power this stack. + /// - root: The view to display when the stack is empty. + /// - destination: A view builder that defines a view to display when an element is appended to + /// the stack's state. The closure takes one argument, which is the initial enum state to + /// present. You can switch over this value and use ``CaseLet`` views to handle each case. + /// - fileID: The fileID. + /// - filePath: The filePath. + /// - line: The line. + /// - column: The column. + @_disfavoredOverload + public init( + _ store: Store, StackAction>, + @ViewBuilder root: () -> Root, + @ViewBuilder destination: @escaping (_ initialState: State) -> D, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) where Destination == SwitchStore { + func navigationDestination( + component: StackState.Component + ) -> Destination { + let id = store.id( + state: + \.[ + id: component.id, + fileID: _HashableStaticString(rawValue: fileID), + filePath: _HashableStaticString(rawValue: filePath), + line: line, + column: column + ], + action: \.[id: component.id] + ) + if let child = store.children[id] as? Store { + return SwitchStore(child, content: destination) + } else { + @MainActor + func open( + _ core: some Core, StackAction> + ) -> any Core { + IfLetCore( + base: core, + cachedState: component.element, + stateKeyPath: + \.[ + id: component.id, + fileID: _HashableStaticString(rawValue: fileID), + filePath: _HashableStaticString(rawValue: filePath), + line: line, + column: column + ], + actionKeyPath: \.[id: component.id] + ) } + return SwitchStore(store.scope(id: id, childCore: open(store.core)), content: destination) } + } + + self.root = root() + self.destination = navigationDestination(component:) + self._viewStore = ObservedObject( + wrappedValue: ViewStore( + store, + observe: { $0 }, + removeDuplicates: { areOrderedSetsDuplicates($0.ids, $1.ids) } + ) ) - ) { - self.root - .environment(\.navigationDestinationType, State.self) - .navigationDestination(for: StackState.Component.self) { component in - NavigationDestinationView(component: component, destination: self.destination) - } + } + + public var body: some View { + NavigationStack( + path: self.viewStore.binding( + get: { $0.path }, + compactSend: { newPath in + if newPath.count > self.viewStore.path.count, let component = newPath.last { + return .push(id: component.id, state: component.element) + } else if newPath.count < self.viewStore.path.count { + return .popFrom(id: self.viewStore.path[newPath.count].id) + } else { + return nil + } + } + ) + ) { + self.root + .environment(\.navigationDestinationType, State.self) + .navigationDestination(for: StackState.Component.self) { component in + NavigationDestinationView(component: component, destination: self.destination) + } + } } } -} -private struct NavigationDestinationView: View { - let component: StackState.Component - let destination: (StackState.Component) -> Destination - var body: some View { - self.destination(self.component) - .environment(\.navigationDestinationType, State.self) - .id(self.component.id) + private struct NavigationDestinationView: View { + let component: StackState.Component + let destination: (StackState.Component) -> Destination + var body: some View { + self.destination(self.component) + .environment(\.navigationDestinationType, State.self) + .id(self.component.id) + } } -} +#endif diff --git a/Sources/ComposableArchitecture/SwiftUI/Popover.swift b/Sources/ComposableArchitecture/SwiftUI/Popover.swift index 812a400d9e4b..9e84b33d816c 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Popover.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Popover.swift @@ -1,116 +1,119 @@ -import SwiftUI +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + import SwiftUI -@available(tvOS, unavailable) -@available(watchOS, unavailable) -extension View { - /// Presents a popover using the given store as a data source for the popover's content. - /// - /// > This is a Composable Architecture-friendly version of SwiftUI's `popover` view modifier. - /// - /// - Parameters: - /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for - /// a modal. When `store`'s state is non-`nil`, the system passes a store of unwrapped `State` - /// and `Action` to the modifier's closure. You use this store to power the content in a - /// popover you create that the system displays to the user. If `store`'s state is `nil`-ed - /// out, the system dismisses the currently displayed popover. - /// - 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 in macOS. iOS ignores this parameter. - /// - content: A closure returning the content of the popover. - @available( - iOS, deprecated: 9999, - message: - "Pass a binding of a store to 'popover(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" - ) - @available( - macOS, deprecated: 9999, - message: - "Pass a binding of a store to 'popover(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" - ) - @available( - tvOS, deprecated: 9999, - message: - "Pass a binding of a store to 'popover(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" - ) - @available( - watchOS, deprecated: 9999, - message: - "Pass a binding of a store to 'popover(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" - ) - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public func popover( - store: Store, PresentationAction>, - attachmentAnchor: PopoverAttachmentAnchor = .rect(.bounds), - arrowEdge: Edge = .top, - @ViewBuilder content: @escaping (_ store: Store) -> Content - ) -> some View { - self.presentation(store: store) { `self`, $item, destination in - self.popover(item: $item, attachmentAnchor: attachmentAnchor, arrowEdge: arrowEdge) { _ in - destination(content) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension View { + /// Presents a popover using the given store as a data source for the popover's content. + /// + /// > This is a Composable Architecture-friendly version of SwiftUI's `popover` view modifier. + /// + /// - Parameters: + /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for + /// a modal. When `store`'s state is non-`nil`, the system passes a store of unwrapped `State` + /// and `Action` to the modifier's closure. You use this store to power the content in a + /// popover you create that the system displays to the user. If `store`'s state is `nil`-ed + /// out, the system dismisses the currently displayed popover. + /// - 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 in macOS. iOS ignores this parameter. + /// - content: A closure returning the content of the popover. + @available( + iOS, deprecated: 9999, + message: + "Pass a binding of a store to 'popover(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + @available( + macOS, deprecated: 9999, + message: + "Pass a binding of a store to 'popover(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + @available( + tvOS, deprecated: 9999, + message: + "Pass a binding of a store to 'popover(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + @available( + watchOS, deprecated: 9999, + message: + "Pass a binding of a store to 'popover(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency@MainActor + #endif + public func popover( + store: Store, PresentationAction>, + attachmentAnchor: PopoverAttachmentAnchor = .rect(.bounds), + arrowEdge: Edge = .top, + @ViewBuilder content: @escaping (_ store: Store) -> Content + ) -> some View { + self.presentation(store: store) { `self`, $item, destination in + self.popover(item: $item, attachmentAnchor: attachmentAnchor, arrowEdge: arrowEdge) { _ in + destination(content) + } } } - } - /// Presents a popover using the given store as a data source for the popover's content. - /// - /// > This is a Composable Architecture-friendly version of SwiftUI's `popover` view modifier. - /// - /// - Parameters: - /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for - /// a modal. When `store`'s state is non-`nil`, the system passes a store of unwrapped `State` - /// and `Action` to the modifier's closure. You use this store to power the content in a - /// popover you create that the system displays to the user. If `store`'s state is `nil`-ed - /// out, the system dismisses the currently displayed popover. - /// - toDestinationState: A transformation to extract popover state from the presentation state. - /// - fromDestinationAction: A transformation to embed popover actions into the presentation - /// action. - /// - 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 in macOS. iOS ignores this parameter. - /// - content: A closure returning the content of the popover. - @available( - iOS, deprecated: 9999, - message: - "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - @available( - macOS, deprecated: 9999, - message: - "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - @available( - tvOS, deprecated: 9999, - message: - "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - @available( - watchOS, deprecated: 9999, - message: - "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public func popover( - store: Store, PresentationAction>, - state toDestinationState: @escaping (_ state: State) -> DestinationState?, - action fromDestinationAction: @escaping (_ destinationAction: DestinationAction) -> Action, - attachmentAnchor: PopoverAttachmentAnchor = .rect(.bounds), - arrowEdge: Edge = .top, - @ViewBuilder content: @escaping (_ store: Store) -> Content - ) -> some View { - self.presentation( - store: store, state: toDestinationState, action: fromDestinationAction - ) { `self`, $item, destination in - self.popover(item: $item, attachmentAnchor: attachmentAnchor, arrowEdge: arrowEdge) { _ in - destination(content) + /// Presents a popover using the given store as a data source for the popover's content. + /// + /// > This is a Composable Architecture-friendly version of SwiftUI's `popover` view modifier. + /// + /// - Parameters: + /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for + /// a modal. When `store`'s state is non-`nil`, the system passes a store of unwrapped `State` + /// and `Action` to the modifier's closure. You use this store to power the content in a + /// popover you create that the system displays to the user. If `store`'s state is `nil`-ed + /// out, the system dismisses the currently displayed popover. + /// - toDestinationState: A transformation to extract popover state from the presentation state. + /// - fromDestinationAction: A transformation to embed popover actions into the presentation + /// action. + /// - 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 in macOS. iOS ignores this parameter. + /// - content: A closure returning the content of the popover. + @available( + iOS, deprecated: 9999, + message: + "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + @available( + macOS, deprecated: 9999, + message: + "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + @available( + tvOS, deprecated: 9999, + message: + "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + @available( + watchOS, deprecated: 9999, + message: + "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency@MainActor + #endif + public func popover( + store: Store, PresentationAction>, + state toDestinationState: @escaping (_ state: State) -> DestinationState?, + action fromDestinationAction: @escaping (_ destinationAction: DestinationAction) -> Action, + attachmentAnchor: PopoverAttachmentAnchor = .rect(.bounds), + arrowEdge: Edge = .top, + @ViewBuilder content: + @escaping (_ store: Store) -> Content + ) -> some View { + self.presentation( + store: store, state: toDestinationState, action: fromDestinationAction + ) { `self`, $item, destination in + self.popover(item: $item, attachmentAnchor: attachmentAnchor, arrowEdge: arrowEdge) { _ in + destination(content) + } } } } -} +#endif diff --git a/Sources/ComposableArchitecture/SwiftUI/PresentationModifier.swift b/Sources/ComposableArchitecture/SwiftUI/PresentationModifier.swift index 0bf535b27471..b55978954ac8 100644 --- a/Sources/ComposableArchitecture/SwiftUI/PresentationModifier.swift +++ b/Sources/ComposableArchitecture/SwiftUI/PresentationModifier.swift @@ -1,379 +1,398 @@ -import Combine -import SwiftUI +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + import SwiftUI -extension View { - @_spi(Presentation) - #if swift(<5.10) - @MainActor(unsafe) + #if canImport(Combine) + import Combine #else - @preconcurrency@MainActor + import OpenCombine #endif - public func presentation( - store: Store, PresentationAction>, - @ViewBuilder body: @escaping ( - _ content: Self, - _ isPresented: Binding, - _ destination: DestinationContent - ) -> Content - ) -> some View { - self.presentation(store: store) { `self`, $item, destination in - body(self, Binding($item), destination) + + extension View { + @_spi(Presentation) + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency@MainActor + #endif + public func presentation( + store: Store, PresentationAction>, + @ViewBuilder body: + @escaping ( + _ content: Self, + _ isPresented: Binding, + _ destination: DestinationContent + ) -> Content + ) -> some View { + self.presentation(store: store) { `self`, $item, destination in + body(self, Binding($item), destination) + } } - } - @_disfavoredOverload - @_spi(Presentation) - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public func presentation( - store: Store, PresentationAction>, - @ViewBuilder body: @escaping ( - _ content: Self, - _ item: Binding, - _ destination: DestinationContent - ) -> Content - ) -> some View { - self.presentation( - store: store, - id: { $0.wrappedValue.map { _ in ObjectIdentifier(State.self) } } - ) { `self`, $item, destination in - body(self, $item, destination) + @_disfavoredOverload + @_spi(Presentation) + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency@MainActor + #endif + public func presentation( + store: Store, PresentationAction>, + @ViewBuilder body: + @escaping ( + _ content: Self, + _ item: Binding, + _ destination: DestinationContent + ) -> Content + ) -> some View { + self.presentation( + store: store, + id: { $0.wrappedValue.map { _ in ObjectIdentifier(State.self) } } + ) { `self`, $item, destination in + body(self, $item, destination) + } } - } - @_disfavoredOverload - @_spi(Presentation) - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public func presentation( - store: Store, PresentationAction>, - id toID: @escaping (PresentationState) -> AnyHashable?, - @ViewBuilder body: @escaping ( - _ content: Self, - _ item: Binding, - _ destination: DestinationContent - ) -> Content - ) -> some View { - PresentationStore(store, id: toID) { $item, destination in - body(self, $item, destination) + @_disfavoredOverload + @_spi(Presentation) + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency@MainActor + #endif + public func presentation( + store: Store, PresentationAction>, + id toID: @escaping (PresentationState) -> AnyHashable?, + @ViewBuilder body: + @escaping ( + _ content: Self, + _ item: Binding, + _ destination: DestinationContent + ) -> Content + ) -> some View { + PresentationStore(store, id: toID) { $item, destination in + body(self, $item, destination) + } } - } - @_spi(Presentation) - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public func presentation< - State, - Action, - DestinationState, - DestinationAction, - Content: View - >( - store: Store, PresentationAction>, - state toDestinationState: @escaping (_ state: State) -> DestinationState?, - action fromDestinationAction: @escaping (_ destinationAction: DestinationAction) -> Action, - @ViewBuilder body: @escaping ( - _ content: Self, - _ isPresented: Binding, - _ destination: DestinationContent - ) -> Content - ) -> some View { - self.presentation( - store: store, state: toDestinationState, action: fromDestinationAction - ) { `self`, $item, destination in - body(self, Binding($item), destination) + @_spi(Presentation) + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency@MainActor + #endif + public func presentation< + State, + Action, + DestinationState, + DestinationAction, + Content: View + >( + store: Store, PresentationAction>, + state toDestinationState: @escaping (_ state: State) -> DestinationState?, + action fromDestinationAction: @escaping (_ destinationAction: DestinationAction) -> Action, + @ViewBuilder body: + @escaping ( + _ content: Self, + _ isPresented: Binding, + _ destination: DestinationContent + ) -> Content + ) -> some View { + self.presentation( + store: store, state: toDestinationState, action: fromDestinationAction + ) { `self`, $item, destination in + body(self, Binding($item), destination) + } } - } - @_disfavoredOverload - @_spi(Presentation) - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public func presentation< - State, - Action, - DestinationState, - DestinationAction, - Content: View - >( - store: Store, PresentationAction>, - state toDestinationState: @escaping (_ state: State) -> DestinationState?, - action fromDestinationAction: @escaping (_ destinationAction: DestinationAction) -> Action, - @ViewBuilder body: @escaping ( - _ content: Self, - _ item: Binding, - _ destination: DestinationContent - ) -> Content - ) -> some View { - self.presentation( - store: store, - state: toDestinationState, - id: { $0.id }, - action: fromDestinationAction, - body: body - ) - } + @_disfavoredOverload + @_spi(Presentation) + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency@MainActor + #endif + public func presentation< + State, + Action, + DestinationState, + DestinationAction, + Content: View + >( + store: Store, PresentationAction>, + state toDestinationState: @escaping (_ state: State) -> DestinationState?, + action fromDestinationAction: @escaping (_ destinationAction: DestinationAction) -> Action, + @ViewBuilder body: + @escaping ( + _ content: Self, + _ item: Binding, + _ destination: DestinationContent + ) -> Content + ) -> some View { + self.presentation( + store: store, + state: toDestinationState, + id: { $0.id }, + action: fromDestinationAction, + body: body + ) + } - @_spi(Presentation) - @ViewBuilder - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public func presentation< - State, - Action, - DestinationState, - DestinationAction, - Content: View - >( - store: Store, PresentationAction>, - state toDestinationState: @escaping (State) -> DestinationState?, - id toID: @escaping (PresentationState) -> AnyHashable?, - action fromDestinationAction: @escaping (DestinationAction) -> Action, - @ViewBuilder body: @escaping ( - Self, - Binding, - DestinationContent - ) -> Content - ) -> some View { - PresentationStore( - store, state: toDestinationState, id: toID, action: fromDestinationAction - ) { $item, destination in - body(self, $item, destination) + @_spi(Presentation) + @ViewBuilder + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency@MainActor + #endif + public func presentation< + State, + Action, + DestinationState, + DestinationAction, + Content: View + >( + store: Store, PresentationAction>, + state toDestinationState: @escaping (State) -> DestinationState?, + id toID: @escaping (PresentationState) -> AnyHashable?, + action fromDestinationAction: @escaping (DestinationAction) -> Action, + @ViewBuilder body: + @escaping ( + Self, + Binding, + DestinationContent + ) -> Content + ) -> some View { + PresentationStore( + store, state: toDestinationState, id: toID, action: fromDestinationAction + ) { $item, destination in + body(self, $item, destination) + } } } -} -@_spi(Presentation) -public struct PresentationStore< - State, Action, DestinationState, DestinationAction, Content: View ->: View { - let store: Store, PresentationAction> - let toDestinationState: (State) -> DestinationState? - let toID: (PresentationState) -> AnyHashable? - let fromDestinationAction: (DestinationAction) -> Action - let destinationStore: Store - let content: - ( - Binding, - DestinationContent - ) -> Content + @_spi(Presentation) + public struct PresentationStore< + State, Action, DestinationState, DestinationAction, Content: View + >: View { + let store: Store, PresentationAction> + let toDestinationState: (State) -> DestinationState? + let toID: (PresentationState) -> AnyHashable? + let fromDestinationAction: (DestinationAction) -> Action + let destinationStore: Store + let content: + ( + Binding, + DestinationContent + ) -> Content - @ObservedObject var viewStore: ViewStore, PresentationAction> + @ObservedObject var viewStore: ViewStore, PresentationAction> - public init( - _ store: Store, PresentationAction>, - @ViewBuilder content: @escaping ( - _ isPresented: Binding, - _ destination: DestinationContent - ) -> Content - ) where State == DestinationState, Action == DestinationAction { - self.init(store) { $item, destination in - content(Binding($item), destination) + public init( + _ store: Store, PresentationAction>, + @ViewBuilder content: + @escaping ( + _ isPresented: Binding, + _ destination: DestinationContent + ) -> Content + ) where State == DestinationState, Action == DestinationAction { + self.init(store) { $item, destination in + content(Binding($item), destination) + } } - } - - @_disfavoredOverload - public init( - _ store: Store, PresentationAction>, - @ViewBuilder content: @escaping ( - _ item: Binding, - _ destination: DestinationContent - ) -> Content - ) where State == DestinationState, Action == DestinationAction { - self.init( - store, - id: { $0.id }, - content: content - ) - } - public init( - _ store: Store, PresentationAction>, - state toDestinationState: @escaping (_ state: State) -> DestinationState?, - action fromDestinationAction: @escaping (_ destinationAction: DestinationAction) -> Action, - @ViewBuilder content: @escaping ( - _ isPresented: Binding, - _ destination: DestinationContent - ) -> Content - ) { - self.init( - store, state: toDestinationState, action: fromDestinationAction - ) { $item, destination in - content(Binding($item), destination) + @_disfavoredOverload + public init( + _ store: Store, PresentationAction>, + @ViewBuilder content: + @escaping ( + _ item: Binding, + _ destination: DestinationContent + ) -> Content + ) where State == DestinationState, Action == DestinationAction { + self.init( + store, + id: { $0.id }, + content: content + ) } - } - @_disfavoredOverload - public init( - _ store: Store, PresentationAction>, - state toDestinationState: @escaping (_ state: State) -> DestinationState?, - action fromDestinationAction: @escaping (_ destinationAction: DestinationAction) -> Action, - @ViewBuilder content: @escaping ( - _ item: Binding, - _ destination: DestinationContent - ) -> Content - ) { - self.init( - store, - state: toDestinationState, - id: { $0.id }, - action: fromDestinationAction, - content: content - ) - } + public init( + _ store: Store, PresentationAction>, + state toDestinationState: @escaping (_ state: State) -> DestinationState?, + action fromDestinationAction: @escaping (_ destinationAction: DestinationAction) -> Action, + @ViewBuilder content: + @escaping ( + _ isPresented: Binding, + _ destination: DestinationContent + ) -> Content + ) { + self.init( + store, state: toDestinationState, action: fromDestinationAction + ) { $item, destination in + content(Binding($item), destination) + } + } - fileprivate init( - _ store: Store, PresentationAction>, - id toID: @escaping (PresentationState) -> ID?, - content: @escaping ( - _ item: Binding, - _ destination: DestinationContent - ) -> Content - ) where State == DestinationState, Action == DestinationAction { - func open( - _ core: some Core, PresentationAction> - ) -> any Core, PresentationAction> { - PresentationCore(base: core, toDestinationState: { $0 }) + @_disfavoredOverload + public init( + _ store: Store, PresentationAction>, + state toDestinationState: @escaping (_ state: State) -> DestinationState?, + action fromDestinationAction: @escaping (_ destinationAction: DestinationAction) -> Action, + @ViewBuilder content: + @escaping ( + _ item: Binding, + _ destination: DestinationContent + ) -> Content + ) { + self.init( + store, + state: toDestinationState, + id: { $0.id }, + action: fromDestinationAction, + content: content + ) } - let store = store.scope( - id: store.id(state: \.self, action: \.self), - childCore: open(store.core) - ) - let viewStore = ViewStore( - store, - observe: { $0 }, - removeDuplicates: { toID($0) == toID($1) } - ) - self.store = store - self.toDestinationState = { $0 } - self.toID = toID - self.fromDestinationAction = { $0 } - self.destinationStore = store.scope(state: \.wrappedValue, action: \.presented) - self.content = content - self.viewStore = viewStore - } + fileprivate init( + _ store: Store, PresentationAction>, + id toID: @escaping (PresentationState) -> ID?, + content: + @escaping ( + _ item: Binding, + _ destination: DestinationContent + ) -> Content + ) where State == DestinationState, Action == DestinationAction { + func open( + _ core: some Core, PresentationAction> + ) -> any Core, PresentationAction> { + PresentationCore(base: core, toDestinationState: { $0 }) + } + let store = store.scope( + id: store.id(state: \.self, action: \.self), + childCore: open(store.core) + ) + let viewStore = ViewStore( + store, + observe: { $0 }, + removeDuplicates: { toID($0) == toID($1) } + ) - fileprivate init( - _ store: Store, PresentationAction>, - state toDestinationState: @escaping (State) -> DestinationState?, - id toID: @escaping (PresentationState) -> ID?, - action fromDestinationAction: @escaping (DestinationAction) -> Action, - content: @escaping ( - _ item: Binding, - _ destination: DestinationContent - ) -> Content - ) { - func open( - _ core: some Core, PresentationAction> - ) -> any Core, PresentationAction> { - PresentationCore(base: core, toDestinationState: toDestinationState) + self.store = store + self.toDestinationState = { $0 } + self.toID = toID + self.fromDestinationAction = { $0 } + self.destinationStore = store.scope(state: \.wrappedValue, action: \.presented) + self.content = content + self.viewStore = viewStore } - let store = store.scope( - id: store.id(state: \.self, action: \.self), - childCore: open(store.core) - ) - let viewStore = ViewStore(store, observe: { $0 }, removeDuplicates: { toID($0) == toID($1) }) - self.store = store - self.toDestinationState = toDestinationState - self.toID = toID - self.fromDestinationAction = fromDestinationAction - self.destinationStore = store._scope( - state: { $0.wrappedValue.flatMap(toDestinationState) }, - action: { .presented(fromDestinationAction($0)) } - ) - self.content = content - self.viewStore = viewStore - } + fileprivate init( + _ store: Store, PresentationAction>, + state toDestinationState: @escaping (State) -> DestinationState?, + id toID: @escaping (PresentationState) -> ID?, + action fromDestinationAction: @escaping (DestinationAction) -> Action, + content: + @escaping ( + _ item: Binding, + _ destination: DestinationContent + ) -> Content + ) { + func open( + _ core: some Core, PresentationAction> + ) -> any Core, PresentationAction> { + PresentationCore(base: core, toDestinationState: toDestinationState) + } + let store = store.scope( + id: store.id(state: \.self, action: \.self), + childCore: open(store.core) + ) + let viewStore = ViewStore(store, observe: { $0 }, removeDuplicates: { toID($0) == toID($1) }) - public var body: some View { - let id = self.toID(self.viewStore.state) - self.content( - self.viewStore.binding( - get: { - $0.wrappedValue.flatMap(toDestinationState) != nil - ? toID($0).map { AnyIdentifiable(Identified($0) { $0 }) } - : nil - }, - compactSend: { [weak viewStore = self.viewStore] in - guard - let viewStore = viewStore, - $0 == nil, - viewStore.wrappedValue != nil, - id == nil || self.toID(viewStore.state) == id - else { return nil } - return .dismiss - } - ), - DestinationContent(store: self.destinationStore) - ) - } -} + self.store = store + self.toDestinationState = toDestinationState + self.toID = toID + self.fromDestinationAction = fromDestinationAction + self.destinationStore = store._scope( + state: { $0.wrappedValue.flatMap(toDestinationState) }, + action: { .presented(fromDestinationAction($0)) } + ) + self.content = content + self.viewStore = viewStore + } -final class PresentationCore< - Base: Core, PresentationAction>, - State, - Action, - DestinationState ->: Core { - let base: Base - let toDestinationState: (State) -> DestinationState? - init( - base: Base, - toDestinationState: @escaping (State) -> DestinationState? - ) { - self.base = base - self.toDestinationState = toDestinationState - } - var state: Base.State { - base.state + public var body: some View { + let id = self.toID(self.viewStore.state) + self.content( + self.viewStore.binding( + get: { + $0.wrappedValue.flatMap(toDestinationState) != nil + ? toID($0).map { AnyIdentifiable(Identified($0) { $0 }) } + : nil + }, + compactSend: { [weak viewStore = self.viewStore] in + guard + let viewStore = viewStore, + $0 == nil, + viewStore.wrappedValue != nil, + id == nil || self.toID(viewStore.state) == id + else { return nil } + return .dismiss + } + ), + DestinationContent(store: self.destinationStore) + ) + } } - func send(_ action: Base.Action) -> Task? { - base.send(action) + + final class PresentationCore< + Base: Core, PresentationAction>, + State, + Action, + DestinationState + >: Core { + let base: Base + let toDestinationState: (State) -> DestinationState? + init( + base: Base, + toDestinationState: @escaping (State) -> DestinationState? + ) { + self.base = base + self.toDestinationState = toDestinationState + } + var state: Base.State { + base.state + } + func send(_ action: Base.Action) -> Task? { + base.send(action) + } + var canStoreCacheChildren: Bool { base.canStoreCacheChildren } + var didSet: CurrentValueRelay { base.didSet } + var isInvalid: Bool { state.wrappedValue.flatMap(toDestinationState) == nil || base.isInvalid } + var effectCancellables: [UUID: AnyCancellable] { base.effectCancellables } } - var canStoreCacheChildren: Bool { base.canStoreCacheChildren } - var didSet: CurrentValueRelay { base.didSet } - var isInvalid: Bool { state.wrappedValue.flatMap(toDestinationState) == nil || base.isInvalid } - var effectCancellables: [UUID: AnyCancellable] { base.effectCancellables } -} -@_spi(Presentation) -public struct AnyIdentifiable: Identifiable { - public let id: AnyHashable + @_spi(Presentation) + public struct AnyIdentifiable: Identifiable { + public let id: AnyHashable - public init(_ base: Base) { - self.id = base.id + public init(_ base: Base) { + self.id = base.id + } } -} -#if swift(<5.10) - @MainActor(unsafe) -#else - @preconcurrency@MainActor -#endif -@_spi(Presentation) -public struct DestinationContent { - let store: Store + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency @MainActor + #endif + @_spi(Presentation) + public struct DestinationContent { + let store: Store - public func callAsFunction( - @ViewBuilder _ body: @escaping (_ store: Store) -> Content - ) -> some View { - IfLetStore(self.store, then: body) + public func callAsFunction( + @ViewBuilder _ body: @escaping (_ store: Store) -> Content + ) -> some View { + IfLetStore(self.store, then: body) + } } -} +#endif diff --git a/Sources/ComposableArchitecture/SwiftUI/Sheet.swift b/Sources/ComposableArchitecture/SwiftUI/Sheet.swift index ea530f645fbd..612c6005571b 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Sheet.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Sheet.swift @@ -1,108 +1,111 @@ -import SwiftUI +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + import SwiftUI -extension View { - /// Presents a sheet using the given store as a data source for the sheet's content. - /// - /// > This is a Composable Architecture-friendly version of SwiftUI's `sheet` view modifier. - /// - /// - Parameters: - /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for - /// a modal. When `store`'s state is non-`nil`, the system passes a store of unwrapped `State` - /// and `Action` to the modifier's closure. You use this store to power the content in a sheet - /// you create that the system displays to the user. If `store`'s state is `nil`-ed out, the - /// system dismisses the currently displayed sheet. - /// - onDismiss: The closure to execute when dismissing the modal view. - /// - content: A closure returning the content of the modal view. - @available( - iOS, deprecated: 9999, - message: - "Pass a binding of a store to 'sheet(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" - ) - @available( - macOS, deprecated: 9999, - message: - "Pass a binding of a store to 'sheet(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" - ) - @available( - tvOS, deprecated: 9999, - message: - "Pass a binding of a store to 'sheet(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" - ) - @available( - watchOS, deprecated: 9999, - message: - "Pass a binding of a store to 'sheet(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" - ) - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public func sheet( - store: Store, PresentationAction>, - onDismiss: (() -> Void)? = nil, - @ViewBuilder content: @escaping (_ store: Store) -> Content - ) -> some View { - self.presentation(store: store) { `self`, $item, destination in - self.sheet(item: $item, onDismiss: onDismiss) { _ in - destination(content) + extension View { + /// Presents a sheet using the given store as a data source for the sheet's content. + /// + /// > This is a Composable Architecture-friendly version of SwiftUI's `sheet` view modifier. + /// + /// - Parameters: + /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for + /// a modal. When `store`'s state is non-`nil`, the system passes a store of unwrapped `State` + /// and `Action` to the modifier's closure. You use this store to power the content in a sheet + /// you create that the system displays to the user. If `store`'s state is `nil`-ed out, the + /// system dismisses the currently displayed sheet. + /// - onDismiss: The closure to execute when dismissing the modal view. + /// - content: A closure returning the content of the modal view. + @available( + iOS, deprecated: 9999, + message: + "Pass a binding of a store to 'sheet(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + @available( + macOS, deprecated: 9999, + message: + "Pass a binding of a store to 'sheet(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + @available( + tvOS, deprecated: 9999, + message: + "Pass a binding of a store to 'sheet(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + @available( + watchOS, deprecated: 9999, + message: + "Pass a binding of a store to 'sheet(item:)' instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency@MainActor + #endif + public func sheet( + store: Store, PresentationAction>, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping (_ store: Store) -> Content + ) -> some View { + self.presentation(store: store) { `self`, $item, destination in + self.sheet(item: $item, onDismiss: onDismiss) { _ in + destination(content) + } } } - } - /// Presents a sheet using the given store as a data source for the sheet's content. - /// - /// > This is a Composable Architecture-friendly version of SwiftUI's `sheet` view modifier. - /// - /// - Parameters: - /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for - /// a modal. When `store`'s state is non-`nil`, the system passes a store of unwrapped `State` - /// and `Action` to the modifier's closure. You use this store to power the content in a sheet - /// you create that the system displays to the user. If `store`'s state is `nil`-ed out, the - /// system dismisses the currently displayed sheet. - /// - toDestinationState: A transformation to extract modal state from the presentation state. - /// - fromDestinationAction: A transformation to embed modal actions into the presentation - /// action. - /// - onDismiss: The closure to execute when dismissing the modal view. - /// - content: A closure returning the content of the modal view. - @available( - iOS, deprecated: 9999, - message: - "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - @available( - macOS, deprecated: 9999, - message: - "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - @available( - tvOS, deprecated: 9999, - message: - "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - @available( - watchOS, deprecated: 9999, - message: - "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" - ) - #if swift(<5.10) - @MainActor(unsafe) - #else - @preconcurrency@MainActor - #endif - public func sheet( - store: Store, PresentationAction>, - state toDestinationState: @escaping (_ state: State) -> DestinationState?, - action fromDestinationAction: @escaping (_ destinationAction: DestinationAction) -> Action, - onDismiss: (() -> Void)? = nil, - @ViewBuilder content: @escaping (_ store: Store) -> Content - ) -> some View { - self.presentation( - store: store, state: toDestinationState, action: fromDestinationAction - ) { `self`, $item, destination in - self.sheet(item: $item, onDismiss: onDismiss) { _ in - destination(content) + /// Presents a sheet using the given store as a data source for the sheet's content. + /// + /// > This is a Composable Architecture-friendly version of SwiftUI's `sheet` view modifier. + /// + /// - Parameters: + /// - store: A store that is focused on ``PresentationState`` and ``PresentationAction`` for + /// a modal. When `store`'s state is non-`nil`, the system passes a store of unwrapped `State` + /// and `Action` to the modifier's closure. You use this store to power the content in a sheet + /// you create that the system displays to the user. If `store`'s state is `nil`-ed out, the + /// system dismisses the currently displayed sheet. + /// - toDestinationState: A transformation to extract modal state from the presentation state. + /// - fromDestinationAction: A transformation to embed modal actions into the presentation + /// action. + /// - onDismiss: The closure to execute when dismissing the modal view. + /// - content: A closure returning the content of the modal view. + @available( + iOS, deprecated: 9999, + message: + "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + @available( + macOS, deprecated: 9999, + message: + "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + @available( + tvOS, deprecated: 9999, + message: + "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + @available( + watchOS, deprecated: 9999, + message: + "Further scope the store into the 'state' and 'action' cases, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Enum-driven-navigation-APIs" + ) + #if swift(<5.10) + @MainActor(unsafe) + #else + @preconcurrency@MainActor + #endif + public func sheet( + store: Store, PresentationAction>, + state toDestinationState: @escaping (_ state: State) -> DestinationState?, + action fromDestinationAction: @escaping (_ destinationAction: DestinationAction) -> Action, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: + @escaping (_ store: Store) -> Content + ) -> some View { + self.presentation( + store: store, state: toDestinationState, action: fromDestinationAction + ) { `self`, $item, destination in + self.sheet(item: $item, onDismiss: onDismiss) { _ in + destination(content) + } } } } -} +#endif diff --git a/Sources/ComposableArchitecture/SwiftUI/SwitchStore.swift b/Sources/ComposableArchitecture/SwiftUI/SwitchStore.swift index 48f6d86510c3..9dfc9478ad1b 100644 --- a/Sources/ComposableArchitecture/SwiftUI/SwitchStore.swift +++ b/Sources/ComposableArchitecture/SwiftUI/SwitchStore.swift @@ -1,260 +1,260 @@ -@_spi(Reflection) import CasePaths -import SwiftUI - -/// A view that observes when enum state held in a store changes cases, and provides stores to -/// ``CaseLet`` views. -/// -/// An application may model parts of its state with enums. For example, app state may differ if a -/// user is logged-in or not: -/// -/// ```swift -/// @Reducer -/// struct AppFeature { -/// enum State { -/// case loggedIn(LoggedInState) -/// case loggedOut(LoggedOutState) -/// } -/// // ... -/// } -/// ``` -/// -/// In the view layer, a store on this state can switch over each case using a ``SwitchStore`` and -/// a ``CaseLet`` view per case: -/// -/// ```swift -/// struct AppView: View { -/// let store: StoreOf -/// -/// var body: some View { -/// SwitchStore(self.store) { state in -/// switch state { -/// case .loggedIn: -/// CaseLet( -/// /AppFeature.State.loggedIn, action: AppFeature.Action.loggedIn -/// ) { loggedInStore in -/// LoggedInView(store: loggedInStore) -/// } -/// case .loggedOut: -/// CaseLet( -/// /AppFeature.State.loggedOut, action: AppFeature.Action.loggedOut -/// ) { loggedOutStore in -/// LoggedOutView(store: loggedOutStore) -/// } -/// } -/// } -/// } -/// } -/// ``` -/// -/// > Important: The `SwitchStore` view builder is only evaluated when the case of state passed to -/// > it changes. As such, you should not rely on this value for anything other than checking the -/// > current case, _e.g._ by switching on it and routing to an appropriate `CaseLet`. -/// -/// See ``Reducer/ifCaseLet(_:action:then:fileID:filePath:line:column:)-7sg8d`` and -/// ``Scope/init(state:action:child:fileID:filePath:line:column:)-9g44g`` for embedding reducers -/// that operate on each case of an enum in reducers that operate on the entire enum. -@available( - iOS, deprecated: 9999, - message: - "Use 'switch' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]" -) -@available( - macOS, deprecated: 9999, - message: - "Use 'switch' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]" -) -@available( - tvOS, deprecated: 9999, - message: - "Use 'switch' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]" -) -@available( - watchOS, deprecated: 9999, - message: - "Use 'switch' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]" -) -public struct SwitchStore: View { - public let store: Store - public let content: (State) -> Content +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + @_spi(Reflection) import CasePaths + /// A view that observes when enum state held in a store changes cases, and provides stores to + /// ``CaseLet`` views. + /// + /// An application may model parts of its state with enums. For example, app state may differ if a + /// user is logged-in or not: + /// + /// ```swift + /// @Reducer + /// struct AppFeature { + /// enum State { + /// case loggedIn(LoggedInState) + /// case loggedOut(LoggedOutState) + /// } + /// // ... + /// } + /// ``` + /// + /// In the view layer, a store on this state can switch over each case using a ``SwitchStore`` and + /// a ``CaseLet`` view per case: + /// + /// ```swift + /// struct AppView: View { + /// let store: StoreOf + /// + /// var body: some View { + /// SwitchStore(self.store) { state in + /// switch state { + /// case .loggedIn: + /// CaseLet( + /// /AppFeature.State.loggedIn, action: AppFeature.Action.loggedIn + /// ) { loggedInStore in + /// LoggedInView(store: loggedInStore) + /// } + /// case .loggedOut: + /// CaseLet( + /// /AppFeature.State.loggedOut, action: AppFeature.Action.loggedOut + /// ) { loggedOutStore in + /// LoggedOutView(store: loggedOutStore) + /// } + /// } + /// } + /// } + /// } + /// ``` + /// + /// > Important: The `SwitchStore` view builder is only evaluated when the case of state passed to + /// > it changes. As such, you should not rely on this value for anything other than checking the + /// > current case, _e.g._ by switching on it and routing to an appropriate `CaseLet`. + /// + /// See ``Reducer/ifCaseLet(_:action:then:fileID:filePath:line:column:)-7sg8d`` and + /// ``Scope/init(state:action:child:fileID:filePath:line:column:)-9g44g`` for embedding reducers + /// that operate on each case of an enum in reducers that operate on the entire enum. + @available( + iOS, deprecated: 9999, + message: + "Use 'switch' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]" + ) + @available( + macOS, deprecated: 9999, + message: + "Use 'switch' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]" + ) + @available( + tvOS, deprecated: 9999, + message: + "Use 'switch' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]" + ) + @available( + watchOS, deprecated: 9999, + message: + "Use 'switch' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]" + ) + public struct SwitchStore: View { + public let store: Store + public let content: (State) -> Content - public init( - _ store: Store, - @ViewBuilder content: @escaping (_ initialState: State) -> Content - ) { - self.store = store - self.content = content - } + public init( + _ store: Store, + @ViewBuilder content: @escaping (_ initialState: State) -> Content + ) { + self.store = store + self.content = content + } - public var body: some View { - WithViewStore( - self.store, observe: { $0 }, removeDuplicates: { enumTag($0) == enumTag($1) } - ) { viewStore in - self.content(viewStore.state) - .environmentObject(StoreObservableObject(store: self.store)) + public var body: some View { + WithViewStore( + self.store, observe: { $0 }, removeDuplicates: { enumTag($0) == enumTag($1) } + ) { viewStore in + self.content(viewStore.state) + .environmentObject(StoreObservableObject(store: self.store)) + } } } -} -/// A view that handles a specific case of enum state in a ``SwitchStore``. -@available( - iOS, deprecated: 9999, - message: - "Use 'switch' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]" -) -@available( - macOS, deprecated: 9999, - message: - "Use 'switch' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]" -) -@available( - tvOS, deprecated: 9999, - message: - "Use 'switch' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]" -) -@available( - watchOS, deprecated: 9999, - message: - "Use 'switch' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]" -) -public struct CaseLet: View { - public let toCaseState: (EnumState) -> CaseState? - public let fromCaseAction: (CaseAction) -> EnumAction - public let content: (Store) -> Content + /// A view that handles a specific case of enum state in a ``SwitchStore``. + @available( + iOS, deprecated: 9999, + message: + "Use 'switch' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]" + ) + @available( + macOS, deprecated: 9999, + message: + "Use 'switch' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]" + ) + @available( + tvOS, deprecated: 9999, + message: + "Use 'switch' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]" + ) + @available( + watchOS, deprecated: 9999, + message: + "Use 'switch' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]" + ) + public struct CaseLet: View { + public let toCaseState: (EnumState) -> CaseState? + public let fromCaseAction: (CaseAction) -> EnumAction + public let content: (Store) -> Content - private let fileID: StaticString - private let filePath: StaticString - private let line: UInt - private let column: UInt + private let fileID: StaticString + private let filePath: StaticString + private let line: UInt + private let column: UInt - @EnvironmentObject private var store: StoreObservableObject + @EnvironmentObject private var store: StoreObservableObject - /// Initializes a ``CaseLet`` view that computes content depending on if a store of enum state - /// matches a particular case. - /// - /// - Parameters: - /// - toCaseState: A function that can extract a case of switch store state, which can be - /// specified using case path literal syntax, _e.g._ `/State.case`. - /// - fromCaseAction: A function that can embed a case action in a switch store action. - /// - content: A function that is given a store of the given case's state and returns a view - /// that is visible only when the switch store's state matches. - /// - fileID: The fileID. - /// - filePath: The filePath. - /// - line: The line. - /// - column: The column. - public init( - _ toCaseState: @escaping (EnumState) -> CaseState?, - action fromCaseAction: @escaping (CaseAction) -> EnumAction, - @ViewBuilder then content: @escaping (_ store: Store) -> Content, - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) { - self.toCaseState = toCaseState - self.fromCaseAction = fromCaseAction - self.content = content - self.fileID = fileID - self.filePath = filePath - self.line = line - self.column = column - } + /// Initializes a ``CaseLet`` view that computes content depending on if a store of enum state + /// matches a particular case. + /// + /// - Parameters: + /// - toCaseState: A function that can extract a case of switch store state, which can be + /// specified using case path literal syntax, _e.g._ `/State.case`. + /// - fromCaseAction: A function that can embed a case action in a switch store action. + /// - content: A function that is given a store of the given case's state and returns a view + /// that is visible only when the switch store's state matches. + /// - fileID: The fileID. + /// - filePath: The filePath. + /// - line: The line. + /// - column: The column. + public init( + _ toCaseState: @escaping (EnumState) -> CaseState?, + action fromCaseAction: @escaping (CaseAction) -> EnumAction, + @ViewBuilder then content: @escaping (_ store: Store) -> Content, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + self.toCaseState = toCaseState + self.fromCaseAction = fromCaseAction + self.content = content + self.fileID = fileID + self.filePath = filePath + self.line = line + self.column = column + } - public var body: some View { - IfLetStore( - self.store.wrappedValue._scope(state: self.toCaseState, action: self.fromCaseAction), - then: self.content, - else: { - _CaseLetMismatchView( - fileID: self.fileID, - filePath: self.filePath, - line: self.line, - column: self.column - ) - } - ) + public var body: some View { + IfLetStore( + self.store.wrappedValue._scope(state: self.toCaseState, action: self.fromCaseAction), + then: self.content, + else: { + _CaseLetMismatchView( + fileID: self.fileID, + filePath: self.filePath, + line: self.line, + column: self.column + ) + } + ) + } } -} -extension CaseLet where EnumAction == CaseAction { - /// Initializes a ``CaseLet`` view that computes content depending on if a store of enum state - /// matches a particular case. - /// - /// - Parameters: - /// - toCaseState: A function that can extract a case of switch store state, which can be - /// specified using case path literal syntax, _e.g._ `/State.case`. - /// - content: A function that is given a store of the given case's state and returns a view - /// that is visible only when the switch store's state matches. - public init( - state toCaseState: @escaping (EnumState) -> CaseState?, - @ViewBuilder then content: @escaping (_ store: Store) -> Content - ) { - self.init( - toCaseState, - action: { $0 }, - then: content - ) + extension CaseLet where EnumAction == CaseAction { + /// Initializes a ``CaseLet`` view that computes content depending on if a store of enum state + /// matches a particular case. + /// + /// - Parameters: + /// - toCaseState: A function that can extract a case of switch store state, which can be + /// specified using case path literal syntax, _e.g._ `/State.case`. + /// - content: A function that is given a store of the given case's state and returns a view + /// that is visible only when the switch store's state matches. + public init( + state toCaseState: @escaping (EnumState) -> CaseState?, + @ViewBuilder then content: @escaping (_ store: Store) -> Content + ) { + self.init( + toCaseState, + action: { $0 }, + then: content + ) + } } -} -public struct _CaseLetMismatchView: View { - @EnvironmentObject private var store: StoreObservableObject - let fileID: StaticString - let filePath: StaticString - let line: UInt - let column: UInt + public struct _CaseLetMismatchView: View { + @EnvironmentObject private var store: StoreObservableObject + let fileID: StaticString + let filePath: StaticString + let line: UInt + let column: UInt - public var body: some View { - #if DEBUG - let message = """ - Warning: A "CaseLet" at "\(self.fileID):\(self.line)" was encountered when state was set \ - to another case: + public var body: some View { + #if DEBUG + let message = """ + Warning: A "CaseLet" at "\(self.fileID):\(self.line)" was encountered when state was set \ + to another case: - \(debugCaseOutput(self.store.wrappedValue.withState { $0 })) + \(debugCaseOutput(self.store.wrappedValue.withState { $0 })) - This usually happens when there is a mismatch between the case being switched on and the \ - "CaseLet" view being rendered. + This usually happens when there is a mismatch between the case being switched on and the \ + "CaseLet" view being rendered. - For example, if ".screenA" is being switched on, but the "CaseLet" view is pointed to \ - ".screenB": + For example, if ".screenA" is being switched on, but the "CaseLet" view is pointed to \ + ".screenB": - case .screenA: - CaseLet( - /State.screenB, action: Action.screenB - ) { /* ... */ } + case .screenA: + CaseLet( + /State.screenB, action: Action.screenB + ) { /* ... */ } - Look out for typos to ensure that these two cases align. - """ - return VStack(spacing: 17) { - #if os(macOS) - Text("⚠️") - #else - Image(systemName: "exclamationmark.triangle.fill") - .font(.largeTitle) - #endif + Look out for typos to ensure that these two cases align. + """ + return VStack(spacing: 17) { + #if os(macOS) + Text("⚠️") + #else + Image(systemName: "exclamationmark.triangle.fill") + .font(.largeTitle) + #endif - Text(message) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .foregroundColor(.white) - .padding() - .background(Color.red.edgesIgnoringSafeArea(.all)) - .onAppear { - reportIssue(message, fileID: fileID, filePath: filePath, line: line, column: column) - } - #else - return EmptyView() - #endif + Text(message) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .foregroundColor(.white) + .padding() + .background(Color.red.edgesIgnoringSafeArea(.all)) + .onAppear { + reportIssue(message, fileID: fileID, filePath: filePath, line: line, column: column) + } + #else + return EmptyView() + #endif + } } -} -private final class StoreObservableObject: ObservableObject { - let wrappedValue: Store + private final class StoreObservableObject: ObservableObject { + let wrappedValue: Store - init(store: Store) { - self.wrappedValue = store + init(store: Store) { + self.wrappedValue = store + } } -} -private func enumTag(_ `case`: Case) -> UInt32? { - EnumMetadata(Case.self)?.tag(of: `case`) -} + private func enumTag(_ `case`: Case) -> UInt32? { + EnumMetadata(Case.self)?.tag(of: `case`) + } +#endif diff --git a/Sources/ComposableArchitecture/SwiftUI/WithViewStore.swift b/Sources/ComposableArchitecture/SwiftUI/WithViewStore.swift index f62fb26d2a9d..5e8c69971731 100644 --- a/Sources/ComposableArchitecture/SwiftUI/WithViewStore.swift +++ b/Sources/ComposableArchitecture/SwiftUI/WithViewStore.swift @@ -1,616 +1,190 @@ -import CustomDump -import SwiftUI +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + import CustomDump + import SwiftUI -/// A view helper that transforms a ``Store`` into a ``ViewStore`` so that its state can be observed -/// by a view builder. -/// -/// This helper is an alternative to observing the view store manually on your view, which requires -/// the boilerplate of a custom initializer. -/// -/// > Important: It is important to properly leverage the `observe` argument in order to observe -/// only the state that your view needs to do its job. See the "Performance" section below for more -/// information. -/// -/// For example, the following view, which manually observes the store it is handed by constructing -/// a view store in its initializer: -/// -/// ```swift -/// struct ProfileView: View { -/// let store: StoreOf -/// @ObservedObject var viewStore: ViewStoreOf -/// -/// init(store: StoreOf) { -/// self.store = store -/// self.viewStore = ViewStore(store, observe: { $0 }) -/// } -/// -/// var body: some View { -/// Text("\(self.viewStore.username)") -/// // ... -/// } -/// } -/// ``` -/// -/// …can be written more simply using `WithViewStore`: -/// -/// ```swift -/// struct ProfileView: View { -/// let store: StoreOf -/// -/// var body: some View { -/// WithViewStore(self.store, observe: { $0 }) { viewStore in -/// Text("\(viewStore.username)") -/// // ... -/// } -/// } -/// } -/// ``` -/// -/// There may be times where the slightly more verbose style of observing a store is preferred -/// instead of using ``WithViewStore``: -/// -/// 1. When ``WithViewStore`` wraps complex views the Swift compiler can quickly become bogged -/// down, leading to degraded compiler performance and diagnostics. If you are experiencing -/// such instability you should consider manually setting up observation with an -/// `@ObservedObject` property as described above. -/// -/// 2. Sometimes you may want to observe the state in a store in a context that is not a view -/// builder. In such cases ``WithViewStore`` will not work since it is intended only for -/// SwiftUI views. -/// -/// An example of this is interfacing with SwiftUI's `App` protocol, which uses a separate -/// `@SceneBuilder` instead of `@ViewBuilder`. In this case you must use an `@ObservedObject`: -/// -/// ```swift -/// @main -/// struct MyApp: App { -/// let store = StoreOf(/* ... */) -/// @ObservedObject var viewStore: ViewStore -/// -/// struct SceneState: Equatable { -/// // ... -/// init(state: AppFeature.State) { -/// // ... -/// } -/// } -/// -/// init() { -/// self.viewStore = ViewStore( -/// self.store.scope( -/// state: SceneState.init(state:) -/// action: AppFeature.Action.scene -/// ) -/// ) -/// } -/// -/// var body: some Scene { -/// WindowGroup { -/// MyRootView() -/// } -/// .commands { -/// CommandMenu("Help") { -/// Button("About \(self.viewStore.appName)") { -/// self.viewStore.send(.aboutButtonTapped) -/// } -/// } -/// } -/// } -/// } -/// ``` -/// -/// Note that it is highly discouraged for you to observe _all_ of your root store's state. -/// It is almost never needed and will cause many view recomputations leading to poor -/// performance. This is why we construct a separate `SceneState` type that holds onto only the -/// state that the view needs for rendering. See for more information on this -/// topic. -/// -/// If your view does not need access to any state in the store and only needs to be able to send -/// actions, then you should consider not using ``WithViewStore`` at all. Instead, you can send -/// actions directly to a ``Store`` like so: -/// -/// ```swift -/// Button("Tap me") { -/// self.store.send(.buttonTapped) -/// } -/// ``` -/// -/// ## Performance -/// -/// A common performance pitfall when using the library comes from constructing ``ViewStore``s and -/// ``WithViewStore``s. When constructed naively, using either view store's initializer -/// ``ViewStore/init(_:observe:)-3ak1y`` or the SwiftUI helper ``WithViewStore``, it will observe -/// every change to state in the store: -/// -/// ```swift -/// WithViewStore(self.store, observe: { $0 }) { viewStore in -/// // This is executed for every action sent into the system -/// // that causes self.store.state to change. -/// } -/// ``` -/// -/// Most of the time this observes far too much state. A typical feature in the Composable -/// Architecture holds onto not only the state the view needs to present UI, but also state that the -/// feature only needs internally, as well as state of child features embedded in the feature. -/// Changes to the internal and child state should not cause the view's body to re-compute since -/// that state is not needed in the view. -/// -/// For example, if the root of our application was a tab view, then we could model that in state -/// as a struct that holds each tab's state as a property: -/// -/// ```swift -/// @Reducer -/// struct AppFeature { -/// struct State { -/// var activity: Activity.State -/// var search: Search.State -/// var profile: Profile.State -/// } -/// // ... -/// } -/// ``` -/// -/// If the view only needs to construct the views for each tab, then no view store is even needed -/// because we can pass scoped stores to each child feature view: -/// -/// ```swift -/// struct AppView: View { -/// let store: StoreOf -/// -/// var body: some View { -/// // No need to observe state changes because the view does -/// // not need access to the state. -/// TabView { -/// ActivityView( -/// store: self.store -/// .scope(state: \.activity, action: \.activity) -/// ) -/// SearchView( -/// store: self.store -/// .scope(state: \.search, action: \.search) -/// ) -/// ProfileView( -/// store: self.store -/// .scope(state: \.profile, action: \.profile) -/// ) -/// } -/// } -/// } -/// ``` -/// -/// This means `AppView` does not actually need to observe any state changes. This view will only be -/// created a single time, whereas if we observed the store then it would re-compute every time a single -/// thing changed in either the activity, search or profile child features. -/// -/// If sometime in the future we do actually need some state from the store, we can start to observe -/// only the bare essentials of state necessary for the view to do its job. For example, suppose that -/// we need access to the currently selected tab in state: -/// -/// ```swift -/// @Reducer -/// struct AppFeature { -/// enum Tab { case activity, search, profile } -/// struct State { -/// var activity: Activity.State -/// var search: Search.State -/// var profile: Profile.State -/// var selectedTab: Tab -/// } -/// // ... -/// } -/// ``` -/// -/// Then we can observe this state so that we can construct a binding to `selectedTab` for the tab view: -/// -/// ```swift -/// struct AppView: View { -/// let store: StoreOf -/// -/// var body: some View { -/// WithViewStore(self.store, observe: { $0 }) { viewStore in -/// TabView( -/// selection: viewStore.binding(get: \.selectedTab, send: { .tabSelected($0) }) -/// ) { -/// ActivityView( -/// store: self.store.scope(state: \.activity, action: \.activity) -/// ) -/// .tag(AppFeature.Tab.activity) -/// SearchView( -/// store: self.store.scope(state: \.search, action: \.search) -/// ) -/// .tag(AppFeature.Tab.search) -/// ProfileView( -/// store: self.store.scope(state: \.profile, action: \.profile) -/// ) -/// .tag(AppFeature.Tab.profile) -/// } -/// } -/// } -/// } -/// ``` -/// -/// However, this style of state observation is terribly inefficient since _every_ change to -/// `AppFeature.State` will cause the view to re-compute even though the only piece of state we -/// actually care about is the `selectedTab`. The reason we are observing too much state is because -/// we use `observe: { $0 }` in the construction of the ``WithViewStore``, which means the view -/// store will observe all of state. -/// -/// To chisel away at the observed state you can provide a closure for that argument that plucks out -/// the state the view needs. In this case the view only needs a single field: -/// -/// ```swift -/// WithViewStore(self.store, observe: \.selectedTab) { viewStore in -/// TabView(selection: viewStore.binding(send: { .tabSelected($0) }) { -/// // ... -/// } -/// } -/// ``` -/// -/// In the future, the view may need access to more state. For example, suppose `Activity.State` -/// holds onto an `unreadCount` integer to represent how many new activities you have. There's no -/// need to observe _all_ of `Activity.State` to get access to this one field. You can observe just -/// the one field. -/// -/// Technically you can do this by mapping your state into a tuple, but because tuples are not -/// `Equatable` you will need to provide an explicit `removeDuplicates` argument: -/// -/// ```swift -/// WithViewStore( -/// self.store, -/// observe: { (selectedTab: $0.selectedTab, unreadActivityCount: $0.activity.unreadCount) }, -/// removeDuplicates: == -/// ) { viewStore in -/// TabView(selection: viewStore.binding(get: \.selectedTab, send: { .tabSelected($0) }) { -/// ActivityView( -/// store: self.store.scope(state: \.activity, action: \.activity) -/// ) -/// .tag(AppFeature.Tab.activity) -/// .badge("\(viewStore.unreadActivityCount)") -/// -/// // ... -/// } -/// } -/// ``` -/// -/// Alternatively, and recommended, you can introduce a lightweight, equatable `ViewState` struct -/// nested inside your view whose purpose is to transform the `Store`'s full state into the bare -/// essentials of what the view needs: -/// -/// ```swift -/// struct AppView: View { -/// let store: StoreOf -/// -/// struct ViewState: Equatable { -/// let selectedTab: AppFeature.Tab -/// let unreadActivityCount: Int -/// init(state: AppFeature.State) { -/// self.selectedTab = state.selectedTab -/// self.unreadActivityCount = state.activity.unreadCount -/// } -/// } -/// -/// var body: some View { -/// WithViewStore(self.store, observe: ViewState.init) { viewStore in -/// TabView { -/// ActivityView( -/// store: self.store -/// .scope(state: \.activity, action: \.activity) -/// ) -/// .badge("\(viewStore.unreadActivityCount)") -/// -/// // ... -/// } -/// } -/// } -/// } -/// ``` -/// -/// This gives you maximum flexibility in the future for adding new fields to `ViewState` without -/// making your view convoluted. -/// -/// This technique for reducing view re-computations is most effective towards the root of your app -/// hierarchy and least effective towards the leaf nodes of your app. Root features tend to hold -/// lots of state that its view does not need, such as child features, and leaf features tend to -/// only hold what's necessary. If you are going to employ this technique you will get the most -/// benefit by applying it to views closer to the root. At leaf features and views that need access -/// to most of the state, it is fine to continue using `observe: { $0 }` to observe all of the state -/// in the store. -@available( - iOS, - deprecated: 9999, - message: - "Use '@ObservableState', instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Using-ObservableState" -) -@available( - macOS, - deprecated: 9999, - message: - "Use '@ObservableState', instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Using-ObservableState" -) -@available( - tvOS, - deprecated: 9999, - message: - "Use '@ObservableState', instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Using-ObservableState" -) -@available( - watchOS, - deprecated: 9999, - message: - "Use '@ObservableState', instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Using-ObservableState" -) -public struct WithViewStore: View { - private let content: (ViewStore) -> Content - #if DEBUG - private let file: StaticString - private let line: UInt - private var prefix: String? - private var previousState: (ViewState) -> ViewState? - private var storeTypeName: String - #endif - @ObservedObject private var viewStore: ViewStore - - init( - store: Store, - removeDuplicates isDuplicate: @escaping (ViewState, ViewState) -> Bool, - content: @escaping (ViewStore) -> Content, - file: StaticString = #fileID, - line: UInt = #line - ) { - self.content = content - #if DEBUG - self.file = file - self.line = line - var previousState: ViewState? = nil - self.previousState = { currentState in - defer { previousState = currentState } - return previousState - } - self.storeTypeName = ComposableArchitecture.storeTypeName(of: store) - #endif - self.viewStore = ViewStore(store, observe: { $0 }, removeDuplicates: isDuplicate) - } - - /// Prints debug information to the console whenever the view is computed. + /// A view helper that transforms a ``Store`` into a ``ViewStore`` so that its state can be observed + /// by a view builder. /// - /// - Parameter prefix: A string with which to prefix all debug messages. - /// - Returns: A structure that prints debug messages for all computations. - @_documentation(visibility: public) - public func _printChanges(_ prefix: String = "") -> Self { - var view = self - #if DEBUG - view.prefix = prefix - #endif - return view - } - - public var body: Content { - #if DEBUG - Logger.shared.log("WithView\(storeTypeName).body") - if let prefix = self.prefix { - var stateDump = "" - customDump(self.viewStore.state, to: &stateDump, indent: 2) - let difference = - self.previousState(self.viewStore.state) - .map { - diff($0, self.viewStore.state).map { "(Changed state)\n\($0)" } - ?? "(No difference in state detected)" - } - ?? "(Initial state)\n\(stateDump)" - print( - """ - \(prefix.isEmpty ? "" : "\(prefix): ")\ - WithViewStore<\(typeName(ViewState.self)), \(typeName(ViewAction.self)), _>\ - @\(self.file):\(self.line) \(difference) - """ - ) - } - #endif - return self.content(ViewStore(self.viewStore)) - } - - /// Initializes a structure that transforms a ``Store`` into an observable ``ViewStore`` in order - /// to compute views from state. + /// This helper is an alternative to observing the view store manually on your view, which requires + /// the boilerplate of a custom initializer. /// - /// ``WithViewStore`` will re-compute its body for _any_ change to the state it holds. Often the - /// ``Store`` that we want to observe holds onto a lot more state than is necessary to render a - /// view. It may hold onto the state of child features, or internal state for its logic. + /// > Important: It is important to properly leverage the `observe` argument in order to observe + /// only the state that your view needs to do its job. See the "Performance" section below for more + /// information. /// - /// It can be important to transform the ``Store``'s state into something smaller for observation. - /// This will help minimize the number of times your view re-computes its body, and can even avoid - /// certain SwiftUI bugs that happen due to over-rendering. + /// For example, the following view, which manually observes the store it is handed by constructing + /// a view store in its initializer: /// - /// The way to do this is to use the `observe` argument of this initializer. It allows you to - /// turn the full state into a smaller data type, and only changes to that data type will trigger - /// a body re-computation. + /// ```swift + /// struct ProfileView: View { + /// let store: StoreOf + /// @ObservedObject var viewStore: ViewStoreOf /// - /// For example, if your application uses a tab view, then the root state may hold the state - /// for each tab as well as the currently selected tab: + /// init(store: StoreOf) { + /// self.store = store + /// self.viewStore = ViewStore(store, observe: { $0 }) + /// } /// - /// ```swift - /// @Reducer - /// struct AppFeature { - /// enum Tab { case activity, search, profile } - /// struct State { - /// var activity: Activity.State - /// var search: Search.State - /// var profile: Profile.State - /// var selectedTab: Tab + /// var body: some View { + /// Text("\(self.viewStore.username)") + /// // ... /// } - /// // ... /// } /// ``` /// - /// In order to construct a tab view you need to observe this state because changes to - /// `selectedTab` need to make SwiftUI update the visual state of the UI. However, you do not - /// need to observe changes to `activity`, `search` and `profile`. Those are only necessary for - /// those child features, and changes to that state should not cause our tab view to re-compute - /// itself. + /// …can be written more simply using `WithViewStore`: /// /// ```swift - /// struct AppView: View { - /// let store: StoreOf + /// struct ProfileView: View { + /// let store: StoreOf /// /// var body: some View { - /// WithViewStore(self.store, observe: \.selectedTab) { viewStore in - /// TabView(selection: viewStore.binding(send: { .tabSelected($0) }) { - /// ActivityView( - /// store: self.store.scope(state: \.activity, action: \.activity) - /// ) - /// .tag(AppFeature.Tab.activity) - /// SearchView( - /// store: self.store.scope(state: \.search, action: \.search) - /// ) - /// .tag(AppFeature.Tab.search) - /// ProfileView( - /// store: self.store.scope(state: \.profile, action: \.profile) - /// ) - /// .tag(AppFeature.Tab.profile) - /// } + /// WithViewStore(self.store, observe: { $0 }) { viewStore in + /// Text("\(viewStore.username)") + /// // ... /// } /// } /// } /// ``` /// - /// To read more about this performance technique, read the article. - /// - /// - Parameters: - /// - store: A store. - /// - toViewState: A function that transforms store state into observable view state. All - /// changes to the view state will cause the `WithViewStore` to re-compute its view. - /// - fromViewAction: A function that transforms view actions into store action. - /// - isDuplicate: A function to determine when two `ViewState` values are equal. When values - /// are equal, repeat view computations are removed. - /// - content: A function that can generate content from a view store. - /// - file: The file. - /// - line: The line. - public init( - _ store: Store, - observe toViewState: @escaping (_ state: State) -> ViewState, - send fromViewAction: @escaping (_ viewAction: ViewAction) -> Action, - removeDuplicates isDuplicate: @escaping (_ lhs: ViewState, _ rhs: ViewState) -> Bool, - @ViewBuilder content: @escaping (_ viewStore: ViewStore) -> Content, - file: StaticString = #fileID, - line: UInt = #line - ) { - self.init( - store: store._scope(state: toViewState, action: fromViewAction), - removeDuplicates: isDuplicate, - content: content, - file: file, - line: line - ) - } - - /// Initializes a structure that transforms a ``Store`` into an observable ``ViewStore`` in order - /// to compute views from state. + /// There may be times where the slightly more verbose style of observing a store is preferred + /// instead of using ``WithViewStore``: + /// + /// 1. When ``WithViewStore`` wraps complex views the Swift compiler can quickly become bogged + /// down, leading to degraded compiler performance and diagnostics. If you are experiencing + /// such instability you should consider manually setting up observation with an + /// `@ObservedObject` property as described above. + /// + /// 2. Sometimes you may want to observe the state in a store in a context that is not a view + /// builder. In such cases ``WithViewStore`` will not work since it is intended only for + /// SwiftUI views. + /// + /// An example of this is interfacing with SwiftUI's `App` protocol, which uses a separate + /// `@SceneBuilder` instead of `@ViewBuilder`. In this case you must use an `@ObservedObject`: + /// + /// ```swift + /// @main + /// struct MyApp: App { + /// let store = StoreOf(/* ... */) + /// @ObservedObject var viewStore: ViewStore + /// + /// struct SceneState: Equatable { + /// // ... + /// init(state: AppFeature.State) { + /// // ... + /// } + /// } + /// + /// init() { + /// self.viewStore = ViewStore( + /// self.store.scope( + /// state: SceneState.init(state:) + /// action: AppFeature.Action.scene + /// ) + /// ) + /// } + /// + /// var body: some Scene { + /// WindowGroup { + /// MyRootView() + /// } + /// .commands { + /// CommandMenu("Help") { + /// Button("About \(self.viewStore.appName)") { + /// self.viewStore.send(.aboutButtonTapped) + /// } + /// } + /// } + /// } + /// } + /// ``` + /// + /// Note that it is highly discouraged for you to observe _all_ of your root store's state. + /// It is almost never needed and will cause many view recomputations leading to poor + /// performance. This is why we construct a separate `SceneState` type that holds onto only the + /// state that the view needs for rendering. See for more information on this + /// topic. + /// + /// If your view does not need access to any state in the store and only needs to be able to send + /// actions, then you should consider not using ``WithViewStore`` at all. Instead, you can send + /// actions directly to a ``Store`` like so: /// - /// ``WithViewStore`` will re-compute its body for _any_ change to the state it holds. Often the - /// ``Store`` that we want to observe holds onto a lot more state than is necessary to render a - /// view. It may hold onto the state of child features, or internal state for its logic. + /// ```swift + /// Button("Tap me") { + /// self.store.send(.buttonTapped) + /// } + /// ``` + /// + /// ## Performance + /// + /// A common performance pitfall when using the library comes from constructing ``ViewStore``s and + /// ``WithViewStore``s. When constructed naively, using either view store's initializer + /// ``ViewStore/init(_:observe:)-3ak1y`` or the SwiftUI helper ``WithViewStore``, it will observe + /// every change to state in the store: /// - /// It can be important to transform the ``Store``'s state into something smaller for observation. - /// This will help minimize the number of times your view re-computes its body, and can even avoid - /// certain SwiftUI bugs that happen due to over-rendering. + /// ```swift + /// WithViewStore(self.store, observe: { $0 }) { viewStore in + /// // This is executed for every action sent into the system + /// // that causes self.store.state to change. + /// } + /// ``` /// - /// The way to do this is to use the `observe` argument of this initializer. It allows you to - /// turn the full state into a smaller data type, and only changes to that data type will trigger - /// a body re-computation. + /// Most of the time this observes far too much state. A typical feature in the Composable + /// Architecture holds onto not only the state the view needs to present UI, but also state that the + /// feature only needs internally, as well as state of child features embedded in the feature. + /// Changes to the internal and child state should not cause the view's body to re-compute since + /// that state is not needed in the view. /// - /// For example, if your application uses a tab view, then the root state may hold the state - /// for each tab as well as the currently selected tab: + /// For example, if the root of our application was a tab view, then we could model that in state + /// as a struct that holds each tab's state as a property: /// /// ```swift /// @Reducer /// struct AppFeature { - /// enum Tab { case activity, search, profile } /// struct State { /// var activity: Activity.State /// var search: Search.State /// var profile: Profile.State - /// var selectedTab: Tab /// } /// // ... /// } /// ``` /// - /// In order to construct a tab view you need to observe this state because changes to - /// `selectedTab` need to make SwiftUI update the visual state of the UI. However, you do not - /// need to observe changes to `activity`, `search` and `profile`. Those are only necessary for - /// those child features, and changes to that state should not cause our tab view to re-compute - /// itself. + /// If the view only needs to construct the views for each tab, then no view store is even needed + /// because we can pass scoped stores to each child feature view: /// /// ```swift /// struct AppView: View { /// let store: StoreOf /// /// var body: some View { - /// WithViewStore(self.store, observe: \.selectedTab) { viewStore in - /// TabView(selection: viewStore.binding(send: { .tabSelected($0) }) { - /// ActivityView( - /// store: self.store.scope(state: \.activity, action: \.activity) - /// ) - /// .tag(AppFeature.Tab.activity) - /// SearchView( - /// store: self.store.scope(state: \.search, action: \.search) - /// ) - /// .tag(AppFeature.Tab.search) - /// ProfileView( - /// store: self.store.scope(state: \.profile, action: \.profile) - /// ) - /// .tag(AppFeature.Tab.profile) - /// } + /// // No need to observe state changes because the view does + /// // not need access to the state. + /// TabView { + /// ActivityView( + /// store: self.store + /// .scope(state: \.activity, action: \.activity) + /// ) + /// SearchView( + /// store: self.store + /// .scope(state: \.search, action: \.search) + /// ) + /// ProfileView( + /// store: self.store + /// .scope(state: \.profile, action: \.profile) + /// ) /// } /// } /// } /// ``` /// - /// To read more about this performance technique, read the article. - /// - /// - Parameters: - /// - store: A store. - /// - toViewState: A function that transforms store state into observable view state. All - /// changes to the view state will cause the `WithViewStore` to re-compute its view. - /// - isDuplicate: A function to determine when two `ViewState` values are equal. When values - /// are equal, repeat view computations are removed. - /// - content: A function that can generate content from a view store. - /// - file: The file. - /// - line: The line. - public init( - _ store: Store, - observe toViewState: @escaping (_ state: State) -> ViewState, - removeDuplicates isDuplicate: @escaping (_ lhs: ViewState, _ rhs: ViewState) -> Bool, - @ViewBuilder content: @escaping (_ viewStore: ViewStore) -> Content, - file: StaticString = #fileID, - line: UInt = #line - ) { - self.init( - store: store._scope(state: toViewState, action: { $0 }), - removeDuplicates: isDuplicate, - content: content, - file: file, - line: line - ) - } -} - -extension WithViewStore where ViewState: Equatable, Content: View { - /// Initializes a structure that transforms a ``Store`` into an observable ``ViewStore`` in order - /// to compute views from state. - /// - /// ``WithViewStore`` will re-compute its body for _any_ change to the state it holds. Often the - /// ``Store`` that we want to observe holds onto a lot more state than is necessary to render a - /// view. It may hold onto the state of child features, or internal state for its logic. - /// - /// It can be important to transform the ``Store``'s state into something smaller for observation. - /// This will help minimize the number of times your view re-computes its body, and can even avoid - /// certain SwiftUI bugs that happen due to over-rendering. - /// - /// The way to do this is to use the `observe` argument of this initializer. It allows you to - /// turn the full state into a smaller data type, and only changes to that data type will trigger - /// a body re-computation. + /// This means `AppView` does not actually need to observe any state changes. This view will only be + /// created a single time, whereas if we observed the store then it would re-compute every time a single + /// thing changed in either the activity, search or profile child features. /// - /// For example, if your application uses a tab view, then the root state may hold the state - /// for each tab as well as the currently selected tab: + /// If sometime in the future we do actually need some state from the store, we can start to observe + /// only the bare essentials of state necessary for the view to do its job. For example, suppose that + /// we need access to the currently selected tab in state: /// /// ```swift /// @Reducer @@ -626,19 +200,17 @@ extension WithViewStore where ViewState: Equatable, Content: View { /// } /// ``` /// - /// In order to construct a tab view you need to observe this state because changes to - /// `selectedTab` need to make SwiftUI update the visual state of the UI. However, you do not - /// need to observe changes to `activity`, `search` and `profile`. Those are only necessary for - /// those child features, and changes to that state should not cause our tab view to re-compute - /// itself. + /// Then we can observe this state so that we can construct a binding to `selectedTab` for the tab view: /// /// ```swift /// struct AppView: View { /// let store: StoreOf /// /// var body: some View { - /// WithViewStore(self.store, observe: \.selectedTab) { viewStore in - /// TabView(selection: viewStore.binding(send: { .tabSelected($0) }) { + /// WithViewStore(self.store, observe: { $0 }) { viewStore in + /// TabView( + /// selection: viewStore.binding(get: \.selectedTab, send: { .tabSelected($0) }) + /// ) { /// ActivityView( /// store: self.store.scope(state: \.activity, action: \.activity) /// ) @@ -657,142 +229,572 @@ extension WithViewStore where ViewState: Equatable, Content: View { /// } /// ``` /// - /// To read more about this performance technique, read the article. - /// - /// - Parameters: - /// - store: A store. - /// - toViewState: A function that transforms store state into observable view state. All - /// changes to the view state will cause the `WithViewStore` to re-compute its view. - /// - fromViewAction: A function that transforms view actions into store action. - /// - content: A function that can generate content from a view store. - /// - file: The file. - /// - line: The line. - public init( - _ store: Store, - observe toViewState: @escaping (_ state: State) -> ViewState, - send fromViewAction: @escaping (_ viewAction: ViewAction) -> Action, - @ViewBuilder content: @escaping (_ viewStore: ViewStore) -> Content, - file: StaticString = #fileID, - line: UInt = #line - ) { - self.init( - store: store._scope(state: toViewState, action: fromViewAction), - removeDuplicates: ==, - content: content, - file: file, - line: line - ) - } - - /// Initializes a structure that transforms a ``Store`` into an observable ``ViewStore`` in order - /// to compute views from state. + /// However, this style of state observation is terribly inefficient since _every_ change to + /// `AppFeature.State` will cause the view to re-compute even though the only piece of state we + /// actually care about is the `selectedTab`. The reason we are observing too much state is because + /// we use `observe: { $0 }` in the construction of the ``WithViewStore``, which means the view + /// store will observe all of state. /// - /// ``WithViewStore`` will re-compute its body for _any_ change to the state it holds. Often the - /// ``Store`` that we want to observe holds onto a lot more state than is necessary to render a - /// view. It may hold onto the state of child features, or internal state for its logic. + /// To chisel away at the observed state you can provide a closure for that argument that plucks out + /// the state the view needs. In this case the view only needs a single field: /// - /// It can be important to transform the ``Store``'s state into something smaller for observation. - /// This will help minimize the number of times your view re-computes its body, and can even avoid - /// certain SwiftUI bugs that happen due to over-rendering. + /// ```swift + /// WithViewStore(self.store, observe: \.selectedTab) { viewStore in + /// TabView(selection: viewStore.binding(send: { .tabSelected($0) }) { + /// // ... + /// } + /// } + /// ``` /// - /// The way to do this is to use the `observe` argument of this initializer. It allows you to - /// turn the full state into a smaller data type, and only changes to that data type will trigger - /// a body re-computation. + /// In the future, the view may need access to more state. For example, suppose `Activity.State` + /// holds onto an `unreadCount` integer to represent how many new activities you have. There's no + /// need to observe _all_ of `Activity.State` to get access to this one field. You can observe just + /// the one field. /// - /// For example, if your application uses a tab view, then the root state may hold the state - /// for each tab as well as the currently selected tab: + /// Technically you can do this by mapping your state into a tuple, but because tuples are not + /// `Equatable` you will need to provide an explicit `removeDuplicates` argument: /// /// ```swift - /// @Reducer - /// struct AppFeature { - /// enum Tab { case activity, search, profile } - /// struct State { - /// var activity: Activity.State - /// var search: Search.State - /// var profile: Profile.State - /// var selectedTab: Tab + /// WithViewStore( + /// self.store, + /// observe: { (selectedTab: $0.selectedTab, unreadActivityCount: $0.activity.unreadCount) }, + /// removeDuplicates: == + /// ) { viewStore in + /// TabView(selection: viewStore.binding(get: \.selectedTab, send: { .tabSelected($0) }) { + /// ActivityView( + /// store: self.store.scope(state: \.activity, action: \.activity) + /// ) + /// .tag(AppFeature.Tab.activity) + /// .badge("\(viewStore.unreadActivityCount)") + /// + /// // ... /// } - /// // ... /// } /// ``` /// - /// In order to construct a tab view you need to observe this state because changes to - /// `selectedTab` need to make SwiftUI update the visual state of the UI. However, you do not - /// need to observe changes to `activity`, `search` and `profile`. Those are only necessary for - /// those child features, and changes to that state should not cause our tab view to re-compute - /// itself. + /// Alternatively, and recommended, you can introduce a lightweight, equatable `ViewState` struct + /// nested inside your view whose purpose is to transform the `Store`'s full state into the bare + /// essentials of what the view needs: /// /// ```swift /// struct AppView: View { /// let store: StoreOf /// + /// struct ViewState: Equatable { + /// let selectedTab: AppFeature.Tab + /// let unreadActivityCount: Int + /// init(state: AppFeature.State) { + /// self.selectedTab = state.selectedTab + /// self.unreadActivityCount = state.activity.unreadCount + /// } + /// } + /// /// var body: some View { - /// WithViewStore(self.store, observe: \.selectedTab) { viewStore in - /// TabView(selection: viewStore.binding(send: { .tabSelected($0) }) { + /// WithViewStore(self.store, observe: ViewState.init) { viewStore in + /// TabView { /// ActivityView( - /// store: self.store.scope(state: \.activity, action: \.activity) + /// store: self.store + /// .scope(state: \.activity, action: \.activity) /// ) - /// .tag(AppFeature.Tab.activity) - /// SearchView( - /// store: self.store.scope(state: \.search, action: \.search) - /// ) - /// .tag(AppFeature.Tab.search) - /// ProfileView( - /// store: self.store.scope(state: \.profile, action: \.profile) - /// ) - /// .tag(AppFeature.Tab.profile) + /// .badge("\(viewStore.unreadActivityCount)") + /// + /// // ... /// } /// } /// } /// } /// ``` /// - /// To read more about this performance technique, read the article. - /// - /// - Parameters: - /// - store: A store. - /// - toViewState: A function that transforms store state into observable view state. All - /// changes to the view state will cause the `WithViewStore` to re-compute its view. - /// - content: A function that can generate content from a view store. - /// - file: The file. - /// - line: The line. - public init( - _ store: Store, - observe toViewState: @escaping (_ state: State) -> ViewState, - @ViewBuilder content: @escaping (_ viewStore: ViewStore) -> Content, - file: StaticString = #fileID, - line: UInt = #line - ) { - self.init( - store: store._scope(state: toViewState, action: { $0 }), - removeDuplicates: ==, - content: content, - file: file, - line: line - ) - } -} + /// This gives you maximum flexibility in the future for adding new fields to `ViewState` without + /// making your view convoluted. + /// + /// This technique for reducing view re-computations is most effective towards the root of your app + /// hierarchy and least effective towards the leaf nodes of your app. Root features tend to hold + /// lots of state that its view does not need, such as child features, and leaf features tend to + /// only hold what's necessary. If you are going to employ this technique you will get the most + /// benefit by applying it to views closer to the root. At leaf features and views that need access + /// to most of the state, it is fine to continue using `observe: { $0 }` to observe all of the state + /// in the store. + @available( + iOS, + deprecated: 9999, + message: + "Use '@ObservableState', instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Using-ObservableState" + ) + @available( + macOS, + deprecated: 9999, + message: + "Use '@ObservableState', instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Using-ObservableState" + ) + @available( + tvOS, + deprecated: 9999, + message: + "Use '@ObservableState', instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Using-ObservableState" + ) + @available( + watchOS, + deprecated: 9999, + message: + "Use '@ObservableState', instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Using-ObservableState" + ) + public struct WithViewStore: View { + private let content: (ViewStore) -> Content + #if DEBUG + private let file: StaticString + private let line: UInt + private var prefix: String? + private var previousState: (ViewState) -> ViewState? + private var storeTypeName: String + #endif + @ObservedObject private var viewStore: ViewStore + + init( + store: Store, + removeDuplicates isDuplicate: @escaping (ViewState, ViewState) -> Bool, + content: @escaping (ViewStore) -> Content, + file: StaticString = #fileID, + line: UInt = #line + ) { + self.content = content + #if DEBUG + self.file = file + self.line = line + var previousState: ViewState? = nil + self.previousState = { currentState in + defer { previousState = currentState } + return previousState + } + self.storeTypeName = ComposableArchitecture.storeTypeName(of: store) + #endif + self.viewStore = ViewStore(store, observe: { $0 }, removeDuplicates: isDuplicate) + } -#if compiler(>=6) - extension WithViewStore: @preconcurrency DynamicViewContent - where - ViewState: Collection, - Content: DynamicViewContent - { - public typealias Data = ViewState - public var data: ViewState { - self.viewStore.state + /// Prints debug information to the console whenever the view is computed. + /// + /// - Parameter prefix: A string with which to prefix all debug messages. + /// - Returns: A structure that prints debug messages for all computations. + @_documentation(visibility: public) + public func _printChanges(_ prefix: String = "") -> Self { + var view = self + #if DEBUG + view.prefix = prefix + #endif + return view + } + + public var body: Content { + #if DEBUG + Logger.shared.log("WithView\(storeTypeName).body") + if let prefix = self.prefix { + var stateDump = "" + customDump(self.viewStore.state, to: &stateDump, indent: 2) + let difference = + self.previousState(self.viewStore.state) + .map { + diff($0, self.viewStore.state).map { "(Changed state)\n\($0)" } + ?? "(No difference in state detected)" + } + ?? "(Initial state)\n\(stateDump)" + print( + """ + \(prefix.isEmpty ? "" : "\(prefix): ")\ + WithViewStore<\(typeName(ViewState.self)), \(typeName(ViewAction.self)), _>\ + @\(self.file):\(self.line) \(difference) + """ + ) + } + #endif + return self.content(ViewStore(self.viewStore)) + } + + /// Initializes a structure that transforms a ``Store`` into an observable ``ViewStore`` in order + /// to compute views from state. + /// + /// ``WithViewStore`` will re-compute its body for _any_ change to the state it holds. Often the + /// ``Store`` that we want to observe holds onto a lot more state than is necessary to render a + /// view. It may hold onto the state of child features, or internal state for its logic. + /// + /// It can be important to transform the ``Store``'s state into something smaller for observation. + /// This will help minimize the number of times your view re-computes its body, and can even avoid + /// certain SwiftUI bugs that happen due to over-rendering. + /// + /// The way to do this is to use the `observe` argument of this initializer. It allows you to + /// turn the full state into a smaller data type, and only changes to that data type will trigger + /// a body re-computation. + /// + /// For example, if your application uses a tab view, then the root state may hold the state + /// for each tab as well as the currently selected tab: + /// + /// ```swift + /// @Reducer + /// struct AppFeature { + /// enum Tab { case activity, search, profile } + /// struct State { + /// var activity: Activity.State + /// var search: Search.State + /// var profile: Profile.State + /// var selectedTab: Tab + /// } + /// // ... + /// } + /// ``` + /// + /// In order to construct a tab view you need to observe this state because changes to + /// `selectedTab` need to make SwiftUI update the visual state of the UI. However, you do not + /// need to observe changes to `activity`, `search` and `profile`. Those are only necessary for + /// those child features, and changes to that state should not cause our tab view to re-compute + /// itself. + /// + /// ```swift + /// struct AppView: View { + /// let store: StoreOf + /// + /// var body: some View { + /// WithViewStore(self.store, observe: \.selectedTab) { viewStore in + /// TabView(selection: viewStore.binding(send: { .tabSelected($0) }) { + /// ActivityView( + /// store: self.store.scope(state: \.activity, action: \.activity) + /// ) + /// .tag(AppFeature.Tab.activity) + /// SearchView( + /// store: self.store.scope(state: \.search, action: \.search) + /// ) + /// .tag(AppFeature.Tab.search) + /// ProfileView( + /// store: self.store.scope(state: \.profile, action: \.profile) + /// ) + /// .tag(AppFeature.Tab.profile) + /// } + /// } + /// } + /// } + /// ``` + /// + /// To read more about this performance technique, read the article. + /// + /// - Parameters: + /// - store: A store. + /// - toViewState: A function that transforms store state into observable view state. All + /// changes to the view state will cause the `WithViewStore` to re-compute its view. + /// - fromViewAction: A function that transforms view actions into store action. + /// - isDuplicate: A function to determine when two `ViewState` values are equal. When values + /// are equal, repeat view computations are removed. + /// - content: A function that can generate content from a view store. + /// - file: The file. + /// - line: The line. + public init( + _ store: Store, + observe toViewState: @escaping (_ state: State) -> ViewState, + send fromViewAction: @escaping (_ viewAction: ViewAction) -> Action, + removeDuplicates isDuplicate: @escaping (_ lhs: ViewState, _ rhs: ViewState) -> Bool, + @ViewBuilder content: @escaping (_ viewStore: ViewStore) -> Content, + file: StaticString = #fileID, + line: UInt = #line + ) { + self.init( + store: store._scope(state: toViewState, action: fromViewAction), + removeDuplicates: isDuplicate, + content: content, + file: file, + line: line + ) + } + + /// Initializes a structure that transforms a ``Store`` into an observable ``ViewStore`` in order + /// to compute views from state. + /// + /// ``WithViewStore`` will re-compute its body for _any_ change to the state it holds. Often the + /// ``Store`` that we want to observe holds onto a lot more state than is necessary to render a + /// view. It may hold onto the state of child features, or internal state for its logic. + /// + /// It can be important to transform the ``Store``'s state into something smaller for observation. + /// This will help minimize the number of times your view re-computes its body, and can even avoid + /// certain SwiftUI bugs that happen due to over-rendering. + /// + /// The way to do this is to use the `observe` argument of this initializer. It allows you to + /// turn the full state into a smaller data type, and only changes to that data type will trigger + /// a body re-computation. + /// + /// For example, if your application uses a tab view, then the root state may hold the state + /// for each tab as well as the currently selected tab: + /// + /// ```swift + /// @Reducer + /// struct AppFeature { + /// enum Tab { case activity, search, profile } + /// struct State { + /// var activity: Activity.State + /// var search: Search.State + /// var profile: Profile.State + /// var selectedTab: Tab + /// } + /// // ... + /// } + /// ``` + /// + /// In order to construct a tab view you need to observe this state because changes to + /// `selectedTab` need to make SwiftUI update the visual state of the UI. However, you do not + /// need to observe changes to `activity`, `search` and `profile`. Those are only necessary for + /// those child features, and changes to that state should not cause our tab view to re-compute + /// itself. + /// + /// ```swift + /// struct AppView: View { + /// let store: StoreOf + /// + /// var body: some View { + /// WithViewStore(self.store, observe: \.selectedTab) { viewStore in + /// TabView(selection: viewStore.binding(send: { .tabSelected($0) }) { + /// ActivityView( + /// store: self.store.scope(state: \.activity, action: \.activity) + /// ) + /// .tag(AppFeature.Tab.activity) + /// SearchView( + /// store: self.store.scope(state: \.search, action: \.search) + /// ) + /// .tag(AppFeature.Tab.search) + /// ProfileView( + /// store: self.store.scope(state: \.profile, action: \.profile) + /// ) + /// .tag(AppFeature.Tab.profile) + /// } + /// } + /// } + /// } + /// ``` + /// + /// To read more about this performance technique, read the article. + /// + /// - Parameters: + /// - store: A store. + /// - toViewState: A function that transforms store state into observable view state. All + /// changes to the view state will cause the `WithViewStore` to re-compute its view. + /// - isDuplicate: A function to determine when two `ViewState` values are equal. When values + /// are equal, repeat view computations are removed. + /// - content: A function that can generate content from a view store. + /// - file: The file. + /// - line: The line. + public init( + _ store: Store, + observe toViewState: @escaping (_ state: State) -> ViewState, + removeDuplicates isDuplicate: @escaping (_ lhs: ViewState, _ rhs: ViewState) -> Bool, + @ViewBuilder content: @escaping (_ viewStore: ViewStore) -> Content, + file: StaticString = #fileID, + line: UInt = #line + ) { + self.init( + store: store._scope(state: toViewState, action: { $0 }), + removeDuplicates: isDuplicate, + content: content, + file: file, + line: line + ) } } -#else - extension WithViewStore: DynamicViewContent - where - ViewState: Collection, - Content: DynamicViewContent - { - public typealias Data = ViewState - public var data: ViewState { - self.viewStore.state + + extension WithViewStore where ViewState: Equatable, Content: View { + /// Initializes a structure that transforms a ``Store`` into an observable ``ViewStore`` in order + /// to compute views from state. + /// + /// ``WithViewStore`` will re-compute its body for _any_ change to the state it holds. Often the + /// ``Store`` that we want to observe holds onto a lot more state than is necessary to render a + /// view. It may hold onto the state of child features, or internal state for its logic. + /// + /// It can be important to transform the ``Store``'s state into something smaller for observation. + /// This will help minimize the number of times your view re-computes its body, and can even avoid + /// certain SwiftUI bugs that happen due to over-rendering. + /// + /// The way to do this is to use the `observe` argument of this initializer. It allows you to + /// turn the full state into a smaller data type, and only changes to that data type will trigger + /// a body re-computation. + /// + /// For example, if your application uses a tab view, then the root state may hold the state + /// for each tab as well as the currently selected tab: + /// + /// ```swift + /// @Reducer + /// struct AppFeature { + /// enum Tab { case activity, search, profile } + /// struct State { + /// var activity: Activity.State + /// var search: Search.State + /// var profile: Profile.State + /// var selectedTab: Tab + /// } + /// // ... + /// } + /// ``` + /// + /// In order to construct a tab view you need to observe this state because changes to + /// `selectedTab` need to make SwiftUI update the visual state of the UI. However, you do not + /// need to observe changes to `activity`, `search` and `profile`. Those are only necessary for + /// those child features, and changes to that state should not cause our tab view to re-compute + /// itself. + /// + /// ```swift + /// struct AppView: View { + /// let store: StoreOf + /// + /// var body: some View { + /// WithViewStore(self.store, observe: \.selectedTab) { viewStore in + /// TabView(selection: viewStore.binding(send: { .tabSelected($0) }) { + /// ActivityView( + /// store: self.store.scope(state: \.activity, action: \.activity) + /// ) + /// .tag(AppFeature.Tab.activity) + /// SearchView( + /// store: self.store.scope(state: \.search, action: \.search) + /// ) + /// .tag(AppFeature.Tab.search) + /// ProfileView( + /// store: self.store.scope(state: \.profile, action: \.profile) + /// ) + /// .tag(AppFeature.Tab.profile) + /// } + /// } + /// } + /// } + /// ``` + /// + /// To read more about this performance technique, read the article. + /// + /// - Parameters: + /// - store: A store. + /// - toViewState: A function that transforms store state into observable view state. All + /// changes to the view state will cause the `WithViewStore` to re-compute its view. + /// - fromViewAction: A function that transforms view actions into store action. + /// - content: A function that can generate content from a view store. + /// - file: The file. + /// - line: The line. + public init( + _ store: Store, + observe toViewState: @escaping (_ state: State) -> ViewState, + send fromViewAction: @escaping (_ viewAction: ViewAction) -> Action, + @ViewBuilder content: @escaping (_ viewStore: ViewStore) -> Content, + file: StaticString = #fileID, + line: UInt = #line + ) { + self.init( + store: store._scope(state: toViewState, action: fromViewAction), + removeDuplicates: ==, + content: content, + file: file, + line: line + ) + } + + /// Initializes a structure that transforms a ``Store`` into an observable ``ViewStore`` in order + /// to compute views from state. + /// + /// ``WithViewStore`` will re-compute its body for _any_ change to the state it holds. Often the + /// ``Store`` that we want to observe holds onto a lot more state than is necessary to render a + /// view. It may hold onto the state of child features, or internal state for its logic. + /// + /// It can be important to transform the ``Store``'s state into something smaller for observation. + /// This will help minimize the number of times your view re-computes its body, and can even avoid + /// certain SwiftUI bugs that happen due to over-rendering. + /// + /// The way to do this is to use the `observe` argument of this initializer. It allows you to + /// turn the full state into a smaller data type, and only changes to that data type will trigger + /// a body re-computation. + /// + /// For example, if your application uses a tab view, then the root state may hold the state + /// for each tab as well as the currently selected tab: + /// + /// ```swift + /// @Reducer + /// struct AppFeature { + /// enum Tab { case activity, search, profile } + /// struct State { + /// var activity: Activity.State + /// var search: Search.State + /// var profile: Profile.State + /// var selectedTab: Tab + /// } + /// // ... + /// } + /// ``` + /// + /// In order to construct a tab view you need to observe this state because changes to + /// `selectedTab` need to make SwiftUI update the visual state of the UI. However, you do not + /// need to observe changes to `activity`, `search` and `profile`. Those are only necessary for + /// those child features, and changes to that state should not cause our tab view to re-compute + /// itself. + /// + /// ```swift + /// struct AppView: View { + /// let store: StoreOf + /// + /// var body: some View { + /// WithViewStore(self.store, observe: \.selectedTab) { viewStore in + /// TabView(selection: viewStore.binding(send: { .tabSelected($0) }) { + /// ActivityView( + /// store: self.store.scope(state: \.activity, action: \.activity) + /// ) + /// .tag(AppFeature.Tab.activity) + /// SearchView( + /// store: self.store.scope(state: \.search, action: \.search) + /// ) + /// .tag(AppFeature.Tab.search) + /// ProfileView( + /// store: self.store.scope(state: \.profile, action: \.profile) + /// ) + /// .tag(AppFeature.Tab.profile) + /// } + /// } + /// } + /// } + /// ``` + /// + /// To read more about this performance technique, read the article. + /// + /// - Parameters: + /// - store: A store. + /// - toViewState: A function that transforms store state into observable view state. All + /// changes to the view state will cause the `WithViewStore` to re-compute its view. + /// - content: A function that can generate content from a view store. + /// - file: The file. + /// - line: The line. + public init( + _ store: Store, + observe toViewState: @escaping (_ state: State) -> ViewState, + @ViewBuilder content: @escaping (_ viewStore: ViewStore) -> Content, + file: StaticString = #fileID, + line: UInt = #line + ) { + self.init( + store: store._scope(state: toViewState, action: { $0 }), + removeDuplicates: ==, + content: content, + file: file, + line: line + ) } } + + #if compiler(>=6) + extension WithViewStore: @preconcurrency DynamicViewContent + where + ViewState: Collection, + Content: DynamicViewContent + { + public typealias Data = ViewState + public var data: ViewState { + self.viewStore.state + } + } + #else + extension WithViewStore: DynamicViewContent + where + ViewState: Collection, + Content: DynamicViewContent + { + public typealias Data = ViewState + public var data: ViewState { + self.viewStore.state + } + } + #endif #endif diff --git a/Sources/ComposableArchitecture/TestStore.swift b/Sources/ComposableArchitecture/TestStore.swift index dc7c4c190b46..aa3a907ccf0c 100644 --- a/Sources/ComposableArchitecture/TestStore.swift +++ b/Sources/ComposableArchitecture/TestStore.swift @@ -1,5 +1,4 @@ @_spi(Internals) import CasePaths -import Combine import ConcurrencyExtras import CustomDump @_spi(Beta) import Dependencies @@ -7,6 +6,12 @@ import Foundation import IssueReporting @_spi(SharedChangeTracking) import Sharing +#if canImport(Combine) + import Combine +#else + import OpenCombine +#endif + /// A testable runtime for a reducer. /// /// This object aids in writing expressive and exhaustive tests for features built in the @@ -429,7 +434,7 @@ import IssueReporting #if swift(<5.10) @MainActor(unsafe) #else - @preconcurrency@MainActor + @preconcurrency @MainActor #endif public final class TestStore { /// The current dependencies of the test store. @@ -473,13 +478,20 @@ public final class TestStore { /// The current exhaustivity level of the test store. public var exhaustivity: Exhaustivity = .on - /// Serializes all async work to the main thread for the lifetime of the test store. - public var useMainSerialExecutor: Bool { - get { uncheckedUseMainSerialExecutor } - set { uncheckedUseMainSerialExecutor = newValue } - } - private let originalUseMainSerialExecutor = uncheckedUseMainSerialExecutor - + #if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + /// Serializes all async work to the main thread for the lifetime of the test store. + public var useMainSerialExecutor: Bool { + get { uncheckedUseMainSerialExecutor } + set { uncheckedUseMainSerialExecutor = newValue } + } + private let originalUseMainSerialExecutor = uncheckedUseMainSerialExecutor + #else + /// Serializes all async work to the main thread for the lifetime of the test store. + public var useMainSerialExecutor: Bool { + get { false } + set {} + } + #endif /// The current state of the test store. /// /// When read from a trailing closure assertion in @@ -647,7 +659,9 @@ public final class TestStore { } deinit { - uncheckedUseMainSerialExecutor = self.originalUseMainSerialExecutor + #if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + uncheckedUseMainSerialExecutor = self.originalUseMainSerialExecutor + #endif mainActorNow { self.completed() } } @@ -997,13 +1011,19 @@ extension TestStore { column: column ) ) - if uncheckedUseMainSerialExecutor { - await Task.yield() - } else { + #if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + if uncheckedUseMainSerialExecutor { + await Task.yield() + } else { + for await _ in self.reducer.effectDidSubscribe.stream { + break + } + } + #else for await _ in self.reducer.effectDidSubscribe.stream { break } - } + #endif do { let currentState = self.state let currentStackElementID = self.reducer.dependencies.stackElementID @@ -2565,108 +2585,110 @@ extension TestStore { } } -extension TestStore { - /// Returns a binding view store for this store. - /// - /// Useful for testing view state of a store. - /// - /// ```swift - /// let store = TestStore(LoginFeature.State()) { - /// Login.Feature() - /// } - /// await store.send(.view(.set(\.$email, "blob@pointfree.co"))) { - /// $0.email = "blob@pointfree.co" - /// } - /// XCTAssertTrue( - /// LoginView.ViewState(store.bindings(action: \.view)) - /// .isLoginButtonDisabled - /// ) - /// - /// await store.send(.view(.set(\.$password, "whats-the-point?"))) { - /// $0.password = "blob@pointfree.co" - /// $0.isFormValid = true - /// } - /// XCTAssertFalse( - /// LoginView.ViewState(store.bindings(action: \.view)) - /// .isLoginButtonDisabled - /// ) - /// ``` - /// - /// - Parameter toViewAction: A case path from action to a bindable view action. - /// - Returns: A binding view store. - public func bindings( - action toViewAction: CaseKeyPath - ) -> BindingViewStore where State == ViewAction.State, Action: CasePathable { - BindingViewStore( - store: Store(initialState: self.state) { - BindingReducer(action: toViewAction) - } - .scope(state: \.self, action: toViewAction) - ) - } +#if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + extension TestStore { + /// Returns a binding view store for this store. + /// + /// Useful for testing view state of a store. + /// + /// ```swift + /// let store = TestStore(LoginFeature.State()) { + /// Login.Feature() + /// } + /// await store.send(.view(.set(\.$email, "blob@pointfree.co"))) { + /// $0.email = "blob@pointfree.co" + /// } + /// XCTAssertTrue( + /// LoginView.ViewState(store.bindings(action: \.view)) + /// .isLoginButtonDisabled + /// ) + /// + /// await store.send(.view(.set(\.$password, "whats-the-point?"))) { + /// $0.password = "blob@pointfree.co" + /// $0.isFormValid = true + /// } + /// XCTAssertFalse( + /// LoginView.ViewState(store.bindings(action: \.view)) + /// .isLoginButtonDisabled + /// ) + /// ``` + /// + /// - Parameter toViewAction: A case path from action to a bindable view action. + /// - Returns: A binding view store. + public func bindings( + action toViewAction: CaseKeyPath + ) -> BindingViewStore where State == ViewAction.State, Action: CasePathable { + BindingViewStore( + store: Store(initialState: self.state) { + BindingReducer(action: toViewAction) + } + .scope(state: \.self, action: toViewAction) + ) + } - @available( - iOS, - deprecated: 9999, - message: - "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" - ) - @available( - macOS, - deprecated: 9999, - message: - "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" - ) - @available( - tvOS, - deprecated: 9999, - message: - "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" - ) - @available( - watchOS, - deprecated: 9999, - message: - "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" - ) - public func bindings( - action toViewAction: AnyCasePath - ) -> BindingViewStore where State == ViewAction.State { - BindingViewStore( - store: Store(initialState: self.state) { - BindingReducer(action: toViewAction.extract(from:)) - } - ._scope(state: { $0 }, action: toViewAction.embed) + @available( + iOS, + deprecated: 9999, + message: + "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" ) + @available( + macOS, + deprecated: 9999, + message: + "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" + ) + @available( + tvOS, + deprecated: 9999, + message: + "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" + ) + @available( + watchOS, + deprecated: 9999, + message: + "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" + ) + public func bindings( + action toViewAction: AnyCasePath + ) -> BindingViewStore where State == ViewAction.State { + BindingViewStore( + store: Store(initialState: self.state) { + BindingReducer(action: toViewAction.extract(from:)) + } + ._scope(state: { $0 }, action: toViewAction.embed) + ) + } } -} -extension TestStore where Action: BindableAction, State == Action.State { - /// Returns a binding view store for this store. - /// - /// Useful for testing view state of a store. - /// - /// ```swift - /// let store = TestStore(LoginFeature.State()) { - /// Login.Feature() - /// } - /// await store.send(.set(\.$email, "blob@pointfree.co")) { - /// $0.email = "blob@pointfree.co" - /// } - /// XCTAssertTrue(LoginView.ViewState(store.bindings).isLoginButtonDisabled) - /// - /// await store.send(.set(\.$password, "whats-the-point?")) { - /// $0.password = "blob@pointfree.co" - /// $0.isFormValid = true - /// } - /// XCTAssertFalse(LoginView.ViewState(store.bindings).isLoginButtonDisabled) - /// ``` - /// - /// - Returns: A binding view store. - public var bindings: BindingViewStore { - self.bindings(action: AnyCasePath()) + extension TestStore where Action: BindableAction, State == Action.State { + /// Returns a binding view store for this store. + /// + /// Useful for testing view state of a store. + /// + /// ```swift + /// let store = TestStore(LoginFeature.State()) { + /// Login.Feature() + /// } + /// await store.send(.set(\.$email, "blob@pointfree.co")) { + /// $0.email = "blob@pointfree.co" + /// } + /// XCTAssertTrue(LoginView.ViewState(store.bindings).isLoginButtonDisabled) + /// + /// await store.send(.set(\.$password, "whats-the-point?")) { + /// $0.password = "blob@pointfree.co" + /// $0.isFormValid = true + /// } + /// XCTAssertFalse(LoginView.ViewState(store.bindings).isLoginButtonDisabled) + /// ``` + /// + /// - Returns: A binding view store. + public var bindings: BindingViewStore { + self.bindings(action: AnyCasePath()) + } } -} +#endif /// The type returned from ``TestStore/send(_:assert:fileID:file:line:column:)-8f2pl`` that represents the /// lifecycle of the effect started from sending an action. @@ -2935,6 +2957,10 @@ class TestReducer: Reducer { } } +#if os(Linux) || os(Android) + let NSEC_PER_SEC: UInt64 = 1_000_000_000 +#endif + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) extension Duration { fileprivate var nanoseconds: UInt64 { diff --git a/Sources/ComposableArchitecture/UIKit/IfLetUIKit.swift b/Sources/ComposableArchitecture/UIKit/IfLetUIKit.swift index b6b2d3c3040a..614eed802f69 100644 --- a/Sources/ComposableArchitecture/UIKit/IfLetUIKit.swift +++ b/Sources/ComposableArchitecture/UIKit/IfLetUIKit.swift @@ -1,4 +1,8 @@ -import Combine +#if canImport(Combine) + import Combine +#else + import OpenCombine +#endif extension Store { /// Calls one of two closures depending on whether a store's optional state is `nil` or not, and diff --git a/Sources/ComposableArchitecture/ViewStore.swift b/Sources/ComposableArchitecture/ViewStore.swift index baf99575607a..b4adbab6dd6d 100644 --- a/Sources/ComposableArchitecture/ViewStore.swift +++ b/Sources/ComposableArchitecture/ViewStore.swift @@ -1,5 +1,8 @@ -@preconcurrency import Combine -import SwiftUI +#if canImport(Combine) + @preconcurrency import Combine +#else + @preconcurrency import OpenCombine +#endif /// A `ViewStore` is an object that can observe state changes and send actions. They are most /// commonly used in views, such as SwiftUI views, UIView or UIViewController, but they can be used @@ -93,7 +96,7 @@ public final class ViewStore: ObservableObject { private let _state: CurrentValueRelay private var viewCancellable: AnyCancellable? - #if DEBUG + #if DEBUG && !os(Linux) && !os(Android) private let storeTypeName: String #endif let store: Store @@ -146,7 +149,7 @@ public final class ViewStore: ObservableObject { send fromViewAction: @escaping (_ viewAction: ViewAction) -> Action, removeDuplicates isDuplicate: @escaping (_ lhs: ViewState, _ rhs: ViewState) -> Bool ) { - #if DEBUG + #if DEBUG && !os(Linux) && !os(Android) self.storeTypeName = ComposableArchitecture.storeTypeName(of: store) Logger.shared.log("View\(self.storeTypeName).init") #endif @@ -163,7 +166,7 @@ public final class ViewStore: ObservableObject { } init(_ viewStore: ViewStore) { - #if DEBUG + #if DEBUG && !os(Linux) && !os(Android) self.storeTypeName = viewStore.storeTypeName Logger.shared.log("View\(self.storeTypeName).init") #endif @@ -175,7 +178,7 @@ public final class ViewStore: ObservableObject { } } - #if DEBUG + #if DEBUG && !os(Linux) && !os(Android) deinit { guard Thread.isMainThread else { return } MainActor._assumeIsolated { @@ -247,31 +250,33 @@ public final class ViewStore: ObservableObject { self.store.send(action) } - /// Sends an action to the store with a given animation. - /// - /// See ``ViewStore/send(_:)`` for more info. - /// - /// - Parameters: - /// - action: An action. - /// - animation: An animation. - @discardableResult - public func send(_ action: ViewAction, animation: Animation?) -> StoreTask { - self.send(action, transaction: Transaction(animation: animation)) - } + #if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + /// Sends an action to the store with a given animation. + /// + /// See ``ViewStore/send(_:)`` for more info. + /// + /// - Parameters: + /// - action: An action. + /// - animation: An animation. + @discardableResult + public func send(_ action: ViewAction, animation: Animation?) -> StoreTask { + self.send(action, transaction: Transaction(animation: animation)) + } - /// Sends an action to the store with a given transaction. - /// - /// See ``ViewStore/send(_:)`` for more info. - /// - /// - Parameters: - /// - action: An action. - /// - transaction: A transaction. - @discardableResult - public func send(_ action: ViewAction, transaction: Transaction) -> StoreTask { - withTransaction(transaction) { - self.send(action) + /// Sends an action to the store with a given transaction. + /// + /// See ``ViewStore/send(_:)`` for more info. + /// + /// - Parameters: + /// - action: An action. + /// - transaction: A transaction. + @discardableResult + public func send(_ action: ViewAction, transaction: Transaction) -> StoreTask { + withTransaction(transaction) { + self.send(action) + } } - } + #endif /// Sends an action into the store and then suspends while a piece of state is `true`. /// @@ -359,27 +364,29 @@ public final class ViewStore: ObservableObject { } } - /// Sends an action into the store and then suspends while a piece of state is `true`. - /// - /// See the documentation of ``send(_:while:)`` for more information. - /// - /// - Parameters: - /// - action: An action. - /// - animation: The animation to perform when the action is sent. - /// - predicate: A predicate on `ViewState` that determines for how long this method should - /// suspend. - public func send( - _ action: ViewAction, - animation: Animation?, - while predicate: @escaping (_ state: ViewState) -> Bool - ) async { - let task = withAnimation(animation) { self.send(action) } - await withTaskCancellationHandler { - await self.yield(while: predicate) - } onCancel: { - task.cancel() + #if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + /// Sends an action into the store and then suspends while a piece of state is `true`. + /// + /// See the documentation of ``send(_:while:)`` for more information. + /// + /// - Parameters: + /// - action: An action. + /// - animation: The animation to perform when the action is sent. + /// - predicate: A predicate on `ViewState` that determines for how long this method should + /// suspend. + public func send( + _ action: ViewAction, + animation: Animation?, + while predicate: @escaping (_ state: ViewState) -> Bool + ) async { + let task = withAnimation(animation) { self.send(action) } + await withTaskCancellationHandler { + await self.yield(while: predicate) + } onCancel: { + task.cancel() + } } - } + #endif /// Suspends the current task while a predicate on state is `true`. /// @@ -412,139 +419,141 @@ public final class ViewStore: ObservableObject { } } - /// Derives a binding from the store that prevents direct writes to state and instead sends - /// actions to the store. - /// - /// The method is useful for dealing with SwiftUI components that work with two-way `Binding`s - /// since the ``Store`` does not allow directly writing its state; it only allows reading state - /// and sending actions. - /// - /// For example, a text field binding can be created like this: - /// - /// ```swift - /// struct State { var name = "" } - /// enum Action { case nameChanged(String) } - /// - /// TextField( - /// "Enter name", - /// text: viewStore.binding( - /// get: { $0.name }, - /// send: { Action.nameChanged($0) } - /// ) - /// ) - /// ``` - /// - /// - Parameters: - /// - get: A function to get the state for the binding from the view store's full state. - /// - valueToAction: A function that transforms the binding's value into an action that can be - /// sent to the store. - /// - Returns: A binding. - public func binding( - get: @escaping (_ state: ViewState) -> Value, - send valueToAction: @escaping (_ value: Value) -> ViewAction - ) -> Binding { - ObservedObject(wrappedValue: self) - .projectedValue[get: .init(rawValue: get), send: .init(rawValue: valueToAction)] - } + #if os(macOS) || os(iOS) || os(watchOS) || os(visionOS) || os(tvOS) + /// Derives a binding from the store that prevents direct writes to state and instead sends + /// actions to the store. + /// + /// The method is useful for dealing with SwiftUI components that work with two-way `Binding`s + /// since the ``Store`` does not allow directly writing its state; it only allows reading state + /// and sending actions. + /// + /// For example, a text field binding can be created like this: + /// + /// ```swift + /// struct State { var name = "" } + /// enum Action { case nameChanged(String) } + /// + /// TextField( + /// "Enter name", + /// text: viewStore.binding( + /// get: { $0.name }, + /// send: { Action.nameChanged($0) } + /// ) + /// ) + /// ``` + /// + /// - Parameters: + /// - get: A function to get the state for the binding from the view store's full state. + /// - valueToAction: A function that transforms the binding's value into an action that can be + /// sent to the store. + /// - Returns: A binding. + public func binding( + get: @escaping (_ state: ViewState) -> Value, + send valueToAction: @escaping (_ value: Value) -> ViewAction + ) -> Binding { + ObservedObject(wrappedValue: self) + .projectedValue[get: .init(rawValue: get), send: .init(rawValue: valueToAction)] + } - @_disfavoredOverload - func binding( - get: @escaping (_ state: ViewState) -> Value, - compactSend valueToAction: @escaping (_ value: Value) -> ViewAction? - ) -> Binding { - ObservedObject(wrappedValue: self) - .projectedValue[get: .init(rawValue: get), send: .init(rawValue: valueToAction)] - } + @_disfavoredOverload + func binding( + get: @escaping (_ state: ViewState) -> Value, + compactSend valueToAction: @escaping (_ value: Value) -> ViewAction? + ) -> Binding { + ObservedObject(wrappedValue: self) + .projectedValue[get: .init(rawValue: get), send: .init(rawValue: valueToAction)] + } - /// Derives a binding from the store that prevents direct writes to state and instead sends - /// actions to the store. - /// - /// The method is useful for dealing with SwiftUI components that work with two-way `Binding`s - /// since the ``Store`` does not allow directly writing its state; it only allows reading state - /// and sending actions. - /// - /// For example, an alert binding can be dealt with like this: - /// - /// ```swift - /// struct State { var alert: String? } - /// enum Action { case alertDismissed } - /// - /// .alert( - /// item: viewStore.binding( - /// get: { $0.alert }, - /// send: .alertDismissed - /// ) - /// ) { alert in Alert(title: Text(alert.message)) } - /// ``` - /// - /// - Parameters: - /// - get: A function to get the state for the binding from the view store's full state. - /// - action: The action to send when the binding is written to. - /// - Returns: A binding. - public func binding( - get: @escaping (_ state: ViewState) -> Value, - send action: ViewAction - ) -> Binding { - self.binding(get: get, send: { _ in action }) - } + /// Derives a binding from the store that prevents direct writes to state and instead sends + /// actions to the store. + /// + /// The method is useful for dealing with SwiftUI components that work with two-way `Binding`s + /// since the ``Store`` does not allow directly writing its state; it only allows reading state + /// and sending actions. + /// + /// For example, an alert binding can be dealt with like this: + /// + /// ```swift + /// struct State { var alert: String? } + /// enum Action { case alertDismissed } + /// + /// .alert( + /// item: viewStore.binding( + /// get: { $0.alert }, + /// send: .alertDismissed + /// ) + /// ) { alert in Alert(title: Text(alert.message)) } + /// ``` + /// + /// - Parameters: + /// - get: A function to get the state for the binding from the view store's full state. + /// - action: The action to send when the binding is written to. + /// - Returns: A binding. + public func binding( + get: @escaping (_ state: ViewState) -> Value, + send action: ViewAction + ) -> Binding { + self.binding(get: get, send: { _ in action }) + } - /// Derives a binding from the store that prevents direct writes to state and instead sends - /// actions to the store. - /// - /// The method is useful for dealing with SwiftUI components that work with two-way `Binding`s - /// since the ``Store`` does not allow directly writing its state; it only allows reading state - /// and sending actions. - /// - /// For example, a text field binding can be created like this: - /// - /// ```swift - /// typealias State = String - /// enum Action { case nameChanged(String) } - /// - /// TextField( - /// "Enter name", - /// text: viewStore.binding( - /// send: { Action.nameChanged($0) } - /// ) - /// ) - /// ``` - /// - /// - Parameters: - /// - valueToAction: A function that transforms the binding's value into an action that can be - /// sent to the store. - /// - Returns: A binding. - public func binding( - send valueToAction: @escaping (_ state: ViewState) -> ViewAction - ) -> Binding { - self.binding(get: { $0 }, send: valueToAction) - } + /// Derives a binding from the store that prevents direct writes to state and instead sends + /// actions to the store. + /// + /// The method is useful for dealing with SwiftUI components that work with two-way `Binding`s + /// since the ``Store`` does not allow directly writing its state; it only allows reading state + /// and sending actions. + /// + /// For example, a text field binding can be created like this: + /// + /// ```swift + /// typealias State = String + /// enum Action { case nameChanged(String) } + /// + /// TextField( + /// "Enter name", + /// text: viewStore.binding( + /// send: { Action.nameChanged($0) } + /// ) + /// ) + /// ``` + /// + /// - Parameters: + /// - valueToAction: A function that transforms the binding's value into an action that can be + /// sent to the store. + /// - Returns: A binding. + public func binding( + send valueToAction: @escaping (_ state: ViewState) -> ViewAction + ) -> Binding { + self.binding(get: { $0 }, send: valueToAction) + } - /// Derives a binding from the store that prevents direct writes to state and instead sends - /// actions to the store. - /// - /// The method is useful for dealing with SwiftUI components that work with two-way `Binding`s - /// since the ``Store`` does not allow directly writing its state; it only allows reading state - /// and sending actions. - /// - /// For example, an alert binding can be dealt with like this: - /// - /// ```swift - /// typealias State = String - /// enum Action { case alertDismissed } - /// - /// .alert( - /// item: viewStore.binding( - /// send: .alertDismissed - /// ) - /// ) { title in Alert(title: Text(title)) } - /// ``` - /// - /// - Parameters: - /// - action: The action to send when the binding is written to. - /// - Returns: A binding. - public func binding(send action: ViewAction) -> Binding { - self.binding(send: { _ in action }) - } + /// Derives a binding from the store that prevents direct writes to state and instead sends + /// actions to the store. + /// + /// The method is useful for dealing with SwiftUI components that work with two-way `Binding`s + /// since the ``Store`` does not allow directly writing its state; it only allows reading state + /// and sending actions. + /// + /// For example, an alert binding can be dealt with like this: + /// + /// ```swift + /// typealias State = String + /// enum Action { case alertDismissed } + /// + /// .alert( + /// item: viewStore.binding( + /// send: .alertDismissed + /// ) + /// ) { title in Alert(title: Text(title)) } + /// ``` + /// + /// - Parameters: + /// - action: The action to send when the binding is written to. + /// - Returns: A binding. + public func binding(send action: ViewAction) -> Binding { + self.binding(send: { _ in action }) + } + #endif private subscript( get fromState: HashableWrapper<(ViewState) -> Value>, diff --git a/Tests/ComposableArchitectureTests/BindableStoreTests.swift b/Tests/ComposableArchitectureTests/BindableStoreTests.swift index a506a1b01b0b..12125e27386f 100644 --- a/Tests/ComposableArchitectureTests/BindableStoreTests.swift +++ b/Tests/ComposableArchitectureTests/BindableStoreTests.swift @@ -1,8 +1,13 @@ -import Combine @_spi(Internals) import ComposableArchitecture import SwiftUI import XCTest +#if canImport(Combine) + import Combine +#else + import OpenCombine +#endif + @available(*, deprecated, message: "TODO: Update to use case pathable syntax with Swift 5.9") final class BindableStoreTests: XCTestCase { func testBindableStore() { diff --git a/Tests/ComposableArchitectureTests/CompatibilityTests.swift b/Tests/ComposableArchitectureTests/CompatibilityTests.swift index 781051c5b250..f6de45dce824 100644 --- a/Tests/ComposableArchitectureTests/CompatibilityTests.swift +++ b/Tests/ComposableArchitectureTests/CompatibilityTests.swift @@ -1,7 +1,12 @@ -import Combine import ComposableArchitecture import XCTest +#if canImport(Combine) + import Combine +#else + import OpenCombine +#endif + final class CompatibilityTests: BaseTCATestCase { // Actions can be re-entrantly sent into the store if an action is sent that holds an object // which sends an action on deinit. In order to prevent a simultaneous access exception for this diff --git a/Tests/ComposableArchitectureTests/ComposableArchitectureTests.swift b/Tests/ComposableArchitectureTests/ComposableArchitectureTests.swift index 90bbff16a387..8217396037ff 100644 --- a/Tests/ComposableArchitectureTests/ComposableArchitectureTests.swift +++ b/Tests/ComposableArchitectureTests/ComposableArchitectureTests.swift @@ -1,5 +1,13 @@ -import Combine -import CombineSchedulers +#if canImport(Combine) + import Combine +#else + import OpenCombine +#endif +#if canImport(Combine) + import Combine +#else + import OpenCombine +#endifSchedulers import ComposableArchitecture import XCTest diff --git a/Tests/ComposableArchitectureTests/CurrentValueRelayTests.swift b/Tests/ComposableArchitectureTests/CurrentValueRelayTests.swift index e026f024098b..4314fc69fed6 100644 --- a/Tests/ComposableArchitectureTests/CurrentValueRelayTests.swift +++ b/Tests/ComposableArchitectureTests/CurrentValueRelayTests.swift @@ -1,5 +1,14 @@ #if DEBUG - @preconcurrency import Combine + + #if canImport(Combine) + @preconcurrency #if canImport(Combine) + import Combine +#else + import OpenCombine +#endif + #else + @preconcurrency import OpenCombine + #endif @testable @preconcurrency import ComposableArchitecture import XCTest diff --git a/Tests/ComposableArchitectureTests/DebugTests.swift b/Tests/ComposableArchitectureTests/DebugTests.swift index 16e8c2bb7ca6..e4578046cf43 100644 --- a/Tests/ComposableArchitectureTests/DebugTests.swift +++ b/Tests/ComposableArchitectureTests/DebugTests.swift @@ -1,5 +1,9 @@ #if DEBUG - import Combine + #if canImport(Combine) + import Combine + #else + import OpenCombine + #endif import CustomDump import XCTest diff --git a/Tests/ComposableArchitectureTests/EffectCancellationTests.swift b/Tests/ComposableArchitectureTests/EffectCancellationTests.swift index 7fe5fd49bb09..a0e4b83dfc96 100644 --- a/Tests/ComposableArchitectureTests/EffectCancellationTests.swift +++ b/Tests/ComposableArchitectureTests/EffectCancellationTests.swift @@ -1,7 +1,12 @@ -import Combine @_spi(Internals) import ComposableArchitecture import XCTest +#if canImport(Combine) + import Combine +#else + import OpenCombine +#endif + final class EffectCancellationTests: BaseTCATestCase { struct CancelID: Hashable {} var cancellables: Set = [] diff --git a/Tests/ComposableArchitectureTests/EffectDebounceTests.swift b/Tests/ComposableArchitectureTests/EffectDebounceTests.swift index cf8bdde44545..fd710f5c82d1 100644 --- a/Tests/ComposableArchitectureTests/EffectDebounceTests.swift +++ b/Tests/ComposableArchitectureTests/EffectDebounceTests.swift @@ -1,7 +1,12 @@ -import Combine @_spi(Internals) import ComposableArchitecture import XCTest +#if canImport(Combine) + import Combine +#else + import OpenCombine +#endif + final class EffectDebounceTests: BaseTCATestCase { @MainActor func testDebounce() async { diff --git a/Tests/ComposableArchitectureTests/EffectFailureTests.swift b/Tests/ComposableArchitectureTests/EffectFailureTests.swift index 3d3f5745e152..f2e183a9ff7c 100644 --- a/Tests/ComposableArchitectureTests/EffectFailureTests.swift +++ b/Tests/ComposableArchitectureTests/EffectFailureTests.swift @@ -1,5 +1,9 @@ #if DEBUG - import Combine + #if canImport(Combine) + import Combine + #else + import OpenCombine + #endif @_spi(Internals) import ComposableArchitecture import XCTest diff --git a/Tests/ComposableArchitectureTests/EffectRunTests.swift b/Tests/ComposableArchitectureTests/EffectRunTests.swift index cc2cb8c8134e..26ec75c9a2c3 100644 --- a/Tests/ComposableArchitectureTests/EffectRunTests.swift +++ b/Tests/ComposableArchitectureTests/EffectRunTests.swift @@ -1,7 +1,12 @@ -import Combine import ComposableArchitecture import XCTest +#if canImport(Combine) + import Combine +#else + import OpenCombine +#endif + final class EffectRunTests: BaseTCATestCase { func testRun() async { struct State: Equatable {} diff --git a/Tests/ComposableArchitectureTests/EffectTests.swift b/Tests/ComposableArchitectureTests/EffectTests.swift index a277d446998c..213d19f2cdce 100644 --- a/Tests/ComposableArchitectureTests/EffectTests.swift +++ b/Tests/ComposableArchitectureTests/EffectTests.swift @@ -1,7 +1,12 @@ -import Combine @_spi(Canary) @_spi(Internals) import ComposableArchitecture import XCTest +#if canImport(Combine) + import Combine +#else + import OpenCombine +#endif + final class EffectTests: BaseTCATestCase { var cancellables: Set = [] let mainQueue = DispatchQueue.test @@ -139,7 +144,7 @@ final class EffectTests: BaseTCATestCase { return .run { send in await send(.response(Int(self.date.now.timeIntervalSinceReferenceDate))) } - case let .response(value): + case .response(let value): state = value return .none } @@ -172,7 +177,7 @@ final class EffectTests: BaseTCATestCase { return .run { send in await send(.response(Int(self.date.now.timeIntervalSinceReferenceDate))) } - case let .response(value): + case .response(let value): state = value return .none } diff --git a/Tests/ComposableArchitectureTests/MemoryManagementTests.swift b/Tests/ComposableArchitectureTests/MemoryManagementTests.swift index 4ac885d30e7d..01dbb054cdc8 100644 --- a/Tests/ComposableArchitectureTests/MemoryManagementTests.swift +++ b/Tests/ComposableArchitectureTests/MemoryManagementTests.swift @@ -1,7 +1,12 @@ -import Combine import ComposableArchitecture import XCTest +#if canImport(Combine) + import Combine +#else + import OpenCombine +#endif + final class MemoryManagementTests: BaseTCATestCase { var cancellables: Set = [] diff --git a/Tests/ComposableArchitectureTests/ObservableTests.swift b/Tests/ComposableArchitectureTests/ObservableTests.swift index 3c171ed944c4..9ab095baaca8 100644 --- a/Tests/ComposableArchitectureTests/ObservableTests.swift +++ b/Tests/ComposableArchitectureTests/ObservableTests.swift @@ -1,7 +1,12 @@ -import Combine import ComposableArchitecture import XCTest +#if canImport(Combine) + import Combine +#else + import OpenCombine +#endif + final class ObservableTests: BaseTCATestCase { func testBasics() async { var state = ChildState() diff --git a/Tests/ComposableArchitectureTests/ReducerTests.swift b/Tests/ComposableArchitectureTests/ReducerTests.swift index 8b7d1686bf4e..b099aca5f63a 100644 --- a/Tests/ComposableArchitectureTests/ReducerTests.swift +++ b/Tests/ComposableArchitectureTests/ReducerTests.swift @@ -1,9 +1,14 @@ -import Combine @_spi(Internals) import ComposableArchitecture import CustomDump import XCTest import os.signpost +#if canImport(Combine) + import Combine +#else + import OpenCombine +#endif + final class ReducerTests: BaseTCATestCase { var cancellables: Set = [] diff --git a/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift b/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift index 77423aa1ac58..5972f81656ac 100644 --- a/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift +++ b/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift @@ -1,5 +1,9 @@ #if DEBUG - import Combine + #if canImport(Combine) + import Combine + #else + import OpenCombine + #endif @_spi(Internals) import ComposableArchitecture import XCTest diff --git a/Tests/ComposableArchitectureTests/StoreLifetimeTests.swift b/Tests/ComposableArchitectureTests/StoreLifetimeTests.swift index 69d96cc4b238..3ae288e9ccbf 100644 --- a/Tests/ComposableArchitectureTests/StoreLifetimeTests.swift +++ b/Tests/ComposableArchitectureTests/StoreLifetimeTests.swift @@ -1,7 +1,12 @@ -import Combine @_spi(Logging) import ComposableArchitecture import XCTest +#if canImport(Combine) + import Combine +#else + import OpenCombine +#endif + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) final class StoreLifetimeTests: BaseTCATestCase { @available(*, deprecated) diff --git a/Tests/ComposableArchitectureTests/StoreTests.swift b/Tests/ComposableArchitectureTests/StoreTests.swift index efd15ed960c4..1781ebf31d71 100644 --- a/Tests/ComposableArchitectureTests/StoreTests.swift +++ b/Tests/ComposableArchitectureTests/StoreTests.swift @@ -1,7 +1,12 @@ -@preconcurrency import Combine @_spi(Internals) import ComposableArchitecture import XCTest +#if canImport(Combine) + @preconcurrency import Combine +#else + @preconcurrency import OpenCombine +#endif + #if canImport(Testing) import Testing #endif @@ -490,7 +495,7 @@ final class StoreTests: BaseTCATestCase { state.child = .init(count: nil) return .none - case let .child(childCount): + case .child(let childCount): state.count = childCount return .none } @@ -670,21 +675,21 @@ final class StoreTests: BaseTCATestCase { } operation: { .run { send in await send(.response1(self.count.value)) } } - case let .response1(count): + case .response1(let count): state.count = count return withDependencies { $0.count.value += 1 } operation: { .run { send in await send(.response2(self.count.value)) } } - case let .response2(count): + case .response2(let count): state.count = count return withDependencies { $0.count.value += 1 } operation: { .run { send in await send(.response3(self.count.value)) } } - case let .response3(count): + case .response3(let count): state.count = count return .none } @@ -732,21 +737,21 @@ final class StoreTests: BaseTCATestCase { } operation: { .run { send in await send(.response1(self.count.value)) } } - case let .response1(count): + case .response1(let count): state.count = count return withDependencies { $0.count.value += 1 } operation: { .run { send in await send(.response2(self.count.value)) } } - case let .response2(count): + case .response2(let count): state.count = count return withDependencies { $0.count.value += 1 } operation: { .run { send in await send(.response3(self.count.value)) } } - case let .response3(count): + case .response3(let count): state.count = count return .none } diff --git a/Tests/ComposableArchitectureTests/TaskCancellationTests.swift b/Tests/ComposableArchitectureTests/TaskCancellationTests.swift index b828b4a8e405..5626d7eb0b59 100644 --- a/Tests/ComposableArchitectureTests/TaskCancellationTests.swift +++ b/Tests/ComposableArchitectureTests/TaskCancellationTests.swift @@ -1,7 +1,12 @@ -import Combine @_spi(Internals) import ComposableArchitecture import XCTest +#if canImport(Combine) + import Combine +#else + import OpenCombine +#endif + final class TaskCancellationTests: BaseTCATestCase { func testCancellation() async throws { enum CancelID { case task } diff --git a/Tests/ComposableArchitectureTests/TestStoreTests.swift b/Tests/ComposableArchitectureTests/TestStoreTests.swift index 6a2a1c4d0d3e..3df0977fce7f 100644 --- a/Tests/ComposableArchitectureTests/TestStoreTests.swift +++ b/Tests/ComposableArchitectureTests/TestStoreTests.swift @@ -1,7 +1,12 @@ -@preconcurrency import Combine import ComposableArchitecture import XCTest +#if canImport(Combine) + @preconcurrency import Combine +#else + @preconcurrency import OpenCombine +#endif + final class TestStoreTests: BaseTCATestCase { func testEffectConcatenation() async { struct State: Equatable {} @@ -61,7 +66,7 @@ final class TestStoreTests: BaseTCATestCase { switch action { case .tap: return .run { send in await send(.response(42)) } - case let .response(number): + case .response(let number): state = number return .none } @@ -91,7 +96,7 @@ final class TestStoreTests: BaseTCATestCase { case .increment: state.isChanging = true return .send(.changed(from: state.count, to: state.count + 1)) - case let .changed(from, to): + case .changed(let from, let to): state.isChanging = false if state.count == from { state.count = to @@ -369,7 +374,7 @@ final class TestStoreTests: BaseTCATestCase { case .tap: state.count += 1 return .run { send in await send(.response(42)) } - case let .response(number): + case .response(let number): state.count = number state.date = now return .none @@ -581,7 +586,7 @@ final class TestStoreTests: BaseTCATestCase { case .view(.tap): state = state + 1 return .send(.delegate(.success(42 * 42))) - case let .view(.delete(indexSet)): + case .view(.delete(let indexSet)): let sum = indexSet.reduce(0, +) if sum == 42 { state = state + 1 diff --git a/Tests/ComposableArchitectureTests/ThrottleTests.swift b/Tests/ComposableArchitectureTests/ThrottleTests.swift index 519a9115c751..5bbcb7066ee6 100644 --- a/Tests/ComposableArchitectureTests/ThrottleTests.swift +++ b/Tests/ComposableArchitectureTests/ThrottleTests.swift @@ -1,7 +1,12 @@ -import Combine import ComposableArchitecture @preconcurrency import XCTest +#if canImport(Combine) + import Combine +#else + import OpenCombine +#endif + final class EffectThrottleTests: BaseTCATestCase { let mainQueue = DispatchQueue.test @@ -192,10 +197,10 @@ struct ThrottleFeature { var body: some Reducer { Reduce { state, action in switch action { - case let .tap(value): + case .tap(let value): return .send(.throttledResponse(value)) .throttle(id: self.id, for: .seconds(1), scheduler: self.mainQueue, latest: self.latest) - case let .throttledResponse(value): + case .throttledResponse(let value): state.count = value return .none } diff --git a/Tests/ComposableArchitectureTests/ViewStoreTests.swift b/Tests/ComposableArchitectureTests/ViewStoreTests.swift index f699997f69bb..54ab0e092ac3 100644 --- a/Tests/ComposableArchitectureTests/ViewStoreTests.swift +++ b/Tests/ComposableArchitectureTests/ViewStoreTests.swift @@ -1,7 +1,12 @@ -@preconcurrency import Combine import ComposableArchitecture import XCTest +#if canImport(Combine) + @preconcurrency import Combine +#else + @preconcurrency import OpenCombine +#endif + final class ViewStoreTests: BaseTCATestCase { var cancellables: Set = [] @@ -253,7 +258,7 @@ final class ViewStoreTests: BaseTCATestCase { return .run { send in await send(.response(42)) } - case let .response(value): + case .response(let value): state = value return .none } @@ -282,7 +287,7 @@ final class ViewStoreTests: BaseTCATestCase { try await Task.sleep(nanoseconds: NSEC_PER_SEC) await send(.response(42)) } - case let .response(value): + case .response(let value): state = value return .none }