From c7d51b623518a1bd219d302ff0f8ebb70ff4dc79 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 25 May 2023 17:29:14 -0500 Subject: [PATCH 1/9] paste in WorkflowSwiftUI types From https://github.com/square/workflow-swift/tree/93c3b5d281bff9de2e40390c1192a25dfe8a7a45/WorkflowSwiftUI/Sources --- Package.swift | 2 +- WorkflowSwiftUI.podspec | 1 + .../EnvironmentValues+ViewEnvironment.swift | 29 +++++ .../Sources/ObservableValue+Binding.swift | 48 ++++++++ WorkflowSwiftUI/Sources/ObservableValue.swift | 106 ++++++++++++++++++ WorkflowSwiftUI/Sources/SwiftUIScreen.swift | 93 +++++++++++++++ WorkflowSwiftUI/Sources/WithModel.swift | 34 ++++++ 7 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 WorkflowSwiftUI/Sources/EnvironmentValues+ViewEnvironment.swift create mode 100644 WorkflowSwiftUI/Sources/ObservableValue+Binding.swift create mode 100644 WorkflowSwiftUI/Sources/ObservableValue.swift create mode 100644 WorkflowSwiftUI/Sources/SwiftUIScreen.swift create mode 100644 WorkflowSwiftUI/Sources/WithModel.swift diff --git a/Package.swift b/Package.swift index ad63389ee..c762ca935 100644 --- a/Package.swift +++ b/Package.swift @@ -133,7 +133,7 @@ let package = Package( ), .target( name: "WorkflowSwiftUI", - dependencies: ["Workflow"], + dependencies: ["Workflow", "WorkflowUI"], path: "WorkflowSwiftUI/Sources" ), diff --git a/WorkflowSwiftUI.podspec b/WorkflowSwiftUI.podspec index 709a229cc..f8a614082 100644 --- a/WorkflowSwiftUI.podspec +++ b/WorkflowSwiftUI.podspec @@ -19,6 +19,7 @@ Pod::Spec.new do |s| s.source_files = 'WorkflowSwiftUI/Sources/*.swift' s.dependency 'Workflow', "#{s.version}" + s.dependency 'WorkflowUI', "#{s.version}" s.pod_target_xcconfig = { 'APPLICATION_EXTENSION_API_ONLY' => 'YES' } diff --git a/WorkflowSwiftUI/Sources/EnvironmentValues+ViewEnvironment.swift b/WorkflowSwiftUI/Sources/EnvironmentValues+ViewEnvironment.swift new file mode 100644 index 000000000..9d28205ac --- /dev/null +++ b/WorkflowSwiftUI/Sources/EnvironmentValues+ViewEnvironment.swift @@ -0,0 +1,29 @@ +/* + * Copyright 2023 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI +import WorkflowUI + +private struct ViewEnvironmentKey: EnvironmentKey { + static let defaultValue: ViewEnvironment = .empty +} + +public extension EnvironmentValues { + var viewEnvironment: ViewEnvironment { + get { self[ViewEnvironmentKey.self] } + set { self[ViewEnvironmentKey.self] = newValue } + } +} diff --git a/WorkflowSwiftUI/Sources/ObservableValue+Binding.swift b/WorkflowSwiftUI/Sources/ObservableValue+Binding.swift new file mode 100644 index 000000000..8ae1d6f57 --- /dev/null +++ b/WorkflowSwiftUI/Sources/ObservableValue+Binding.swift @@ -0,0 +1,48 @@ +/* + * Copyright 2023 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#if canImport(UIKit) + +import SwiftUI + +public extension ObservableValue { + func binding( + get: @escaping (Value) -> T, + set: @escaping (Value) -> (T) -> Void + ) -> Binding { + // This convoluted way of creating a `Binding`, relative to `Binding.init(get:set:)`, is + // a workaround borrowed from TCA for a SwiftUI issue: + // https://github.com/pointfreeco/swift-composable-architecture/pull/770 + ObservedObject(wrappedValue: self) + .projectedValue[get: .init(rawValue: get), set: .init(rawValue: set)] + } + + private subscript( + get get: HashableWrapper<(Value) -> T>, + set set: HashableWrapper<(Value) -> (T) -> Void> + ) -> T { + get { get.rawValue(value) } + set { set.rawValue(value)(newValue) } + } + + private struct HashableWrapper: Hashable { + let rawValue: Value + static func == (lhs: Self, rhs: Self) -> Bool { false } + func hash(into hasher: inout Hasher) {} + } +} + +#endif diff --git a/WorkflowSwiftUI/Sources/ObservableValue.swift b/WorkflowSwiftUI/Sources/ObservableValue.swift new file mode 100644 index 000000000..03c80e2ee --- /dev/null +++ b/WorkflowSwiftUI/Sources/ObservableValue.swift @@ -0,0 +1,106 @@ +/* + * Copyright 2023 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Combine +import Workflow + +@dynamicMemberLookup +public final class ObservableValue: ObservableObject { + private var internalValue: Value + private let subject = PassthroughSubject() + private var cancellable: AnyCancellable? + private var isDuplicate: ((Value, Value) -> Bool)? + public private(set) var value: Value { + get { + return internalValue + } + set { + subject.send(newValue) + } + } + + public private(set) lazy var objectWillChange = ObservableObjectPublisher() + private var parentCancellable: AnyCancellable? + + public static func makeObservableValue( + _ value: Value, + isDuplicate: ((Value, Value) -> Bool)? = nil + ) -> (ObservableValue, Sink) { + let observableValue = ObservableValue(value: value, isDuplicate: isDuplicate) + let sink = Sink { newValue in + observableValue.value = newValue + } + + return (observableValue, sink) + } + + private init(value: Value, isDuplicate: ((Value, Value) -> Bool)?) { + self.internalValue = value + self.isDuplicate = isDuplicate + self.cancellable = valuePublisher() + .dropFirst() + .sink { [weak self] newValue in + guard let self = self else { return } + self.objectWillChange.send() + self.internalValue = newValue + } + // Allows removeDuplicates operator to have the initial value. + subject.send(value) + } + + //// Scopes the ObservableValue to a subset of Value to LocalValue given the supplied closure while allowing to optionally remove duplicates. + /// - Parameters: + /// - toLocalValue: A closure that takes a Value and returns a LocalValue. + /// - isDuplicate: An optional closure that checks to see if a LocalValue is a duplicate. + /// - Returns: a scoped ObservableValue of LocalValue. + public func scope(_ toLocalValue: @escaping (Value) -> LocalValue, isDuplicate: ((LocalValue, LocalValue) -> Bool)? = nil) -> ObservableValue { + return scopeToLocalValue(toLocalValue, isDuplicate: isDuplicate) + } + + /// Scopes the ObservableValue to a subset of Value to LocalValue given the supplied closure and removes duplicate values using Equatable. + /// - Parameter toLocalValue: A closure that takes a Value and returns a LocalValue. + /// - Returns: a scoped ObservableValue of LocalValue. + public func scope(_ toLocalValue: @escaping (Value) -> LocalValue) -> ObservableValue where LocalValue: Equatable { + return scopeToLocalValue(toLocalValue, isDuplicate: { $0 == $1 }) + } + + /// Returns the value at the given keypath of ``Value``. + /// + /// In combination with `@dynamicMemberLookup`, this allows us to write `model.myProperty` instead of + /// `model.value.myProperty` where `model` has type `ObservableValue`. + public subscript(dynamicMember keyPath: KeyPath) -> T { + internalValue[keyPath: keyPath] + } + + private func scopeToLocalValue(_ toLocalValue: @escaping (Value) -> LocalValue, isDuplicate: ((LocalValue, LocalValue) -> Bool)? = nil) -> ObservableValue { + let localObservableValue = ObservableValue( + value: toLocalValue(internalValue), + isDuplicate: isDuplicate + ) + localObservableValue.parentCancellable = valuePublisher().sink(receiveValue: { newValue in + localObservableValue.value = toLocalValue(newValue) + }) + return localObservableValue + } + + private func valuePublisher() -> AnyPublisher { + guard let isDuplicate = isDuplicate else { + return subject.eraseToAnyPublisher() + } + + return subject.removeDuplicates(by: isDuplicate).eraseToAnyPublisher() + } +} diff --git a/WorkflowSwiftUI/Sources/SwiftUIScreen.swift b/WorkflowSwiftUI/Sources/SwiftUIScreen.swift new file mode 100644 index 000000000..79a9e7114 --- /dev/null +++ b/WorkflowSwiftUI/Sources/SwiftUIScreen.swift @@ -0,0 +1,93 @@ +/* + * Copyright 2023 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#if canImport(UIKit) + +import SwiftUI +import Workflow +import WorkflowUI + +public protocol SwiftUIScreen: Screen { + associatedtype Content: View + + @ViewBuilder + static func makeView(model: ObservableValue) -> Content + + static var isDuplicate: ((Self, Self) -> Bool)? { get } +} + +public extension SwiftUIScreen { + static var isDuplicate: ((Self, Self) -> Bool)? { return nil } +} + +public extension SwiftUIScreen where Self: Equatable { + static var isDuplicate: ((Self, Self) -> Bool)? { { $0 == $1 } } +} + +public extension SwiftUIScreen { + func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription { + ViewControllerDescription( + type: ModeledHostingController>>.self, + environment: environment, + build: { + let (model, modelSink) = ObservableValue.makeObservableValue(self, isDuplicate: Self.isDuplicate) + let (viewEnvironment, envSink) = ObservableValue.makeObservableValue(environment) + return ModeledHostingController( + modelSink: modelSink, + viewEnvironmentSink: envSink, + rootView: WithModel(model, content: { model in + EnvironmentInjectingView( + viewEnvironment: viewEnvironment, + content: Self.makeView(model: model) + ) + }) + ) + }, + update: { + $0.modelSink.send(self) + $0.viewEnvironmentSink.send(environment) + } + ) + } +} + +private struct EnvironmentInjectingView: View { + @ObservedObject var viewEnvironment: ObservableValue + let content: Content + + var body: some View { + content + .environment(\.viewEnvironment, viewEnvironment.value) + } +} + +private final class ModeledHostingController: UIHostingController { + let modelSink: Sink + let viewEnvironmentSink: Sink + + init(modelSink: Sink, viewEnvironmentSink: Sink, rootView: Content) { + self.modelSink = modelSink + self.viewEnvironmentSink = viewEnvironmentSink + + super.init(rootView: rootView) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("not implemented") + } +} + +#endif diff --git a/WorkflowSwiftUI/Sources/WithModel.swift b/WorkflowSwiftUI/Sources/WithModel.swift new file mode 100644 index 000000000..a380e2544 --- /dev/null +++ b/WorkflowSwiftUI/Sources/WithModel.swift @@ -0,0 +1,34 @@ +/* + * Copyright 2023 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +struct WithModel: View { + @ObservedObject private var model: ObservableValue + private let content: (ObservableValue) -> Content + + init( + _ model: ObservableValue, + @ViewBuilder content: @escaping (ObservableValue) -> Content + ) { + self.model = model + self.content = content + } + + var body: Content { + content(model) + } +} From 2fbfc5e2e91ab316a17d178a315c156f0ca4a12d Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 26 May 2023 09:05:58 -0500 Subject: [PATCH 2/9] rewrite LoginScreen as a SwiftUIScreen --- Development.podspec | 1 + .../Sources/Authentication/LoginScreen.swift | 143 ++++-------------- 2 files changed, 30 insertions(+), 114 deletions(-) diff --git a/Development.podspec b/Development.podspec index d6ce1725e..a9b987a0d 100644 --- a/Development.podspec +++ b/Development.podspec @@ -63,6 +63,7 @@ Pod::Spec.new do |s| app_spec.dependency 'BackStackContainer' app_spec.dependency 'ModalContainer' app_spec.dependency 'AlertContainer' + app_spec.dependency 'WorkflowSwiftUI' end s.test_spec 'TicTacToeTests' do |test_spec| diff --git a/Samples/TicTacToe/Sources/Authentication/LoginScreen.swift b/Samples/TicTacToe/Sources/Authentication/LoginScreen.swift index c9298b058..6261c18b1 100644 --- a/Samples/TicTacToe/Sources/Authentication/LoginScreen.swift +++ b/Samples/TicTacToe/Sources/Authentication/LoginScreen.swift @@ -14,10 +14,10 @@ * limitations under the License. */ -import Workflow -import WorkflowUI +import SwiftUI +import WorkflowSwiftUI -struct LoginScreen: Screen { +struct LoginScreen: SwiftUIScreen { var title: String var email: String var onEmailChanged: (String) -> Void @@ -25,117 +25,32 @@ struct LoginScreen: Screen { var onPasswordChanged: (String) -> Void var onLoginTapped: () -> Void - func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription { - return ViewControllerDescription( - environment: environment, - build: { LoginViewController() }, - update: { $0.update(with: self) } - ) - } -} - -private final class LoginViewController: UIViewController { - private let welcomeLabel: UILabel = UILabel(frame: .zero) - private let emailField: UITextField = UITextField(frame: .zero) - private let passwordField: UITextField = UITextField(frame: .zero) - private let button: UIButton = UIButton(frame: .zero) - private var onEmailChanged: (String) -> Void = { _ in } - private var onPasswordChanged: (String) -> Void = { _ in } - private var onLoginTapped: () -> Void = {} - - override func viewDidLoad() { - super.viewDidLoad() - - view.backgroundColor = .white - - welcomeLabel.textAlignment = .center - - emailField.placeholder = "email@address.com" - emailField.autocapitalizationType = .none - emailField.autocorrectionType = .no - emailField.textContentType = .emailAddress - emailField.backgroundColor = UIColor(white: 0.92, alpha: 1.0) - emailField.addTarget(self, action: #selector(textDidChange(sender:)), for: .editingChanged) - - passwordField.placeholder = "password" - passwordField.isSecureTextEntry = true - passwordField.backgroundColor = UIColor(white: 0.92, alpha: 1.0) - passwordField.addTarget(self, action: #selector(textDidChange(sender:)), for: .editingChanged) - - button.backgroundColor = UIColor(red: 41 / 255, green: 150 / 255, blue: 204 / 255, alpha: 1.0) - button.setTitle("Login", for: .normal) - button.addTarget(self, action: #selector(buttonTapped(sender:)), for: .touchUpInside) - - view.addSubview(welcomeLabel) - view.addSubview(emailField) - view.addSubview(passwordField) - view.addSubview(button) - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - - let inset: CGFloat = 12.0 - let height: CGFloat = 44.0 - var yOffset = (view.bounds.size.height - (3 * height + inset)) / 2.0 - - welcomeLabel.frame = CGRect( - x: view.bounds.origin.x, - y: view.bounds.origin.y, - width: view.bounds.size.width, - height: yOffset - ) - - emailField.frame = CGRect( - x: view.bounds.origin.x, - y: yOffset, - width: view.bounds.size.width, - height: height - ) - .insetBy(dx: inset, dy: 0.0) - - yOffset += height + inset - - passwordField.frame = CGRect( - x: view.bounds.origin.x, - y: yOffset, - width: view.bounds.size.width, - height: height - ) - .insetBy(dx: inset, dy: 0.0) - - yOffset += height + inset - - button.frame = CGRect( - x: view.bounds.origin.x, - y: yOffset, - width: view.bounds.size.width, - height: height - ) - .insetBy(dx: inset, dy: 0.0) - } - - func update(with screen: LoginScreen) { - welcomeLabel.text = screen.title - emailField.text = screen.email - passwordField.text = screen.password - onEmailChanged = screen.onEmailChanged - onPasswordChanged = screen.onPasswordChanged - onLoginTapped = screen.onLoginTapped - } - - @objc private func textDidChange(sender: UITextField) { - guard let text = sender.text else { - return + static func makeView(model: ObservableValue) -> some View { + VStack(spacing: 16) { + Text(model.title) + + TextField( + "email@address.com", + text: model.binding( + get: \.email, + set: \.onEmailChanged + ) + ) + .autocapitalization(.none) + .autocorrectionDisabled() + .textContentType(.emailAddress) + + SecureField( + "password", + text: model.binding( + get: \.password, + set: \.onPasswordChanged + ), + onCommit: model.onLoginTapped + ) + + Button("Login", action: model.onLoginTapped) } - if sender == emailField { - onEmailChanged(text) - } else if sender == passwordField { - onPasswordChanged(text) - } - } - - @objc private func buttonTapped(sender: UIButton) { - onLoginTapped() + .frame(maxWidth: 400) } } From 0340049e1e80261193db11dfe2fe27782470c8f3 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 26 May 2023 16:47:51 -0500 Subject: [PATCH 3/9] replace callbacks in Rendering w/ ScreenActionSink --- Development.podspec | 1 + .../Sources/Authentication/LoginScreen.swift | 14 +++--- .../Authentication/LoginWorkflow.swift | 16 ++----- .../Tests/AuthenticationWorkflowTests.swift | 24 ++++------ .../Sources/ScreenActionSink.swift | 44 +++++++++++++++++++ WorkflowSwiftUI/Sources/SwiftUIScreen.swift | 3 ++ 6 files changed, 65 insertions(+), 37 deletions(-) create mode 100644 WorkflowSwiftUI/Sources/ScreenActionSink.swift diff --git a/Development.podspec b/Development.podspec index a9b987a0d..c31460bbf 100644 --- a/Development.podspec +++ b/Development.podspec @@ -73,6 +73,7 @@ Pod::Spec.new do |s| test_spec.dependency 'BackStackContainer' test_spec.dependency 'ModalContainer' test_spec.dependency 'AlertContainer' + test_spec.dependency 'WorkflowSwiftUI' test_spec.requires_app_host = true test_spec.app_host_name = 'Development/SampleTicTacToe' test_spec.source_files = 'Samples/TicTacToe/Tests/**/*.swift' diff --git a/Samples/TicTacToe/Sources/Authentication/LoginScreen.swift b/Samples/TicTacToe/Sources/Authentication/LoginScreen.swift index 6261c18b1..6971396b2 100644 --- a/Samples/TicTacToe/Sources/Authentication/LoginScreen.swift +++ b/Samples/TicTacToe/Sources/Authentication/LoginScreen.swift @@ -17,13 +17,11 @@ import SwiftUI import WorkflowSwiftUI -struct LoginScreen: SwiftUIScreen { +struct LoginScreen: SwiftUIScreen, Equatable { + var actionSink: ScreenActionSink var title: String var email: String - var onEmailChanged: (String) -> Void var password: String - var onPasswordChanged: (String) -> Void - var onLoginTapped: () -> Void static func makeView(model: ObservableValue) -> some View { VStack(spacing: 16) { @@ -33,7 +31,7 @@ struct LoginScreen: SwiftUIScreen { "email@address.com", text: model.binding( get: \.email, - set: \.onEmailChanged + set: { screen in { screen.actionSink.send(.emailUpdated($0)) } } ) ) .autocapitalization(.none) @@ -44,12 +42,12 @@ struct LoginScreen: SwiftUIScreen { "password", text: model.binding( get: \.password, - set: \.onPasswordChanged + set: { screen in { screen.actionSink.send(.passwordUpdated($0)) } } ), - onCommit: model.onLoginTapped + onCommit: { model.value.actionSink.send(.login) } ) - Button("Login", action: model.onLoginTapped) + Button("Login", action: { model.value.actionSink.send(.login) }) } .frame(maxWidth: 400) } diff --git a/Samples/TicTacToe/Sources/Authentication/LoginWorkflow.swift b/Samples/TicTacToe/Sources/Authentication/LoginWorkflow.swift index 29beafe7f..f3149f37a 100644 --- a/Samples/TicTacToe/Sources/Authentication/LoginWorkflow.swift +++ b/Samples/TicTacToe/Sources/Authentication/LoginWorkflow.swift @@ -72,21 +72,11 @@ extension LoginWorkflow { typealias Rendering = LoginScreen func render(state: LoginWorkflow.State, context: RenderContext) -> Rendering { - let sink = context.makeSink(of: Action.self) - - return LoginScreen( + LoginScreen( + actionSink: .init(context.makeSink(of: Action.self)), title: "Welcome! Please log in to play TicTacToe!", email: state.email, - onEmailChanged: { email in - sink.send(.emailUpdated(email)) - }, - password: state.password, - onPasswordChanged: { password in - sink.send(.passwordUpdated(password)) - }, - onLoginTapped: { - sink.send(.login) - } + password: state.password ) } } diff --git a/Samples/TicTacToe/Tests/AuthenticationWorkflowTests.swift b/Samples/TicTacToe/Tests/AuthenticationWorkflowTests.swift index 42a0ac37f..f70c766bc 100644 --- a/Samples/TicTacToe/Tests/AuthenticationWorkflowTests.swift +++ b/Samples/TicTacToe/Tests/AuthenticationWorkflowTests.swift @@ -183,12 +183,10 @@ class AuthenticationWorkflowTests: XCTestCase { .expectWorkflow( type: LoginWorkflow.self, producingRendering: LoginScreen( + actionSink: .noop(), title: "", email: "", - onEmailChanged: { _ in }, - password: "", - onPasswordChanged: { _ in }, - onLoginTapped: {} + password: "" ) ) .render { screen in @@ -210,12 +208,10 @@ class AuthenticationWorkflowTests: XCTestCase { .expectWorkflow( type: LoginWorkflow.self, producingRendering: LoginScreen( + actionSink: .noop(), title: "", email: "", - onEmailChanged: { _ in }, - password: "", - onPasswordChanged: { _ in }, - onLoginTapped: {} + password: "" ) ) .expect( @@ -244,12 +240,10 @@ class AuthenticationWorkflowTests: XCTestCase { .expectWorkflow( type: LoginWorkflow.self, producingRendering: LoginScreen( + actionSink: .noop(), title: "", email: "", - onEmailChanged: { _ in }, - password: "", - onPasswordChanged: { _ in }, - onLoginTapped: {} + password: "" ) ) .expect( @@ -275,12 +269,10 @@ class AuthenticationWorkflowTests: XCTestCase { .expectWorkflow( type: LoginWorkflow.self, producingRendering: LoginScreen( + actionSink: .noop(), title: "", email: "", - onEmailChanged: { _ in }, - password: "", - onPasswordChanged: { _ in }, - onLoginTapped: {} + password: "" ) ) .render { screen in diff --git a/WorkflowSwiftUI/Sources/ScreenActionSink.swift b/WorkflowSwiftUI/Sources/ScreenActionSink.swift new file mode 100644 index 000000000..76f21a6f9 --- /dev/null +++ b/WorkflowSwiftUI/Sources/ScreenActionSink.swift @@ -0,0 +1,44 @@ +/* + * Copyright 2023 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Workflow + +/// A sink intended specifically to fulfill the `actionSink` requirement of ``SwiftUIScreen``. In order that the +/// `SwiftUIScreen` can automatically be `Equatable`, this sink is `Equatable` and always compares equal. +/// +// TODO: Should this really _always_ compare equal? Can we pass in some identity when initializing? Maybe some identifier from the +/// `RenderContext`, or even just the source location where the sink was initialized? +public struct ScreenActionSink: Equatable { + private let sink: Sink + + public init(_ sink: Sink) { + self.sink = sink + } + + public func send(_ value: Value) { + sink.send(value) + } + + public static func noop() -> ScreenActionSink { + ScreenActionSink(Sink { _ in }) + } + + // MARK: Equatable + + public static func == (lhs: ScreenActionSink, rhs: ScreenActionSink) -> Bool { + true + } +} diff --git a/WorkflowSwiftUI/Sources/SwiftUIScreen.swift b/WorkflowSwiftUI/Sources/SwiftUIScreen.swift index 79a9e7114..72abd1ab2 100644 --- a/WorkflowSwiftUI/Sources/SwiftUIScreen.swift +++ b/WorkflowSwiftUI/Sources/SwiftUIScreen.swift @@ -22,10 +22,13 @@ import WorkflowUI public protocol SwiftUIScreen: Screen { associatedtype Content: View + associatedtype Action @ViewBuilder static func makeView(model: ObservableValue) -> Content + var actionSink: ScreenActionSink { get } + static var isDuplicate: ((Self, Self) -> Bool)? { get } } From 7a9164a4f3a5e37e443b1fb3b1a6c223b4dd83d6 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 26 May 2023 16:57:31 -0500 Subject: [PATCH 4/9] add sugar for passing Action as Binding setter --- .../Sources/Authentication/LoginScreen.swift | 4 ++-- .../Sources/ObservableValue+Binding.swift | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/Samples/TicTacToe/Sources/Authentication/LoginScreen.swift b/Samples/TicTacToe/Sources/Authentication/LoginScreen.swift index 6971396b2..04b3ac121 100644 --- a/Samples/TicTacToe/Sources/Authentication/LoginScreen.swift +++ b/Samples/TicTacToe/Sources/Authentication/LoginScreen.swift @@ -31,7 +31,7 @@ struct LoginScreen: SwiftUIScreen, Equatable { "email@address.com", text: model.binding( get: \.email, - set: { screen in { screen.actionSink.send(.emailUpdated($0)) } } + set: Action.emailUpdated ) ) .autocapitalization(.none) @@ -42,7 +42,7 @@ struct LoginScreen: SwiftUIScreen, Equatable { "password", text: model.binding( get: \.password, - set: { screen in { screen.actionSink.send(.passwordUpdated($0)) } } + set: Action.passwordUpdated ), onCommit: { model.value.actionSink.send(.login) } ) diff --git a/WorkflowSwiftUI/Sources/ObservableValue+Binding.swift b/WorkflowSwiftUI/Sources/ObservableValue+Binding.swift index 8ae1d6f57..59fcb72e3 100644 --- a/WorkflowSwiftUI/Sources/ObservableValue+Binding.swift +++ b/WorkflowSwiftUI/Sources/ObservableValue+Binding.swift @@ -45,4 +45,16 @@ public extension ObservableValue { } } +public extension ObservableValue where Value: SwiftUIScreen { + func binding( + get: @escaping (Value) -> T, + set: @escaping (T) -> (Value.Action) + ) -> Binding { + binding( + get: get, + set: { screen in { screen.actionSink.send(set($0)) } } + ) + } +} + #endif From f039b474084fbf76c85c98f2cc9ede36e9a18b1e Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 30 May 2023 09:48:41 -0500 Subject: [PATCH 5/9] add sugar for passing Action as View callback --- .../Sources/Authentication/LoginScreen.swift | 4 ++-- WorkflowSwiftUI/Sources/ObservableValue.swift | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Samples/TicTacToe/Sources/Authentication/LoginScreen.swift b/Samples/TicTacToe/Sources/Authentication/LoginScreen.swift index 04b3ac121..1e5efab5f 100644 --- a/Samples/TicTacToe/Sources/Authentication/LoginScreen.swift +++ b/Samples/TicTacToe/Sources/Authentication/LoginScreen.swift @@ -44,10 +44,10 @@ struct LoginScreen: SwiftUIScreen, Equatable { get: \.password, set: Action.passwordUpdated ), - onCommit: { model.value.actionSink.send(.login) } + onCommit: model.action(.login) ) - Button("Login", action: { model.value.actionSink.send(.login) }) + Button("Login", action: model.action(.login)) } .frame(maxWidth: 400) } diff --git a/WorkflowSwiftUI/Sources/ObservableValue.swift b/WorkflowSwiftUI/Sources/ObservableValue.swift index 03c80e2ee..e38a4c396 100644 --- a/WorkflowSwiftUI/Sources/ObservableValue.swift +++ b/WorkflowSwiftUI/Sources/ObservableValue.swift @@ -104,3 +104,14 @@ public final class ObservableValue: ObservableObject { return subject.removeDuplicates(by: isDuplicate).eraseToAnyPublisher() } } + +#if canImport(UIKit) + +public extension ObservableValue where Value: SwiftUIScreen { + func action(_ action: Value.Action) -> () -> Void { + // TODO: Should this closure capture the current `actionSink` instead? + { [weak self] in self?.value.actionSink.send(action) } + } +} + +#endif From 53fc65b53fc9d77a73bc532ac7dfb9ae6e41cd9a Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 23 Jun 2023 16:23:50 -0500 Subject: [PATCH 6/9] rewrite TodoEditScreen as SwiftUIScreen --- .../AppHost/Sources/AppDelegate.swift | 2 +- .../Sources/Todo/Edit/TodoEditScreen.swift | 67 +++++-------------- .../Sources/Todo/Edit/TodoEditWorkflow.swift | 7 +- .../Tests/RootWorkflowTests.swift | 2 +- .../Tests/TodoWorkflowTests.swift | 4 +- .../Tutorial5Complete/Tutorial5.podspec | 1 + Samples/Tutorial/Podfile | 1 + 7 files changed, 25 insertions(+), 59 deletions(-) diff --git a/Samples/Tutorial/AppHost/Sources/AppDelegate.swift b/Samples/Tutorial/AppHost/Sources/AppDelegate.swift index 4ae2a577c..77c7437b1 100644 --- a/Samples/Tutorial/AppHost/Sources/AppDelegate.swift +++ b/Samples/Tutorial/AppHost/Sources/AppDelegate.swift @@ -14,7 +14,7 @@ * limitations under the License. */ -import TutorialBase +import Tutorial5 import UIKit @UIApplicationMain diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/Edit/TodoEditScreen.swift b/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/Edit/TodoEditScreen.swift index de5745f2f..47405dbbb 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/Edit/TodoEditScreen.swift +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/Edit/TodoEditScreen.swift @@ -14,60 +14,29 @@ * limitations under the License. */ -import TutorialViews -import Workflow -import WorkflowUI +import SwiftUI +import WorkflowSwiftUI -struct TodoEditScreen: Screen { +struct TodoEditScreen: SwiftUIScreen, Equatable { // The title of this todo item. var title: String // The contents, or "note" of the todo. var note: String - // Callback for when the title or note changes - var onTitleChanged: (String) -> Void - var onNoteChanged: (String) -> Void - - func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription { - return TodoEditViewController.description(for: self, environment: environment) - } -} - -final class TodoEditViewController: ScreenViewController { - private var todoEditView: TodoEditView! - - required init(screen: TodoEditScreen, environment: ViewEnvironment) { - super.init(screen: screen, environment: environment) - } - - override func viewDidLoad() { - super.viewDidLoad() - - todoEditView = TodoEditView(frame: view.bounds) - view.addSubview(todoEditView) - - updateView(with: screen) - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - - todoEditView.frame = view.bounds.inset(by: view.safeAreaInsets) - } - - override func screenDidChange(from previousScreen: TodoEditScreen, previousEnvironment: ViewEnvironment) { - super.screenDidChange(from: previousScreen, previousEnvironment: previousEnvironment) - - guard isViewLoaded else { return } - - updateView(with: screen) - } - - private func updateView(with screen: TodoEditScreen) { - // Update the view with the data from the screen. - todoEditView.title = screen.title - todoEditView.note = screen.note - todoEditView.onTitleChanged = screen.onTitleChanged - todoEditView.onNoteChanged = screen.onNoteChanged + var actionSink: ScreenActionSink + + static func makeView(model: ObservableValue) -> some View { + VStack { + TextField("Title", text: model.binding( + get: \.title, + set: Action.titleChanged + )) + .font(.title) + + TextEditor(text: model.binding( + get: \.note, + set: Action.noteChanged + )) + }.padding() } } diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/Edit/TodoEditWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/Edit/TodoEditWorkflow.swift index cd2c607b2..ef006e3ab 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/Edit/TodoEditWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/Edit/TodoEditWorkflow.swift @@ -116,12 +116,7 @@ extension TodoEditWorkflow { let todoEditScreen = TodoEditScreen( title: state.todo.title, note: state.todo.note, - onTitleChanged: { title in - sink.send(.titleChanged(title)) - }, - onNoteChanged: { note in - sink.send(.noteChanged(note)) - } + actionSink: .init(sink) ) let backStackItem = BackStackScreen.Item( diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/RootWorkflowTests.swift b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/RootWorkflowTests.swift index 66151845b..821ada015 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/RootWorkflowTests.swift +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/RootWorkflowTests.swift @@ -151,7 +151,7 @@ class RootWorkflowTests: XCTestCase { } // Update the title. - editScreen.onTitleChanged("New Title") + editScreen.actionSink.send(.titleChanged("New Title")) } // Save the selected todo. diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoWorkflowTests.swift b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoWorkflowTests.swift index 32ddd55f1..3f251f37f 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoWorkflowTests.swift +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoWorkflowTests.swift @@ -16,6 +16,7 @@ import BackStackContainer import Workflow +import WorkflowSwiftUI import WorkflowTesting import WorkflowUI import XCTest @@ -78,8 +79,7 @@ class TodoWorkflowTests: XCTestCase { screen: TodoEditScreen( title: "Title", note: "Note", - onTitleChanged: { _ in }, - onNoteChanged: { _ in } + actionSink: .noop() ).asAnyScreen() ), // Simulate it emitting an output of `.save` to update the state. diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tutorial5.podspec b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tutorial5.podspec index b7b7a42cb..dd2a892e0 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tutorial5.podspec +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tutorial5.podspec @@ -19,6 +19,7 @@ Pod::Spec.new do |s| s.dependency 'TutorialViews' s.dependency 'Workflow' s.dependency 'WorkflowUI' + s.dependency 'WorkflowSwiftUI' s.dependency 'BackStackContainer' s.dependency 'WorkflowReactiveSwift' diff --git a/Samples/Tutorial/Podfile b/Samples/Tutorial/Podfile index 9409132e9..d2b5de19c 100644 --- a/Samples/Tutorial/Podfile +++ b/Samples/Tutorial/Podfile @@ -6,6 +6,7 @@ platform :ios, WORKFLOW_IOS_DEPLOYMENT_TARGET target 'Tutorial' do pod 'Workflow', path: '../../Workflow.podspec', :testspecs => ['Tests'] pod 'WorkflowUI', path: '../../WorkflowUI.podspec', :testspecs => ['Tests'] + pod 'WorkflowSwiftUI', path: '../../WorkflowSwiftUI.podspec' pod 'ViewEnvironment', path: '../../ViewEnvironment.podspec' pod 'ViewEnvironmentUI', path: '../../ViewEnvironmentUI.podspec', :testspecs => ['Tests'] pod 'WorkflowReactiveSwift', path: '../../WorkflowReactiveSwift.podspec', :testspecs => ['Tests'] From 0d3125c11ed7538f4109c7f2fe3d785099726f1b Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 29 Jun 2023 14:07:00 -0500 Subject: [PATCH 7/9] move ScreenActionSink to Workflow --- Samples/TicTacToe/Sources/Authentication/LoginScreen.swift | 1 + .../Tutorial5Complete/Sources/Todo/Edit/TodoEditScreen.swift | 1 + {WorkflowSwiftUI => Workflow}/Sources/ScreenActionSink.swift | 2 -- 3 files changed, 2 insertions(+), 2 deletions(-) rename {WorkflowSwiftUI => Workflow}/Sources/ScreenActionSink.swift (98%) diff --git a/Samples/TicTacToe/Sources/Authentication/LoginScreen.swift b/Samples/TicTacToe/Sources/Authentication/LoginScreen.swift index 1e5efab5f..2219a212a 100644 --- a/Samples/TicTacToe/Sources/Authentication/LoginScreen.swift +++ b/Samples/TicTacToe/Sources/Authentication/LoginScreen.swift @@ -15,6 +15,7 @@ */ import SwiftUI +import Workflow import WorkflowSwiftUI struct LoginScreen: SwiftUIScreen, Equatable { diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/Edit/TodoEditScreen.swift b/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/Edit/TodoEditScreen.swift index 47405dbbb..10f443f70 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/Edit/TodoEditScreen.swift +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/Edit/TodoEditScreen.swift @@ -15,6 +15,7 @@ */ import SwiftUI +import Workflow import WorkflowSwiftUI struct TodoEditScreen: SwiftUIScreen, Equatable { diff --git a/WorkflowSwiftUI/Sources/ScreenActionSink.swift b/Workflow/Sources/ScreenActionSink.swift similarity index 98% rename from WorkflowSwiftUI/Sources/ScreenActionSink.swift rename to Workflow/Sources/ScreenActionSink.swift index 76f21a6f9..e8bbd0ba7 100644 --- a/WorkflowSwiftUI/Sources/ScreenActionSink.swift +++ b/Workflow/Sources/ScreenActionSink.swift @@ -14,8 +14,6 @@ * limitations under the License. */ -import Workflow - /// A sink intended specifically to fulfill the `actionSink` requirement of ``SwiftUIScreen``. In order that the /// `SwiftUIScreen` can automatically be `Equatable`, this sink is `Equatable` and always compares equal. /// From db9a215c6d136a9b8b3e973d52774784987acf05 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 29 Jun 2023 14:32:13 -0500 Subject: [PATCH 8/9] fix comment formatting --- Workflow/Sources/ScreenActionSink.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Workflow/Sources/ScreenActionSink.swift b/Workflow/Sources/ScreenActionSink.swift index e8bbd0ba7..79b22706d 100644 --- a/Workflow/Sources/ScreenActionSink.swift +++ b/Workflow/Sources/ScreenActionSink.swift @@ -17,8 +17,8 @@ /// A sink intended specifically to fulfill the `actionSink` requirement of ``SwiftUIScreen``. In order that the /// `SwiftUIScreen` can automatically be `Equatable`, this sink is `Equatable` and always compares equal. /// -// TODO: Should this really _always_ compare equal? Can we pass in some identity when initializing? Maybe some identifier from the -/// `RenderContext`, or even just the source location where the sink was initialized? +/// *TODO:* Should this really _always_ compare equal? Can we pass in some identity when initializing? Maybe some identifier from +/// the `RenderContext`, or even just the source location where the sink was initialized? public struct ScreenActionSink: Equatable { private let sink: Sink @@ -40,3 +40,7 @@ public struct ScreenActionSink: Equatable { true } } + +public extension RenderContext { + var actionSink: ScreenActionSink {} +} From b84d318d634ee9d610b266ff654d382521e81fdc Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 29 Jun 2023 14:39:59 -0500 Subject: [PATCH 9/9] add RenderContext.makeSink helper --- Samples/TicTacToe/Sources/Authentication/LoginWorkflow.swift | 2 +- Workflow/Sources/ScreenActionSink.swift | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Samples/TicTacToe/Sources/Authentication/LoginWorkflow.swift b/Samples/TicTacToe/Sources/Authentication/LoginWorkflow.swift index f3149f37a..70694df9d 100644 --- a/Samples/TicTacToe/Sources/Authentication/LoginWorkflow.swift +++ b/Samples/TicTacToe/Sources/Authentication/LoginWorkflow.swift @@ -73,7 +73,7 @@ extension LoginWorkflow { func render(state: LoginWorkflow.State, context: RenderContext) -> Rendering { LoginScreen( - actionSink: .init(context.makeSink(of: Action.self)), + actionSink: context.makeSink(), title: "Welcome! Please log in to play TicTacToe!", email: state.email, password: state.password diff --git a/Workflow/Sources/ScreenActionSink.swift b/Workflow/Sources/ScreenActionSink.swift index 79b22706d..202ea6f46 100644 --- a/Workflow/Sources/ScreenActionSink.swift +++ b/Workflow/Sources/ScreenActionSink.swift @@ -42,5 +42,7 @@ public struct ScreenActionSink: Equatable { } public extension RenderContext { - var actionSink: ScreenActionSink {} + func makeSink() -> ScreenActionSink where Action.WorkflowType == WorkflowType { + ScreenActionSink(makeSink(of: Action.self)) + } }