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 ae0e49029..4fcb17cf5 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,7 @@ source 'https://rubygems.org' gem 'cocoapods-trunk', '>=1.6.0' -gem 'cocoapods' +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 1a4e418bb..5f70cf737 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,30 +1,17 @@ -GEM - remote: https://rubygems.org/ +GIT + remote: https://github.com/watt/cocoapods + revision: d8cd134e64ea6ba275366ffd62c52a0b219cdb56 + branch: podspec-spm specs: - CFPropertyList (3.0.5) - rexml - activesupport (6.1.7.6) - concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (>= 1.6, < 2) - minitest (>= 5.1) - tzinfo (~> 2.0) - zeitwerk (~> 2.3) - addressable (2.8.0) - public_suffix (>= 2.0.2, < 5.0) - algoliasearch (1.27.5) - httpclient (~> 2.8, >= 2.8.3) - json (>= 1.5.1) - atomos (0.1.3) - 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 +19,16 @@ 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) + +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) algoliasearch (~> 1.0) concurrent-ruby (~> 1.1) @@ -44,10 +37,37 @@ 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 (1.6.3) - cocoapods-generate (2.2.3) + cocoapods-downloader (2.1) + cocoapods-generate (2.2.5) cocoapods-disable-podfile-validations (>= 0.1.1, < 0.3.0) cocoapods-plugins (1.0.0) nap @@ -57,46 +77,50 @@ 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.1) 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) + i18n (1.14.4) 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) + 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! + cocoapods-core! cocoapods-generate cocoapods-trunk (>= 1.6.0) BUNDLED WITH - 2.1.4 + 2.5.6 diff --git a/Samples/SwiftUITestbed/Sources/CounterScreen.swift b/Samples/SwiftUITestbed/Sources/CounterScreen.swift new file mode 100644 index 000000000..fb8647b70 --- /dev/null +++ b/Samples/SwiftUITestbed/Sources/CounterScreen.swift @@ -0,0 +1,71 @@ +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 + typealias Model = StoreModel + + static func makeView(store: Store) -> some View { + CounterScreenView(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 CounterScreenView: View { + typealias Model = StoreModel + + let store: Store + + var body: some View { + WithPerceptionTracking { + let _ = Self._printChanges() + CounterView(store: store, index: 0) + } + } +} + +struct CounterView: View { + typealias Model = StoreModel + let store: Store + let index: Int + + var body: some View { + WithPerceptionTracking { + let _ = print("Evaluating CounterView[\(index)].body") + HStack { + Button { + store.send(.decrement) + } label: { + Image(systemName: "minus") + } + + Text("\(store.count)") + .monospacedDigit() + + Button { + store.send(.increment) + } label: { + Image(systemName: "plus") + } + } + .padding() + } + } +} diff --git a/Samples/SwiftUITestbed/Sources/CounterWorkflow.swift b/Samples/SwiftUITestbed/Sources/CounterWorkflow.swift new file mode 100644 index 000000000..f26bbe310 --- /dev/null +++ b/Samples/SwiftUITestbed/Sources/CounterWorkflow.swift @@ -0,0 +1,57 @@ +import Workflow +import ComposableArchitecture + +struct CounterWorkflow: Workflow { + + var resetToken: ResetToken + + @ObservableState + struct State { + var count = 0 + } + + 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: 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 + typealias Model = StoreModel + + func render(state: State, context: RenderContext) -> StoreModel { +// print("CounterWorkflow.render") + return context.makeStoreModel(state: state) + } +} + +extension CounterWorkflow { + struct ResetToken: Equatable { + let id = UUID() + var initialValue = 0 + } +} diff --git a/Samples/SwiftUITestbed/Sources/MainScreen.swift b/Samples/SwiftUITestbed/Sources/MainScreen.swift index 52c3ebb1f..b9b097cac 100644 --- a/Samples/SwiftUITestbed/Sources/MainScreen.swift +++ b/Samples/SwiftUITestbed/Sources/MainScreen.swift @@ -16,29 +16,23 @@ import MarketUI import MarketWorkflowUI +import Perception // for WithPerceptionTracking import ViewEnvironment import WorkflowSwiftUIExperimental +import WorkflowUI struct MainScreen: SwiftUIScreen { - let title: String - let didChangeTitle: (String) -> Void + typealias Model = StoreModel + var model: Model - 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) + public static func makeView(store: Store) -> some View { + MainView(store: store) } } -private struct MainScreenView: View { - @ObservedObject var model: ObservableValue +private struct MainView: View { + typealias Model = StoreModel + @Perception.Bindable var store: Store @Environment(\.viewEnvironment.marketStylesheet) private var styles: MarketStylesheet @Environment(\.viewEnvironment.marketContext) private var context: MarketContext @@ -50,30 +44,33 @@ private struct MainScreenView: View { @FocusState var focusedField: Field? var body: some View { - ScrollView { VStack { + WithPerceptionTracking { ScrollView { VStack { + let _ = Self._printChanges() + + // TODO: + // - suppress double render from textfield binding? + Text("Title") .font(Font(styles.headers.inlineSection20.heading.text.font)) TextField( "Text", - text: model.binding( - get: \.title, - set: \.didChangeTitle - ) + text: $store.title ) .focused($focusedField, equals: .title) .onAppear { focusedField = .title } + Text("What you typed: \(store.title)") + ToggleRow( style: context.stylesheets.testbed.toggleRow, label: "All Caps", - isEnabled: model.allCapsToggleIsEnabled, - isOn: model.binding( - get: \.allCapsToggleIsOn, - set: \.didChangeAllCapsToggle - ) + isEnabled: store.allCapsToggleIsEnabled, + isOn: $store.isAllCaps ) + Button("Append *", action: store.action(.appendStar)) + Spacer(minLength: styles.spacings.spacing50) Text("Navigation") @@ -81,12 +78,12 @@ private struct MainScreenView: View { Button( "Push Screen", - action: model.didTapPushScreen + action: store.action(.pushScreen) ) Button( "Present Screen", - action: model.didTapPresentScreen + action: store.action(.presentScreen) ) Button( @@ -94,15 +91,15 @@ private struct MainScreenView: View { action: { focusedField = nil } ) - } } + } } } } } extension MainScreen: 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: model.title)), + backButton: .close(onTap: { fatalError("TODO") }) // didTapClose.map { .close(onTap: $0) } ?? .automatic() ) } @@ -115,18 +112,13 @@ 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( + didClose: nil ) - .asMarketBackStack() - .marketPreview() + .mapRendering { MainScreen(model: $0).asMarketBackStack() } + .marketPreview { output in + + } } } diff --git a/Samples/SwiftUITestbed/Sources/MainWorkflow.swift b/Samples/SwiftUITestbed/Sources/MainWorkflow.swift index d8b0e5ae0..714239907 100644 --- a/Samples/SwiftUITestbed/Sources/MainWorkflow.swift +++ b/Samples/SwiftUITestbed/Sources/MainWorkflow.swift @@ -14,8 +14,10 @@ * limitations under the License. */ +import ComposableArchitecture // for ObservableState import MarketWorkflowUI import Workflow +import CasePaths struct MainWorkflow: Workflow { let didClose: (() -> Void)? @@ -25,9 +27,14 @@ struct MainWorkflow: Workflow { case presentScreen } + @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 @@ -39,13 +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 changeTitle(String) - case changeAllCaps(Bool) + case titleChanged(String) + case allCapsChanged(Bool) + case appendStar func apply(toState state: inout WorkflowType.State) -> WorkflowType.Output? { switch self { @@ -53,32 +62,32 @@ 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() + case .allCapsChanged(let allCaps): + state.isAllCaps = allCaps + return nil + case .titleChanged(let newTitle): + state.title = newTitle + return nil + case .appendStar: + state.title += "*" + return nil } - return nil } } - typealias Rendering = MainScreen + typealias Rendering = StoreModel func render(state: State, context: RenderContext) -> Rendering { - let sink = context.makeSink(of: Action.self) + print("MainWorkflow.render") + + return context.makeStoreModel(state: state) + } +} + +extension MainWorkflow.State { - 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 - ) + var allCapsToggleIsEnabled: Bool { + !title.isEmpty } } diff --git a/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift b/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift new file mode 100644 index 000000000..731f4220d --- /dev/null +++ b/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift @@ -0,0 +1,35 @@ +// 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 +struct BindableStore: DynamicProperty { + var wrappedValue: Store + init(wrappedValue: Store) { + self.wrappedValue = wrappedValue + } + + 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 new file mode 100644 index 000000000..1d614f046 --- /dev/null +++ b/Samples/SwiftUITestbed/Sources/Observation/Store.swift @@ -0,0 +1,292 @@ +import ComposableArchitecture // for ObservableState and Perception +import SwiftUI +import Workflow + +@dynamicMemberLookup +final class Store: Perceptible { + typealias State = Model.State + + 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.accessor.state + } + + private func send(keyPath: WritableKeyPath, value: Value) { + print("Store.send(\(keyPath), \(value))") + model.accessor.sendValue { state in + state[keyPath: keyPath] = value + } + } + + fileprivate init(_ model: Model) { + self.model = model + } + + fileprivate func setModel(_ newValue: Model) { + if !_$isIdentityEqual(model.accessor.state, newValue.accessor.state) { + _$observationRegistrar.withMutation(of: self, keyPath: \.state) { + model = newValue + } + } else { + model = newValue + } + + for childStore in childStores.values { + childStore.setModel(newValue) + } + } +} + +extension Store where Model: ActionModel { + typealias Action = Model.Action + + func action(_ action: Action) -> () -> Void { + { self.send(action) } + } + + func send(_ action: Action) { + model.sendAction(action) + } + + subscript( + state keyPath: KeyPath, + action: CaseKeyPath + ) -> Value { + get { self.state[keyPath: keyPath] } + set { self.send(action(newValue)) } + } +} + +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] + } + + subscript(dynamicMember keyPath: WritableKeyPath) -> T { + get { + state[keyPath: keyPath] + } + set { + send(keyPath: keyPath, value: newValue) + } + } + + subscript(dynamicMember keyPath: KeyPath>) -> Sink { + model[keyPath: keyPath] + } + + 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 + } + + 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) + } + } + + subscript( + state state: KeyPath, + sink sink: KeyPath>, + action action: CaseKeyPath + ) -> Value { + get { + self.state[keyPath: state] + } + set { + self.model[keyPath: sink].send(action(newValue)) + } + } + + enum BindingKey: Hashable { + case writableKeyPath(AnyKeyPath) + 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 + } + + withPerceptionTracking { + _ = self[keyPath: keyPath] + } onChange: { + print("invalidating binding") + self.bindings[key] = nil + } + + print("Creating binding") + let binding = Binding( + get: { self[keyPath: keyPath] }, + set: { self[keyPath: keyPath] = $0 } + ) + bindings[key] = binding + return binding + } + + func clearBindings() { + print("clearBindings") + bindings.removeAll() + } +} + +extension Binding { + @_disfavoredOverload + subscript( + dynamicMember keyPath: KeyPath + ) -> _StoreBinding + where Value == Store + { + print("Creating _StoreBindable for \(keyPath)") + return _StoreBinding(binding: self, keyPath: keyPath) + } +} + +// 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( + dynamicMember keyPath: KeyPath + ) -> _StoreBindable { + _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 { + self.binding[state: keyPath, sink: sink, action: action] + } +} + +@dynamicMemberLookup +struct _StoreBindable { + fileprivate let bindable: BindableStore + 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] + } + + public func sending( + action: KeyPath Void> + ) -> Binding { + print("Subscripting _StoreBindable for closure action") + return self.bindable[state: keyPath, send: action] + } +} + +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 diff --git a/Samples/SwiftUITestbed/Sources/Observation/StoreModel.swift b/Samples/SwiftUITestbed/Sources/Observation/StoreModel.swift new file mode 100644 index 000000000..70a86c920 --- /dev/null +++ b/Samples/SwiftUITestbed/Sources/Observation/StoreModel.swift @@ -0,0 +1,43 @@ +import ComposableArchitecture +import Workflow + +@dynamicMemberLookup +protocol ObservableModel { + associatedtype State: ObservableState + + var accessor: StateAccessor { get } +} + +extension ObservableModel { + subscript(dynamicMember keyPath: WritableKeyPath) -> T { + get { + accessor.state[keyPath: keyPath] + } + // If desirable, we could further divide this into a read-only and a writable version. + set { + accessor.sendValue { $0[keyPath: keyPath] = newValue } + } + } +} + +protocol ActionModel { + associatedtype Action + + var sendAction: (Action) -> Void { get } +} + +// Simplest form of model, with no actions +struct StateAccessor { + let state: State + let sendValue: (@escaping (inout State) -> Void) -> Void +} + +extension StateAccessor: ObservableModel { + var accessor: StateAccessor { self } +} + +// A common model with 1 action +struct StoreModel: ObservableModel, ActionModel { + let accessor: StateAccessor + let sendAction: (Action) -> Void +} diff --git a/Samples/SwiftUITestbed/Sources/Observation/SwiftUIScreen.swift b/Samples/SwiftUITestbed/Sources/Observation/SwiftUIScreen.swift new file mode 100644 index 000000000..a3c511560 --- /dev/null +++ b/Samples/SwiftUITestbed/Sources/Observation/SwiftUIScreen.swift @@ -0,0 +1,64 @@ +#if canImport(UIKit) + +import ComposableArchitecture // for ObservableState +import SwiftUI +import Workflow +import WorkflowUI + +protocol SwiftUIScreen: Screen { + associatedtype Content: View + associatedtype Model: ObservableModel + + var model: Model { get } + + @ViewBuilder + static func makeView(store: Store) -> Content +} + +extension SwiftUIScreen { + func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription { + ViewControllerDescription( + type: ModeledHostingController>.self, + environment: environment, + build: { + let (store, setModel) = Store.make(model: model) + return ModeledHostingController( + setModel: setModel, + rootView: EnvironmentInjectingView( + environment: environment, + content: Self.makeView(store: store) + ) + ) + }, + update: { hostingController in + hostingController.setModel(model) + // 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 setModel: (Model) -> Void + + init(setModel: @escaping (Model) -> Void, rootView: Content) { + self.setModel = setModel + super.init(rootView: rootView) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("not implemented") + } +} + +#endif diff --git a/Samples/SwiftUITestbed/Sources/RenderContext+Store.swift b/Samples/SwiftUITestbed/Sources/RenderContext+Store.swift new file mode 100644 index 000000000..2cd0a50f3 --- /dev/null +++ b/Samples/SwiftUITestbed/Sources/RenderContext+Store.swift @@ -0,0 +1,30 @@ +// +// RenderContext+Store.swift +// Development-SwiftUITestbed +// +// Created by Andrew Watt on 3/11/24. +// + +import Foundation +import Workflow +import ComposableArchitecture + +extension RenderContext where WorkflowType.State: ObservableState { + + func makeStateAccessor( + state: WorkflowType.State + ) -> StateAccessor { + StateAccessor(state: state, sendValue: makeStateMutationSink().send) + } + + func makeStoreModel( + state: WorkflowType.State + ) -> StoreModel + where Action.WorkflowType == WorkflowType + { + StoreModel( + accessor: makeStateAccessor(state: state), + sendAction: makeSink(of: Action.self).send + ) + } +} diff --git a/Samples/SwiftUITestbed/Sources/RootWorkflow.swift b/Samples/SwiftUITestbed/Sources/RootWorkflow.swift index 716a45eb0..b77eb2eaa 100644 --- a/Samples/SwiftUITestbed/Sources/RootWorkflow.swift +++ b/Samples/SwiftUITestbed/Sources/RootWorkflow.swift @@ -75,10 +75,27 @@ 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() +// let w2: AnyWorkflow = aw.mapRendering { +// return AnyMarketBackStackContentScreen($0) +// } +// let w3 = w2.rendered(in: context, key: id.uuidString) +// return w3 } } 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 diff --git a/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift b/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift new file mode 100644 index 000000000..163c658dc --- /dev/null +++ b/Samples/SwiftUITestbed/Sources/TwoCounterScreen.swift @@ -0,0 +1,112 @@ +// +// TwoCounterScreen.swift +// Development-SwiftUITestbed +// +// Created by Andrew Watt on 3/11/24. +// + +import Foundation +import SwiftUI +import MarketUI +import MarketWorkflowUI +import ViewEnvironment +import Workflow +import Perception + +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 { + // @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 vs wrapped Toggle + Toggle( + "Show Sum", + 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) + } + + Button("Reset Counters") { + // struct action + store.resetAction.send(.init(value: 0)) + } + + CounterView(store: store.counter1, index: 0) + + 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)") + } + } + .padding() + } + } +} + +struct ToggleWrapper: View { + @Binding var isOn: Bool + var body: some View { + WithPerceptionTracking { + let _ = print("Evaluated ToggleWrapper body") + + Toggle("Show Sum", isOn: $isOn) + } + } +} + +struct TwoCounterModel: ObservableModel { + typealias State = TwoCounterWorkflow.State + + let accessor: StateAccessor + + let counter1: CounterWorkflow.Model + let counter2: CounterWorkflow.Model + + let sumAction: Sink + let counterAction: Sink + let resetAction: 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 new file mode 100644 index 000000000..d3ab5c32d --- /dev/null +++ b/Samples/SwiftUITestbed/Sources/TwoCounterWorkflow.swift @@ -0,0 +1,96 @@ +// +// 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 { + var showSum = false + var counterCount = 2 + var resetToken = CounterWorkflow.ResetToken() + } + + func makeInitialState() -> State { + State() + } + + 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 { + typealias WorkflowType = TwoCounterWorkflow + + case showSum(Bool) + + func apply(toState state: inout TwoCounterWorkflow.State) -> Never? { + switch self { + case .showSum(let showSum): + state.showSum = showSum + return nil + } + } + } + + enum CounterAction: WorkflowAction { + 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 + } + } + } + + typealias Output = Never + typealias Rendering = TwoCounterModel + + func render(state: State, context: RenderContext) -> TwoCounterModel { + // TODO: dynamic collection of counters + 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), + counter1: counter1, + counter2: counter2, +// onShowSumToggle: { showSum.send(ShowSumAction(showSum: $0)) }, + sumAction: sumAction, + counterAction: counterAction, + resetAction: resetAction + ) + } +} 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