From 6b24cfd29012f3f6367c6d080f9c43bb7721b4c4 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 20 Dec 2023 12:51:17 -0600 Subject: [PATCH 01/20] 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 02/20] 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 03/20] 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 04/20] 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 05/20] 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 06/20] 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 +} From 46b4cb9ee57e4cb842b1e00ae77330c2eb20d5f4 Mon Sep 17 00:00:00 2001 From: Andrew Watt Date: Wed, 28 Feb 2024 17:35:00 -0800 Subject: [PATCH 07/20] pin to cocoapods 1.15.0 --- Gemfile | 2 +- Gemfile.lock | 63 +++++++++++++++++++++++++++++++--------------------- 2 files changed, 39 insertions(+), 26 deletions(-) diff --git a/Gemfile b/Gemfile index ae0e49029..cffb72b7b 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,6 @@ source 'https://rubygems.org' gem 'cocoapods-trunk', '>=1.6.0' -gem 'cocoapods' +gem 'cocoapods', '= 1.15.0' gem 'cocoapods-generate' diff --git a/Gemfile.lock b/Gemfile.lock index 1a4e418bb..f916e904c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,30 +1,38 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.5) + CFPropertyList (3.0.7) + base64 + nkf rexml - activesupport (6.1.7.6) + activesupport (7.1.3) + base64 + bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) minitest (>= 5.1) + mutex_m tzinfo (~> 2.0) - zeitwerk (~> 2.3) - addressable (2.8.0) - public_suffix (>= 2.0.2, < 5.0) + addressable (2.8.6) + public_suffix (>= 2.0.2, < 6.0) algoliasearch (1.27.5) httpclient (~> 2.8, >= 2.8.3) json (>= 1.5.1) atomos (0.1.3) + base64 (0.2.0) + bigdecimal (3.1.6) claide (1.1.0) - cocoapods (1.11.2) + cocoapods (1.15.0) addressable (~> 2.8) claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.11.2) + cocoapods-core (= 1.15.0) cocoapods-deintegrate (>= 1.0.3, < 2.0) - cocoapods-downloader (>= 1.4.0, < 2.0) + cocoapods-downloader (>= 2.1, < 3.0) cocoapods-plugins (>= 1.0.0, < 2.0) cocoapods-search (>= 1.0.0, < 2.0) - cocoapods-trunk (>= 1.4.0, < 2.0) + cocoapods-trunk (>= 1.6.0, < 2.0) cocoapods-try (>= 1.1.0, < 2.0) colored2 (~> 3.1) escape (~> 0.0.4) @@ -32,10 +40,10 @@ GEM gh_inspector (~> 1.0) molinillo (~> 0.8.0) nap (~> 1.0) - ruby-macho (>= 1.0, < 3.0) - xcodeproj (>= 1.21.0, < 2.0) - cocoapods-core (1.11.2) - activesupport (>= 5.0, < 7) + ruby-macho (>= 2.3.0, < 3.0) + xcodeproj (>= 1.23.0, < 2.0) + cocoapods-core (1.15.0) + activesupport (>= 5.0, < 8) addressable (~> 2.8) algoliasearch (~> 1.0) concurrent-ruby (~> 1.1) @@ -46,7 +54,7 @@ GEM typhoeus (~> 1.0) cocoapods-deintegrate (1.0.5) cocoapods-disable-podfile-validations (0.2.0) - cocoapods-downloader (1.6.3) + cocoapods-downloader (2.1) cocoapods-generate (2.2.3) cocoapods-disable-podfile-validations (>= 0.1.1, < 0.3.0) cocoapods-plugins (1.0.0) @@ -57,44 +65,49 @@ GEM netrc (~> 0.11) cocoapods-try (1.2.0) colored2 (3.1.2) - concurrent-ruby (1.2.2) + concurrent-ruby (1.2.3) + connection_pool (2.4.1) + drb (2.2.0) + ruby2_keywords escape (0.0.4) - ethon (0.15.0) + ethon (0.16.0) ffi (>= 1.15.0) - ffi (1.15.5) + ffi (1.16.3) fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) httpclient (2.8.3) i18n (1.14.1) concurrent-ruby (~> 1.0) - json (2.6.1) - minitest (5.19.0) + json (2.7.1) + minitest (5.22.2) molinillo (0.8.0) + mutex_m (0.2.0) nanaimo (0.3.0) nap (1.1.0) netrc (0.11.0) - public_suffix (4.0.6) - rexml (3.2.5) + nkf (0.2.0) + public_suffix (4.0.7) + rexml (3.2.6) ruby-macho (2.5.1) - typhoeus (1.4.0) + ruby2_keywords (0.0.5) + typhoeus (1.4.1) ethon (>= 0.9.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - xcodeproj (1.21.0) + xcodeproj (1.24.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) nanaimo (~> 0.3.0) rexml (~> 3.2.4) - zeitwerk (2.6.11) PLATFORMS ruby DEPENDENCIES - cocoapods + cocoapods (= 1.15.0) cocoapods-generate cocoapods-trunk (>= 1.6.0) From ab29b6953cd5bf917c845d63503f1c7d84c71bda Mon Sep 17 00:00:00 2001 From: Andrew Watt Date: Thu, 29 Feb 2024 15:27:38 -0800 Subject: [PATCH 08/20] bindings --- .../SwiftUITestbed/Sources/MainScreen.swift | 13 +++++-- .../SwiftUITestbed/Sources/MainWorkflow.swift | 25 +++++-------- .../Observation/Binding+Observation.swift | 35 +++++++++++++++---- .../Sources/Observation/Store.swift | 16 +++++++++ .../Sources/Observation/ViewModel.swift | 2 ++ .../SwiftUITestbed/Sources/ToggleRow.swift | 1 + 6 files changed, 68 insertions(+), 24 deletions(-) diff --git a/Samples/SwiftUITestbed/Sources/MainScreen.swift b/Samples/SwiftUITestbed/Sources/MainScreen.swift index 955923df9..e0bf561cc 100644 --- a/Samples/SwiftUITestbed/Sources/MainScreen.swift +++ b/Samples/SwiftUITestbed/Sources/MainScreen.swift @@ -48,6 +48,12 @@ private struct MainView: View { var body: some View { WithPerceptionTracking { ScrollView { VStack { + let _ = Self._printChanges() + + // TODO: + // - why does ToggleRow think _isOn changed? + // - suppress double render from textfield binding? + Text("Title") .font(Font(styles.headers.inlineSection20.heading.text.font)) @@ -58,11 +64,13 @@ private struct MainView: View { .focused($focusedField, equals: .title) .onAppear { focusedField = .title } + Text("What you typed: \(store.title)") + ToggleRow( style: context.stylesheets.testbed.toggleRow, label: "All Caps", isEnabled: store.allCapsToggleIsEnabled, - isOn: $store.allCapsToggleIsOn + isOn: $store.isAllCaps ) Spacer(minLength: styles.spacings.spacing50) @@ -108,7 +116,8 @@ struct MainScreen_Preview: PreviewProvider { static var previews: some View { MainWorkflow.Rendering( state: .init(title: "Test"), - sendAction: { _ in } + sendAction: { _ in }, + sendValue: { _ in } ) .asMarketBackStack() .marketPreview() diff --git a/Samples/SwiftUITestbed/Sources/MainWorkflow.swift b/Samples/SwiftUITestbed/Sources/MainWorkflow.swift index 308e2ae39..8191c2706 100644 --- a/Samples/SwiftUITestbed/Sources/MainWorkflow.swift +++ b/Samples/SwiftUITestbed/Sources/MainWorkflow.swift @@ -29,7 +29,11 @@ struct MainWorkflow: Workflow { @ObservableState struct State { var title: String - var isAllCaps: Bool + var isAllCaps: Bool { + didSet { + title = isAllCaps ? title.uppercased() : title.lowercased() + } + } init(title: String) { self.title = title @@ -46,8 +50,6 @@ struct MainWorkflow: Workflow { case pushScreen case presentScreen - case changeTitle(String) - case changeAllCaps(Bool) func apply(toState state: inout WorkflowType.State) -> WorkflowType.Output? { switch self { @@ -55,32 +57,23 @@ struct MainWorkflow: Workflow { return .pushScreen case .presentScreen: return .presentScreen - case .changeTitle(let newValue): - state.title = newValue - state.isAllCaps = newValue.isAllCaps - case .changeAllCaps(let isAllCaps): - state.isAllCaps = isAllCaps - state.title = isAllCaps ? state.title.uppercased() : state.title.lowercased() } - return nil } } typealias Rendering = ViewModel func render(state: State, context: RenderContext) -> Rendering { - ViewModel( + print("MainWorkflow.render") + return ViewModel( state: state, - sendAction: context.makeSink(of: Action.self).send + sendAction: context.makeSink(of: Action.self).send, + sendValue: context.makeStateMutationSink().send ) } } extension MainWorkflow.State { - var allCapsToggleIsOn: Bool { - get { isAllCaps } - set { fatalError("TODO") } - } var allCapsToggleIsEnabled: Bool { !title.isEmpty diff --git a/Samples/SwiftUITestbed/Sources/Observation/Binding+Observation.swift b/Samples/SwiftUITestbed/Sources/Observation/Binding+Observation.swift index 6280a8564..8f2af600e 100644 --- a/Samples/SwiftUITestbed/Sources/Observation/Binding+Observation.swift +++ b/Samples/SwiftUITestbed/Sources/Observation/Binding+Observation.swift @@ -5,6 +5,7 @@ import SwiftUI // NB: These overloads ensure runtime warnings aren't emitted for errant SwiftUI bindings. #if DEBUG +/* extension Binding { subscript< State: ObservableState, @@ -15,8 +16,15 @@ extension Binding { ) -> Binding where Value == Store { Binding( - get: { self.wrappedValue.state[keyPath: keyPath] }, - set: { _ in fatalError("TODO") } + get: { + self.wrappedValue.state[keyPath: keyPath] + }, + set: { newValue in + self.wrappedValue.send( + keyPath: keyPath, + value: newValue + ) + } ) } } @@ -28,8 +36,15 @@ extension Bindable { ) -> Binding where Value == Store { Binding( - get: { self.wrappedValue.state[keyPath: keyPath] }, - set: { _ in fatalError("TODO") } + get: { + self.wrappedValue.state[keyPath: keyPath] + }, + set: { newValue in + self.wrappedValue.send( + keyPath: keyPath, + value: newValue + ) + } ) } } @@ -39,9 +54,17 @@ extension BindableStore { dynamicMember keyPath: WritableKeyPath ) -> Binding { Binding( - get: { self.wrappedValue.state[keyPath: keyPath] }, - set: { _ in fatalError("TODO") } + get: { + self.wrappedValue.state[keyPath: keyPath] + }, + set: { newValue in + self.wrappedValue.send( + keyPath: keyPath, + value: newValue + ) + } ) } } + */ #endif diff --git a/Samples/SwiftUITestbed/Sources/Observation/Store.swift b/Samples/SwiftUITestbed/Sources/Observation/Store.swift index d8e513921..c3178b4c1 100644 --- a/Samples/SwiftUITestbed/Sources/Observation/Store.swift +++ b/Samples/SwiftUITestbed/Sources/Observation/Store.swift @@ -16,6 +16,13 @@ final class Store: Perceptible { model.sendAction(action) } + private func send(keyPath: WritableKeyPath, value: Value) { + print("Store.send(\(keyPath), \(value))") + model.sendValue { state in + state[keyPath: keyPath] = value + } + } + fileprivate init(_ model: Model) { self.model = model } @@ -41,6 +48,15 @@ extension Store { state[keyPath: keyPath] } + subscript(dynamicMember keyPath: WritableKeyPath) -> T { + get { + state[keyPath: keyPath] + } + set { + send(keyPath: keyPath, value: newValue) + } + } + func action(_ action: Action) -> () -> Void { { self.send(action) } } diff --git a/Samples/SwiftUITestbed/Sources/Observation/ViewModel.swift b/Samples/SwiftUITestbed/Sources/Observation/ViewModel.swift index 27bd0427f..f953023bf 100644 --- a/Samples/SwiftUITestbed/Sources/Observation/ViewModel.swift +++ b/Samples/SwiftUITestbed/Sources/Observation/ViewModel.swift @@ -1,6 +1,8 @@ import ComposableArchitecture +import Workflow struct ViewModel { let state: State let sendAction: (Action) -> Void + let sendValue: (@escaping (inout State) -> Void) -> Void } diff --git a/Samples/SwiftUITestbed/Sources/ToggleRow.swift b/Samples/SwiftUITestbed/Sources/ToggleRow.swift index 544f913ba..323acdf71 100644 --- a/Samples/SwiftUITestbed/Sources/ToggleRow.swift +++ b/Samples/SwiftUITestbed/Sources/ToggleRow.swift @@ -27,6 +27,7 @@ struct ToggleRow: View { @Binding var isOn: Bool var body: some View { + let _ = Self._printChanges() HStack( alignment: .center, spacing: style.spacing From 6519251d3aa47b6445725b41c8014f38606dcee8 Mon Sep 17 00:00:00 2001 From: Andrew Watt Date: Thu, 29 Feb 2024 17:51:50 -0800 Subject: [PATCH 09/20] update bundler --- Gemfile.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index f916e904c..a3004af21 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -5,7 +5,7 @@ GEM base64 nkf rexml - activesupport (7.1.3) + activesupport (7.1.3.2) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) @@ -55,7 +55,7 @@ GEM cocoapods-deintegrate (1.0.5) cocoapods-disable-podfile-validations (0.2.0) cocoapods-downloader (2.1) - cocoapods-generate (2.2.3) + cocoapods-generate (2.2.5) cocoapods-disable-podfile-validations (>= 0.1.1, < 0.3.0) cocoapods-plugins (1.0.0) nap @@ -112,4 +112,4 @@ DEPENDENCIES cocoapods-trunk (>= 1.6.0) BUNDLED WITH - 2.1.4 + 2.5.6 From fa8ab45bbea0b95f23aacda5dddaf0e14b1fa92c Mon Sep 17 00:00:00 2001 From: Andrew Watt Date: Thu, 29 Feb 2024 18:49:00 -0800 Subject: [PATCH 10/20] counters demo start --- .../Sources/CounterScreen.swift | 55 +++++++++++++++++++ .../Sources/CounterWorkflow.swift | 46 ++++++++++++++++ .../SwiftUITestbed/Sources/MainScreen.swift | 2 + .../SwiftUITestbed/Sources/MainWorkflow.swift | 4 ++ .../SwiftUITestbed/Sources/RootWorkflow.swift | 12 ++++ 5 files changed, 119 insertions(+) create mode 100644 Samples/SwiftUITestbed/Sources/CounterScreen.swift create mode 100644 Samples/SwiftUITestbed/Sources/CounterWorkflow.swift diff --git a/Samples/SwiftUITestbed/Sources/CounterScreen.swift b/Samples/SwiftUITestbed/Sources/CounterScreen.swift new file mode 100644 index 000000000..b5a1678a3 --- /dev/null +++ b/Samples/SwiftUITestbed/Sources/CounterScreen.swift @@ -0,0 +1,55 @@ +import SwiftUI +import MarketUI +import MarketWorkflowUI +import ViewEnvironment +import WorkflowUI +import Perception + +struct CounterScreen: SwiftUIScreen, Screen { + var model: Model + + typealias State = CounterWorkflow.State + + typealias Action = CounterWorkflow.Action + + static func makeView(store: Store) -> some View { + CounterView(store: store) + } +} + +extension CounterScreen: MarketBackStackContentScreen { + func backStackItem(in environment: ViewEnvironment) -> MarketUI.MarketNavigationItem { + MarketNavigationItem( + title: .text(.init(regular: "Counters")), + backButton: .automatic() + ) + } + + var backStackIdentifier: AnyHashable? { nil } +} + +struct CounterView: View { + let store: Store + + var body: some View { + let _ = Self._printChanges() + WithPerceptionTracking { + HStack { + Button { + store.send(.decrement) + } label: { + Image(systemName: "minus") + } + + Text("\(store.count)") + .monospacedDigit() + + Button { + store.send(.increment) + } label: { + Image(systemName: "plus") + } + } + } + } +} diff --git a/Samples/SwiftUITestbed/Sources/CounterWorkflow.swift b/Samples/SwiftUITestbed/Sources/CounterWorkflow.swift new file mode 100644 index 000000000..b0df58863 --- /dev/null +++ b/Samples/SwiftUITestbed/Sources/CounterWorkflow.swift @@ -0,0 +1,46 @@ +import Workflow +import ComposableArchitecture + +struct CounterWorkflow: Workflow { + + @ObservableState + struct State { + var count: Int + } + + enum Action: WorkflowAction { + typealias WorkflowType = CounterWorkflow + + case increment + case decrement + + func apply(toState state: inout CounterWorkflow.State) -> CounterWorkflow.Output? { + switch self { + case .increment: + state.count += 1 + case .decrement: + state.count -= 1 + } + return nil + } + } + + typealias Output = Never + + func makeInitialState() -> State { + State(count: 0) + } + + typealias Rendering = CounterScreen + + func render(state: State, context: RenderContext) -> CounterScreen { + print("CounterWorkflow.render") + return CounterScreen( + model: ViewModel( + state: state, + sendAction: context.makeSink(of: Action.self).send, + sendValue: context.makeStateMutationSink().send + ) + ) + } +} diff --git a/Samples/SwiftUITestbed/Sources/MainScreen.swift b/Samples/SwiftUITestbed/Sources/MainScreen.swift index e0bf561cc..4dcdfbf0e 100644 --- a/Samples/SwiftUITestbed/Sources/MainScreen.swift +++ b/Samples/SwiftUITestbed/Sources/MainScreen.swift @@ -93,6 +93,8 @@ private struct MainView: View { action: { focusedField = nil } ) + Button("Counters", action: store.action(.counters)) + } } } } } diff --git a/Samples/SwiftUITestbed/Sources/MainWorkflow.swift b/Samples/SwiftUITestbed/Sources/MainWorkflow.swift index 8191c2706..1f8f45d00 100644 --- a/Samples/SwiftUITestbed/Sources/MainWorkflow.swift +++ b/Samples/SwiftUITestbed/Sources/MainWorkflow.swift @@ -24,6 +24,7 @@ struct MainWorkflow: Workflow { enum Output { case pushScreen case presentScreen + case counters } @ObservableState @@ -50,6 +51,7 @@ struct MainWorkflow: Workflow { case pushScreen case presentScreen + case counters func apply(toState state: inout WorkflowType.State) -> WorkflowType.Output? { switch self { @@ -57,6 +59,8 @@ struct MainWorkflow: Workflow { return .pushScreen case .presentScreen: return .presentScreen + case .counters: + return .counters } } } diff --git a/Samples/SwiftUITestbed/Sources/RootWorkflow.swift b/Samples/SwiftUITestbed/Sources/RootWorkflow.swift index 716a45eb0..c2b78d3e0 100644 --- a/Samples/SwiftUITestbed/Sources/RootWorkflow.swift +++ b/Samples/SwiftUITestbed/Sources/RootWorkflow.swift @@ -34,6 +34,7 @@ struct RootWorkflow: Workflow { enum Screen { case main(id: UUID = UUID()) + case counters(id: UUID = UUID()) } } @@ -57,6 +58,8 @@ struct RootWorkflow: Workflow { state.backStack.other.append(.main()) case .main(.presentScreen): state.isPresentingModal = true + case .main(.counters): + state.backStack.other.append(.counters()) case .popScreen: state.backStack.other.removeLast() case .dismissScreen: @@ -79,6 +82,15 @@ struct RootWorkflow: Workflow { .mapOutput(Action.main) .mapRendering(AnyMarketBackStackContentScreen.init) .rendered(in: context, key: id.uuidString) + case .counters(let id): + // explicit annotations on every single line or else compiler can't handle it + let w1: CounterWorkflow = CounterWorkflow() + let aw: AnyWorkflow = w1.asAnyWorkflow() + let w2: AnyWorkflow = aw.mapRendering { + return AnyMarketBackStackContentScreen($0) + } + let w3 = w2.rendered(in: context, key: id.uuidString) + return w3 } } From 6b29baa933e89e408cdf176d0c23abb798b83517 Mon Sep 17 00:00:00 2001 From: Andrew Watt Date: Fri, 1 Mar 2024 14:55:02 -0800 Subject: [PATCH 11/20] counter demos working --- .../Sources/CounterScreen.swift | 28 ++++++++++++++++--- .../Sources/CounterWorkflow.swift | 25 +++++++++++------ .../SwiftUITestbed/Sources/RootWorkflow.swift | 12 ++++---- 3 files changed, 47 insertions(+), 18 deletions(-) diff --git a/Samples/SwiftUITestbed/Sources/CounterScreen.swift b/Samples/SwiftUITestbed/Sources/CounterScreen.swift index b5a1678a3..a4e9ad826 100644 --- a/Samples/SwiftUITestbed/Sources/CounterScreen.swift +++ b/Samples/SwiftUITestbed/Sources/CounterScreen.swift @@ -13,7 +13,7 @@ struct CounterScreen: SwiftUIScreen, Screen { typealias Action = CounterWorkflow.Action static func makeView(store: Store) -> some View { - CounterView(store: store) + CounterScreenView(store: store) } } @@ -28,28 +28,48 @@ extension CounterScreen: MarketBackStackContentScreen { var backStackIdentifier: AnyHashable? { nil } } +struct CounterScreenView: View { + let store: Store + + var body: some View { + WithPerceptionTracking { + let _ = Self._printChanges() + VStack { + CounterView(store: store, index: 0) + + CounterView(store: store, index: 1) + } + } + } +} + struct CounterView: View { let store: Store + let index: Int var body: some View { + let _ = print("Evaluating CounterView[\(index)].body") let _ = Self._printChanges() WithPerceptionTracking { + let _ = print("Evaluating CounterView[\(index)].WithPerceptionTracking.body") + let _ = Self._printChanges() HStack { Button { - store.send(.decrement) + store.send(.decrement(index: index)) } label: { Image(systemName: "minus") } - Text("\(store.count)") + Text("\(index == 0 ? store.count1 : store.count2)") .monospacedDigit() Button { - store.send(.increment) + store.send(.increment(index: index)) } label: { Image(systemName: "plus") } } + .padding() } } } diff --git a/Samples/SwiftUITestbed/Sources/CounterWorkflow.swift b/Samples/SwiftUITestbed/Sources/CounterWorkflow.swift index b0df58863..e6876bc32 100644 --- a/Samples/SwiftUITestbed/Sources/CounterWorkflow.swift +++ b/Samples/SwiftUITestbed/Sources/CounterWorkflow.swift @@ -5,21 +5,30 @@ struct CounterWorkflow: Workflow { @ObservableState struct State { - var count: Int + var count1 = 0 + var count2 = 0 } enum Action: WorkflowAction { typealias WorkflowType = CounterWorkflow - case increment - case decrement + case increment(index: Int) + case decrement(index: Int) func apply(toState state: inout CounterWorkflow.State) -> CounterWorkflow.Output? { switch self { - case .increment: - state.count += 1 - case .decrement: - state.count -= 1 + case .increment(let index): + if index == 0 { + state.count1 += 1 + } else { + state.count2 += 1 + } + case .decrement(let index): + if index == 0 { + state.count1 -= 1 + } else { + state.count2 -= 1 + } } return nil } @@ -28,7 +37,7 @@ struct CounterWorkflow: Workflow { typealias Output = Never func makeInitialState() -> State { - State(count: 0) + State() } typealias Rendering = CounterScreen diff --git a/Samples/SwiftUITestbed/Sources/RootWorkflow.swift b/Samples/SwiftUITestbed/Sources/RootWorkflow.swift index c2b78d3e0..1d78c8303 100644 --- a/Samples/SwiftUITestbed/Sources/RootWorkflow.swift +++ b/Samples/SwiftUITestbed/Sources/RootWorkflow.swift @@ -77,12 +77,12 @@ struct RootWorkflow: Workflow { func rendering(_ screen: State.Screen, isRoot: Bool) -> AnyMarketBackStackContentScreen { switch screen { - case .main(let id): - return MainWorkflow(didClose: isRoot ? close : nil) - .mapOutput(Action.main) - .mapRendering(AnyMarketBackStackContentScreen.init) - .rendered(in: context, key: id.uuidString) - case .counters(let id): + case .main(let id), +// return MainWorkflow(didClose: isRoot ? close : nil) +// .mapOutput(Action.main) +// .mapRendering(AnyMarketBackStackContentScreen.init) +// .rendered(in: context, key: id.uuidString) + .counters(let id): // explicit annotations on every single line or else compiler can't handle it let w1: CounterWorkflow = CounterWorkflow() let aw: AnyWorkflow = w1.asAnyWorkflow() From 31b424359650410990ba31c48796cc4fffb42701 Mon Sep 17 00:00:00 2001 From: Andrew Watt Date: Mon, 4 Mar 2024 17:24:06 -0800 Subject: [PATCH 12/20] cached bindings --- .../SwiftUITestbed/Sources/MainScreen.swift | 19 +++---- .../SwiftUITestbed/Sources/MainWorkflow.swift | 21 ++++++-- .../Sources/Observation/BindableStore.swift | 11 ++-- .../Sources/Observation/Store.swift | 52 +++++++++++++++++++ .../SwiftUITestbed/Sources/RootWorkflow.swift | 29 +++++------ 5 files changed, 95 insertions(+), 37 deletions(-) diff --git a/Samples/SwiftUITestbed/Sources/MainScreen.swift b/Samples/SwiftUITestbed/Sources/MainScreen.swift index 4dcdfbf0e..d932684b7 100644 --- a/Samples/SwiftUITestbed/Sources/MainScreen.swift +++ b/Samples/SwiftUITestbed/Sources/MainScreen.swift @@ -51,7 +51,6 @@ private struct MainView: View { let _ = Self._printChanges() // TODO: - // - why does ToggleRow think _isOn changed? // - suppress double render from textfield binding? Text("Title") @@ -60,6 +59,8 @@ private struct MainView: View { TextField( "Text", text: $store.title + // alternatively: + // text: store.binding(for: \.title, action: \.titleChanged) ) .focused($focusedField, equals: .title) .onAppear { focusedField = .title } @@ -73,6 +74,8 @@ private struct MainView: View { isOn: $store.isAllCaps ) + Button("Append *", action: store.action(.appendStar)) + Spacer(minLength: styles.spacings.spacing50) Text("Navigation") @@ -93,8 +96,6 @@ private struct MainView: View { action: { focusedField = nil } ) - Button("Counters", action: store.action(.counters)) - } } } } } @@ -116,13 +117,13 @@ import SwiftUI struct MainScreen_Preview: PreviewProvider { static var previews: some View { - MainWorkflow.Rendering( - state: .init(title: "Test"), - sendAction: { _ in }, - sendValue: { _ in } + MainWorkflow( + didClose: nil ) - .asMarketBackStack() - .marketPreview() + .mapRendering { $0.asMarketBackStack() } + .marketPreview { output in + + } } } diff --git a/Samples/SwiftUITestbed/Sources/MainWorkflow.swift b/Samples/SwiftUITestbed/Sources/MainWorkflow.swift index 1f8f45d00..a9638382d 100644 --- a/Samples/SwiftUITestbed/Sources/MainWorkflow.swift +++ b/Samples/SwiftUITestbed/Sources/MainWorkflow.swift @@ -17,6 +17,7 @@ import ComposableArchitecture // for ObservableState import MarketWorkflowUI import Workflow +import CasePaths struct MainWorkflow: Workflow { let didClose: (() -> Void)? @@ -24,7 +25,6 @@ struct MainWorkflow: Workflow { enum Output { case pushScreen case presentScreen - case counters } @ObservableState @@ -46,12 +46,15 @@ struct MainWorkflow: Workflow { State(title: "New item") } - enum Action: WorkflowAction { + @CasePathable + enum Action: WorkflowAction, Equatable { typealias WorkflowType = MainWorkflow case pushScreen case presentScreen - case counters + case titleChanged(String) + case allCapsChanged(Bool) + case appendStar func apply(toState state: inout WorkflowType.State) -> WorkflowType.Output? { switch self { @@ -59,8 +62,15 @@ struct MainWorkflow: Workflow { return .pushScreen case .presentScreen: return .presentScreen - case .counters: - return .counters + case .allCapsChanged(let allCaps): + state.isAllCaps = allCaps + return nil + case .titleChanged(let newTitle): + state.title = newTitle + return nil + case .appendStar: + state.title += "*" + return nil } } } @@ -69,6 +79,7 @@ struct MainWorkflow: Workflow { func render(state: State, context: RenderContext) -> Rendering { print("MainWorkflow.render") + return ViewModel( state: state, sendAction: context.makeSink(of: Action.self).send, diff --git a/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift b/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift index 00daa25ab..48054da8d 100644 --- a/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift +++ b/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift @@ -27,12 +27,9 @@ struct BindableStore { self } - subscript( - dynamicMember keyPath: ReferenceWritableKeyPath, Subject> - ) -> Binding { - Binding( - get: { self.wrappedValue[keyPath: keyPath] }, - set: { self.wrappedValue[keyPath: keyPath] = $0 } - ) + subscript( + dynamicMember keyPath: ReferenceWritableKeyPath, Value> + ) -> Binding { + wrappedValue.binding(for: keyPath) } } diff --git a/Samples/SwiftUITestbed/Sources/Observation/Store.swift b/Samples/SwiftUITestbed/Sources/Observation/Store.swift index c3178b4c1..09c385004 100644 --- a/Samples/SwiftUITestbed/Sources/Observation/Store.swift +++ b/Samples/SwiftUITestbed/Sources/Observation/Store.swift @@ -1,4 +1,5 @@ import ComposableArchitecture // for ObservableState and Perception +import SwiftUI @dynamicMemberLookup final class Store: Perceptible { @@ -7,6 +8,8 @@ final class Store: Perceptible { private var model: Model private let _$observationRegistrar = PerceptionRegistrar() + private var bindings: [BindingKey: Any] = [:] + var state: State { _$observationRegistrar.access(self, keyPath: \.state) return model.state @@ -60,6 +63,55 @@ extension Store { func action(_ action: Action) -> () -> Void { { self.send(action) } } + + func binding( + for keyPath: ReferenceWritableKeyPath, Value> + ) -> Binding { + let key = BindingKey(keyPath: keyPath, action: nil) + + if let binding = bindings[key] as? Binding { + print("cached binding for \(keyPath)") + return binding + } + + print("new binding for \(keyPath)") + let binding = Binding( + get: { self[keyPath: keyPath] }, + set: { self[keyPath: keyPath] = $0 } + ) + + bindings[key] = binding + + return binding + + } + + func binding( + for keyPath: KeyPath, + action: CaseKeyPath + ) -> Binding { + let key = BindingKey(keyPath: keyPath, action: action) + + if let binding = bindings[key] as? Binding { + print("cached binding for \(keyPath)") + return binding + } + + print("new binding for \(keyPath)") + let binding = Binding( + get: { self.state[keyPath: keyPath] }, + set: { self.send(action($0)) } + ) + + bindings[key] = binding + + return binding + } + + struct BindingKey: Hashable { + let keyPath: AnyKeyPath + let action: AnyKeyPath? + } } extension Store: Equatable { diff --git a/Samples/SwiftUITestbed/Sources/RootWorkflow.swift b/Samples/SwiftUITestbed/Sources/RootWorkflow.swift index 1d78c8303..6f10be0ee 100644 --- a/Samples/SwiftUITestbed/Sources/RootWorkflow.swift +++ b/Samples/SwiftUITestbed/Sources/RootWorkflow.swift @@ -34,7 +34,6 @@ struct RootWorkflow: Workflow { enum Screen { case main(id: UUID = UUID()) - case counters(id: UUID = UUID()) } } @@ -58,8 +57,6 @@ struct RootWorkflow: Workflow { state.backStack.other.append(.main()) case .main(.presentScreen): state.isPresentingModal = true - case .main(.counters): - state.backStack.other.append(.counters()) case .popScreen: state.backStack.other.removeLast() case .dismissScreen: @@ -77,20 +74,20 @@ struct RootWorkflow: Workflow { func rendering(_ screen: State.Screen, isRoot: Bool) -> AnyMarketBackStackContentScreen { switch screen { - case .main(let id), -// return MainWorkflow(didClose: isRoot ? close : nil) -// .mapOutput(Action.main) -// .mapRendering(AnyMarketBackStackContentScreen.init) -// .rendered(in: context, key: id.uuidString) - .counters(let id): + case .main(let id): + return MainWorkflow(didClose: isRoot ? close : nil) + .mapOutput(Action.main) + .mapRendering(AnyMarketBackStackContentScreen.init) + .rendered(in: context, key: id.uuidString) + // explicit annotations on every single line or else compiler can't handle it - let w1: CounterWorkflow = CounterWorkflow() - let aw: AnyWorkflow = w1.asAnyWorkflow() - let w2: AnyWorkflow = aw.mapRendering { - return AnyMarketBackStackContentScreen($0) - } - let w3 = w2.rendered(in: context, key: id.uuidString) - return w3 +// let w1: CounterWorkflow = CounterWorkflow() +// let aw: AnyWorkflow = w1.asAnyWorkflow() +// let w2: AnyWorkflow = aw.mapRendering { +// return AnyMarketBackStackContentScreen($0) +// } +// let w3 = w2.rendered(in: context, key: id.uuidString) +// return w3 } } From 1d457b1729ea40425c7424f71db7dd3dfc7349c0 Mon Sep 17 00:00:00 2001 From: Andrew Watt Date: Mon, 11 Mar 2024 18:06:08 -0700 Subject: [PATCH 13/20] composable models --- .../Sources/CounterScreen.swift | 11 ++-- .../Sources/CounterWorkflow.swift | 15 +++--- .../SwiftUITestbed/Sources/MainScreen.swift | 21 ++++---- .../SwiftUITestbed/Sources/MainWorkflow.swift | 4 +- .../Sources/Observation/BindableStore.swift | 10 ++-- .../Sources/Observation/Store.swift | 46 +++++++++++++--- .../Sources/Observation/StoreModel.swift | 19 +++++++ .../Sources/Observation/SwiftUIScreen.swift | 9 ++-- .../Sources/Observation/ViewModel.swift | 8 --- .../Sources/RenderContext+Store.swift | 23 ++++++++ .../SwiftUITestbed/Sources/RootWorkflow.swift | 14 +++-- .../Sources/TwoCounterScreen.swift | 53 +++++++++++++++++++ .../Sources/TwoCounterWorkflow.swift | 45 ++++++++++++++++ 13 files changed, 224 insertions(+), 54 deletions(-) create mode 100644 Samples/SwiftUITestbed/Sources/Observation/StoreModel.swift delete mode 100644 Samples/SwiftUITestbed/Sources/Observation/ViewModel.swift create mode 100644 Samples/SwiftUITestbed/Sources/RenderContext+Store.swift create mode 100644 Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift create mode 100644 Samples/SwiftUITestbed/Sources/TwoCounterWorkflow.swift diff --git a/Samples/SwiftUITestbed/Sources/CounterScreen.swift b/Samples/SwiftUITestbed/Sources/CounterScreen.swift index a4e9ad826..8fe658817 100644 --- a/Samples/SwiftUITestbed/Sources/CounterScreen.swift +++ b/Samples/SwiftUITestbed/Sources/CounterScreen.swift @@ -9,10 +9,10 @@ struct CounterScreen: SwiftUIScreen, Screen { var model: Model typealias State = CounterWorkflow.State - typealias Action = CounterWorkflow.Action + typealias Model = StoreModel - static func makeView(store: Store) -> some View { + static func makeView(store: Store) -> some View { CounterScreenView(store: store) } } @@ -29,7 +29,9 @@ extension CounterScreen: MarketBackStackContentScreen { } struct CounterScreenView: View { - let store: Store + typealias Model = StoreModel + + let store: Store var body: some View { WithPerceptionTracking { @@ -44,7 +46,8 @@ struct CounterScreenView: View { } struct CounterView: View { - let store: Store + typealias Model = StoreModel + let store: Store let index: Int var body: some View { diff --git a/Samples/SwiftUITestbed/Sources/CounterWorkflow.swift b/Samples/SwiftUITestbed/Sources/CounterWorkflow.swift index e6876bc32..89e8017f8 100644 --- a/Samples/SwiftUITestbed/Sources/CounterWorkflow.swift +++ b/Samples/SwiftUITestbed/Sources/CounterWorkflow.swift @@ -40,16 +40,15 @@ struct CounterWorkflow: Workflow { State() } - typealias Rendering = CounterScreen + typealias Rendering = StoreModel + typealias Model = StoreModel - func render(state: State, context: RenderContext) -> CounterScreen { + func render(state: State, context: RenderContext) -> StoreModel { print("CounterWorkflow.render") - return CounterScreen( - model: ViewModel( - state: state, - sendAction: context.makeSink(of: Action.self).send, - sendValue: context.makeStateMutationSink().send - ) + return StoreModel( + state: state, + sendAction: context.makeSink(of: Action.self).send, + sendValue: context.makeStateMutationSink().send ) } } diff --git a/Samples/SwiftUITestbed/Sources/MainScreen.swift b/Samples/SwiftUITestbed/Sources/MainScreen.swift index d932684b7..ea4418071 100644 --- a/Samples/SwiftUITestbed/Sources/MainScreen.swift +++ b/Samples/SwiftUITestbed/Sources/MainScreen.swift @@ -21,21 +21,18 @@ import ViewEnvironment import WorkflowSwiftUIExperimental 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 { - var model: Model { - self - } +struct MainScreen: SwiftUIScreen { + typealias Model = StoreModel + var model: Model - public static func makeView(store: Store) -> some View { + public static func makeView(store: Store) -> some View { MainView(store: store) } } private struct MainView: View { - @BindableStore var store: Store + typealias Model = StoreModel + @BindableStore var store: Store @Environment(\.viewEnvironment.marketStylesheet) private var styles: MarketStylesheet @Environment(\.viewEnvironment.marketContext) private var context: MarketContext @@ -100,10 +97,10 @@ private struct MainView: View { } } -extension MainWorkflow.Rendering: MarketBackStackContentScreen { +extension MainScreen: MarketBackStackContentScreen { func backStackItem(in environment: ViewEnvironment) -> MarketUI.MarketNavigationItem { MarketNavigationItem( - title: .text(.init(regular: state.title)), + title: .text(.init(regular: model.state.title)), backButton: .close(onTap: { fatalError("TODO") }) // didTapClose.map { .close(onTap: $0) } ?? .automatic() ) } @@ -120,7 +117,7 @@ struct MainScreen_Preview: PreviewProvider { MainWorkflow( didClose: nil ) - .mapRendering { $0.asMarketBackStack() } + .mapRendering { MainScreen(model: $0).asMarketBackStack() } .marketPreview { output in } diff --git a/Samples/SwiftUITestbed/Sources/MainWorkflow.swift b/Samples/SwiftUITestbed/Sources/MainWorkflow.swift index a9638382d..5579e7a3d 100644 --- a/Samples/SwiftUITestbed/Sources/MainWorkflow.swift +++ b/Samples/SwiftUITestbed/Sources/MainWorkflow.swift @@ -75,12 +75,12 @@ struct MainWorkflow: Workflow { } } - typealias Rendering = ViewModel + typealias Rendering = StoreModel func render(state: State, context: RenderContext) -> Rendering { print("MainWorkflow.render") - return ViewModel( + return StoreModel( state: state, sendAction: context.makeSink(of: Action.self).send, sendValue: context.makeStateMutationSink().send diff --git a/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift b/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift index 48054da8d..a238e1c86 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 -struct BindableStore { - var wrappedValue: Store - init(wrappedValue: Store) { +struct BindableStore { + var wrappedValue: Store + init(wrappedValue: Store) { self.wrappedValue = wrappedValue } - var projectedValue: BindableStore { + var projectedValue: Self { self } subscript( - dynamicMember keyPath: ReferenceWritableKeyPath, Value> + dynamicMember keyPath: ReferenceWritableKeyPath, Value> ) -> Binding { wrappedValue.binding(for: keyPath) } diff --git a/Samples/SwiftUITestbed/Sources/Observation/Store.swift b/Samples/SwiftUITestbed/Sources/Observation/Store.swift index 09c385004..70125d78c 100644 --- a/Samples/SwiftUITestbed/Sources/Observation/Store.swift +++ b/Samples/SwiftUITestbed/Sources/Observation/Store.swift @@ -2,26 +2,28 @@ import ComposableArchitecture // for ObservableState and Perception import SwiftUI @dynamicMemberLookup -final class Store: Perceptible { - typealias Model = ViewModel +final class Store: Perceptible { + typealias State = Model.State + typealias Action = Model.Action private var model: Model private let _$observationRegistrar = PerceptionRegistrar() private var bindings: [BindingKey: Any] = [:] + private var childStores: [AnyKeyPath: ChildStore] = [:] var state: State { _$observationRegistrar.access(self, keyPath: \.state) - return model.state + return model.model.state } func send(_ action: Action) { - model.sendAction(action) + model.model.sendAction(action) } private func send(keyPath: WritableKeyPath, value: Value) { print("Store.send(\(keyPath), \(value))") - model.sendValue { state in + model.model.sendValue { state in state[keyPath: keyPath] = value } } @@ -31,13 +33,17 @@ final class Store: Perceptible { } fileprivate func setModel(_ newValue: Model) { - if !_$isIdentityEqual(model.state, newValue.state) { + if !_$isIdentityEqual(model.model.state, newValue.model.state) { _$observationRegistrar.withMutation(of: self, keyPath: \.state) { model = newValue } } else { model = newValue } + + for childStore in childStores.values { + childStore.setModel(newValue) + } } } @@ -60,12 +66,38 @@ extension Store { } } + func scope(keyPath: KeyPath) -> Store { + if let childStore = childStores[keyPath]?.store as? Store { + return childStore + } + + let childModel = model[keyPath: keyPath] + let childStore = Store(childModel) + + childStores[keyPath] = ChildStore(store: childStore, setModel: { model in + childStore.setModel(model[keyPath: keyPath]) + }) + + return childStore + } + + // TODO: child stores for optionals, collections, etc + + subscript(dynamicMember keyPath: KeyPath) -> Store { + scope(keyPath: keyPath) + } + + struct ChildStore { + var store: Any + var setModel: (Model) -> Void + } + func action(_ action: Action) -> () -> Void { { self.send(action) } } func binding( - for keyPath: ReferenceWritableKeyPath, Value> + for keyPath: ReferenceWritableKeyPath, Value> ) -> Binding { let key = BindingKey(keyPath: keyPath, action: nil) diff --git a/Samples/SwiftUITestbed/Sources/Observation/StoreModel.swift b/Samples/SwiftUITestbed/Sources/Observation/StoreModel.swift new file mode 100644 index 000000000..e38451635 --- /dev/null +++ b/Samples/SwiftUITestbed/Sources/Observation/StoreModel.swift @@ -0,0 +1,19 @@ +import ComposableArchitecture +import Workflow + +struct StoreModel: ObservableModel { + let state: State + let sendAction: (Action) -> Void + let sendValue: (@escaping (inout State) -> Void) -> Void + + var model: StoreModel { + self + } +} + +protocol ObservableModel { + associatedtype State: ObservableState + associatedtype Action + + var model: StoreModel { get } +} diff --git a/Samples/SwiftUITestbed/Sources/Observation/SwiftUIScreen.swift b/Samples/SwiftUITestbed/Sources/Observation/SwiftUIScreen.swift index e5fae93f9..bc5448e3e 100644 --- a/Samples/SwiftUITestbed/Sources/Observation/SwiftUIScreen.swift +++ b/Samples/SwiftUITestbed/Sources/Observation/SwiftUIScreen.swift @@ -6,16 +6,15 @@ import Workflow import WorkflowUI protocol SwiftUIScreen: Screen { - associatedtype State: ObservableState - associatedtype Action +// associatedtype State: ObservableState +// associatedtype Action associatedtype Content: View - - typealias Model = ViewModel + associatedtype Model: ObservableModel var model: Model { get } @ViewBuilder - static func makeView(store: Store) -> Content + static func makeView(store: Store) -> Content } extension SwiftUIScreen { diff --git a/Samples/SwiftUITestbed/Sources/Observation/ViewModel.swift b/Samples/SwiftUITestbed/Sources/Observation/ViewModel.swift deleted file mode 100644 index f953023bf..000000000 --- a/Samples/SwiftUITestbed/Sources/Observation/ViewModel.swift +++ /dev/null @@ -1,8 +0,0 @@ -import ComposableArchitecture -import Workflow - -struct ViewModel { - let state: State - let sendAction: (Action) -> Void - let sendValue: (@escaping (inout State) -> Void) -> Void -} diff --git a/Samples/SwiftUITestbed/Sources/RenderContext+Store.swift b/Samples/SwiftUITestbed/Sources/RenderContext+Store.swift new file mode 100644 index 000000000..3d47cc507 --- /dev/null +++ b/Samples/SwiftUITestbed/Sources/RenderContext+Store.swift @@ -0,0 +1,23 @@ +// +// RenderContext+Store.swift +// Development-SwiftUITestbed +// +// Created by Andrew Watt on 3/11/24. +// + +import Foundation +import Workflow + +extension RenderContext { + func makeStoreModel( + state: WorkflowType.State + ) -> StoreModel + where Action.WorkflowType == WorkflowType + { + StoreModel( + state: state, + sendAction: makeSink(of: Action.self).send, + sendValue: makeStateMutationSink().send + ) + } +} diff --git a/Samples/SwiftUITestbed/Sources/RootWorkflow.swift b/Samples/SwiftUITestbed/Sources/RootWorkflow.swift index 6f10be0ee..b77eb2eaa 100644 --- a/Samples/SwiftUITestbed/Sources/RootWorkflow.swift +++ b/Samples/SwiftUITestbed/Sources/RootWorkflow.swift @@ -75,11 +75,19 @@ struct RootWorkflow: Workflow { func rendering(_ screen: State.Screen, isRoot: Bool) -> AnyMarketBackStackContentScreen { switch screen { case .main(let id): - return MainWorkflow(didClose: isRoot ? close : nil) - .mapOutput(Action.main) - .mapRendering(AnyMarketBackStackContentScreen.init) + let w: AnyWorkflow = TwoCounterWorkflow() + .asAnyWorkflow() + + return w.mapRendering { (rendering: TwoCounterModel) in + TwoCounterScreen(model: rendering).asAnyMarketBackStackContentScreen() + } .rendered(in: context, key: id.uuidString) +// return MainWorkflow(didClose: isRoot ? close : nil) +// .mapOutput(Action.main) +// .mapRendering { MainScreen(model: $0).asAnyMarketBackStackContentScreen() } +// .rendered(in: context, key: id.uuidString) + // explicit annotations on every single line or else compiler can't handle it // let w1: CounterWorkflow = CounterWorkflow() // let aw: AnyWorkflow = w1.asAnyWorkflow() diff --git a/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift b/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift new file mode 100644 index 000000000..81b95586b --- /dev/null +++ b/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift @@ -0,0 +1,53 @@ +// +// TwoCounterScreen.swift +// Development-SwiftUITestbed +// +// Created by Andrew Watt on 3/11/24. +// + +import Foundation +import SwiftUI +import MarketUI +import MarketWorkflowUI +import ViewEnvironment + +struct TwoCounterScreen: SwiftUIScreen { + + let model: TwoCounterModel + + static func makeView(store: Store) -> some View { + TwoCounterView(store: store) + } +} + +extension TwoCounterScreen: MarketBackStackContentScreen { + var backStackIdentifier: AnyHashable? { + "TwoCounterScreen" + } + + func backStackItem(in environment: ViewEnvironment) -> MarketNavigationItem { + MarketNavigationItem(title: .text("Two Counters")) + } +} + +struct TwoCounterView: View { + let store: Store + + var body: some View { + VStack { + CounterView(store: store.counter1, index: 0) + + CounterView(store: store.counter2, index: 1) + } + } +} + +struct TwoCounterModel: ObservableModel { + typealias State = TwoCounterWorkflow.State + typealias Action = TwoCounterWorkflow.Action + typealias Model = StoreModel + + let model: Model + let counter1: CounterWorkflow.Model + let counter2: CounterWorkflow.Model +} diff --git a/Samples/SwiftUITestbed/Sources/TwoCounterWorkflow.swift b/Samples/SwiftUITestbed/Sources/TwoCounterWorkflow.swift new file mode 100644 index 000000000..925c4ff47 --- /dev/null +++ b/Samples/SwiftUITestbed/Sources/TwoCounterWorkflow.swift @@ -0,0 +1,45 @@ +// +// TwoCounterWorkflow.swift +// Development-SwiftUITestbed +// +// Created by Andrew Watt on 3/11/24. +// + +import Foundation +import Workflow +import ComposableArchitecture +import SwiftUI + +struct TwoCounterWorkflow: Workflow { + + @ObservableState + struct State {} + + func makeInitialState() -> State { + State() + } + + enum Action: WorkflowAction { + typealias WorkflowType = TwoCounterWorkflow + + func apply(toState state: inout TwoCounterWorkflow.State) -> Never? { + return nil + } + } + + typealias Output = Never + typealias Rendering = TwoCounterModel + + func render(state: State, context: RenderContext) -> TwoCounterModel { + let model: StoreModel = context.makeStoreModel(state: state) + let counter1: CounterWorkflow.Model = CounterWorkflow().rendered(in: context, key: "1") + let counter2: CounterWorkflow.Model = CounterWorkflow().rendered(in: context, key: "2") + + return TwoCounterModel( + model: model, + counter1: counter1, + counter2: counter2 + ) + } +} + From fb773a846550d666ca72f227e579fd79dd1875fa Mon Sep 17 00:00:00 2001 From: Andrew Watt Date: Tue, 12 Mar 2024 14:30:46 -0700 Subject: [PATCH 14/20] cocoapods spm dependency via fork --- Development.podspec | 8 ++++ Gemfile | 3 +- Gemfile.lock | 71 +++++++++++++++++------------ WorkflowSwiftUIExperimental.podspec | 6 +++ 4 files changed, 57 insertions(+), 31 deletions(-) diff --git a/Development.podspec b/Development.podspec index 2644ed181..1eb4bad6d 100644 --- a/Development.podspec +++ b/Development.podspec @@ -83,6 +83,14 @@ Pod::Spec.new do |s| app_spec.source_files = 'Samples/SwiftUITestbed/Sources/**/*.swift' app_spec.dependency 'MarketWorkflowUI', '80.0.0' app_spec.dependency 'WorkflowSwiftUIExperimental' + + # app spec SPM dependencies not supported yet + # app_spec.spm_dependency( + # :url => 'https://github.com/pointfreeco/swift-composable-architecture', + # :requirement => {:kind => 'upToNextMajorVersion', :minimumVersion => '1.9.0'}, + # :products => ['ComposableArchitecture'] + # ) + end s.test_spec 'SwiftUITestbedTests' do |test_spec| diff --git a/Gemfile b/Gemfile index cffb72b7b..4fcb17cf5 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,7 @@ source 'https://rubygems.org' gem 'cocoapods-trunk', '>=1.6.0' -gem 'cocoapods', '= 1.15.0' +gem 'cocoapods', git: 'https://github.com/watt/cocoapods', branch: 'podspec-spm' +gem 'cocoapods-core', git: 'https://github.com/watt/cocoapods-core', branch: 'podspec-spm' gem 'cocoapods-generate' diff --git a/Gemfile.lock b/Gemfile.lock index a3004af21..5f70cf737 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,29 +1,8 @@ -GEM - remote: https://rubygems.org/ +GIT + remote: https://github.com/watt/cocoapods + revision: d8cd134e64ea6ba275366ffd62c52a0b219cdb56 + branch: podspec-spm specs: - CFPropertyList (3.0.7) - base64 - nkf - rexml - activesupport (7.1.3.2) - base64 - bigdecimal - concurrent-ruby (~> 1.0, >= 1.0.2) - connection_pool (>= 2.2.5) - drb - i18n (>= 1.6, < 2) - minitest (>= 5.1) - mutex_m - tzinfo (~> 2.0) - addressable (2.8.6) - public_suffix (>= 2.0.2, < 6.0) - algoliasearch (1.27.5) - httpclient (~> 2.8, >= 2.8.3) - json (>= 1.5.1) - atomos (0.1.3) - base64 (0.2.0) - bigdecimal (3.1.6) - claide (1.1.0) cocoapods (1.15.0) addressable (~> 2.8) claide (>= 1.0.2, < 2.0) @@ -42,6 +21,12 @@ GEM nap (~> 1.0) ruby-macho (>= 2.3.0, < 3.0) xcodeproj (>= 1.23.0, < 2.0) + +GIT + remote: https://github.com/watt/cocoapods-core + revision: 10b4e89e768016159026182b3349af3a2936070e + branch: podspec-spm + specs: cocoapods-core (1.15.0) activesupport (>= 5.0, < 8) addressable (~> 2.8) @@ -52,6 +37,33 @@ GEM netrc (~> 0.11) public_suffix (~> 4.0) typhoeus (~> 1.0) + +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.7) + base64 + nkf + rexml + activesupport (7.1.3.2) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + minitest (>= 5.1) + mutex_m + tzinfo (~> 2.0) + addressable (2.8.6) + public_suffix (>= 2.0.2, < 6.0) + algoliasearch (1.27.5) + httpclient (~> 2.8, >= 2.8.3) + json (>= 1.5.1) + atomos (0.1.3) + base64 (0.2.0) + bigdecimal (3.1.6) + claide (1.1.0) cocoapods-deintegrate (1.0.5) cocoapods-disable-podfile-validations (0.2.0) cocoapods-downloader (2.1) @@ -67,8 +79,7 @@ GEM colored2 (3.1.2) concurrent-ruby (1.2.3) connection_pool (2.4.1) - drb (2.2.0) - ruby2_keywords + drb (2.2.1) escape (0.0.4) ethon (0.16.0) ffi (>= 1.15.0) @@ -77,7 +88,7 @@ GEM fuzzy_match (2.0.4) gh_inspector (1.1.3) httpclient (2.8.3) - i18n (1.14.1) + i18n (1.14.4) concurrent-ruby (~> 1.0) json (2.7.1) minitest (5.22.2) @@ -90,7 +101,6 @@ GEM public_suffix (4.0.7) rexml (3.2.6) ruby-macho (2.5.1) - ruby2_keywords (0.0.5) typhoeus (1.4.1) ethon (>= 0.9.0) tzinfo (2.0.6) @@ -107,7 +117,8 @@ PLATFORMS ruby DEPENDENCIES - cocoapods (= 1.15.0) + cocoapods! + cocoapods-core! cocoapods-generate cocoapods-trunk (>= 1.6.0) diff --git a/WorkflowSwiftUIExperimental.podspec b/WorkflowSwiftUIExperimental.podspec index ef2faaba5..e6f5f569b 100644 --- a/WorkflowSwiftUIExperimental.podspec +++ b/WorkflowSwiftUIExperimental.podspec @@ -21,5 +21,11 @@ Pod::Spec.new do |s| s.dependency 'Workflow', WORKFLOW_VERSION s.dependency 'WorkflowUI', WORKFLOW_VERSION + s.spm_dependency( + :url => 'https://github.com/pointfreeco/swift-composable-architecture', + :requirement => {:kind => 'upToNextMajorVersion', :minimumVersion => '1.9.0'}, + :products => ['ComposableArchitecture'] + ) + s.pod_target_xcconfig = { 'APPLICATION_EXTENSION_API_ONLY' => 'YES' } end From bb94d5378bf3ba2108ce824a44d82313ab4e4349 Mon Sep 17 00:00:00 2001 From: Andrew Watt Date: Wed, 13 Mar 2024 19:42:05 -0700 Subject: [PATCH 15/20] separate action from state --- .../Sources/CounterScreen.swift | 12 ++-- .../Sources/CounterWorkflow.swift | 29 +++----- .../SwiftUITestbed/Sources/MainScreen.swift | 2 +- .../SwiftUITestbed/Sources/MainWorkflow.swift | 6 +- .../Sources/Observation/Store.swift | 71 ++++++++++--------- .../Sources/Observation/StoreModel.swift | 41 ++++++++--- .../Sources/RenderContext+Store.swift | 15 ++-- .../Sources/TwoCounterScreen.swift | 5 +- .../Sources/TwoCounterWorkflow.swift | 6 +- 9 files changed, 99 insertions(+), 88 deletions(-) diff --git a/Samples/SwiftUITestbed/Sources/CounterScreen.swift b/Samples/SwiftUITestbed/Sources/CounterScreen.swift index 8fe658817..7c0dfb470 100644 --- a/Samples/SwiftUITestbed/Sources/CounterScreen.swift +++ b/Samples/SwiftUITestbed/Sources/CounterScreen.swift @@ -36,11 +36,7 @@ struct CounterScreenView: View { var body: some View { WithPerceptionTracking { let _ = Self._printChanges() - VStack { - CounterView(store: store, index: 0) - - CounterView(store: store, index: 1) - } + CounterView(store: store, index: 0) } } } @@ -58,16 +54,16 @@ struct CounterView: View { let _ = Self._printChanges() HStack { Button { - store.send(.decrement(index: index)) + store.send(.decrement) } label: { Image(systemName: "minus") } - Text("\(index == 0 ? store.count1 : store.count2)") + Text("\(store.count)") .monospacedDigit() Button { - store.send(.increment(index: index)) + store.send(.increment) } label: { Image(systemName: "plus") } diff --git a/Samples/SwiftUITestbed/Sources/CounterWorkflow.swift b/Samples/SwiftUITestbed/Sources/CounterWorkflow.swift index 89e8017f8..c0f6f2acc 100644 --- a/Samples/SwiftUITestbed/Sources/CounterWorkflow.swift +++ b/Samples/SwiftUITestbed/Sources/CounterWorkflow.swift @@ -5,30 +5,21 @@ struct CounterWorkflow: Workflow { @ObservableState struct State { - var count1 = 0 - var count2 = 0 + var count = 0 } enum Action: WorkflowAction { typealias WorkflowType = CounterWorkflow - case increment(index: Int) - case decrement(index: Int) + case increment + case decrement func apply(toState state: inout CounterWorkflow.State) -> CounterWorkflow.Output? { switch self { - case .increment(let index): - if index == 0 { - state.count1 += 1 - } else { - state.count2 += 1 - } - case .decrement(let index): - if index == 0 { - state.count1 -= 1 - } else { - state.count2 -= 1 - } + case .increment: + state.count += 1 + case .decrement: + state.count -= 1 } return nil } @@ -45,10 +36,6 @@ struct CounterWorkflow: Workflow { func render(state: State, context: RenderContext) -> StoreModel { print("CounterWorkflow.render") - return StoreModel( - state: state, - sendAction: context.makeSink(of: Action.self).send, - sendValue: context.makeStateMutationSink().send - ) + return context.makeStoreModel(state: state) } } diff --git a/Samples/SwiftUITestbed/Sources/MainScreen.swift b/Samples/SwiftUITestbed/Sources/MainScreen.swift index ea4418071..28140a2c3 100644 --- a/Samples/SwiftUITestbed/Sources/MainScreen.swift +++ b/Samples/SwiftUITestbed/Sources/MainScreen.swift @@ -100,7 +100,7 @@ private struct MainView: View { extension MainScreen: MarketBackStackContentScreen { func backStackItem(in environment: ViewEnvironment) -> MarketUI.MarketNavigationItem { MarketNavigationItem( - title: .text(.init(regular: model.state.title)), + title: .text(.init(regular: model.title)), backButton: .close(onTap: { fatalError("TODO") }) // didTapClose.map { .close(onTap: $0) } ?? .automatic() ) } diff --git a/Samples/SwiftUITestbed/Sources/MainWorkflow.swift b/Samples/SwiftUITestbed/Sources/MainWorkflow.swift index 5579e7a3d..714239907 100644 --- a/Samples/SwiftUITestbed/Sources/MainWorkflow.swift +++ b/Samples/SwiftUITestbed/Sources/MainWorkflow.swift @@ -80,11 +80,7 @@ struct MainWorkflow: Workflow { func render(state: State, context: RenderContext) -> Rendering { print("MainWorkflow.render") - return StoreModel( - state: state, - sendAction: context.makeSink(of: Action.self).send, - sendValue: context.makeStateMutationSink().send - ) + return context.makeStoreModel(state: state) } } diff --git a/Samples/SwiftUITestbed/Sources/Observation/Store.swift b/Samples/SwiftUITestbed/Sources/Observation/Store.swift index 70125d78c..20ee84eaf 100644 --- a/Samples/SwiftUITestbed/Sources/Observation/Store.swift +++ b/Samples/SwiftUITestbed/Sources/Observation/Store.swift @@ -4,7 +4,6 @@ import SwiftUI @dynamicMemberLookup final class Store: Perceptible { typealias State = Model.State - typealias Action = Model.Action private var model: Model private let _$observationRegistrar = PerceptionRegistrar() @@ -14,16 +13,12 @@ final class Store: Perceptible { var state: State { _$observationRegistrar.access(self, keyPath: \.state) - return model.model.state - } - - func send(_ action: Action) { - model.model.sendAction(action) + return model.lens.state } private func send(keyPath: WritableKeyPath, value: Value) { print("Store.send(\(keyPath), \(value))") - model.model.sendValue { state in + model.lens.sendValue { state in state[keyPath: keyPath] = value } } @@ -33,7 +28,7 @@ final class Store: Perceptible { } fileprivate func setModel(_ newValue: Model) { - if !_$isIdentityEqual(model.model.state, newValue.model.state) { + if !_$isIdentityEqual(model.lens.state, newValue.lens.state) { _$observationRegistrar.withMutation(of: self, keyPath: \.state) { model = newValue } @@ -47,6 +42,40 @@ final class Store: Perceptible { } } +extension Store where Model: ActionModel { + typealias Action = Model.Action + + func action(_ action: Action) -> () -> Void { + { self.send(action) } + } + + func send(_ action: Action) { + model.sendAction(action) + } + + func binding( + for keyPath: KeyPath, + action: CaseKeyPath + ) -> Binding { + let key = BindingKey(keyPath: keyPath, action: action) + + if let binding = bindings[key] as? Binding { + print("cached binding for \(keyPath)") + return binding + } + + print("new binding for \(keyPath)") + let binding = Binding( + get: { self.state[keyPath: keyPath] }, + set: { self.send(action($0)) } + ) + + bindings[key] = binding + + return binding + } +} + extension Store { static func make(model: Model) -> (Store, (Model) -> Void) { let store = Store(model) @@ -92,10 +121,6 @@ extension Store { var setModel: (Model) -> Void } - func action(_ action: Action) -> () -> Void { - { self.send(action) } - } - func binding( for keyPath: ReferenceWritableKeyPath, Value> ) -> Binding { @@ -118,28 +143,6 @@ extension Store { } - func binding( - for keyPath: KeyPath, - action: CaseKeyPath - ) -> Binding { - let key = BindingKey(keyPath: keyPath, action: action) - - if let binding = bindings[key] as? Binding { - print("cached binding for \(keyPath)") - return binding - } - - print("new binding for \(keyPath)") - let binding = Binding( - get: { self.state[keyPath: keyPath] }, - set: { self.send(action($0)) } - ) - - bindings[key] = binding - - return binding - } - struct BindingKey: Hashable { let keyPath: AnyKeyPath let action: AnyKeyPath? diff --git a/Samples/SwiftUITestbed/Sources/Observation/StoreModel.swift b/Samples/SwiftUITestbed/Sources/Observation/StoreModel.swift index e38451635..bcb7b5ad8 100644 --- a/Samples/SwiftUITestbed/Sources/Observation/StoreModel.swift +++ b/Samples/SwiftUITestbed/Sources/Observation/StoreModel.swift @@ -1,19 +1,42 @@ import ComposableArchitecture import Workflow -struct StoreModel: ObservableModel { - let state: State - let sendAction: (Action) -> Void - let sendValue: (@escaping (inout State) -> Void) -> Void +@dynamicMemberLookup +protocol ObservableModel { + associatedtype State: ObservableState - var model: StoreModel { - self + var lens: StateLens { get } +} + +extension ObservableModel { + subscript(dynamicMember keyPath: WritableKeyPath) -> T { + get { + lens.state[keyPath: keyPath] + } + set { + lens.sendValue { $0[keyPath: keyPath] = newValue } + } } } -protocol ObservableModel { - associatedtype State: ObservableState +protocol ActionModel { associatedtype Action - var model: StoreModel { get } + var sendAction: (Action) -> Void { get } +} + +// Simplest form of model, with no actions +struct StateLens { + let state: State + let sendValue: (@escaping (inout State) -> Void) -> Void +} + +extension StateLens: ObservableModel { + var lens: StateLens { self } +} + +// A common model with 1 action +struct StoreModel: ObservableModel, ActionModel { + let lens: StateLens + let sendAction: (Action) -> Void } diff --git a/Samples/SwiftUITestbed/Sources/RenderContext+Store.swift b/Samples/SwiftUITestbed/Sources/RenderContext+Store.swift index 3d47cc507..2b9b3b1ec 100644 --- a/Samples/SwiftUITestbed/Sources/RenderContext+Store.swift +++ b/Samples/SwiftUITestbed/Sources/RenderContext+Store.swift @@ -7,17 +7,24 @@ import Foundation import Workflow +import ComposableArchitecture + +extension RenderContext where WorkflowType.State: ObservableState { + + func makeStateLens( + state: WorkflowType.State + ) -> StateLens { + StateLens(state: state, sendValue: makeStateMutationSink().send) + } -extension RenderContext { func makeStoreModel( state: WorkflowType.State ) -> StoreModel where Action.WorkflowType == WorkflowType { StoreModel( - state: state, - sendAction: makeSink(of: Action.self).send, - sendValue: makeStateMutationSink().send + lens: makeStateLens(state: state), + sendAction: makeSink(of: Action.self).send ) } } diff --git a/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift b/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift index 81b95586b..c9a4a1189 100644 --- a/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift +++ b/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift @@ -44,10 +44,9 @@ struct TwoCounterView: View { struct TwoCounterModel: ObservableModel { typealias State = TwoCounterWorkflow.State - typealias Action = TwoCounterWorkflow.Action - typealias Model = StoreModel - let model: Model + let lens: StateLens + let counter1: CounterWorkflow.Model let counter2: CounterWorkflow.Model } diff --git a/Samples/SwiftUITestbed/Sources/TwoCounterWorkflow.swift b/Samples/SwiftUITestbed/Sources/TwoCounterWorkflow.swift index 925c4ff47..be13679f0 100644 --- a/Samples/SwiftUITestbed/Sources/TwoCounterWorkflow.swift +++ b/Samples/SwiftUITestbed/Sources/TwoCounterWorkflow.swift @@ -19,6 +19,8 @@ struct TwoCounterWorkflow: Workflow { State() } + // TODO: 2 kinds of actions + enum Action: WorkflowAction { typealias WorkflowType = TwoCounterWorkflow @@ -31,15 +33,13 @@ struct TwoCounterWorkflow: Workflow { typealias Rendering = TwoCounterModel func render(state: State, context: RenderContext) -> TwoCounterModel { - let model: StoreModel = context.makeStoreModel(state: state) let counter1: CounterWorkflow.Model = CounterWorkflow().rendered(in: context, key: "1") let counter2: CounterWorkflow.Model = CounterWorkflow().rendered(in: context, key: "2") return TwoCounterModel( - model: model, + lens: context.makeStateLens(state: state), counter1: counter1, counter2: counter2 ) } } - From efbb2faacac4464a8c7b67f709510b1621391012 Mon Sep 17 00:00:00 2001 From: Andrew Watt Date: Thu, 14 Mar 2024 16:58:30 -0700 Subject: [PATCH 16/20] centralize binding cache --- .../Sources/CounterScreen.swift | 8 +- .../Sources/CounterWorkflow.swift | 2 +- .../Sources/Observation/Store.swift | 92 +++++++++++++------ .../Sources/Observation/StoreModel.swift | 15 +-- .../Sources/RenderContext+Store.swift | 8 +- .../Sources/TwoCounterScreen.swift | 63 ++++++++++++- .../Sources/TwoCounterWorkflow.swift | 37 ++++++-- 7 files changed, 168 insertions(+), 57 deletions(-) diff --git a/Samples/SwiftUITestbed/Sources/CounterScreen.swift b/Samples/SwiftUITestbed/Sources/CounterScreen.swift index 7c0dfb470..4bcb3c8e7 100644 --- a/Samples/SwiftUITestbed/Sources/CounterScreen.swift +++ b/Samples/SwiftUITestbed/Sources/CounterScreen.swift @@ -47,11 +47,11 @@ struct CounterView: View { let index: Int var body: some View { - let _ = print("Evaluating CounterView[\(index)].body") - let _ = Self._printChanges() +// let _ = print("Evaluating CounterView[\(index)].body") +// let _ = Self._printChanges() WithPerceptionTracking { - let _ = print("Evaluating CounterView[\(index)].WithPerceptionTracking.body") - let _ = Self._printChanges() +// let _ = print("Evaluating CounterView[\(index)].WithPerceptionTracking.body") +// let _ = Self._printChanges() HStack { Button { store.send(.decrement) diff --git a/Samples/SwiftUITestbed/Sources/CounterWorkflow.swift b/Samples/SwiftUITestbed/Sources/CounterWorkflow.swift index c0f6f2acc..c2755eeae 100644 --- a/Samples/SwiftUITestbed/Sources/CounterWorkflow.swift +++ b/Samples/SwiftUITestbed/Sources/CounterWorkflow.swift @@ -35,7 +35,7 @@ struct CounterWorkflow: Workflow { typealias Model = StoreModel func render(state: State, context: RenderContext) -> StoreModel { - print("CounterWorkflow.render") +// print("CounterWorkflow.render") return context.makeStoreModel(state: state) } } diff --git a/Samples/SwiftUITestbed/Sources/Observation/Store.swift b/Samples/SwiftUITestbed/Sources/Observation/Store.swift index 20ee84eaf..0eec5de8f 100644 --- a/Samples/SwiftUITestbed/Sources/Observation/Store.swift +++ b/Samples/SwiftUITestbed/Sources/Observation/Store.swift @@ -1,5 +1,6 @@ import ComposableArchitecture // for ObservableState and Perception import SwiftUI +import Workflow @dynamicMemberLookup final class Store: Perceptible { @@ -13,12 +14,12 @@ final class Store: Perceptible { var state: State { _$observationRegistrar.access(self, keyPath: \.state) - return model.lens.state + return model.accessor.state } private func send(keyPath: WritableKeyPath, value: Value) { print("Store.send(\(keyPath), \(value))") - model.lens.sendValue { state in + model.accessor.sendValue { state in state[keyPath: keyPath] = value } } @@ -28,7 +29,7 @@ final class Store: Perceptible { } fileprivate func setModel(_ newValue: Model) { - if !_$isIdentityEqual(model.lens.state, newValue.lens.state) { + if !_$isIdentityEqual(model.accessor.state, newValue.accessor.state) { _$observationRegistrar.withMutation(of: self, keyPath: \.state) { model = newValue } @@ -57,22 +58,13 @@ extension Store where Model: ActionModel { for keyPath: KeyPath, action: CaseKeyPath ) -> Binding { - let key = BindingKey(keyPath: keyPath, action: action) - - if let binding = bindings[key] as? Binding { - print("cached binding for \(keyPath)") - return binding + // \Model.sendAction is not ideal for "sink path" but unique for prototyping + binding(key: .keyPathSinkAction(keyPath: keyPath, sinkPath: \Model.sendAction, actionPath: action)) { + Binding( + get: { self.state[keyPath: keyPath] }, + set: { self.send(action($0)) } + ) } - - print("new binding for \(keyPath)") - let binding = Binding( - get: { self.state[keyPath: keyPath] }, - set: { self.send(action($0)) } - ) - - bindings[key] = binding - - return binding } } @@ -95,6 +87,10 @@ extension Store { } } + subscript(dynamicMember keyPath: KeyPath>) -> Sink { + model[keyPath: keyPath] + } + func scope(keyPath: KeyPath) -> Store { if let childStore = childStores[keyPath]?.store as? Store { return childStore @@ -124,28 +120,64 @@ extension Store { func binding( for keyPath: ReferenceWritableKeyPath, Value> ) -> Binding { - let key = BindingKey(keyPath: keyPath, action: nil) + binding(key: .writableKeyPath(keyPath)) { + Binding( + get: { self[keyPath: keyPath] }, + set: { self[keyPath: keyPath] = $0 } + ) + } + } + + func binding( + for keyPath: KeyPath, + send: KeyPath Void> + ) -> Binding { + binding(key: .keyPathSend(keyPath: keyPath, sendPath: send)) { + Binding( + get: { + let val = self.state[keyPath: keyPath] + print("get \(keyPath) -> \(val)") + return val + }, + set: { + print("set \(keyPath) <- \($0)") + self.model[keyPath: send]($0) + } + ) + } + } + func binding( + for keyPath: KeyPath, + sink: KeyPath>, + action: CaseKeyPath + ) -> Binding { + binding(key: .keyPathSinkAction(keyPath: keyPath, sinkPath: sink, actionPath: action)) { + Binding( + get: { self.state[keyPath: keyPath] }, + set: { self.model[keyPath: sink].send(action($0)) } + ) + } + } + + private func binding(key: BindingKey, create: () -> Binding) -> Binding { if let binding = bindings[key] as? Binding { - print("cached binding for \(keyPath)") + print("cached binding for \(key)") + _ = binding.wrappedValue return binding } - print("new binding for \(keyPath)") - let binding = Binding( - get: { self[keyPath: keyPath] }, - set: { self[keyPath: keyPath] = $0 } - ) - + print("new binding for \(key)") + let binding = create() bindings[key] = binding return binding - } - struct BindingKey: Hashable { - let keyPath: AnyKeyPath - let action: AnyKeyPath? + enum BindingKey: Hashable { + case writableKeyPath(AnyKeyPath) + case keyPathSend(keyPath: AnyKeyPath, sendPath: AnyKeyPath) + case keyPathSinkAction(keyPath: AnyKeyPath, sinkPath: AnyKeyPath, actionPath: AnyKeyPath) } } diff --git a/Samples/SwiftUITestbed/Sources/Observation/StoreModel.swift b/Samples/SwiftUITestbed/Sources/Observation/StoreModel.swift index bcb7b5ad8..70a86c920 100644 --- a/Samples/SwiftUITestbed/Sources/Observation/StoreModel.swift +++ b/Samples/SwiftUITestbed/Sources/Observation/StoreModel.swift @@ -5,16 +5,17 @@ import Workflow protocol ObservableModel { associatedtype State: ObservableState - var lens: StateLens { get } + var accessor: StateAccessor { get } } extension ObservableModel { subscript(dynamicMember keyPath: WritableKeyPath) -> T { get { - lens.state[keyPath: keyPath] + accessor.state[keyPath: keyPath] } + // If desirable, we could further divide this into a read-only and a writable version. set { - lens.sendValue { $0[keyPath: keyPath] = newValue } + accessor.sendValue { $0[keyPath: keyPath] = newValue } } } } @@ -26,17 +27,17 @@ protocol ActionModel { } // Simplest form of model, with no actions -struct StateLens { +struct StateAccessor { let state: State let sendValue: (@escaping (inout State) -> Void) -> Void } -extension StateLens: ObservableModel { - var lens: StateLens { self } +extension StateAccessor: ObservableModel { + var accessor: StateAccessor { self } } // A common model with 1 action struct StoreModel: ObservableModel, ActionModel { - let lens: StateLens + let accessor: StateAccessor let sendAction: (Action) -> Void } diff --git a/Samples/SwiftUITestbed/Sources/RenderContext+Store.swift b/Samples/SwiftUITestbed/Sources/RenderContext+Store.swift index 2b9b3b1ec..2cd0a50f3 100644 --- a/Samples/SwiftUITestbed/Sources/RenderContext+Store.swift +++ b/Samples/SwiftUITestbed/Sources/RenderContext+Store.swift @@ -11,10 +11,10 @@ import ComposableArchitecture extension RenderContext where WorkflowType.State: ObservableState { - func makeStateLens( + func makeStateAccessor( state: WorkflowType.State - ) -> StateLens { - StateLens(state: state, sendValue: makeStateMutationSink().send) + ) -> StateAccessor { + StateAccessor(state: state, sendValue: makeStateMutationSink().send) } func makeStoreModel( @@ -23,7 +23,7 @@ extension RenderContext where WorkflowType.State: ObservableState { where Action.WorkflowType == WorkflowType { StoreModel( - lens: makeStateLens(state: state), + accessor: makeStateAccessor(state: state), sendAction: makeSink(of: Action.self).send ) } diff --git a/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift b/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift index c9a4a1189..abd269362 100644 --- a/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift +++ b/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift @@ -10,6 +10,8 @@ import SwiftUI import MarketUI import MarketWorkflowUI import ViewEnvironment +import Workflow +import Perception struct TwoCounterScreen: SwiftUIScreen { @@ -31,13 +33,46 @@ extension TwoCounterScreen: MarketBackStackContentScreen { } struct TwoCounterView: View { - let store: Store + @Perception.Bindable var store: Store var body: some View { - VStack { - CounterView(store: store.counter1, index: 0) + WithPerceptionTracking { + let _ = Self._printChanges() + VStack { + $store. + @Binding var showSum = store.binding(for: \.showSum, send: \.onShowSumToggle) +// @Perception.Bindable var showSum = store.showSum + Toggle( + "Show Sum", +// isOn: store.binding(for: \.showSum, send: \.onShowSumToggle) + isOn: $store.showSum + ) + ToggleWrapper(isOn: $store.showSum) - CounterView(store: store.counter2, index: 1) + Button("Add Counter") { + store.counterAction.send(.addCounter) + } + + CounterView(store: store.counter1, index: 0) + + CounterView(store: store.counter2, index: 1) + + if store.showSum { + Text("Sum: \(store.counter1.count + store.counter2.count)") + } + } + .padding() + } + } +} + +struct ToggleWrapper: View { + @Binding var isOn: Bool + var body: some View { + WithPerceptionTracking { + let _ = Self._printChanges() + + Toggle("Show Sum", isOn: $isOn) } } } @@ -45,8 +80,26 @@ struct TwoCounterView: View { struct TwoCounterModel: ObservableModel { typealias State = TwoCounterWorkflow.State - let lens: StateLens + let accessor: StateAccessor let counter1: CounterWorkflow.Model let counter2: CounterWorkflow.Model + + let onShowSumToggle: (Bool) -> Void + let counterAction: Sink +} + +#if DEBUG + +import SwiftUI + +struct TwoCounterScreen_Preview: PreviewProvider { + static var previews: some View { + TwoCounterWorkflow() + .mapRendering(TwoCounterScreen.init) + .marketPreview { output in + } + } } + +#endif diff --git a/Samples/SwiftUITestbed/Sources/TwoCounterWorkflow.swift b/Samples/SwiftUITestbed/Sources/TwoCounterWorkflow.swift index be13679f0..0f865e004 100644 --- a/Samples/SwiftUITestbed/Sources/TwoCounterWorkflow.swift +++ b/Samples/SwiftUITestbed/Sources/TwoCounterWorkflow.swift @@ -13,33 +13,58 @@ import SwiftUI struct TwoCounterWorkflow: Workflow { @ObservableState - struct State {} + struct State { + var showSum = false + var counterCount = 2 + } func makeInitialState() -> State { State() } - // TODO: 2 kinds of actions - - enum Action: WorkflowAction { + struct ShowSumAction: WorkflowAction { typealias WorkflowType = TwoCounterWorkflow + var showSum: Bool + func apply(toState state: inout TwoCounterWorkflow.State) -> Never? { + print("ShowSumAction: \(showSum)") + state.showSum = showSum return nil } } + enum CounterAction: WorkflowAction { + typealias WorkflowType = TwoCounterWorkflow + + case addCounter + + func apply(toState state: inout TwoCounterWorkflow.State) -> Never? { + switch self { + case .addCounter: + state.counterCount += 1 + return nil + } + } + } + typealias Output = Never typealias Rendering = TwoCounterModel func render(state: State, context: RenderContext) -> TwoCounterModel { + // TODO: dynamic collection of counters let counter1: CounterWorkflow.Model = CounterWorkflow().rendered(in: context, key: "1") let counter2: CounterWorkflow.Model = CounterWorkflow().rendered(in: context, key: "2") + print("TwoCounterWorkflow render showSum: \(state.showSum)") + let showSum = context.makeSink(of: ShowSumAction.self) + let counterAction = context.makeSink(of: CounterAction.self) return TwoCounterModel( - lens: context.makeStateLens(state: state), + accessor: context.makeStateAccessor(state: state), counter1: counter1, - counter2: counter2 + counter2: counter2, + onShowSumToggle: { showSum.send(ShowSumAction(showSum: $0)) }, + counterAction: counterAction ) } } From 7dfc4586f248521cc43052d8fb746309025724b0 Mon Sep 17 00:00:00 2001 From: Andrew Watt Date: Thu, 14 Mar 2024 18:52:01 -0700 Subject: [PATCH 17/20] convert bindings to subscript into root binding --- .../SwiftUITestbed/Sources/MainScreen.swift | 2 +- .../Sources/Observation/BindableStore.swift | 43 ++--- .../Sources/Observation/Store.swift | 166 ++++++++++++------ .../Sources/TwoCounterScreen.swift | 20 +-- .../Sources/TwoCounterWorkflow.swift | 31 +++- 5 files changed, 165 insertions(+), 97 deletions(-) diff --git a/Samples/SwiftUITestbed/Sources/MainScreen.swift b/Samples/SwiftUITestbed/Sources/MainScreen.swift index 28140a2c3..5f46f593e 100644 --- a/Samples/SwiftUITestbed/Sources/MainScreen.swift +++ b/Samples/SwiftUITestbed/Sources/MainScreen.swift @@ -32,7 +32,7 @@ struct MainScreen: SwiftUIScreen { private struct MainView: View { typealias Model = StoreModel - @BindableStore var store: Store + @Perception.Bindable var store: Store @Environment(\.viewEnvironment.marketStylesheet) private var styles: MarketStylesheet @Environment(\.viewEnvironment.marketContext) private var context: MarketContext diff --git a/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift b/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift index a238e1c86..b4992b8f5 100644 --- a/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift +++ b/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift @@ -11,25 +11,28 @@ import SwiftUI /// /// 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 -struct BindableStore { - var wrappedValue: Store - init(wrappedValue: Store) { - self.wrappedValue = wrappedValue - } +//@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 +//struct BindableStore { +// var wrappedValue: Store +// init(wrappedValue: Store) { +// self.wrappedValue = wrappedValue +// } +// +// var projectedValue: Self { +// self +// } +// +// subscript( +// dynamicMember keyPath: ReferenceWritableKeyPath, Value> +// ) -> Binding { +// wrappedValue.binding(for: keyPath) +// } +//} - var projectedValue: Self { - self - } +// TODO: can we use this instead of Perception.Bindable to re-introduce a cache on the root Binding? - subscript( - dynamicMember keyPath: ReferenceWritableKeyPath, Value> - ) -> Binding { - wrappedValue.binding(for: keyPath) - } -} diff --git a/Samples/SwiftUITestbed/Sources/Observation/Store.swift b/Samples/SwiftUITestbed/Sources/Observation/Store.swift index 0eec5de8f..116cfdd18 100644 --- a/Samples/SwiftUITestbed/Sources/Observation/Store.swift +++ b/Samples/SwiftUITestbed/Sources/Observation/Store.swift @@ -54,17 +54,12 @@ extension Store where Model: ActionModel { model.sendAction(action) } - func binding( - for keyPath: KeyPath, + subscript( + state keyPath: KeyPath, action: CaseKeyPath - ) -> Binding { - // \Model.sendAction is not ideal for "sink path" but unique for prototyping - binding(key: .keyPathSinkAction(keyPath: keyPath, sinkPath: \Model.sendAction, actionPath: action)) { - Binding( - get: { self.state[keyPath: keyPath] }, - set: { self.send(action($0)) } - ) - } + ) -> Value { + get { self.state[keyPath: keyPath] } + set { self.send(action(newValue)) } } } @@ -117,67 +112,122 @@ extension Store { var setModel: (Model) -> Void } - func binding( - for keyPath: ReferenceWritableKeyPath, Value> - ) -> Binding { - binding(key: .writableKeyPath(keyPath)) { - Binding( - get: { self[keyPath: keyPath] }, - set: { self[keyPath: keyPath] = $0 } - ) + subscript( + state state: KeyPath, + send send: KeyPath Void> + ) -> Value { + get { + let val = self.state[keyPath: state] + print("get \(state) -> \(val)") + return val + } + set { + print("set \(state) <- \(newValue)") + self.model[keyPath: send](newValue) } } - func binding( - for keyPath: KeyPath, - send: KeyPath Void> - ) -> Binding { - binding(key: .keyPathSend(keyPath: keyPath, sendPath: send)) { - Binding( - get: { - let val = self.state[keyPath: keyPath] - print("get \(keyPath) -> \(val)") - return val - }, - set: { - print("set \(keyPath) <- \($0)") - self.model[keyPath: send]($0) - } - ) + subscript( + state state: KeyPath, + sink sink: KeyPath>, + action action: CaseKeyPath + ) -> Value { + get { + self.state[keyPath: state] + } + set { + self.model[keyPath: sink].send(action(newValue)) } } - func binding( - for keyPath: KeyPath, + enum BindingKey: Hashable { + case writableKeyPath(AnyKeyPath) + case keyPathSend(keyPath: AnyKeyPath, sendPath: AnyKeyPath) + case keyPathSinkAction(keyPath: AnyKeyPath, sinkPath: AnyKeyPath, actionPath: AnyKeyPath) + } +} + +extension Binding { + @_disfavoredOverload + subscript( + dynamicMember keyPath: KeyPath + ) -> _StoreBinding + where Value == Store + { + print("Creating _StoreBindable for \(keyPath)") + return _StoreBinding(binding: self, keyPath: keyPath) + } +} + +extension Perception.Bindable { + @_disfavoredOverload + subscript( + dynamicMember keyPath: KeyPath + ) -> _StoreBindable + where Value == Store + { + print("Creating _StoreBindable for \(keyPath)") + return _StoreBindable(bindable: self, keyPath: keyPath) + } +} + +@dynamicMemberLookup +struct _StoreBinding { + fileprivate let binding: Binding> + fileprivate let keyPath: KeyPath + + subscript( + dynamicMember keyPath: KeyPath + ) -> _StoreBinding { + _StoreBinding( + binding: self.binding, + keyPath: self.keyPath.appending(path: keyPath) + ) + } + + /// Creates a binding to the value by sending new values through the given action. + /// + /// - Parameter action: An action for the binding to send values through. + /// - Returns: A binding. + public func sending( sink: KeyPath>, action: CaseKeyPath ) -> Binding { - binding(key: .keyPathSinkAction(keyPath: keyPath, sinkPath: sink, actionPath: action)) { - Binding( - get: { self.state[keyPath: keyPath] }, - set: { self.model[keyPath: sink].send(action($0)) } - ) - } + self.binding[state: keyPath, sink: sink, action: action] } +} - private func binding(key: BindingKey, create: () -> Binding) -> Binding { - if let binding = bindings[key] as? Binding { - print("cached binding for \(key)") - _ = binding.wrappedValue - return binding - } - - print("new binding for \(key)") - let binding = create() - bindings[key] = binding - - return binding +@dynamicMemberLookup +struct _StoreBindable { + fileprivate let bindable: Perception.Bindable> + fileprivate let keyPath: KeyPath + + subscript( + dynamicMember keyPath: KeyPath + ) -> _StoreBindable { + _StoreBindable( + bindable: self.bindable, + keyPath: self.keyPath.appending(path: keyPath) + ) + } + + /// Creates a binding to the value by sending new values through the given action. + /// + /// - Parameter action: An action for the binding to send values through. + /// - Returns: A binding. + public func sending( + sink: KeyPath>, + action: CaseKeyPath + ) -> Binding { + print("Subscripting _StoreBindable for \(keyPath) sink + action") + return self.bindable[state: keyPath, sink: sink, action: action] } - enum BindingKey: Hashable { - case writableKeyPath(AnyKeyPath) - case keyPathSend(keyPath: AnyKeyPath, sendPath: AnyKeyPath) - case keyPathSinkAction(keyPath: AnyKeyPath, sinkPath: AnyKeyPath, actionPath: AnyKeyPath) + public func sending( + action: KeyPath Void> + ) -> Binding { + print("Subscripting _StoreBindable for closure action") + return self.bindable[state: keyPath, send: action] } } diff --git a/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift b/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift index abd269362..f0dd98b28 100644 --- a/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift +++ b/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift @@ -39,14 +39,11 @@ struct TwoCounterView: View { WithPerceptionTracking { let _ = Self._printChanges() VStack { - $store. - @Binding var showSum = store.binding(for: \.showSum, send: \.onShowSumToggle) -// @Perception.Bindable var showSum = store.showSum - Toggle( - "Show Sum", -// isOn: store.binding(for: \.showSum, send: \.onShowSumToggle) - isOn: $store.showSum - ) +// Toggle( +// "Show Sum", +// isOn: $store.showSum.sending(sink: \.sumAction, action: \.showSum) +// ) +// ToggleWrapper(isOn: $store.showSum.sending(sink: \.sumAction, action: \.showSum)) ToggleWrapper(isOn: $store.showSum) Button("Add Counter") { @@ -69,11 +66,11 @@ struct TwoCounterView: View { struct ToggleWrapper: View { @Binding var isOn: Bool var body: some View { - WithPerceptionTracking { +// WithPerceptionTracking { let _ = Self._printChanges() Toggle("Show Sum", isOn: $isOn) - } +// } } } @@ -85,7 +82,8 @@ struct TwoCounterModel: ObservableModel { let counter1: CounterWorkflow.Model let counter2: CounterWorkflow.Model - let onShowSumToggle: (Bool) -> Void +// let onShowSumToggle: (Bool) -> Void + let sumAction: Sink let counterAction: Sink } diff --git a/Samples/SwiftUITestbed/Sources/TwoCounterWorkflow.swift b/Samples/SwiftUITestbed/Sources/TwoCounterWorkflow.swift index 0f865e004..8a1d6282a 100644 --- a/Samples/SwiftUITestbed/Sources/TwoCounterWorkflow.swift +++ b/Samples/SwiftUITestbed/Sources/TwoCounterWorkflow.swift @@ -14,6 +14,7 @@ struct TwoCounterWorkflow: Workflow { @ObservableState struct State { +// fileprivate(set) var showSum = false var counterCount = 2 } @@ -22,15 +23,30 @@ struct TwoCounterWorkflow: Workflow { State() } - struct ShowSumAction: WorkflowAction { +// struct ShowSumAction: WorkflowAction { +// typealias WorkflowType = TwoCounterWorkflow +// +// var showSum: Bool +// +// func apply(toState state: inout TwoCounterWorkflow.State) -> Never? { +// print("ShowSumAction: \(showSum)") +// state.showSum = showSum +// return nil +// } +// } + + @CasePathable + enum SumAction: WorkflowAction { typealias WorkflowType = TwoCounterWorkflow - var showSum: Bool + case showSum(Bool) func apply(toState state: inout TwoCounterWorkflow.State) -> Never? { - print("ShowSumAction: \(showSum)") - state.showSum = showSum - return nil + switch self { + case .showSum(let showSum): + state.showSum = showSum + return nil + } } } @@ -56,14 +72,15 @@ struct TwoCounterWorkflow: Workflow { let counter1: CounterWorkflow.Model = CounterWorkflow().rendered(in: context, key: "1") let counter2: CounterWorkflow.Model = CounterWorkflow().rendered(in: context, key: "2") print("TwoCounterWorkflow render showSum: \(state.showSum)") - let showSum = context.makeSink(of: ShowSumAction.self) + let sumAction = context.makeSink(of: SumAction.self) let counterAction = context.makeSink(of: CounterAction.self) return TwoCounterModel( accessor: context.makeStateAccessor(state: state), counter1: counter1, counter2: counter2, - onShowSumToggle: { showSum.send(ShowSumAction(showSum: $0)) }, +// onShowSumToggle: { showSum.send(ShowSumAction(showSum: $0)) }, + sumAction: sumAction, counterAction: counterAction ) } From f12427d1969576f9b415a3286f945c27651ac1fa Mon Sep 17 00:00:00 2001 From: Andrew Watt Date: Thu, 14 Mar 2024 23:21:39 -0700 Subject: [PATCH 18/20] restore binding cache with invalidation on set --- .../Sources/Observation/BindableStore.swift | 43 +++++++++---------- .../Sources/Observation/Store.swift | 37 ++++++++++++++++ .../Sources/TwoCounterScreen.swift | 17 +++++--- 3 files changed, 68 insertions(+), 29 deletions(-) diff --git a/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift b/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift index b4992b8f5..731f4220d 100644 --- a/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift +++ b/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift @@ -11,28 +11,25 @@ import SwiftUI /// /// 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 -//struct BindableStore { -// var wrappedValue: Store -// init(wrappedValue: Store) { -// self.wrappedValue = wrappedValue -// } -// -// var projectedValue: Self { -// self -// } -// -// subscript( -// dynamicMember keyPath: ReferenceWritableKeyPath, Value> -// ) -> Binding { -// wrappedValue.binding(for: keyPath) -// } -//} +@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 +struct BindableStore: DynamicProperty { + var wrappedValue: Store + init(wrappedValue: Store) { + self.wrappedValue = wrappedValue + } -// TODO: can we use this instead of Perception.Bindable to re-introduce a cache on the root Binding? + var projectedValue: Self { + self + } + subscript( + dynamicMember keyPath: ReferenceWritableKeyPath, Value> + ) -> Binding { + wrappedValue.binding(for: keyPath) + } +} diff --git a/Samples/SwiftUITestbed/Sources/Observation/Store.swift b/Samples/SwiftUITestbed/Sources/Observation/Store.swift index 116cfdd18..e5eeef4dc 100644 --- a/Samples/SwiftUITestbed/Sources/Observation/Store.swift +++ b/Samples/SwiftUITestbed/Sources/Observation/Store.swift @@ -145,6 +145,43 @@ extension Store { case keyPathSend(keyPath: AnyKeyPath, sendPath: AnyKeyPath) case keyPathSinkAction(keyPath: AnyKeyPath, sinkPath: AnyKeyPath, actionPath: AnyKeyPath) } + + func binding(for keyPath: ReferenceWritableKeyPath, Value>) -> Binding { + let key = BindingKey.writableKeyPath(keyPath) + if let binding = bindings[key] as? Binding { +// print("Reusing binding")// for \(keyPath)") + _ = binding.wrappedValue + return binding + } + + // TODO: better to just do this in the setter? + withPerceptionTracking { + _ = self[keyPath: keyPath] + } onChange: { + print("invalidating binding") + self.bindings[key] = nil + } + + print("Creating binding") + let binding = Binding( + get: { + let val = self[keyPath: keyPath] + print("get -> \(val)") + return val + }, + set: { + print("set <- \($0)") + self[keyPath: keyPath] = $0 + } + ) + bindings[key] = binding + return binding + } + + func clearBindings() { + print("clearBindings") + bindings.removeAll() + } } extension Binding { diff --git a/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift b/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift index f0dd98b28..6a4f0a896 100644 --- a/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift +++ b/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift @@ -33,17 +33,22 @@ extension TwoCounterScreen: MarketBackStackContentScreen { } struct TwoCounterView: View { - @Perception.Bindable var store: Store +// @Perception.Bindable var store: Store + @BindableStore var store: Store var body: some View { WithPerceptionTracking { - let _ = Self._printChanges() + let _ = print("Evaluated TwoCounterView body") VStack { // Toggle( // "Show Sum", // isOn: $store.showSum.sending(sink: \.sumAction, action: \.showSum) // ) // ToggleWrapper(isOn: $store.showSum.sending(sink: \.sumAction, action: \.showSum)) + Toggle( + "Show Sum (top)", + isOn: $store.showSum + ) ToggleWrapper(isOn: $store.showSum) Button("Add Counter") { @@ -66,11 +71,11 @@ struct TwoCounterView: View { struct ToggleWrapper: View { @Binding var isOn: Bool var body: some View { -// WithPerceptionTracking { - let _ = Self._printChanges() + WithPerceptionTracking { + let _ = print("Evaluated ToggleWrapper body") - Toggle("Show Sum", isOn: $isOn) -// } + Toggle("Show Sum (nested)", isOn: $isOn) + } } } From c41e83b6a945193a3e88bf9c8d725d7fba7ad6c6 Mon Sep 17 00:00:00 2001 From: Andrew Watt Date: Tue, 19 Mar 2024 17:34:56 -0700 Subject: [PATCH 19/20] reset state --- .../Sources/CounterWorkflow.swift | 18 +++++++- .../Sources/TwoCounterScreen.swift | 9 +++- .../Sources/TwoCounterWorkflow.swift | 41 +++++++++++-------- 3 files changed, 49 insertions(+), 19 deletions(-) diff --git a/Samples/SwiftUITestbed/Sources/CounterWorkflow.swift b/Samples/SwiftUITestbed/Sources/CounterWorkflow.swift index c2755eeae..f26bbe310 100644 --- a/Samples/SwiftUITestbed/Sources/CounterWorkflow.swift +++ b/Samples/SwiftUITestbed/Sources/CounterWorkflow.swift @@ -3,6 +3,8 @@ import ComposableArchitecture struct CounterWorkflow: Workflow { + var resetToken: ResetToken + @ObservableState struct State { var count = 0 @@ -28,7 +30,14 @@ struct CounterWorkflow: Workflow { typealias Output = Never func makeInitialState() -> State { - State() + State(count: resetToken.initialValue) + } + + func workflowDidChange(from previousWorkflow: CounterWorkflow, state: inout State) { + if resetToken != previousWorkflow.resetToken { + // this state reset will totally invalidate the body even if `count` doesn't change + state = State(count: resetToken.initialValue) + } } typealias Rendering = StoreModel @@ -39,3 +48,10 @@ struct CounterWorkflow: Workflow { return context.makeStoreModel(state: state) } } + +extension CounterWorkflow { + struct ResetToken: Equatable { + let id = UUID() + var initialValue = 0 + } +} diff --git a/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift b/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift index 6a4f0a896..488cb2ba4 100644 --- a/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift +++ b/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift @@ -55,6 +55,11 @@ struct TwoCounterView: View { store.counterAction.send(.addCounter) } + Button("Reset Counters") { + // struct action + store.resetAction.send(.init(value: 0)) + } + CounterView(store: store.counter1, index: 0) CounterView(store: store.counter2, index: 1) @@ -74,7 +79,7 @@ struct ToggleWrapper: View { WithPerceptionTracking { let _ = print("Evaluated ToggleWrapper body") - Toggle("Show Sum (nested)", isOn: $isOn) + Toggle("Show Sum", isOn: $isOn) } } } @@ -87,9 +92,9 @@ struct TwoCounterModel: ObservableModel { let counter1: CounterWorkflow.Model let counter2: CounterWorkflow.Model -// let onShowSumToggle: (Bool) -> Void let sumAction: Sink let counterAction: Sink + let resetAction: Sink } #if DEBUG diff --git a/Samples/SwiftUITestbed/Sources/TwoCounterWorkflow.swift b/Samples/SwiftUITestbed/Sources/TwoCounterWorkflow.swift index 8a1d6282a..d3ab5c32d 100644 --- a/Samples/SwiftUITestbed/Sources/TwoCounterWorkflow.swift +++ b/Samples/SwiftUITestbed/Sources/TwoCounterWorkflow.swift @@ -14,26 +14,25 @@ struct TwoCounterWorkflow: Workflow { @ObservableState struct State { -// fileprivate(set) var showSum = false var counterCount = 2 + var resetToken = CounterWorkflow.ResetToken() } func makeInitialState() -> State { State() } -// struct ShowSumAction: WorkflowAction { -// typealias WorkflowType = TwoCounterWorkflow -// -// var showSum: Bool -// -// func apply(toState state: inout TwoCounterWorkflow.State) -> Never? { -// print("ShowSumAction: \(showSum)") -// state.showSum = showSum -// return nil -// } -// } + struct ResetAction: WorkflowAction { + typealias WorkflowType = TwoCounterWorkflow + + var value: Int + + func apply(toState state: inout TwoCounterWorkflow.State) -> Never? { + state.resetToken = .init(initialValue: value) + return nil + } + } @CasePathable enum SumAction: WorkflowAction { @@ -54,12 +53,16 @@ struct TwoCounterWorkflow: Workflow { typealias WorkflowType = TwoCounterWorkflow case addCounter + case reset func apply(toState state: inout TwoCounterWorkflow.State) -> Never? { switch self { case .addCounter: state.counterCount += 1 return nil + case .reset: + state.resetToken = CounterWorkflow.ResetToken() + return nil } } } @@ -69,11 +72,16 @@ struct TwoCounterWorkflow: Workflow { func render(state: State, context: RenderContext) -> TwoCounterModel { // TODO: dynamic collection of counters - let counter1: CounterWorkflow.Model = CounterWorkflow().rendered(in: context, key: "1") - let counter2: CounterWorkflow.Model = CounterWorkflow().rendered(in: context, key: "2") - print("TwoCounterWorkflow render showSum: \(state.showSum)") + let counter1: CounterWorkflow.Model = CounterWorkflow(resetToken: state.resetToken) + .rendered(in: context, key: "1") + let counter2: CounterWorkflow.Model = CounterWorkflow(resetToken: state.resetToken) + .rendered(in: context, key: "2") + + print("TwoCounterWorkflow render") + let sumAction = context.makeSink(of: SumAction.self) let counterAction = context.makeSink(of: CounterAction.self) + let resetAction = context.makeSink(of: ResetAction.self) return TwoCounterModel( accessor: context.makeStateAccessor(state: state), @@ -81,7 +89,8 @@ struct TwoCounterWorkflow: Workflow { counter2: counter2, // onShowSumToggle: { showSum.send(ShowSumAction(showSum: $0)) }, sumAction: sumAction, - counterAction: counterAction + counterAction: counterAction, + resetAction: resetAction ) } } From 39ede5a86306deb6846ae4485be4ed2fd6cb4dfa Mon Sep 17 00:00:00 2001 From: Andrew Watt Date: Wed, 20 Mar 2024 18:20:54 -0700 Subject: [PATCH 20/20] cleanup --- .../Sources/CounterScreen.swift | 5 +- .../SwiftUITestbed/Sources/MainScreen.swift | 2 - .../Observation/Binding+Observation.swift | 70 ------------------- .../Sources/Observation/Store.swift | 44 ++++++------ .../Sources/Observation/SwiftUIScreen.swift | 2 - .../Sources/TwoCounterScreen.swift | 15 ++-- 6 files changed, 31 insertions(+), 107 deletions(-) delete mode 100644 Samples/SwiftUITestbed/Sources/Observation/Binding+Observation.swift diff --git a/Samples/SwiftUITestbed/Sources/CounterScreen.swift b/Samples/SwiftUITestbed/Sources/CounterScreen.swift index 4bcb3c8e7..fb8647b70 100644 --- a/Samples/SwiftUITestbed/Sources/CounterScreen.swift +++ b/Samples/SwiftUITestbed/Sources/CounterScreen.swift @@ -47,11 +47,8 @@ struct CounterView: View { let index: Int var body: some View { -// let _ = print("Evaluating CounterView[\(index)].body") -// let _ = Self._printChanges() WithPerceptionTracking { -// let _ = print("Evaluating CounterView[\(index)].WithPerceptionTracking.body") -// let _ = Self._printChanges() + let _ = print("Evaluating CounterView[\(index)].body") HStack { Button { store.send(.decrement) diff --git a/Samples/SwiftUITestbed/Sources/MainScreen.swift b/Samples/SwiftUITestbed/Sources/MainScreen.swift index 5f46f593e..b9b097cac 100644 --- a/Samples/SwiftUITestbed/Sources/MainScreen.swift +++ b/Samples/SwiftUITestbed/Sources/MainScreen.swift @@ -56,8 +56,6 @@ private struct MainView: View { TextField( "Text", text: $store.title - // alternatively: - // text: store.binding(for: \.title, action: \.titleChanged) ) .focused($focusedField, equals: .title) .onAppear { focusedField = .title } diff --git a/Samples/SwiftUITestbed/Sources/Observation/Binding+Observation.swift b/Samples/SwiftUITestbed/Sources/Observation/Binding+Observation.swift deleted file mode 100644 index 8f2af600e..000000000 --- a/Samples/SwiftUITestbed/Sources/Observation/Binding+Observation.swift +++ /dev/null @@ -1,70 +0,0 @@ -// 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 { - subscript< - State: ObservableState, - Action, - Member: Equatable - >( - dynamicMember keyPath: WritableKeyPath - ) -> Binding - where Value == Store { - Binding( - get: { - self.wrappedValue.state[keyPath: keyPath] - }, - set: { newValue in - self.wrappedValue.send( - keyPath: keyPath, - value: newValue - ) - } - ) - } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension Bindable { - subscript( - dynamicMember keyPath: WritableKeyPath - ) -> Binding - where Value == Store { - Binding( - get: { - self.wrappedValue.state[keyPath: keyPath] - }, - set: { newValue in - self.wrappedValue.send( - keyPath: keyPath, - value: newValue - ) - } - ) - } -} - -extension BindableStore { - public subscript( - dynamicMember keyPath: WritableKeyPath - ) -> Binding { - Binding( - get: { - self.wrappedValue.state[keyPath: keyPath] - }, - set: { newValue in - self.wrappedValue.send( - keyPath: keyPath, - value: newValue - ) - } - ) - } -} - */ -#endif diff --git a/Samples/SwiftUITestbed/Sources/Observation/Store.swift b/Samples/SwiftUITestbed/Sources/Observation/Store.swift index e5eeef4dc..1d614f046 100644 --- a/Samples/SwiftUITestbed/Sources/Observation/Store.swift +++ b/Samples/SwiftUITestbed/Sources/Observation/Store.swift @@ -118,11 +118,11 @@ extension Store { ) -> Value { get { let val = self.state[keyPath: state] - print("get \(state) -> \(val)") + //print("get \(state) -> \(val)") return val } set { - print("set \(state) <- \(newValue)") + //print("set \(state) <- \(newValue)") self.model[keyPath: send](newValue) } } @@ -149,12 +149,11 @@ extension Store { func binding(for keyPath: ReferenceWritableKeyPath, Value>) -> Binding { let key = BindingKey.writableKeyPath(keyPath) if let binding = bindings[key] as? Binding { -// print("Reusing binding")// for \(keyPath)") + // print("Reusing binding")// for \(keyPath)") _ = binding.wrappedValue return binding } - // TODO: better to just do this in the setter? withPerceptionTracking { _ = self[keyPath: keyPath] } onChange: { @@ -164,15 +163,8 @@ extension Store { print("Creating binding") let binding = Binding( - get: { - let val = self[keyPath: keyPath] - print("get -> \(val)") - return val - }, - set: { - print("set <- \($0)") - self[keyPath: keyPath] = $0 - } + get: { self[keyPath: keyPath] }, + set: { self[keyPath: keyPath] = $0 } ) bindings[key] = binding return binding @@ -196,15 +188,25 @@ extension Binding { } } -extension Perception.Bindable { +// Moved onto BindableStore +//extension Perception.Bindable { +// @_disfavoredOverload +// subscript( +// dynamicMember keyPath: KeyPath +// ) -> _StoreBindable +// where Value == Store +// { +// print("Creating _StoreBindable for \(keyPath)") +// return _StoreBindable(bindable: self, keyPath: keyPath) +// } +//} + +extension BindableStore { @_disfavoredOverload - subscript( + subscript( dynamicMember keyPath: KeyPath - ) -> _StoreBindable - where Value == Store - { - print("Creating _StoreBindable for \(keyPath)") - return _StoreBindable(bindable: self, keyPath: keyPath) + ) -> _StoreBindable { + _StoreBindable(bindable: self, keyPath: keyPath) } } @@ -236,7 +238,7 @@ struct _StoreBinding { @dynamicMemberLookup struct _StoreBindable { - fileprivate let bindable: Perception.Bindable> + fileprivate let bindable: BindableStore fileprivate let keyPath: KeyPath subscript( diff --git a/Samples/SwiftUITestbed/Sources/Observation/SwiftUIScreen.swift b/Samples/SwiftUITestbed/Sources/Observation/SwiftUIScreen.swift index bc5448e3e..a3c511560 100644 --- a/Samples/SwiftUITestbed/Sources/Observation/SwiftUIScreen.swift +++ b/Samples/SwiftUITestbed/Sources/Observation/SwiftUIScreen.swift @@ -6,8 +6,6 @@ import Workflow import WorkflowUI protocol SwiftUIScreen: Screen { -// associatedtype State: ObservableState -// associatedtype Action associatedtype Content: View associatedtype Model: ObservableModel diff --git a/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift b/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift index 488cb2ba4..163c658dc 100644 --- a/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift +++ b/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift @@ -33,23 +33,21 @@ extension TwoCounterScreen: MarketBackStackContentScreen { } struct TwoCounterView: View { -// @Perception.Bindable var store: Store + // @BindableStore instead of @Perception.Bindable gives us a chance to cache the binding @BindableStore var store: Store var body: some View { WithPerceptionTracking { let _ = print("Evaluated TwoCounterView body") VStack { -// Toggle( -// "Show Sum", -// isOn: $store.showSum.sending(sink: \.sumAction, action: \.showSum) -// ) -// ToggleWrapper(isOn: $store.showSum.sending(sink: \.sumAction, action: \.showSum)) + + // Toggle vs wrapped Toggle Toggle( - "Show Sum (top)", + "Show Sum", isOn: $store.showSum ) - ToggleWrapper(isOn: $store.showSum) + // Binding with a custom setter action + ToggleWrapper(isOn: $store.showSum.sending(sink: \.sumAction, action: \.showSum)) Button("Add Counter") { store.counterAction.send(.addCounter) @@ -64,6 +62,7 @@ struct TwoCounterView: View { CounterView(store: store.counter2, index: 1) + // When showSum is false, changes to counters do not invalidate this body if store.showSum { Text("Sum: \(store.counter1.count + store.counter2.count)") }