From 57a73bd6c5b19d1eccceaf6dc41d6c518bea156f Mon Sep 17 00:00:00 2001 From: Daniel Aditya Istyana Date: Mon, 7 Aug 2023 16:54:23 +0700 Subject: [PATCH] Implement ViewStore (#96) * viewstore and withviewstore implementation * update environment examples * remove deprecated api * [ci enable] [run test] [upload] remove print --- Examples/Examples.xcodeproj/project.pbxproj | 8 + .../xcshareddata/swiftpm/Package.resolved | 29 +- .../1-BasicUsage/BasicUsageView.swift | 62 ++ .../EnvironmentDemoVC+Reducer.swift | 4 + .../2-Environment/EnvironmentRouteView.swift | 154 ++++ Examples/Examples/RouteVC.swift | 48 +- Sources/RxComposableArchitecture/Store.swift | 40 + .../SwiftUI/Alert.swift | 297 +++++++ .../SwiftUI/ButtonState.swift | 320 ++++++++ .../SwiftUI/Internal/ButtonStateSlider.swift | 32 + .../SwiftUI/TextState.swift | 741 ++++++++++++++++++ .../SwiftUI/WithViewStore.swift | 571 ++++++++++++++ .../RxComposableArchitecture/ViewStore.swift | 574 ++++++++++++++ .../ViewStoreTests.swift | 323 ++++++++ 14 files changed, 3186 insertions(+), 17 deletions(-) create mode 100644 Examples/Examples/1-BasicUsage/BasicUsageView.swift create mode 100644 Examples/Examples/2-Environment/EnvironmentRouteView.swift create mode 100644 Sources/RxComposableArchitecture/SwiftUI/Alert.swift create mode 100644 Sources/RxComposableArchitecture/SwiftUI/ButtonState.swift create mode 100644 Sources/RxComposableArchitecture/SwiftUI/Internal/ButtonStateSlider.swift create mode 100644 Sources/RxComposableArchitecture/SwiftUI/TextState.swift create mode 100644 Sources/RxComposableArchitecture/SwiftUI/WithViewStore.swift create mode 100644 Sources/RxComposableArchitecture/ViewStore.swift create mode 100644 Tests/RxComposableArchitectureTests/ViewStoreTests.swift diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 5148793..cab7249 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -38,6 +38,8 @@ 1D6402F9284DA71E00C4F882 /* NeverEqualVC+Reducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D6402F7284DA71E00C4F882 /* NeverEqualVC+Reducer.swift */; }; 1D6402FA284DA71E00C4F882 /* NeverEqualVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D6402F8284DA71E00C4F882 /* NeverEqualVC.swift */; }; 1D6402FC284DA73400C4F882 /* NeverEqualReducerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D6402FB284DA73400C4F882 /* NeverEqualReducerTests.swift */; }; + 22E337FA2A6FAED6001BC3EF /* BasicUsageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E337F92A6FAED6001BC3EF /* BasicUsageView.swift */; }; + 22E337FC2A70F60D001BC3EF /* EnvironmentRouteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E337FB2A70F60D001BC3EF /* EnvironmentRouteView.swift */; }; 9C82D5B228EFE16200E623DF /* TimerVC+Reducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C82D5B028EFE13800E623DF /* TimerVC+Reducer.swift */; }; 9C82D5B328EFE16200E623DF /* TimerVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C82D5B128EFE13800E623DF /* TimerVC.swift */; }; /* End PBXBuildFile section */ @@ -95,6 +97,8 @@ 1D6402F7284DA71E00C4F882 /* NeverEqualVC+Reducer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NeverEqualVC+Reducer.swift"; sourceTree = ""; }; 1D6402F8284DA71E00C4F882 /* NeverEqualVC.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NeverEqualVC.swift; sourceTree = ""; }; 1D6402FB284DA73400C4F882 /* NeverEqualReducerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NeverEqualReducerTests.swift; sourceTree = ""; }; + 22E337F92A6FAED6001BC3EF /* BasicUsageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicUsageView.swift; sourceTree = ""; }; + 22E337FB2A70F60D001BC3EF /* EnvironmentRouteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentRouteView.swift; sourceTree = ""; }; 9C82D5B028EFE13800E623DF /* TimerVC+Reducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimerVC+Reducer.swift"; sourceTree = ""; }; 9C82D5B128EFE13800E623DF /* TimerVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerVC.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -209,6 +213,7 @@ children = ( 1D43C2C02833CBA7009C1A8B /* BasicUsageVC.swift */, 1D3CEFCB283E49F300A5105A /* BasicUsage+Reducer.swift */, + 22E337F92A6FAED6001BC3EF /* BasicUsageView.swift */, ); path = "1-BasicUsage"; sourceTree = ""; @@ -220,6 +225,7 @@ 1D6402C92848D69300C4F882 /* EnvironmentDemoVC.swift */, 1D6402D02849855B00C4F882 /* EnvironmentDemoVC+Reducer.swift */, 1D6402D2284985B700C4F882 /* EnvironmentVC+Mock.swift */, + 22E337FB2A70F60D001BC3EF /* EnvironmentRouteView.swift */, ); path = "2-Environment"; sourceTree = ""; @@ -414,6 +420,8 @@ buildActionMask = 2147483647; files = ( 9C82D5B228EFE16200E623DF /* TimerVC+Reducer.swift in Sources */, + 22E337FA2A6FAED6001BC3EF /* BasicUsageView.swift in Sources */, + 22E337FC2A70F60D001BC3EF /* EnvironmentRouteView.swift in Sources */, 9C82D5B328EFE16200E623DF /* TimerVC.swift in Sources */, 1D6402D12849855B00C4F882 /* EnvironmentDemoVC+Reducer.swift in Sources */, 1D43C2902833C36C009C1A8B /* RouteVC.swift in Sources */, diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1d5a6eb..8fd807d 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/pointfreeco/combine-schedulers", "state": { "branch": null, - "revision": "882ac01eb7ef9e36d4467eb4b1151e74fcef85ab", - "version": "0.9.1" + "revision": "ec62f32d21584214a4b27c8cee2b2ad70ab2c38a", + "version": "0.11.0" } }, { @@ -51,8 +51,17 @@ "repositoryURL": "https://github.com/pointfreeco/swift-clocks", "state": { "branch": null, - "revision": "20b25ca0dd88ebfb9111ec937814ddc5a8880172", - "version": "0.2.0" + "revision": "0fbaebfc013715dab44d715a4d350ba37f297e4d", + "version": "0.4.0" + } + }, + { + "package": "swift-concurrency-extras", + "repositoryURL": "https://github.com/pointfreeco/swift-concurrency-extras", + "state": { + "branch": null, + "revision": "479750bd98fac2e813fffcf2af0728b5b0085795", + "version": "0.1.1" } }, { @@ -66,11 +75,11 @@ }, { "package": "swift-dependencies", - "repositoryURL": "https://github.com/TokoFree/swift-dependencies", + "repositoryURL": "https://github.com/pointfreeco/swift-dependencies", "state": { - "branch": "rxswift-compatible-scheduler", - "revision": "6df2e8576a9433cfd8841697b86be20aa804e37c", - "version": null + "branch": null, + "revision": "16fd42ae04c6e7f74a6a86395d04722c641cccee", + "version": "0.6.0" } }, { @@ -78,8 +87,8 @@ "repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay", "state": { "branch": null, - "revision": "ab8c9f45843694dd16be4297e6d44c0634fd9913", - "version": "0.8.4" + "revision": "50843cbb8551db836adec2290bb4bc6bac5c1865", + "version": "0.9.0" } } ] diff --git a/Examples/Examples/1-BasicUsage/BasicUsageView.swift b/Examples/Examples/1-BasicUsage/BasicUsageView.swift new file mode 100644 index 0000000..802fdd0 --- /dev/null +++ b/Examples/Examples/1-BasicUsage/BasicUsageView.swift @@ -0,0 +1,62 @@ +// +// BasicUsageView.swift +// Examples +// +// Created by daniel.istyana on 25/07/23. +// + +import SwiftUI +import RxComposableArchitecture + +struct BasicUsageView: View { + let store: StoreOf + + var body: some View { + WithViewStore(self.store) { viewStore in + VStack { + Text("This is a demo for Basic usage State, Action, Reducer, and how to bind it to the UI using SwiftUI") + .padding() + Spacer() + HStack(spacing: 48) { + Button { + viewStore.send(.didTapPlus) + } label: { + Label { + Text("+") + .font(.title3) + } icon: { + Image(systemName: "plus") + } + .labelStyle(.titleOnly) + + } + + Text("\(viewStore.number)") + .font(.largeTitle) + + + Button { + viewStore.send(.didTapMinus) + } label: { + Label { + Text("-") + .font(.title3) + } icon: { + Image(systemName: "plus") + } + .labelStyle(.titleOnly) + + } + } + Spacer() + } + + } + } +} + +struct BasicUsageView_Previews: PreviewProvider { + static var previews: some View { + BasicUsageView(store: Store(initialState: Basic.State(number: 0), reducer: Basic())) + } +} diff --git a/Examples/Examples/2-Environment/EnvironmentDemoVC+Reducer.swift b/Examples/Examples/2-Environment/EnvironmentDemoVC+Reducer.swift index 93169e1..9bc6368 100644 --- a/Examples/Examples/2-Environment/EnvironmentDemoVC+Reducer.swift +++ b/Examples/Examples/2-Environment/EnvironmentDemoVC+Reducer.swift @@ -19,6 +19,10 @@ struct Environment: ReducerProtocol { var alertMessage: String? var uuidString: String = "NONE" var currentDate: Date? + + var isShowingAlert: Bool { + self.alertMessage != nil + } } enum Action: Equatable { diff --git a/Examples/Examples/2-Environment/EnvironmentRouteView.swift b/Examples/Examples/2-Environment/EnvironmentRouteView.swift new file mode 100644 index 0000000..51def0d --- /dev/null +++ b/Examples/Examples/2-Environment/EnvironmentRouteView.swift @@ -0,0 +1,154 @@ +// +// EnvironmentRouteView.swift +// Examples +// +// Created by daniel.istyana on 26/07/23. +// + +import RxComposableArchitecture +import SwiftUI + +struct EnvironmentRouteView: View { + var body: some View { + List(EnvironmentRouteVC.Route.allCases, id: \.self) { route in + NavigationLink(route.rawValue) { + switch route { + case .live: + EnvironmentDemoView( + store: Store( + initialState: Environment.State(), + reducer: Environment() + .dependency(\.envVCEnvironment, .live) + ) + ) + case .mockSuccess: + EnvironmentDemoView( + store: Store( + initialState: Environment.State(), + reducer: Environment() + .dependency(\.envVCEnvironment, .mockSuccess) + ) + ) + case .mockFailed: + EnvironmentDemoView( + store: Store( + initialState: Environment.State(), + reducer: Environment() + .dependency(\.envVCEnvironment, .mockFailed) + ) + ) + case .mockRandom: + EnvironmentDemoView( + store: Store( + initialState: Environment.State(), + reducer: Environment() + .dependency(\.envVCEnvironment, .mockRandom) + ) + ) + } + } + } + .navigationTitle("Environment") + } +} + +struct EnvironmentDemoView: View { + let store: StoreOf + var body: some View { + WithViewStore(self.store) { viewStore in + VStack { + Text("In this example, you will learn how to use Environment. You'll also learn how to use side effect (such as networking and analytics) Because we can initialize the environment in init, you can easily swap the environment from the EnvironmentRoute.swift. You can try to change from .live to .mock") + + HStack { + Text(viewStore.text) + .font(.title3) + + if viewStore.isLoading { + Spacer() + ProgressView() + } else { + Spacer() + } + } + .padding(.top) + + Button { + viewStore.send(.refresh) + } label: { + Text("Reload") + .font(.largeTitle) + } + + RoundedRectangle(cornerRadius: 8) + .frame(height: 3) + + if let date = viewStore.currentDate { + HStack { + Text(DateFormatter.convertToString(date: date)) + .font(.title3) + Spacer() + } + } + + Button { + viewStore.send(.getCurrentDate) + } label: { + Text("Get New Date") + .font(.largeTitle) + } + + + .frame(height: 3) + + if viewStore.uuidString.isEmpty { + HStack { + Text("None") + .font(.title3) + Spacer() + } + } else { + HStack { + Text(viewStore.uuidString) + Spacer() + } + } + + Button { + viewStore.send(.generateUUID) + } label: { + Text("Get New UUID") + .font(.largeTitle) + } + } + .alert( + isPresented: viewStore.binding( + get: \.isShowingAlert, + send: { _ in Environment.Action.dismissAlert } + ), content: { + Alert(title: Text("Test"), message: Text("Test")) + } + ) + .padding() + } + } +} + +struct EnvironmentRouteView_Previews: PreviewProvider { + static var previews: some View { + EnvironmentDemoView( + store: Store( + initialState: Environment.State(), + reducer: Environment() + ) + ) + } +} + +extension DateFormatter { + static func convertToString(date: Date) -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .full + dateFormatter.timeStyle = .full + return dateFormatter.string(from: date) + } +} diff --git a/Examples/Examples/RouteVC.swift b/Examples/Examples/RouteVC.swift index 1435f7f..331fd9a 100644 --- a/Examples/Examples/RouteVC.swift +++ b/Examples/Examples/RouteVC.swift @@ -7,9 +7,14 @@ import RxComposableArchitecture import UIKit +import SwiftUI class RouteVC: UITableViewController { internal enum Route: String, CaseIterable { + internal enum UIFramework: String, CaseIterable { + case SwiftUI + case UIKit + } case basic = "1. State, Action, Reducer" case environment = "2. Environment" case scoping = "3. Scope" @@ -19,7 +24,10 @@ class RouteVC: UITableViewController { case timer = "7. Demo Timer" } - internal var routes: [Route] = Route.allCases + internal var routes: [Route.UIFramework: [Route]] = [ + .UIKit: Route.allCases, + .SwiftUI: Route.allCases + ] internal init() { super.init(style: .insetGrouped) title = "RxComposableArchitecture Examples" @@ -27,9 +35,10 @@ class RouteVC: UITableViewController { } override internal func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) { + let route = routes[indexPath.section == 0 ? .UIKit : .SwiftUI]! tableView.deselectRow(at: indexPath, animated: true) - let selectedRoute = routes[indexPath.row] + let selectedRoute = route[indexPath.row] switch selectedRoute { case .basic: let viewController = BasicUsageVC( @@ -38,9 +47,21 @@ class RouteVC: UITableViewController { reducer: Basic() ) ) - navigationController?.pushViewController(viewController, animated: true) + let swiftUIController = UIHostingController( + rootView: BasicUsageView( + store: Store( + initialState: Basic.State(number: 0), + reducer: Basic() + ) + ) + ) + navigationController?.pushViewController(indexPath.section == 0 ? viewController : swiftUIController, animated: true) + case .environment: - navigationController?.pushViewController(EnvironmentRouteVC(), animated: true) + let uikitVC = EnvironmentRouteVC() + let swiftUIVC = UIHostingController(rootView: EnvironmentRouteView()) + navigationController?.pushViewController(indexPath.section == 0 ? uikitVC : swiftUIVC, animated: true) + case .scoping: let viewController = ScopingVC( store: StoreOf( @@ -84,13 +105,26 @@ class RouteVC: UITableViewController { } } - override func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { - routes.count + override func numberOfSections(in tableView: UITableView) -> Int { + return routes.keys.count + } + + override func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int { + return Route.allCases.count + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch section { + case 0: return "UIKit" + case 1: return "SwiftUI" + default: return nil + } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) - cell.textLabel?.text = routes[indexPath.row].rawValue + let route = routes[indexPath.section == 0 ? .UIKit : .SwiftUI]! + cell.textLabel?.text = route[indexPath.row].rawValue return cell } diff --git a/Sources/RxComposableArchitecture/Store.swift b/Sources/RxComposableArchitecture/Store.swift index 04d0039..0336b11 100644 --- a/Sources/RxComposableArchitecture/Store.swift +++ b/Sources/RxComposableArchitecture/Store.swift @@ -890,3 +890,43 @@ extension ScopedReducer: AnyScopedReducer { } } #endif + +/// The type returned from ``Store/send(_:)`` that represents the lifecycle of the effect +/// started from sending an action. +/// +/// You can use this value to tie the effect's lifecycle _and_ cancellation to an asynchronous +/// context, such as the `task` view modifier. +/// +/// ```swift +/// .task { await store.send(.task).finish() } +/// ``` +/// +/// > Note: Unlike Swift's `Task` type, ``StoreTask`` automatically sets up a cancellation +/// > handler between the current async context and the task. +/// +/// See ``TestStoreTask`` for the analog returned from ``TestStore``. +public struct StoreTask: Hashable, Sendable { + internal let rawValue: Task? + + internal init(rawValue: Task?) { + self.rawValue = rawValue + } + + /// Cancels the underlying task. + public func cancel() { + self.rawValue?.cancel() + } + + /// Waits for the task to finish. + public func finish() async { + await self.rawValue?.cancellableValue + } + + /// A Boolean value that indicates whether the task should stop executing. + /// + /// After the value of this property becomes `true`, it remains `true` indefinitely. There is no + /// way to uncancel a task. + public var isCancelled: Bool { + self.rawValue?.isCancelled ?? true + } +} diff --git a/Sources/RxComposableArchitecture/SwiftUI/Alert.swift b/Sources/RxComposableArchitecture/SwiftUI/Alert.swift new file mode 100644 index 0000000..bf85ee9 --- /dev/null +++ b/Sources/RxComposableArchitecture/SwiftUI/Alert.swift @@ -0,0 +1,297 @@ +import CustomDump +import SwiftUI + +private struct OldAlertModifier: ViewModifier { + @ObservedObject var viewStore: ViewStore?, Action> + let dismiss: Action + + func body(content: Content) -> some View { + content.alert(item: viewStore.binding(send: dismiss)) { state in + Alert(state) { action in + if let action = action { + viewStore.send(action) + } + } + } + } +} + +extension View { + /// Displays an alert when then store's state becomes non-`nil`, and dismisses it when it becomes + /// `nil`. + /// + /// - Parameters: + /// - store: A store that describes if the alert is shown or dismissed. + /// - dismiss: An action to send when the alert is dismissed through non-user actions, such + /// as when an alert is automatically dismissed by the system. Use this action to `nil` out + /// the associated alert state. + public func alert( + _ store: Store?, Action>, + dismiss: Action + ) -> some View { + self.modifier( + OldAlertModifier( + viewStore: ViewStore(store, removeDuplicates: { $0?.id == $1?.id }), + dismiss: dismiss + ) + ) + } +} + +/// A data type that describes the state of an alert that can be shown to the user. The `Action` +/// generic is the type of actions that can be sent from tapping on a button in the alert. +/// +/// This type can be used in your application's state in order to control the presentation and +/// actions of alerts. This API can be used to push the logic of alert presentation and actions into +/// your model, making it easier to test, and simplifying your view layer. +/// +/// To use this API, you first describe all of the actions that can take place in all of your +/// alerts as an enum: +/// +/// ```swift +/// class HomeScreenModel: ObservableObject { +/// enum AlertAction { +/// case delete +/// case removeFromHomeScreen +/// } +/// // ... +/// } +/// ``` +/// +/// Then you hold onto optional `AlertState` as a `@Published` field in your model, which can +/// start off as `nil`: +/// +/// ```swift +/// class HomeScreenModel: ObservableObject { +/// @Published var alert: AlertState? +/// // ... +/// } +/// ``` +/// +/// And you define an endpoint for handling each alert action: +/// +/// ```swift +/// class HomeScreenModel: ObservableObject { +/// // ... +/// func alertButtonTapped(_ action: AlertAction) { +/// switch action { +/// case .delete: +/// // ... +/// case .removeFromHomeScreen: +/// // ... +/// } +/// } +/// } +/// ``` +/// +/// Then, whenever you need to show an alert you can simply construct an ``AlertState`` value to +/// represent the alert: +/// +/// ```swift +/// class HomeScreenModel: ObservableObject { +/// // ... +/// func deleteAppButtonTapped() { +/// self.alert = AlertState { +/// TextState(#"Remove "Twitter"?"#) +/// } actions: { +/// ButtonState(role: .destructive, action: .send(.delete)) { +/// TextState("Delete App") +/// } +/// ButtonState(action: .send(.removeFromHomeScreen)) { +/// TextState("Remove from Home Screen") +/// } +/// } message: { +/// TextState( +/// "Removing from Home Screen will keep the app in your App Library." +/// ) +/// } +/// } +/// } +/// ``` +/// +/// And in your view you can use the `.alert(unwrapping:action:)` view modifier to present the +/// alert: +/// +/// ```swift +/// struct FeatureView: View { +/// @ObservedObject var model: HomeScreenModel +/// +/// var body: some View { +/// VStack { +/// Button("Delete") { +/// self.model.deleteAppButtonTapped() +/// } +/// } +/// .alert(unwrapping: self.$model.alert) { action in +/// self.model.alertButtonTapped(action) +/// } +/// } +/// } +/// ``` +/// +/// This makes your model in complete control of when the alert is shown or dismissed, and makes it +/// so that any choice made in the alert is automatically fed back into the model so that you can +/// handle its logic. +/// +/// Even better, because `AlertState` is equatable (when `Action` is equatable), you can instantly +/// write tests that your alert behavior works as expected: +/// +/// ```swift +/// let model = HomeScreenModel() +/// +/// model.deleteAppButtonTapped() +/// XCTAssertEqual( +/// model.alert, +/// AlertState { +/// TextState(#"Remove "Twitter"?"#) +/// } actions: { +/// ButtonState(role: .destructive, action: .deleteButtonTapped) { +/// TextState("Delete App"), +/// }, +/// ButtonState(action: .removeFromHomeScreenButtonTapped) { +/// TextState("Remove from Home Screen"), +/// } +/// } message: { +/// TextState( +/// "Removing from Home Screen will keep the app in your App Library." +/// ) +/// } +/// ) +/// +/// model.alertButtonTapped(.delete) { +/// // Also verify that delete logic executed correctly +/// } +/// model.alert = nil +/// ``` +public struct AlertState: Identifiable { + public let id: UUID + public var buttons: [ButtonState] + public var message: TextState? + public var title: TextState + + init( + id: UUID, + buttons: [ButtonState], + message: TextState?, + title: TextState + ) { + self.id = id + self.buttons = buttons + self.message = message + self.title = title + } + + /// Creates alert state. + /// + /// - Parameters: + /// - title: The title of the alert. + /// - actions: A ``ButtonStateBuilder`` returning the alert's actions. + /// - message: The message for the alert. + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + public init( + title: () -> TextState, + @ButtonStateBuilder actions: () -> [ButtonState] = { [] }, + message: (() -> TextState)? = nil + ) { + self.init( + id: UUID(), + buttons: actions(), + message: message?(), + title: title() + ) + } + + public func map(_ transform: (Action?) -> NewAction?) -> AlertState { + AlertState( + id: self.id, + buttons: self.buttons.map { $0.map(transform) }, + message: self.message, + title: self.title + ) + } +} + +extension AlertState: CustomDumpReflectable { + public var customDumpMirror: Mirror { + var children: [(label: String?, value: Any)] = [ + ("title", self.title) + ] + if !self.buttons.isEmpty { + children.append(("actions", self.buttons)) + } + if let message = self.message { + children.append(("message", message)) + } + return Mirror( + self, + children: children, + displayStyle: .struct + ) + } +} + +extension AlertState: Equatable where Action: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.title == rhs.title + && lhs.message == rhs.message + && lhs.buttons == rhs.buttons + } +} + +extension AlertState: Hashable where Action: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(self.title) + hasher.combine(self.message) + hasher.combine(self.buttons) + } +} + +// MARK: - SwiftUI bridging + +extension Alert { + /// Creates an alert from alert state. + /// + /// - Parameters: + /// - state: Alert state used to populate the alert. + /// - action: An action handler, called when a button with an action is tapped, by passing the + /// action to the closure. + public init(_ state: AlertState, action: @escaping (Action?) -> Void) { + if state.buttons.count == 2 { + self.init( + title: Text(state.title), + message: state.message.map { Text($0) }, + primaryButton: .init(state.buttons[0], action: action), + secondaryButton: .init(state.buttons[1], action: action) + ) + } else { + self.init( + title: Text(state.title), + message: state.message.map { Text($0) }, + dismissButton: state.buttons.first.map { .init($0, action: action) } + ) + } + } + + /// Creates an alert from alert state. + /// + /// - Parameters: + /// - state: Alert state used to populate the alert. + /// - action: An action handler, called when a button with an action is tapped, by passing the + /// action to the closure. + public init(_ state: AlertState, action: @escaping (Action?) async -> Void) { + if state.buttons.count == 2 { + self.init( + title: Text(state.title), + message: state.message.map { Text($0) }, + primaryButton: .init(state.buttons[0], action: action), + secondaryButton: .init(state.buttons[1], action: action) + ) + } else { + self.init( + title: Text(state.title), + message: state.message.map { Text($0) }, + dismissButton: state.buttons.first.map { .init($0, action: action) } + ) + } + } +} diff --git a/Sources/RxComposableArchitecture/SwiftUI/ButtonState.swift b/Sources/RxComposableArchitecture/SwiftUI/ButtonState.swift new file mode 100644 index 0000000..c1ac3bc --- /dev/null +++ b/Sources/RxComposableArchitecture/SwiftUI/ButtonState.swift @@ -0,0 +1,320 @@ +import CustomDump +import SwiftUI + +public struct ButtonState: Identifiable { + public let id: UUID + public let action: ButtonStateAction + public let label: TextState + public let role: ButtonStateRole? + + init( + id: UUID, + action: ButtonStateAction, + label: TextState, + role: ButtonStateRole? + ) { + self.id = id + self.action = action + self.label = label + self.role = role + } + + /// Creates button state. + /// + /// - Parameters: + /// - role: An optional semantic role that describes the button. A value of `nil` means that the + /// button doesn't have an assigned role. + /// - action: The action to send when the user interacts with the button. + /// - label: A view that describes the purpose of the button's `action`. + public init( + role: ButtonStateRole? = nil, + action: ButtonStateAction = .send(nil), + label: () -> TextState + ) { + self.init(id: UUID(), action: action, label: label(), role: role) + } + + /// Creates button state. + /// + /// - Parameters: + /// - role: An optional semantic role that describes the button. A value of `nil` means that the + /// button doesn't have an assigned role. + /// - action: The action to send when the user interacts with the button. + /// - label: A view that describes the purpose of the button's `action`. + public init( + role: ButtonStateRole? = nil, + action: Action, + label: () -> TextState + ) { + self.init(id: UUID(), action: .send(action), label: label(), role: role) + } + + /// Handle the button's action in a closure. + /// + /// - Parameter perform: Unwraps and passes a button's action to a closure to be performed. If the + /// action has an associated animation, the context will be wrapped using SwiftUI's + /// `withAnimation`. + public func withAction(_ perform: (Action?) -> Void) { + switch self.action.type { + case let .send(action): + perform(action) + case let .animatedSend(action, animation): + withAnimation(animation) { + perform(action) + } + } + } + + /// Handle the button's action in an async closure. + /// + /// > Warning: Async closures cannot be performed with animation. If the underlying action is + /// > animated, a runtime warning will be emitted. + /// + /// - Parameter perform: Unwraps and passes a button's action to a closure to be performed. + public func withAction(_ perform: (Action?) async -> Void) async { + switch self.action.type { + case let .send(action): + await perform(action) + case let .animatedSend(action, _): + var output = "" + customDump(self.action, to: &output, indent: 4) + runtimeWarn( + """ + An animated action was performed asynchronously: … + + Action: + \((output)) + + Asynchronous actions cannot be animated. Evaluate this action in a synchronous closure, or \ + use 'SwiftUI.withAnimation' explicitly. + """ + ) + await perform(action) + } + } + + /// Transforms a button state's action into a new action. + /// + /// - Parameter transform: A closure that transforms an optional action into a new optional + /// action. + /// - Returns: Button state over a new action. + public func map(_ transform: (Action?) -> NewAction?) -> ButtonState { + ButtonState( + id: self.id, + action: self.action.map(transform), + label: self.label, + role: self.role + ) + } +} + +/// A type that wraps an action with additional context, _e.g._ for animation. +public struct ButtonStateAction { + public let type: _ActionType + + public static func send(_ action: Action?) -> Self { + .init(type: .send(action)) + } + + public static func send(_ action: Action?, animation: Animation?) -> Self { + .init(type: .animatedSend(action, animation: animation)) + } + + public var action: Action? { + switch self.type { + case let .animatedSend(action, animation: _), let .send(action): + return action + } + } + + public func map( + _ transform: (Action?) -> NewAction? + ) -> ButtonStateAction { + switch self.type { + case let .animatedSend(action, animation: animation): + return .send(transform(action), animation: animation) + case let .send(action): + return .send(transform(action)) + } + } + + public enum _ActionType { + case send(Action?) + case animatedSend(Action?, animation: Animation?) + } +} + +/// A value that describes the purpose of a button. +/// +/// See `SwiftUI.ButtonRole` for more information. +public enum ButtonStateRole { + /// A role that indicates a cancel button. + /// + /// See `SwiftUI.ButtonRole.cancel` for more information. + case cancel + + /// A role that indicates a destructive button. + /// + /// See `SwiftUI.ButtonRole.destructive` for more information. + case destructive +} + +extension ButtonState: CustomDumpReflectable { + public var customDumpMirror: Mirror { + var children: [(label: String?, value: Any)] = [] + if let role = self.role { + children.append(("role", role)) + } + children.append(("action", self.action)) + children.append(("label", self.label)) + return Mirror( + self, + children: children, + displayStyle: .struct + ) + } +} + +extension ButtonStateAction: CustomDumpReflectable { + public var customDumpMirror: Mirror { + switch self.type { + case let .send(action): + return Mirror( + self, + children: [ + "send": action as Any + ], + displayStyle: .enum + ) + case let .animatedSend(action, animation): + return Mirror( + self, + children: [ + "send": (action, animation: animation) + ], + displayStyle: .enum + ) + } + } +} + +extension ButtonStateAction: Equatable where Action: Equatable {} +extension ButtonStateAction._ActionType: Equatable where Action: Equatable {} +extension ButtonStateRole: Equatable {} +extension ButtonState: Equatable where Action: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.action == rhs.action + && lhs.label == rhs.label + && lhs.role == rhs.role + } +} + +extension ButtonStateAction: Hashable where Action: Hashable {} +extension ButtonStateAction._ActionType: Hashable where Action: Hashable { + public func hash(into hasher: inout Hasher) { + switch self { + case let .send(action), let .animatedSend(action, animation: _): + hasher.combine(action) + } + } +} +extension ButtonStateRole: Hashable {} +extension ButtonState: Hashable where Action: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(self.action) + hasher.combine(self.label) + hasher.combine(self.role) + } +} + +// MARK: - SwiftUI bridging + +extension Alert.Button { + /// Initializes a `SwiftUI.Alert.Button` from `ButtonState` and an action handler. + /// + /// - Parameters: + /// - button: Button state. + /// - action: An action closure that is invoked when the button is tapped. + public init(_ button: ButtonState, action: @escaping (Action?) -> Void) { + let action = { button.withAction(action) } + switch button.role { + case .cancel: + self = .cancel(Text(button.label), action: action) + case .destructive: + self = .destructive(Text(button.label), action: action) + case .none: + self = .default(Text(button.label), action: action) + } + } + + /// Initializes a `SwiftUI.Alert.Button` from `ButtonState` and an async action handler. + /// + /// > Warning: Async closures cannot be performed with animation. If the underlying action is + /// > animated, a runtime warning will be emitted. + /// + /// - Parameters: + /// - button: Button state. + /// - action: An action closure that is invoked when the button is tapped. + public init(_ button: ButtonState, action: @escaping (Action?) async -> Void) { + let action = { _ = Task { await button.withAction(action) } } + switch button.role { + case .cancel: + self = .cancel(Text(button.label), action: action) + case .destructive: + self = .destructive(Text(button.label), action: action) + case .none: + self = .default(Text(button.label), action: action) + } + } +} + +@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) +extension ButtonRole { + public init(_ role: ButtonStateRole) { + switch role { + case .cancel: + self = .cancel + case .destructive: + self = .destructive + } + } +} + +extension Button where Label == Text { + /// Initializes a `SwiftUI.Button` from `ButtonState` and an async action handler. + /// + /// - Parameters: + /// - button: Button state. + /// - action: An action closure that is invoked when the button is tapped. + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + public init(_ button: ButtonState, action: @escaping (Action?) -> Void) { + self.init( + role: button.role.map(ButtonRole.init), + action: { button.withAction(action) } + ) { + Text(button.label) + } + } + + /// Initializes a `SwiftUI.Button` from `ButtonState` and an action handler. + /// + /// > Warning: Async closures cannot be performed with animation. If the underlying action is + /// > animated, a runtime warning will be emitted. + /// + /// - Parameters: + /// - button: Button state. + /// - action: An action closure that is invoked when the button is tapped. + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + public init(_ button: ButtonState, action: @escaping (Action?) async -> Void) { + self.init( + role: button.role.map(ButtonRole.init), + action: { Task { await button.withAction(action) } } + ) { + Text(button.label) + } + } +} + +private func isUnlabeledArgument(_ label: String) -> Bool { + label.firstIndex(where: { $0 != "." && !$0.isNumber }) == nil +} diff --git a/Sources/RxComposableArchitecture/SwiftUI/Internal/ButtonStateSlider.swift b/Sources/RxComposableArchitecture/SwiftUI/Internal/ButtonStateSlider.swift new file mode 100644 index 0000000..46963bf --- /dev/null +++ b/Sources/RxComposableArchitecture/SwiftUI/Internal/ButtonStateSlider.swift @@ -0,0 +1,32 @@ +@resultBuilder +public enum ButtonStateBuilder { + public static func buildArray(_ components: [[ButtonState]]) -> [ButtonState] { + components.flatMap { $0 } + } + + public static func buildBlock(_ components: [ButtonState]...) -> [ButtonState] { + components.flatMap { $0 } + } + + public static func buildLimitedAvailability( + _ component: [ButtonState] + ) -> [ButtonState] { + component + } + + public static func buildEither(first component: [ButtonState]) -> [ButtonState] { + component + } + + public static func buildEither(second component: [ButtonState]) -> [ButtonState] { + component + } + + public static func buildExpression(_ expression: ButtonState) -> [ButtonState] { + [expression] + } + + public static func buildOptional(_ component: [ButtonState]?) -> [ButtonState] { + component ?? [] + } +} diff --git a/Sources/RxComposableArchitecture/SwiftUI/TextState.swift b/Sources/RxComposableArchitecture/SwiftUI/TextState.swift new file mode 100644 index 0000000..f6d1f48 --- /dev/null +++ b/Sources/RxComposableArchitecture/SwiftUI/TextState.swift @@ -0,0 +1,741 @@ +import CustomDump +import SwiftUI + +/// An equatable description of SwiftUI `Text`. Useful for storing rich text in feature models +/// that can still be tested for equality. +/// +/// Although `SwiftUI.Text` and `SwiftUI.LocalizedStringKey` are value types that conform to +/// `Equatable`, their `==` do not return `true` when used with seemingly equal values. If we were +/// to naively store these values in state, our tests may begin to fail. +/// +/// ``TextState`` solves this problem by providing an interface similar to `SwiftUI.Text` that can +/// be held in state and asserted against. +/// +/// Let's say you wanted to hold some dynamic, styled text content in your app state. You could use +/// ``TextState``: +/// +/// ```swift +/// class Model: Equatable { +/// @Published var label = TextState("") +/// } +/// ``` +/// +/// Your model can then assign a value to this state using an API similar to that of `SwiftUI.Text`. +/// +/// ```swift +/// self.label = TextState("Hello, ") + TextState(name).bold() + TextState("!") +/// ``` +/// +/// And your view can render it by passing it to a `SwiftUI.Text` initializer: +/// +/// ```swift +/// var body: some View { +/// Text(self.model.label) +/// } +/// ``` +/// +/// SwiftUI Navigation comes with a few convenience APIs for alerts and dialogs that wrap +/// ``TextState`` under the hood. See ``AlertState`` and ``ConfirmationDialogState`` accordingly. +/// +/// In the future, should `SwiftUI.Text` and `SwiftUI.LocalizedStringKey` reliably conform to +/// `Equatable`, ``TextState`` may be deprecated. +/// +/// - Note: ``TextState`` does not support _all_ `LocalizedStringKey` permutations at this time +/// (interpolated `SwiftUI.Image`s, for example). ``TextState`` also uses reflection to determine +/// `LocalizedStringKey` equatability, so be mindful of edge cases. +public struct TextState: Equatable, Hashable { + fileprivate var modifiers: [Modifier] = [] + fileprivate let storage: Storage + + fileprivate enum Modifier: Equatable, Hashable { + case accessibilityHeading(AccessibilityHeadingLevel) + case accessibilityLabel(TextState) + case accessibilityTextContentType(AccessibilityTextContentType) + case baselineOffset(CGFloat) + case bold(isActive: Bool) + case font(Font?) + case fontDesign(Font.Design?) + case fontWeight(Font.Weight?) + case fontWidth(FontWidth?) + case foregroundColor(Color?) + case italic(isActive: Bool) + case kerning(CGFloat) + case monospacedDigit + case speechAdjustedPitch(Double) + case speechAlwaysIncludesPunctuation(Bool) + case speechAnnouncementsQueued(Bool) + case speechSpellsOutCharacters(Bool) + case strikethrough(isActive: Bool, pattern: LineStylePattern?, color: Color?) + case tracking(CGFloat) + case underline(isActive: Bool, pattern: LineStylePattern?, color: Color?) + } + + public enum FontWidth: String, Equatable, Hashable { + case compressed + case condensed + case expanded + case standard + +#if swift(>=5.7.1) + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + var toSwiftUI: SwiftUI.Font.Width { + switch self { + case .compressed: return .compressed + case .condensed: return .condensed + case .expanded: return .expanded + case .standard: return .standard + } + } +#endif + } + + public enum LineStylePattern: String, Equatable, Hashable { + case dash + case dashDot + case dashDotDot + case dot + case solid + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + var toSwiftUI: SwiftUI.Text.LineStyle.Pattern { + switch self { + case .dash: return .dash + case .dashDot: return .dashDot + case .dashDotDot: return .dashDotDot + case .dot: return .dot + case .solid: return .solid + } + } + } + + fileprivate enum Storage: Equatable, Hashable { + indirect case concatenated(TextState, TextState) + case localized(LocalizedStringKey, tableName: String?, bundle: Bundle?, comment: StaticString?) + case verbatim(String) + + static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case let (.concatenated(l1, l2), .concatenated(r1, r2)): + return l1 == r1 && l2 == r2 + + case let (.localized(lk, lt, lb, lc), .localized(rk, rt, rb, rc)): + return lk.formatted(tableName: lt, bundle: lb, comment: lc) + == rk.formatted(tableName: rt, bundle: rb, comment: rc) + + case let (.verbatim(lhs), .verbatim(rhs)): + return lhs == rhs + + case let (.localized(key, tableName, bundle, comment), .verbatim(string)), + let (.verbatim(string), .localized(key, tableName, bundle, comment)): + return key.formatted(tableName: tableName, bundle: bundle, comment: comment) == string + + // NB: We do not attempt to equate concatenated cases. + default: + return false + } + } + + func hash(into hasher: inout Hasher) { + enum Key { + case concatenated + case localized + case verbatim + } + + switch self { + case let (.concatenated(first, second)): + hasher.combine(Key.concatenated) + hasher.combine(first) + hasher.combine(second) + + case let .localized(key, tableName, bundle, comment): + hasher.combine(Key.localized) + hasher.combine(key.formatted(tableName: tableName, bundle: bundle, comment: comment)) + + case let .verbatim(string): + hasher.combine(Key.verbatim) + hasher.combine(string) + } + } + } +} + +// MARK: - API + +extension TextState { + public init(verbatim content: String) { + self.storage = .verbatim(content) + } + + @_disfavoredOverload + public init(_ content: S) { + self.init(verbatim: String(content)) + } + + public init( + _ key: LocalizedStringKey, + tableName: String? = nil, + bundle: Bundle? = nil, + comment: StaticString? = nil + ) { + self.storage = .localized(key, tableName: tableName, bundle: bundle, comment: comment) + } + + public static func + (lhs: Self, rhs: Self) -> Self { + .init(storage: .concatenated(lhs, rhs)) + } + + public func baselineOffset(_ baselineOffset: CGFloat) -> Self { + var `self` = self + `self`.modifiers.append(.baselineOffset(baselineOffset)) + return `self` + } + + public func bold() -> Self { + var `self` = self + `self`.modifiers.append(.bold(isActive: true)) + return `self` + } + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public func bold(isActive: Bool) -> Self { + var `self` = self + `self`.modifiers.append(.bold(isActive: isActive)) + return `self` + } + + public func font(_ font: Font?) -> Self { + var `self` = self + `self`.modifiers.append(.font(font)) + return `self` + } + + public func fontDesign(_ design: Font.Design?) -> Self { + var `self` = self + `self`.modifiers.append(.fontDesign(design)) + return `self` + } + + public func fontWeight(_ weight: Font.Weight?) -> Self { + var `self` = self + `self`.modifiers.append(.fontWeight(weight)) + return `self` + } + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public func fontWidth(_ width: FontWidth?) -> Self { + var `self` = self + `self`.modifiers.append(.fontWidth(width)) + return `self` + } + + public func foregroundColor(_ color: Color?) -> Self { + var `self` = self + `self`.modifiers.append(.foregroundColor(color)) + return `self` + } + + public func italic() -> Self { + var `self` = self + `self`.modifiers.append(.italic(isActive: true)) + return `self` + } + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public func italic(isActive: Bool) -> Self { + var `self` = self + `self`.modifiers.append(.italic(isActive: isActive)) + return `self` + } + + public func kerning(_ kerning: CGFloat) -> Self { + var `self` = self + `self`.modifiers.append(.kerning(kerning)) + return `self` + } + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + public func monospacedDigit() -> Self { + var `self` = self + `self`.modifiers.append(.monospacedDigit) + return `self` + } + + public func strikethrough(_ isActive: Bool = true, color: Color? = nil) -> Self { + var `self` = self + `self`.modifiers.append(.strikethrough(isActive: isActive, pattern: .solid, color: color)) + return `self` + } + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public func strikethrough( + _ isActive: Bool = true, + pattern: LineStylePattern, + color: Color? = nil + ) -> Self { + var `self` = self + `self`.modifiers.append(.strikethrough(isActive: isActive, pattern: pattern, color: color)) + return `self` + } + + public func tracking(_ tracking: CGFloat) -> Self { + var `self` = self + `self`.modifiers.append(.tracking(tracking)) + return `self` + } + + public func underline(_ isActive: Bool = true, color: Color? = nil) -> Self { + var `self` = self + `self`.modifiers.append(.underline(isActive: isActive, pattern: .solid, color: color)) + return `self` + } + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public func underline( + _ isActive: Bool = true, + pattern: LineStylePattern, + color: Color? = nil + ) -> Self { + var `self` = self + `self`.modifiers.append(.underline(isActive: isActive, pattern: pattern, color: color)) + return `self` + } +} + +// MARK: Accessibility + +extension TextState { + public enum AccessibilityTextContentType: String, Equatable, Hashable { + case console, fileSystem, messaging, narrative, plain, sourceCode, spreadsheet, wordProcessing + +#if compiler(>=5.5.1) + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + var toSwiftUI: SwiftUI.AccessibilityTextContentType { + switch self { + case .console: return .console + case .fileSystem: return .fileSystem + case .messaging: return .messaging + case .narrative: return .narrative + case .plain: return .plain + case .sourceCode: return .sourceCode + case .spreadsheet: return .spreadsheet + case .wordProcessing: return .wordProcessing + } + } +#endif + } + + public enum AccessibilityHeadingLevel: String, Equatable, Hashable { + case h1, h2, h3, h4, h5, h6, unspecified + +#if compiler(>=5.5.1) + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + var toSwiftUI: SwiftUI.AccessibilityHeadingLevel { + switch self { + case .h1: return .h1 + case .h2: return .h2 + case .h3: return .h3 + case .h4: return .h4 + case .h5: return .h5 + case .h6: return .h6 + case .unspecified: return .unspecified + } + } +#endif + } +} + +@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) +extension TextState { + public func accessibilityHeading(_ headingLevel: AccessibilityHeadingLevel) -> Self { + var `self` = self + `self`.modifiers.append(.accessibilityHeading(headingLevel)) + return `self` + } + + public func accessibilityLabel(_ label: Self) -> Self { + var `self` = self + `self`.modifiers.append(.accessibilityLabel(label)) + return `self` + } + + public func accessibilityLabel(_ string: String) -> Self { + var `self` = self + `self`.modifiers.append(.accessibilityLabel(.init(string))) + return `self` + } + + public func accessibilityLabel(_ string: S) -> Self { + var `self` = self + `self`.modifiers.append(.accessibilityLabel(.init(string))) + return `self` + } + + public func accessibilityLabel( + _ key: LocalizedStringKey, tableName: String? = nil, bundle: Bundle? = nil, + comment: StaticString? = nil + ) -> Self { + var `self` = self + `self`.modifiers.append( + .accessibilityLabel(.init(key, tableName: tableName, bundle: bundle, comment: comment))) + return `self` + } + + public func accessibilityTextContentType(_ type: AccessibilityTextContentType) -> Self { + var `self` = self + `self`.modifiers.append(.accessibilityTextContentType(type)) + return `self` + } + + public func speechAdjustedPitch(_ value: Double) -> Self { + var `self` = self + `self`.modifiers.append(.speechAdjustedPitch(value)) + return `self` + } + + public func speechAlwaysIncludesPunctuation(_ value: Bool = true) -> Self { + var `self` = self + `self`.modifiers.append(.speechAlwaysIncludesPunctuation(value)) + return `self` + } + + public func speechAnnouncementsQueued(_ value: Bool = true) -> Self { + var `self` = self + `self`.modifiers.append(.speechAnnouncementsQueued(value)) + return `self` + } + + public func speechSpellsOutCharacters(_ value: Bool = true) -> Self { + var `self` = self + `self`.modifiers.append(.speechSpellsOutCharacters(value)) + return `self` + } +} + +extension Text { + public init(_ state: TextState) { + let text: Text + switch state.storage { + case let .concatenated(first, second): + text = Text(first) + Text(second) + case let .localized(content, tableName, bundle, comment): + text = .init(content, tableName: tableName, bundle: bundle, comment: comment) + case let .verbatim(content): + text = .init(verbatim: content) + } + self = state.modifiers.reduce(text) { text, modifier in + switch modifier { +#if compiler(>=5.5.1) + case let .accessibilityHeading(level): + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + return text.accessibilityHeading(level.toSwiftUI) + } else { + return text + } + case let .accessibilityLabel(value): + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + switch value.storage { + case let .verbatim(string): + return text.accessibilityLabel(string) + case let .localized(key, tableName, bundle, comment): + return text.accessibilityLabel( + Text(key, tableName: tableName, bundle: bundle, comment: comment)) + case .concatenated(_, _): + assertionFailure("`.accessibilityLabel` does not support concatenated `TextState`") + return text + } + } else { + return text + } + case let .accessibilityTextContentType(type): + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + return text.accessibilityTextContentType(type.toSwiftUI) + } else { + return text + } +#else + case .accessibilityHeading, + .accessibilityLabel, + .accessibilityTextContentType: + return text +#endif + case let .baselineOffset(baselineOffset): + return text.baselineOffset(baselineOffset) + case let .bold(isActive): +#if swift(>=5.7.1) + if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { + return text.bold(isActive) + } else { + return text.bold() + } +#else + _ = isActive + return text.bold() +#endif + case let .font(font): + return text.font(font) + case let .fontDesign(design): +#if swift(>=5.7.1) + if #available(iOS 16.1, macOS 13, tvOS 16.1, watchOS 9.1, *) { + return text.fontDesign(design) + } else { + return text + } +#else + _ = design + return text +#endif + case let .fontWeight(weight): + return text.fontWeight(weight) + case let .fontWidth(width): +#if swift(>=5.7.1) + if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { + return text.fontWidth(width?.toSwiftUI) + } else { + return text + } +#else + _ = width + return text +#endif + case let .foregroundColor(color): + return text.foregroundColor(color) + case let .italic(isActive): +#if swift(>=5.7.1) + if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { + return text.italic(isActive) + } else { + return text.italic() + } +#else + _ = isActive + return text.italic() +#endif + case let .kerning(kerning): + return text.kerning(kerning) + case .monospacedDigit: + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + return text.monospacedDigit() + } else { + return text + } + case let .speechAdjustedPitch(value): + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + return text.speechAdjustedPitch(value) + } else { + return text + } + case let .speechAlwaysIncludesPunctuation(value): + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + return text.speechAlwaysIncludesPunctuation(value) + } else { + return text + } + case let .speechAnnouncementsQueued(value): + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + return text.speechAnnouncementsQueued(value) + } else { + return text + } + case let .speechSpellsOutCharacters(value): + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + return text.speechSpellsOutCharacters(value) + } else { + return text + } + case let .strikethrough(isActive, pattern, color): +#if swift(>=5.7.1) + if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *), let pattern = pattern { + return text.strikethrough(isActive, pattern: pattern.toSwiftUI, color: color) + } else { + return text.strikethrough(isActive, color: color) + } +#else + _ = pattern + return text.strikethrough(isActive, color: color) +#endif + case let .tracking(tracking): + return text.tracking(tracking) + case let .underline(isActive, pattern, color): +#if swift(>=5.7.1) + if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *), let pattern = pattern { + return text.underline(isActive, pattern: pattern.toSwiftUI, color: color) + } else { + return text.underline(isActive, color: color) + } +#else + _ = pattern + return text.strikethrough(isActive, color: color) +#endif + } + } + } +} + +extension String { + public init(state: TextState, locale: Locale? = nil) { + switch state.storage { + case let .concatenated(lhs, rhs): + self = String(state: lhs, locale: locale) + String(state: rhs, locale: locale) + + case let .localized(key, tableName, bundle, comment): + self = key.formatted( + locale: locale, + tableName: tableName, + bundle: bundle, + comment: comment + ) + + case let .verbatim(string): + self = string + } + } +} + +extension LocalizedStringKey { + // NB: `LocalizedStringKey` conforms to `Equatable` but returns false for equivalent format + // strings. To account for this we reflect on it to extract and string-format its storage. + fileprivate func formatted( + locale: Locale? = nil, + tableName: String? = nil, + bundle: Bundle? = nil, + comment: StaticString? = nil + ) -> String { + let children = Array(Mirror(reflecting: self).children) + let key = children[0].value as! String + let arguments: [CVarArg] = Array(Mirror(reflecting: children[2].value).children) + .compactMap { + let children = Array(Mirror(reflecting: $0.value).children) + let value: Any + let formatter: Formatter? + // `LocalizedStringKey.FormatArgument` differs depending on OS/platform. + if children[0].label == "storage" { + (value, formatter) = + Array(Mirror(reflecting: children[0].value).children)[0].value as! (Any, Formatter?) + } else { + value = children[0].value + formatter = children[1].value as? Formatter + } + return formatter?.string(for: value) ?? value as! CVarArg + } + + let format = NSLocalizedString( + key, + tableName: tableName, + bundle: bundle ?? .main, + value: "", + comment: comment.map(String.init) ?? "" + ) + return String(format: format, locale: locale, arguments: arguments) + } +} + +// MARK: - CustomDumpRepresentable + +extension TextState: CustomDumpRepresentable { + public var customDumpValue: Any { + func dumpHelp(_ textState: Self) -> String { + var output: String + switch textState.storage { + case let .concatenated(lhs, rhs): + output = dumpHelp(lhs) + dumpHelp(rhs) + case let .localized(key, tableName, bundle, comment): + output = key.formatted(tableName: tableName, bundle: bundle, comment: comment) + case let .verbatim(string): + output = string + } + func tag(_ name: String, attribute: String? = nil, _ value: String? = nil) { + output = """ + <\(name)\(attribute.map { " \($0)" } ?? "")\(value.map { "=\($0)" } ?? "")>\ + \(output)\ + + """ + } + for modifier in textState.modifiers { + switch modifier { + case let .accessibilityHeading(headingLevel): + tag("accessibility-heading-level", headingLevel.rawValue) + case let .accessibilityLabel(value): + tag("accessibility-label", dumpHelp(value)) + case let .accessibilityTextContentType(type): + tag("accessibility-text-content-type", type.rawValue) + case let .baselineOffset(baselineOffset): + tag("baseline-offset", "\(baselineOffset)") + case .bold(isActive: true), .fontWeight(.some(.bold)): + output = "**\(output)**" + case .font(.some): + break // TODO: capture Font description using DSL similar to TextState and print here + case let .fontDesign(.some(design)): + func describe(design: Font.Design) -> String { + switch design { + case .default: return "default" + case .serif: return "serif" + case .rounded: return "rounded" + case .monospaced: return "monospaced" + @unknown default: return "\(design)" + } + } + tag("font-design", describe(design: design)) + case let .fontWeight(.some(weight)): + func describe(weight: Font.Weight) -> String { + switch weight { + case .black: return "black" + case .bold: return "bold" + case .heavy: return "heavy" + case .light: return "light" + case .medium: return "medium" + case .regular: return "regular" + case .semibold: return "semibold" + case .thin: return "thin" + default: return "\(weight)" + } + } + tag("font-weight", describe(weight: weight)) + case let .fontWidth(.some(width)): + tag("font-width", width.rawValue) + case let .foregroundColor(.some(color)): + tag("foreground-color", "\(color)") + case .italic(isActive: true): + output = "_\(output)_" + case let .kerning(kerning): + tag("kerning", "\(kerning)") + case let .speechAdjustedPitch(value): + tag("speech-adjusted-pitch", "\(value)") + case .speechAlwaysIncludesPunctuation(true): + tag("speech-always-includes-punctuation") + case .speechAnnouncementsQueued(true): + tag("speech-announcements-queued") + case .speechSpellsOutCharacters(true): + tag("speech-spells-out-characters") + case let .strikethrough(isActive: true, pattern: _, color: .some(color)): + tag("s", attribute: "color", "\(color)") + case .strikethrough(isActive: true, pattern: _, color: .none): + output = "~~\(output)~~" + case let .tracking(tracking): + tag("tracking", "\(tracking)") + case let .underline(isActive: true, pattern: _, .some(color)): + tag("u", attribute: "color", "\(color)") + case .underline(isActive: true, pattern: _, color: .none): + tag("u") + case .bold(isActive: false), + .font(.none), + .fontDesign(.none), + .fontWeight(.none), + .fontWidth(.none), + .foregroundColor(.none), + .italic(isActive: false), + .monospacedDigit, + .speechAlwaysIncludesPunctuation(false), + .speechAnnouncementsQueued(false), + .speechSpellsOutCharacters(false), + .strikethrough(isActive: false, pattern: _, color: _), + .underline(isActive: false, pattern: _, color: _): + break + } + } + return output + } + + return dumpHelp(self) + } +} diff --git a/Sources/RxComposableArchitecture/SwiftUI/WithViewStore.swift b/Sources/RxComposableArchitecture/SwiftUI/WithViewStore.swift new file mode 100644 index 0000000..5f93b01 --- /dev/null +++ b/Sources/RxComposableArchitecture/SwiftUI/WithViewStore.swift @@ -0,0 +1,571 @@ +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. +/// +/// 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 to a ``Store`` in a lightweight way like so: +/// +/// ```swift +/// Button("Tap me") { +/// ViewStore(self.store).send(.buttonTapped) +/// } +/// ``` +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? + #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 + } + #endif + self.viewStore = ViewStore(store, removeDuplicates: isDuplicate) + } + + /// 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. + public func _printChanges(_ prefix: String = "") -> Self { + var view = self + #if DEBUG + view.prefix = prefix + #endif + return view + } + + public var body: Content { + #if DEBUG + 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 + /// struct AppFeature: ReducerProtocol { + /// 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: AppFeature.Action.tabSelected) { + /// ActivityView( + /// store: self.store.scope(state: \.activity, action: AppFeature.Action.activity) + /// ) + /// .tag(AppFeature.Tab.activity) + /// SearchView( + /// store: self.store.scope(state: \.search, action: AppFeature.Action.search) + /// ) + /// .tag(AppFeature.Tab.search) + /// ProfileView( + /// store: self.store.scope(state: \.profile, action: AppFeature.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. + 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 + /// struct AppFeature: ReducerProtocol { + /// 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: AppFeature.Action.tabSelected) { + /// ActivityView( + /// store: self.store.scope(state: \.activity, action: AppFeature.Action.activity) + /// ) + /// .tag(AppFeature.Tab.activity) + /// SearchView( + /// store: self.store.scope(state: \.search, action: AppFeature.Action.search) + /// ) + /// .tag(AppFeature.Tab.search) + /// ProfileView( + /// store: self.store.scope(state: \.profile, action: AppFeature.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. + 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 + ) + } + + public init( + _ store: Store, + removeDuplicates isDuplicate: @escaping (_ lhs: ViewState, _ rhs: ViewState) -> Bool, + @ViewBuilder content: @escaping (_ viewStore: ViewStore) -> Content, + file: StaticString = #fileID, + line: UInt = #line + ) { + self.init( + store: store, + 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. + /// + /// 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 + /// struct AppFeature: ReducerProtocol { + /// 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: AppFeature.Action.tabSelected) { + /// ActivityView( + /// store: self.store.scope(state: \.activity, action: AppFeature.Action.activity) + /// ) + /// .tag(AppFeature.Tab.activity) + /// SearchView( + /// store: self.store.scope(state: \.search, action: AppFeature.Action.search) + /// ) + /// .tag(AppFeature.Tab.search) + /// ProfileView( + /// store: self.store.scope(state: \.profile, action: AppFeature.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. + 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 + /// struct AppFeature: ReducerProtocol { + /// 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: AppFeature.Action.tabSelected) { + /// ActivityView( + /// store: self.store.scope(state: \.activity, action: AppFeature.Action.activity) + /// ) + /// .tag(AppFeature.Tab.activity) + /// SearchView( + /// store: self.store.scope(state: \.search, action: AppFeature.Action.search) + /// ) + /// .tag(AppFeature.Tab.search) + /// ProfileView( + /// store: self.store.scope(state: \.profile, action: AppFeature.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. + 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 + ) + } + + public init( + _ store: Store, + @ViewBuilder content: @escaping (_ viewStore: ViewStore) -> Content, + file: StaticString = #fileID, + line: UInt = #line + ) { + self.init(store, removeDuplicates: ==, content: content, file: file, line: line) + } +} + +extension WithViewStore: DynamicViewContent +where +ViewState: Collection, +Content: DynamicViewContent +{ + public typealias Data = ViewState + + public var data: ViewState { + self.viewStore.state + } +} + diff --git a/Sources/RxComposableArchitecture/ViewStore.swift b/Sources/RxComposableArchitecture/ViewStore.swift new file mode 100644 index 0000000..c4ead24 --- /dev/null +++ b/Sources/RxComposableArchitecture/ViewStore.swift @@ -0,0 +1,574 @@ +import Combine +import SwiftUI +import RxSwift +import RxRelay +import OSLog + +@dynamicMemberLookup +public final class ViewStore: ObservableObject { + public private(set) lazy var objectWillChange = ObservableObjectPublisher() + private let _send: (ViewAction) -> Task? + fileprivate let _state: BehaviorRelay + private var viewDisposable = DisposeBag() + public var observable: Observable { + self._state.asObservable() + } + + /// Initializes a view store from a store which observes changes to state. + /// + /// It is recommended that the `observe` argument transform the store's state into the bare + /// minimum of data needed for the feature to do its job in order to not hinder performance. + /// This is especially true for root level features, and less important for leaf features. + /// + /// To read more about this performance technique, read the article. + /// + /// - Parameters: + /// - store: A store. + /// - toViewState: A transformation of `ViewState` to the state that will be observed for + /// changes. + /// - isDuplicate: A function to determine when two `State` values are equal. When values are + /// equal, repeat view computations are removed. + public init( + _ store: Store, + observe toViewState: @escaping (_ state: State) -> ViewState, + removeDuplicates isDuplicate: @escaping (_ lhs: ViewState, _ rhs: ViewState) -> Bool + ) { + self._send = { store.send($0, originatingFrom: nil) } + self._state = BehaviorRelay(value: toViewState(store.state)) + store.observable + .map(toViewState) + .distinctUntilChanged(isDuplicate) + .subscribe(onNext: { [weak objectWillChange = self.objectWillChange, weak _state = self._state] in + guard let objectWillChange = objectWillChange, let _state = _state else { return } + objectWillChange.send() + _state.accept($0) + }) + .disposed(by: self.viewDisposable) + } + + /// Initializes a view store from a store which observes changes to state. + /// + /// It is recommended that the `observe` argument transform the store's state into the bare + /// minimum of data needed for the feature to do its job in order to not hinder performance. + /// This is especially true for root level features, and less important for leaf features. + /// + /// To read more about this performance technique, read the article. + /// + /// - Parameters: + /// - store: A store. + /// - toViewState: A transformation of `ViewState` to the state that will be observed for + /// changes. + /// - fromViewAction: A transformation of `ViewAction` that describes what actions can be sent. + /// - isDuplicate: A function to determine when two `State` values are equal. When values are + /// equal, repeat view computations are removed. + public init( + _ store: Store, + observe toViewState: @escaping (_ state: State) -> ViewState, + send fromViewAction: @escaping (_ viewAction: ViewAction) -> Action, + removeDuplicates isDuplicate: @escaping (_ lhs: ViewState, _ rhs: ViewState) -> Bool + ) { + self._send = { store.send(fromViewAction($0), originatingFrom: nil) } + self._state = BehaviorRelay(value: toViewState(store.state)) + store.observable + .map(toViewState) + .distinctUntilChanged(isDuplicate) + .subscribe(onNext: { [weak objectWillChange = self.objectWillChange, weak _state = self._state] in + guard let objectWillChange = objectWillChange, let _state = _state else { return } + objectWillChange.send() + _state.accept($0) + }) + .disposed(by: self.viewDisposable) + } + + public init( + _ store: Store, + removeDuplicates isDuplicate: @escaping (_ lhs: ViewState, _ rhs: ViewState) -> Bool + ) { + self._send = { store.send($0, originatingFrom: nil) } + self._state = BehaviorRelay(value: store.state) + store.observable + .distinctUntilChanged(isDuplicate) + .subscribe(onNext: { [weak objectWillChange = self.objectWillChange, weak _state = self._state] in + guard let objectWillChange = objectWillChange, let _state = _state else { return } + objectWillChange.send() + _state.accept($0) + }) + .disposed(by: self.viewDisposable) + } + + init(_ viewStore: ViewStore) { + self._send = viewStore._send + self._state = viewStore._state + self.objectWillChange = viewStore.objectWillChange + self.viewDisposable = viewStore.viewDisposable + } + + deinit { + print("viewStore deinit") + } + + + /// A publisher that emits when state changes. + /// + /// This publisher supports dynamic member lookup so that you can pluck out a specific field in + /// the state: + /// + /// ```swift + /// viewStore.publisher.alert + /// .sink { ... } + /// ``` + /// + /// When the emission happens the ``ViewStore``'s state has been updated, and so the following + /// precondition will pass: + /// + /// ```swift + /// viewStore.publisher + /// .sink { precondition($0 == viewStore.state) } + /// ``` + /// + /// This means you can either use the value passed to the closure or you can reach into + /// `viewStore.state` directly. + /// + /// - Note: Due to a bug in Combine (or feature?), the order you `.sink` on a publisher has no + /// bearing on the order the `.sink` closures are called. This means the work performed inside + /// `viewStore.publisher.sink` closures should be completely independent of each other. Later + /// closures cannot assume that earlier ones have already run. + /// public var publisher: StorePublisher { + /// StorePublisher(viewStore: self) + /// } + + public var state: ViewState { + self._state.value + } + + /// Returns the resulting value of a given key path. + public subscript(dynamicMember keyPath: KeyPath) -> Value { + self._state.value[keyPath: keyPath] + } + + /// Sends an action to the store. + /// + /// This method returns a ``StoreTask``, which represents the lifecycle of the effect started + /// from sending an action. You can use this value to tie the effect's lifecycle _and_ + /// cancellation to an asynchronous context, such as SwiftUI's `task` view modifier: + /// + /// ```swift + /// .task { await viewStore.send(.task).finish() } + /// ``` + /// + /// > Important: ``ViewStore`` is not thread safe and you should only send actions to it from the + /// > main thread. If you want to send actions on background threads due to the fact that the + /// > reducer is performing computationally expensive work, then a better way to handle this is to + /// > wrap that work in an ``EffectTask`` that is performed on a background thread so that the + /// > result can be fed back into the store. + /// + /// - Parameter action: An action. + /// - Returns: A ``StoreTask`` that represents the lifecycle of the effect executed when + /// sending the action. + @discardableResult + public func send(_ action: ViewAction) -> StoreTask { + .init(rawValue: self._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 { + 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 into the store and then suspends while a piece of state is `true`. + /// + /// This method can be used to interact with async/await code, allowing you to suspend while work + /// is being performed in an effect. One common example of this is using SwiftUI's `.refreshable` + /// method, which shows a loading indicator on the screen while work is being performed. + /// + /// For example, suppose we wanted to load some data from the network when a pull-to-refresh + /// gesture is performed on a list. The domain and logic for this feature can be modeled like so: + /// + /// ```swift + /// struct Feature: ReducerProtocol { + /// struct State: Equatable { + /// var isLoading = false + /// var response: String? + /// } + /// enum Action { + /// case pulledToRefresh + /// case receivedResponse(TaskResult) + /// } + /// @Dependency(\.fetch) var fetch + /// + /// func reduce(into state: inout State, action: Action) -> EffectTask { + /// switch action { + /// case .pulledToRefresh: + /// state.isLoading = true + /// return .run { send in + /// await send(.receivedResponse(TaskResult { try await self.fetch() })) + /// } + /// + /// case let .receivedResponse(result): + /// state.isLoading = false + /// state.response = try? result.value + /// return .none + /// } + /// } + /// } + /// ``` + /// + /// Note that we keep track of an `isLoading` boolean in our state so that we know exactly when + /// the network response is being performed. + /// + /// The view can show the fact in a `List`, if it's present, and we can use the `.refreshable` + /// view modifier to enhance the list with pull-to-refresh capabilities: + /// + /// ```swift + /// struct MyView: View { + /// let store: Store + /// + /// var body: some View { + /// WithViewStore(self.store, observe: { $0 }) { viewStore in + /// List { + /// if let response = viewStore.response { + /// Text(response) + /// } + /// } + /// .refreshable { + /// await viewStore.send(.pulledToRefresh, while: \.isLoading) + /// } + /// } + /// } + /// } + /// ``` + /// + /// Here we've used the ``send(_:while:)`` method to suspend while the `isLoading` state is + /// `true`. Once that piece of state flips back to `false` the method will resume, signaling to + /// `.refreshable` that the work has finished which will cause the loading indicator to disappear. + /// + /// - Parameters: + /// - action: An action. + /// - predicate: A predicate on `ViewState` that determines for how long this method should + /// suspend. + @MainActor + public func send( + _ action: ViewAction, + while predicate: @escaping (_ state: ViewState) -> Bool + ) async { + let task = self.send(action) + await withTaskCancellationHandler { + await self.yield(while: predicate) + } onCancel: { + task.rawValue?.cancel() + } + } + + /// 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. + @MainActor + 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.rawValue?.cancel() + } + } + + /// Suspends the current task while a predicate on state is `true`. + /// + /// If you want to suspend at the same time you send an action to the view store, use + /// ``send(_:while:)``. + /// + /// - Parameter predicate: A predicate on `ViewState` that determines for how long this method + /// should suspend. + @MainActor + public func yield(while predicate: @escaping (_ state: ViewState) -> Bool) async { + let cancellable = TaskBox(wrappedValue: nil) + try? await withTaskCancellationHandler(operation: { + try Task.checkCancellation() + try await withUnsafeThrowingContinuation { + (continuation: UnsafeContinuation) in + guard !Task.isCancelled else { + continuation.resume(throwing: CancellationError()) + return + } + cancellable.wrappedValue = self._state.asObservable() + .filter { !predicate($0) } + .take(1) + .subscribe(onNext: { _ in + continuation.resume() + _ = cancellable + }) + } + }, onCancel: { + cancellable.wrappedValue?.dispose() + }) + } + + /// 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)] + } + + /// 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, 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 }) + } + + private subscript( + get fromState: HashableWrapper<(ViewState) -> Value>, + send toAction: HashableWrapper<(Value) -> ViewAction?> + ) -> Value { + get { fromState.rawValue(self.state) } + set { + BindingLocal.$isActive.withValue(true) { + if let action = toAction.rawValue(newValue) { + self.send(action) + } + } + } + } +} + +/// A convenience type alias for referring to a view store of a given reducer's domain. +/// +/// Instead of specifying two generics: +/// +/// ```swift +/// let viewStore: ViewStore +/// ``` +/// +/// You can specify a single generic: +/// +/// ```swift +/// let viewStore: ViewStoreOf +/// ``` +public typealias ViewStoreOf = ViewStore + +extension ViewStore where ViewState: Equatable { + /// Initializes a view store from a store which observes changes to state. + /// + /// It is recommended that the `observe` argument transform the store's state into the bare + /// minimum of data needed for the feature to do its job in order to not hinder performance. + /// This is especially true for root level features, and less important for leaf features. + /// + /// To read more about this performance technique, read the article. + /// + /// - Parameters: + /// - store: A store. + /// - toViewState: A transformation of `ViewState` to the state that will be observed for + /// changes. + public convenience init( + _ store: Store, + observe toViewState: @escaping (_ state: State) -> ViewState + ) { + self.init(store, observe: toViewState, removeDuplicates: ==) + } + + /// Initializes a view store from a store which observes changes to state. + /// + /// It is recommended that the `observe` argument transform the store's state into the bare + /// minimum of data needed for the feature to do its job in order to not hinder performance. + /// This is especially true for root level features, and less important for leaf features. + /// + /// To read more about this performance technique, read the article. + /// + /// - Parameters: + /// - store: A store. + /// - toViewState: A transformation of `ViewState` to the state that will be observed for + /// changes. + /// - fromViewAction: A transformation of `ViewAction` that describes what actions can be sent. + public convenience init( + _ store: Store, + observe toViewState: @escaping (_ state: State) -> ViewState, + send fromViewAction: @escaping (_ viewAction: ViewAction) -> Action + ) { + self.init(store, observe: toViewState, send: fromViewAction, removeDuplicates: ==) + } +} + +private struct HashableWrapper: Hashable { + let rawValue: Value + static func == (lhs: Self, rhs: Self) -> Bool { false } + func hash(into hasher: inout Hasher) {} +} + +enum BindingLocal { + @TaskLocal static var isActive = false +} + +@dynamicMemberLookup +public struct StorePublisher: ObservableType { + public typealias Element = State + public let upstream: Observable + + public func subscribe(_ observer: Observer) -> Disposable + where Observer: ObserverType, Element == Observer.Element { + upstream.subscribe(observer) + } + + init(_ upstream: Observable) { + self.upstream = upstream + } + + /// Returns the resulting publisher of a given key path. + public subscript( + dynamicMember keyPath: KeyPath + ) -> StorePublisher + where LocalState: Equatable { + .init(self.upstream.map { $0[keyPath: keyPath] }.distinctUntilChanged()) + } +} diff --git a/Tests/RxComposableArchitectureTests/ViewStoreTests.swift b/Tests/RxComposableArchitectureTests/ViewStoreTests.swift new file mode 100644 index 0000000..79a4ae2 --- /dev/null +++ b/Tests/RxComposableArchitectureTests/ViewStoreTests.swift @@ -0,0 +1,323 @@ +import RxComposableArchitecture +import RxSwift +import XCTest +import Combine + +@MainActor +internal final class ViewStoreTests: XCTestCase { + internal var disposeBag = DisposeBag() + + override func setUp() { + super.setUp() + self.disposeBag = DisposeBag() + equalityChecks = 0 + subEqualityChecks = 0 + } + + func testPublisherFirehose() { + let store = Store(initialState: 0, reducer: EmptyReducer()) + let viewStore = ViewStore(store, observe: { $0 }) + var emissionCount = 0 + + viewStore.observable + .subscribe(onNext: { _ in + emissionCount += 1 + }) + .disposed(by: self.disposeBag) + + XCTAssertEqual(emissionCount, 1) + viewStore.send(()) + XCTAssertEqual(emissionCount, 1) + viewStore.send(()) + XCTAssertEqual(emissionCount, 1) + viewStore.send(()) + XCTAssertEqual(emissionCount, 1) + } + + func testEqualityChecks() { + let store = Store(initialState: State(), reducer: EmptyReducer()) + + let store1 = store.scope(state: { $0 }, action: { $0 }) + let store2 = store1.scope(state: { $0 }, action: { $0 }) + let store3 = store2.scope(state: { $0 }, action: { $0 }) + let store4 = store3.scope(state: { $0 }, action: { $0 }) + + let viewStore1 = ViewStore(store1, observe: { $0 } ) + let viewStore2 = ViewStore(store2, observe: { $0 } ) + let viewStore3 = ViewStore(store3, observe: { $0 } ) + let viewStore4 = ViewStore(store4, observe: { $0 } ) + + viewStore1.observable + .subscribe(onNext: { _ in }) + .disposed(by: self.disposeBag) + viewStore2.observable + .subscribe(onNext: { _ in }) + .disposed(by: self.disposeBag) + viewStore3.observable + .subscribe(onNext: { _ in }) + .disposed(by: self.disposeBag) + viewStore4.observable + .subscribe(onNext: { _ in }) + .disposed(by: self.disposeBag) + + viewStore1.observable + .map(\.substate) + .subscribe(onNext: { _ in }) + .disposed(by: self.disposeBag) + viewStore2.observable + .map(\.substate) + .subscribe(onNext: { _ in }) + .disposed(by: self.disposeBag) + viewStore3.observable + .map(\.substate) + .subscribe(onNext: { _ in }) + .disposed(by: self.disposeBag) + viewStore4.observable + .map(\.substate) + .subscribe(onNext: { _ in }) + .disposed(by: self.disposeBag) + + XCTAssertEqual(0, equalityChecks) + XCTAssertEqual(0, subEqualityChecks) + viewStore4.send(()) + XCTAssertEqual(4, equalityChecks) + XCTAssertEqual(4, subEqualityChecks) + viewStore4.send(()) + XCTAssertEqual(8, equalityChecks) + XCTAssertEqual(8, subEqualityChecks) + viewStore4.send(()) + XCTAssertEqual(12, equalityChecks) + XCTAssertEqual(12, subEqualityChecks) + viewStore4.send(()) + XCTAssertEqual(16, equalityChecks) + XCTAssertEqual(16, subEqualityChecks) + } + + func testAccessViewStoreStateInPublisherSink() { + let reducer = Reduce { count, _ in + count += 1 + return .none + } + + let store = Store(initialState: 0, reducer: reducer) + let viewStore = ViewStore(store, observe: { $0 }) + + var results: [Int] = [] + + viewStore.observable + .subscribe(onNext: { _ in results.append(viewStore.state) }) + .disposed(by: self.disposeBag) + + viewStore.send(()) + viewStore.send(()) + viewStore.send(()) + + XCTAssertEqual([0, 1, 2, 3], results) + } + + func testWillSet() { + var cancellables: Set = [] + let reducer = Reduce { count, _ in + count += 1 + return .none + } + + let store = Store(initialState: 0, reducer: reducer) + let viewStore = ViewStore(store, observe: { $0 }) + + var results: [Int] = [] + + viewStore.objectWillChange + .sink { _ in results.append(viewStore.state) } + .store(in: &cancellables) + + viewStore.send(()) + viewStore.send(()) + viewStore.send(()) + + XCTAssertEqual([0, 1, 2], results) + } + + func testPublisherOwnsViewStore() { + let reducer = Reduce { count, _ in + count += 1 + return .none + } + let store = Store(initialState: 0, reducer: reducer) + + var results: [Int] = [] + ViewStore(store, observe: { $0 }) + .observable + .subscribe(onNext: { results.append($0) }) + .disposed(by: self.disposeBag) + + + ViewStore(store, observe: { $0 }).send(()) + + ViewStore(store, observe: { $0 }) + .observable + .subscribe(onNext: { results.append($0) }) + .disposed(by: self.disposeBag) + + XCTAssertEqual(results, [0, 1]) + } + + func testStorePublisherSubscriptionOrder() { + let reducer = Reduce { count, _ in + count += 1 + return .none + } + let store = Store(initialState: 0, reducer: reducer) + let viewStore = ViewStore(store, observe: { $0 }) + + var results: [Int] = [] + + viewStore.observable + .subscribe(onNext: { _ in results.append(0) }) + .disposed(by: self.disposeBag) + + viewStore.observable + .subscribe(onNext: { _ in results.append(1) }) + .disposed(by: self.disposeBag) + + viewStore.observable + .subscribe(onNext: { _ in results.append(2) }) + .disposed(by: self.disposeBag) + + + XCTAssertEqual(results, [0, 1, 2]) + + for _ in 0..<9 { + viewStore.send(()) + } + + XCTAssertEqual(results, Array(repeating: [0, 1, 2], count: 10).flatMap { $0 }) + } + + func testSendWhile() async { + enum Action { + case response + case tapped + } + let reducer = Reduce { state, action in + switch action { + case .response: + state = false + return .none + case .tapped: + state = true + return .task { .response } + } + } + + let store = Store(initialState: false, reducer: reducer) + let viewStore = ViewStore(store, observe: { $0 }) + + XCTAssertEqual(viewStore.state, false) + await viewStore.send(.tapped, while: { $0 }) + XCTAssertEqual(viewStore.state, false) + } + + func testSuspend() { + let expectation = self.expectation(description: "await") + Task { + enum Action { + case response + case tapped + } + let reducer = Reduce { state, action in + switch action { + case .response: + state = false + return .none + case .tapped: + state = true + return .task { .response } + } + } + + let store = Store(initialState: false, reducer: reducer) + let viewStore = ViewStore(store, observe: { $0 }) + + XCTAssertEqual(viewStore.state, false) + _ = { viewStore.send(.tapped) }() + XCTAssertEqual(viewStore.state, true) + await viewStore.yield(while: { $0 }) + XCTAssertEqual(viewStore.state, false) + expectation.fulfill() + } + self.wait(for: [expectation], timeout: 1) + } + + func testAsyncSend() async throws { + enum Action { + case tap + case response(Int) + } + let store = Store(initialState: 0, reducer: Reduce { state, action in + switch action { + case .tap: + return .task { + return .response(42) + } + case let .response(value): + state = value + return .none + } + }) + + let viewStore = ViewStore(store, observe: { $0 }) + + XCTAssertEqual(viewStore.state, 0) + await viewStore.send(.tap).finish() + XCTAssertEqual(viewStore.state, 42) + } + + func testAsyncSendCancellation() async throws { + enum Action { + case tap + case response(Int) + } + let store = Store(initialState: 0, reducer: Reduce { state, action in + switch action { + case .tap: + return .task { + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + return .response(42) + } + case let .response(value): + state = value + return .none + } + }) + + let viewStore = ViewStore(store, observe: { $0 }) + + XCTAssertEqual(viewStore.state, 0) + let task = viewStore.send(.tap) + task.cancel() + try await Task.sleep(nanoseconds: NSEC_PER_MSEC) + XCTAssertEqual(viewStore.state, 0) + } +} + +private struct State: Equatable { + var substate = Substate() + + static func == (lhs: Self, rhs: Self) -> Bool { + equalityChecks += 1 + return lhs.substate == rhs.substate + } +} + +private struct Substate: Equatable { + var name = "Blob" + + static func == (lhs: Self, rhs: Self) -> Bool { + subEqualityChecks += 1 + return lhs.name == rhs.name + } +} + +private var equalityChecks = 0 +private var subEqualityChecks = 0