From 6b24cfd29012f3f6367c6d080f9c43bb7721b4c4 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 20 Dec 2023 12:51:17 -0600 Subject: [PATCH 1/6] remove ObservedObject property wrapper `MainScreen` now no longer reacts properly to state changes. --- Samples/SwiftUITestbed/Sources/MainScreen.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Samples/SwiftUITestbed/Sources/MainScreen.swift b/Samples/SwiftUITestbed/Sources/MainScreen.swift index 52c3ebb1f..0be761207 100644 --- a/Samples/SwiftUITestbed/Sources/MainScreen.swift +++ b/Samples/SwiftUITestbed/Sources/MainScreen.swift @@ -38,7 +38,7 @@ struct MainScreen: SwiftUIScreen { } private struct MainScreenView: View { - @ObservedObject var model: ObservableValue + var model: ObservableValue @Environment(\.viewEnvironment.marketStylesheet) private var styles: MarketStylesheet @Environment(\.viewEnvironment.marketContext) private var context: MarketContext From aa7cfa79a7e50d72cddd006ff9a98c5910b515cf Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 20 Dec 2023 17:09:09 -0600 Subject: [PATCH 2/6] implement Store Not used anywhere yet. --- .../Sources/Observation/Store.swift | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 Samples/SwiftUITestbed/Sources/Observation/Store.swift diff --git a/Samples/SwiftUITestbed/Sources/Observation/Store.swift b/Samples/SwiftUITestbed/Sources/Observation/Store.swift new file mode 100644 index 000000000..3cfc5d982 --- /dev/null +++ b/Samples/SwiftUITestbed/Sources/Observation/Store.swift @@ -0,0 +1,60 @@ +import ComposableArchitecture + +@dynamicMemberLookup +public final class Store: Perceptible { + private var _state: State + let _$observationRegistrar = PerceptionRegistrar() + + fileprivate(set) var state: State { + get { + _$observationRegistrar.access(self, keyPath: \.state) + return _state + } + set { + if !_$isIdentityEqual(state, newValue) { + _$observationRegistrar.withMutation(of: self, keyPath: \.state) { + _state = newValue + } + } else { + _state = newValue + } + } + } + + private init(state: State) { + self._state = state + } +} + +public extension Store { + static func make(initialState: State) -> (Store, (State) -> Void) { + let store = Store(state: initialState) + let sink = { store.state = $0 } + return (store, sink) + } + + subscript(dynamicMember keyPath: KeyPath) -> T { + state[keyPath: keyPath] + } +} + +extension Store: Equatable { + public static func == (lhs: Store, rhs: Store) -> Bool { + lhs === rhs + } +} + +extension Store: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } +} + +extension Store: Identifiable {} + +#if canImport(Observation) +import Observation + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension Store: Observable {} +#endif From 2be92b5f6ced92613d0060896e35c6e809593d10 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 21 Dec 2023 09:19:15 -0600 Subject: [PATCH 3/6] introduce new SwiftUIScreen protocol --- .../SwiftUITestbed/Sources/MainScreen.swift | 65 +++++++--------- .../SwiftUITestbed/Sources/MainWorkflow.swift | 28 ++++--- .../Sources/Observation/Store.swift | 6 +- .../Sources/Observation/SwiftUIScreen.swift | 76 +++++++++++++++++++ 4 files changed, 121 insertions(+), 54 deletions(-) create mode 100644 Samples/SwiftUITestbed/Sources/Observation/SwiftUIScreen.swift diff --git a/Samples/SwiftUITestbed/Sources/MainScreen.swift b/Samples/SwiftUITestbed/Sources/MainScreen.swift index 0be761207..ecbd61049 100644 --- a/Samples/SwiftUITestbed/Sources/MainScreen.swift +++ b/Samples/SwiftUITestbed/Sources/MainScreen.swift @@ -16,29 +16,22 @@ import MarketUI import MarketWorkflowUI +import Perception // for WithPerceptionTracking import ViewEnvironment import WorkflowSwiftUIExperimental - -struct MainScreen: SwiftUIScreen { - let title: String - let didChangeTitle: (String) -> Void - - let allCapsToggleIsOn: Bool - let allCapsToggleIsEnabled: Bool - let didChangeAllCapsToggle: (Bool) -> Void - - let didTapPushScreen: () -> Void - let didTapPresentScreen: () -> Void - - let didTapClose: (() -> Void)? - - static func makeView(model: ObservableValue) -> some View { - MainScreenView(model: model) +import WorkflowUI + +// Compiler requires explicit conformance to `Screen`, or else: "Conditional +// conformance of type 'ViewModel' to protocol 'SwiftUIScreen' +// does not imply conformance to inherited protocol 'Screen'" +extension MainWorkflow.Rendering: SwiftUIScreen, Screen { + static func makeView(store: Store, sendAction: @escaping (Action) -> Void) -> some View { + MainScreenView(model: store) } } private struct MainScreenView: View { - var model: ObservableValue + var model: Store @Environment(\.viewEnvironment.marketStylesheet) private var styles: MarketStylesheet @Environment(\.viewEnvironment.marketContext) private var context: MarketContext @@ -50,15 +43,15 @@ private struct MainScreenView: View { @FocusState var focusedField: Field? var body: some View { - ScrollView { VStack { + WithPerceptionTracking { ScrollView { VStack { Text("Title") .font(Font(styles.headers.inlineSection20.heading.text.font)) TextField( "Text", - text: model.binding( - get: \.title, - set: \.didChangeTitle + text: Binding( + get: { model.title }, + set: { _ in fatalError("TODO") } ) ) .focused($focusedField, equals: .title) @@ -68,9 +61,9 @@ private struct MainScreenView: View { style: context.stylesheets.testbed.toggleRow, label: "All Caps", isEnabled: model.allCapsToggleIsEnabled, - isOn: model.binding( - get: \.allCapsToggleIsOn, - set: \.didChangeAllCapsToggle + isOn: Binding( + get: { model.allCapsToggleIsOn }, + set: { _ in fatalError("TODO") } ) ) @@ -81,12 +74,12 @@ private struct MainScreenView: View { Button( "Push Screen", - action: model.didTapPushScreen + action: { fatalError("TODO") } ) Button( "Present Screen", - action: model.didTapPresentScreen + action: { fatalError("TODO") } ) Button( @@ -94,15 +87,15 @@ private struct MainScreenView: View { action: { focusedField = nil } ) - } } + } } } } } -extension MainScreen: MarketBackStackContentScreen { +extension MainWorkflow.Rendering: MarketBackStackContentScreen { func backStackItem(in environment: ViewEnvironment) -> MarketUI.MarketNavigationItem { MarketNavigationItem( - title: .text(.init(regular: title)), - backButton: didTapClose.map { .close(onTap: $0) } ?? .automatic() + title: .text(.init(regular: state.title)), + backButton: .close(onTap: { fatalError("TODO") }) // didTapClose.map { .close(onTap: $0) } ?? .automatic() ) } @@ -115,15 +108,9 @@ import SwiftUI struct MainScreen_Preview: PreviewProvider { static var previews: some View { - MainScreen( - title: "New item", - didChangeTitle: { _ in }, - allCapsToggleIsOn: true, - allCapsToggleIsEnabled: true, - didChangeAllCapsToggle: { _ in }, - didTapPushScreen: {}, - didTapPresentScreen: {}, - didTapClose: {} + MainWorkflow.Rendering( + state: .init(title: "Test"), + sendAction: { _ in } ) .asMarketBackStack() .marketPreview() diff --git a/Samples/SwiftUITestbed/Sources/MainWorkflow.swift b/Samples/SwiftUITestbed/Sources/MainWorkflow.swift index d8b0e5ae0..72ca41a42 100644 --- a/Samples/SwiftUITestbed/Sources/MainWorkflow.swift +++ b/Samples/SwiftUITestbed/Sources/MainWorkflow.swift @@ -14,6 +14,7 @@ * limitations under the License. */ +import ComposableArchitecture // for ObservableState import MarketWorkflowUI import Workflow @@ -25,6 +26,7 @@ struct MainWorkflow: Workflow { case presentScreen } + @ObservableState struct State { var title: String var isAllCaps: Bool @@ -64,24 +66,26 @@ struct MainWorkflow: Workflow { } } - typealias Rendering = MainScreen + typealias Rendering = ViewModel func render(state: State, context: RenderContext) -> Rendering { - let sink = context.makeSink(of: Action.self) - - return MainScreen( - title: state.title, - didChangeTitle: { sink.send(.changeTitle($0)) }, - allCapsToggleIsOn: state.isAllCaps, - allCapsToggleIsEnabled: !state.title.isEmpty, - didChangeAllCapsToggle: { sink.send(.changeAllCaps($0)) }, - didTapPushScreen: { sink.send(.pushScreen) }, - didTapPresentScreen: { sink.send(.presentScreen) }, - didTapClose: didClose + ViewModel( + state: state, + sendAction: context.makeSink(of: Action.self).send ) } } +extension MainWorkflow.State { + var allCapsToggleIsOn: Bool { + isAllCaps + } + + var allCapsToggleIsEnabled: Bool { + !title.isEmpty + } +} + private extension String { var isAllCaps: Bool { allSatisfy { character in diff --git a/Samples/SwiftUITestbed/Sources/Observation/Store.swift b/Samples/SwiftUITestbed/Sources/Observation/Store.swift index 3cfc5d982..8b4466798 100644 --- a/Samples/SwiftUITestbed/Sources/Observation/Store.swift +++ b/Samples/SwiftUITestbed/Sources/Observation/Store.swift @@ -1,4 +1,4 @@ -import ComposableArchitecture +import ComposableArchitecture // for ObservableState and Perception @dynamicMemberLookup public final class Store: Perceptible { @@ -29,8 +29,8 @@ public final class Store: Perceptible { public extension Store { static func make(initialState: State) -> (Store, (State) -> Void) { let store = Store(state: initialState) - let sink = { store.state = $0 } - return (store, sink) + let setState = { store.state = $0 } + return (store, setState) } subscript(dynamicMember keyPath: KeyPath) -> T { diff --git a/Samples/SwiftUITestbed/Sources/Observation/SwiftUIScreen.swift b/Samples/SwiftUITestbed/Sources/Observation/SwiftUIScreen.swift new file mode 100644 index 000000000..8dfb6dda3 --- /dev/null +++ b/Samples/SwiftUITestbed/Sources/Observation/SwiftUIScreen.swift @@ -0,0 +1,76 @@ +#if canImport(UIKit) + +import ComposableArchitecture // for ObservableState +import SwiftUI +import Workflow +import WorkflowUI + +struct ViewModel { + let state: State + let sendAction: (Action) -> Void +} + +protocol SwiftUIScreen: Screen { + associatedtype State: ObservableState + associatedtype Action + associatedtype Content: View + + var state: State { get } + var sendAction: (Action) -> Void { get } + + @ViewBuilder + static func makeView(store: Store, sendAction: @escaping (Action) -> Void) -> Content +} + +extension SwiftUIScreen { + func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription { + ViewControllerDescription( + type: ModeledHostingController>.self, + environment: environment, + build: { + let (store, setState) = Store.make(initialState: state) + return ModeledHostingController( + setState: setState, + rootView: EnvironmentInjectingView( + environment: environment, + content: Self.makeView( + store: store, + sendAction: { _ in + fatalError("TODO") + } + ) + ) + ) + }, + update: { hostingController in + hostingController.setState(state) + // TODO: update viewEnvironment + } + ) + } +} + +private struct EnvironmentInjectingView: View { + var environment: ViewEnvironment + let content: Content + + var body: some View { + content + .environment(\.viewEnvironment, environment) + } +} + +private final class ModeledHostingController: UIHostingController { + let setState: (State) -> Void + + init(setState: @escaping (State) -> Void, rootView: Content) { + self.setState = setState + super.init(rootView: rootView) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("not implemented") + } +} + +#endif From 647c2611f1b6dccb75db7a6b189b2086b03277b2 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 21 Dec 2023 10:01:54 -0600 Subject: [PATCH 4/6] use BindableStore to generate bindings --- .../SwiftUITestbed/Sources/MainScreen.swift | 12 ++---- .../SwiftUITestbed/Sources/MainWorkflow.swift | 3 +- .../Sources/Observation/BindableStore.swift | 38 +++++++++++++++++++ 3 files changed, 43 insertions(+), 10 deletions(-) create mode 100644 Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift diff --git a/Samples/SwiftUITestbed/Sources/MainScreen.swift b/Samples/SwiftUITestbed/Sources/MainScreen.swift index ecbd61049..483feefd6 100644 --- a/Samples/SwiftUITestbed/Sources/MainScreen.swift +++ b/Samples/SwiftUITestbed/Sources/MainScreen.swift @@ -31,7 +31,7 @@ extension MainWorkflow.Rendering: SwiftUIScreen, Screen { } private struct MainScreenView: View { - var model: Store + @BindableStore var model: Store @Environment(\.viewEnvironment.marketStylesheet) private var styles: MarketStylesheet @Environment(\.viewEnvironment.marketContext) private var context: MarketContext @@ -49,10 +49,7 @@ private struct MainScreenView: View { TextField( "Text", - text: Binding( - get: { model.title }, - set: { _ in fatalError("TODO") } - ) + text: $model.title ) .focused($focusedField, equals: .title) .onAppear { focusedField = .title } @@ -61,10 +58,7 @@ private struct MainScreenView: View { style: context.stylesheets.testbed.toggleRow, label: "All Caps", isEnabled: model.allCapsToggleIsEnabled, - isOn: Binding( - get: { model.allCapsToggleIsOn }, - set: { _ in fatalError("TODO") } - ) + isOn: $model.allCapsToggleIsOn ) Spacer(minLength: styles.spacings.spacing50) diff --git a/Samples/SwiftUITestbed/Sources/MainWorkflow.swift b/Samples/SwiftUITestbed/Sources/MainWorkflow.swift index 72ca41a42..308e2ae39 100644 --- a/Samples/SwiftUITestbed/Sources/MainWorkflow.swift +++ b/Samples/SwiftUITestbed/Sources/MainWorkflow.swift @@ -78,7 +78,8 @@ struct MainWorkflow: Workflow { extension MainWorkflow.State { var allCapsToggleIsOn: Bool { - isAllCaps + get { isAllCaps } + set { fatalError("TODO") } } var allCapsToggleIsEnabled: Bool { diff --git a/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift b/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift new file mode 100644 index 000000000..77626bea7 --- /dev/null +++ b/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift @@ -0,0 +1,38 @@ +// Copied from https://github.com/pointfreeco/swift-composable-architecture/blob/acfbab4290adda4e47026d059db36361958d495c/Sources/ComposableArchitecture/Observation/BindableStore.swift + +import ComposableArchitecture +import SwiftUI + +/// A property wrapper type that supports creating bindings to the mutable properties of a +/// ``Store``. +/// +/// Use this property wrapper in iOS 16, macOS 13, tvOS 16, watchOS 9, and earlier, when `@Bindable` +/// is unavailable, to derive bindings to properties of your features. +/// +/// If you are targeting iOS 17, macOS 14, tvOS 17, watchOS 9, or later, then you can replace +/// ``BindableStore`` with SwiftUI's `@Bindable`. +@available(iOS, deprecated: 17, renamed: "Bindable") +@available(macOS, deprecated: 14, renamed: "Bindable") +@available(tvOS, deprecated: 17, renamed: "Bindable") +@available(watchOS, deprecated: 10, renamed: "Bindable") +@propertyWrapper +@dynamicMemberLookup +public struct BindableStore { + public var wrappedValue: Store + public init(wrappedValue: Store) { + self.wrappedValue = wrappedValue + } + + public var projectedValue: BindableStore { + self + } + + public subscript( + dynamicMember keyPath: ReferenceWritableKeyPath, Subject> + ) -> Binding { + Binding( + get: { self.wrappedValue[keyPath: keyPath] }, + set: { self.wrappedValue[keyPath: keyPath] = $0 } + ) + } +} From a5c285e739b9ea5d700ec3ca42f56ee6140ce318 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 21 Dec 2023 11:18:32 -0600 Subject: [PATCH 5/6] eliminate runtime warnings in Binding getters --- .../Observation/Binding+Observation.swift | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 Samples/SwiftUITestbed/Sources/Observation/Binding+Observation.swift diff --git a/Samples/SwiftUITestbed/Sources/Observation/Binding+Observation.swift b/Samples/SwiftUITestbed/Sources/Observation/Binding+Observation.swift new file mode 100644 index 000000000..a34b4bf7d --- /dev/null +++ b/Samples/SwiftUITestbed/Sources/Observation/Binding+Observation.swift @@ -0,0 +1,43 @@ +// Copied from https://github.com/pointfreeco/swift-composable-architecture/blob/acfbab4290adda4e47026d059db36361958d495c/Sources/ComposableArchitecture/Observation/Binding%2BObservation.swift + +import ComposableArchitecture +import SwiftUI + +// NB: These overloads ensure runtime warnings aren't emitted for errant SwiftUI bindings. +#if DEBUG +extension Binding { + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> Binding + where Value == Store { + Binding( + get: { self.wrappedValue.state[keyPath: keyPath] }, + set: { _ in fatalError("TODO") } + ) + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension Bindable { + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> Binding + where Value == Store { + Binding( + get: { self.wrappedValue.state[keyPath: keyPath] }, + set: { _ in fatalError("TODO") } + ) + } +} + +extension BindableStore { + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> Binding { + Binding( + get: { self.wrappedValue.state[keyPath: keyPath] }, + set: { _ in fatalError("TODO") } + ) + } +} +#endif From 32668e4333900e6c30ec4e5c83222bbc98187ad8 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 21 Dec 2023 14:18:43 -0600 Subject: [PATCH 6/6] send actions to store --- .../SwiftUITestbed/Sources/MainScreen.swift | 22 ++++--- .../Sources/Observation/BindableStore.swift | 12 ++-- .../Observation/Binding+Observation.swift | 12 ++-- .../Sources/Observation/Store.swift | 58 +++++++++++-------- .../Sources/Observation/SwiftUIScreen.swift | 35 +++++------ .../Sources/Observation/ViewModel.swift | 6 ++ 6 files changed, 79 insertions(+), 66 deletions(-) create mode 100644 Samples/SwiftUITestbed/Sources/Observation/ViewModel.swift diff --git a/Samples/SwiftUITestbed/Sources/MainScreen.swift b/Samples/SwiftUITestbed/Sources/MainScreen.swift index 483feefd6..955923df9 100644 --- a/Samples/SwiftUITestbed/Sources/MainScreen.swift +++ b/Samples/SwiftUITestbed/Sources/MainScreen.swift @@ -25,13 +25,17 @@ import WorkflowUI // conformance of type 'ViewModel' to protocol 'SwiftUIScreen' // does not imply conformance to inherited protocol 'Screen'" extension MainWorkflow.Rendering: SwiftUIScreen, Screen { - static func makeView(store: Store, sendAction: @escaping (Action) -> Void) -> some View { - MainScreenView(model: store) + var model: Model { + self + } + + public static func makeView(store: Store) -> some View { + MainView(store: store) } } -private struct MainScreenView: View { - @BindableStore var model: Store +private struct MainView: View { + @BindableStore var store: Store @Environment(\.viewEnvironment.marketStylesheet) private var styles: MarketStylesheet @Environment(\.viewEnvironment.marketContext) private var context: MarketContext @@ -49,7 +53,7 @@ private struct MainScreenView: View { TextField( "Text", - text: $model.title + text: $store.title ) .focused($focusedField, equals: .title) .onAppear { focusedField = .title } @@ -57,8 +61,8 @@ private struct MainScreenView: View { ToggleRow( style: context.stylesheets.testbed.toggleRow, label: "All Caps", - isEnabled: model.allCapsToggleIsEnabled, - isOn: $model.allCapsToggleIsOn + isEnabled: store.allCapsToggleIsEnabled, + isOn: $store.allCapsToggleIsOn ) Spacer(minLength: styles.spacings.spacing50) @@ -68,12 +72,12 @@ private struct MainScreenView: View { Button( "Push Screen", - action: { fatalError("TODO") } + action: store.action(.pushScreen) ) Button( "Present Screen", - action: { fatalError("TODO") } + action: store.action(.presentScreen) ) Button( diff --git a/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift b/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift index 77626bea7..00daa25ab 100644 --- a/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift +++ b/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift @@ -17,18 +17,18 @@ import SwiftUI @available(watchOS, deprecated: 10, renamed: "Bindable") @propertyWrapper @dynamicMemberLookup -public struct BindableStore { - public var wrappedValue: Store - public init(wrappedValue: Store) { +struct BindableStore { + var wrappedValue: Store + init(wrappedValue: Store) { self.wrappedValue = wrappedValue } - public var projectedValue: BindableStore { + var projectedValue: BindableStore { self } - public subscript( - dynamicMember keyPath: ReferenceWritableKeyPath, Subject> + subscript( + dynamicMember keyPath: ReferenceWritableKeyPath, Subject> ) -> Binding { Binding( get: { self.wrappedValue[keyPath: keyPath] }, diff --git a/Samples/SwiftUITestbed/Sources/Observation/Binding+Observation.swift b/Samples/SwiftUITestbed/Sources/Observation/Binding+Observation.swift index a34b4bf7d..6280a8564 100644 --- a/Samples/SwiftUITestbed/Sources/Observation/Binding+Observation.swift +++ b/Samples/SwiftUITestbed/Sources/Observation/Binding+Observation.swift @@ -6,10 +6,14 @@ import SwiftUI // NB: These overloads ensure runtime warnings aren't emitted for errant SwiftUI bindings. #if DEBUG extension Binding { - public subscript( + subscript< + State: ObservableState, + Action, + Member: Equatable + >( dynamicMember keyPath: WritableKeyPath ) -> Binding - where Value == Store { + where Value == Store { Binding( get: { self.wrappedValue.state[keyPath: keyPath] }, set: { _ in fatalError("TODO") } @@ -19,10 +23,10 @@ extension Binding { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Bindable { - public subscript( + subscript( dynamicMember keyPath: WritableKeyPath ) -> Binding - where Value == Store { + where Value == Store { Binding( get: { self.wrappedValue.state[keyPath: keyPath] }, set: { _ in fatalError("TODO") } diff --git a/Samples/SwiftUITestbed/Sources/Observation/Store.swift b/Samples/SwiftUITestbed/Sources/Observation/Store.swift index 8b4466798..d8e513921 100644 --- a/Samples/SwiftUITestbed/Sources/Observation/Store.swift +++ b/Samples/SwiftUITestbed/Sources/Observation/Store.swift @@ -1,41 +1,49 @@ import ComposableArchitecture // for ObservableState and Perception @dynamicMemberLookup -public final class Store: Perceptible { - private var _state: State - let _$observationRegistrar = PerceptionRegistrar() - - fileprivate(set) var state: State { - get { - _$observationRegistrar.access(self, keyPath: \.state) - return _state - } - set { - if !_$isIdentityEqual(state, newValue) { - _$observationRegistrar.withMutation(of: self, keyPath: \.state) { - _state = newValue - } - } else { - _state = newValue - } - } +final class Store: Perceptible { + typealias Model = ViewModel + + private var model: Model + private let _$observationRegistrar = PerceptionRegistrar() + + var state: State { + _$observationRegistrar.access(self, keyPath: \.state) + return model.state + } + + func send(_ action: Action) { + model.sendAction(action) } - private init(state: State) { - self._state = state + fileprivate init(_ model: Model) { + self.model = model + } + + fileprivate func setModel(_ newValue: Model) { + if !_$isIdentityEqual(model.state, newValue.state) { + _$observationRegistrar.withMutation(of: self, keyPath: \.state) { + model = newValue + } + } else { + model = newValue + } } } -public extension Store { - static func make(initialState: State) -> (Store, (State) -> Void) { - let store = Store(state: initialState) - let setState = { store.state = $0 } - return (store, setState) +extension Store { + static func make(model: Model) -> (Store, (Model) -> Void) { + let store = Store(model) + return (store, store.setModel) } subscript(dynamicMember keyPath: KeyPath) -> T { state[keyPath: keyPath] } + + func action(_ action: Action) -> () -> Void { + { self.send(action) } + } } extension Store: Equatable { diff --git a/Samples/SwiftUITestbed/Sources/Observation/SwiftUIScreen.swift b/Samples/SwiftUITestbed/Sources/Observation/SwiftUIScreen.swift index 8dfb6dda3..e5fae93f9 100644 --- a/Samples/SwiftUITestbed/Sources/Observation/SwiftUIScreen.swift +++ b/Samples/SwiftUITestbed/Sources/Observation/SwiftUIScreen.swift @@ -5,45 +5,36 @@ import SwiftUI import Workflow import WorkflowUI -struct ViewModel { - let state: State - let sendAction: (Action) -> Void -} - protocol SwiftUIScreen: Screen { associatedtype State: ObservableState associatedtype Action associatedtype Content: View - var state: State { get } - var sendAction: (Action) -> Void { get } + typealias Model = ViewModel + + var model: Model { get } @ViewBuilder - static func makeView(store: Store, sendAction: @escaping (Action) -> Void) -> Content + static func makeView(store: Store) -> Content } extension SwiftUIScreen { func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription { ViewControllerDescription( - type: ModeledHostingController>.self, + type: ModeledHostingController>.self, environment: environment, build: { - let (store, setState) = Store.make(initialState: state) + let (store, setModel) = Store.make(model: model) return ModeledHostingController( - setState: setState, + setModel: setModel, rootView: EnvironmentInjectingView( environment: environment, - content: Self.makeView( - store: store, - sendAction: { _ in - fatalError("TODO") - } - ) + content: Self.makeView(store: store) ) ) }, update: { hostingController in - hostingController.setState(state) + hostingController.setModel(model) // TODO: update viewEnvironment } ) @@ -60,11 +51,11 @@ private struct EnvironmentInjectingView: View { } } -private final class ModeledHostingController: UIHostingController { - let setState: (State) -> Void +private final class ModeledHostingController: UIHostingController { + let setModel: (Model) -> Void - init(setState: @escaping (State) -> Void, rootView: Content) { - self.setState = setState + init(setModel: @escaping (Model) -> Void, rootView: Content) { + self.setModel = setModel super.init(rootView: rootView) } diff --git a/Samples/SwiftUITestbed/Sources/Observation/ViewModel.swift b/Samples/SwiftUITestbed/Sources/Observation/ViewModel.swift new file mode 100644 index 000000000..27bd0427f --- /dev/null +++ b/Samples/SwiftUITestbed/Sources/Observation/ViewModel.swift @@ -0,0 +1,6 @@ +import ComposableArchitecture + +struct ViewModel { + let state: State + let sendAction: (Action) -> Void +}