Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] @Writable properties on Screens for SwiftUI Bindings #228

Closed
wants to merge 9 commits into from
14 changes: 4 additions & 10 deletions Samples/TicTacToe/Sources/Authentication/LoginScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,30 +21,24 @@ import WorkflowSwiftUI
struct LoginScreen: SwiftUIScreen, Equatable {
var actionSink: ScreenActionSink<LoginWorkflow.Action>
var title: String
var email: String
var password: String
@Writable var email: String
@Writable var password: String

static func makeView(model: ObservableValue<LoginScreen>) -> some View {
VStack(spacing: 16) {
Text(model.title)

TextField(
"[email protected]",
text: model.binding(
get: \.email,
set: Action.emailUpdated
)
text: model.$email
)
.autocapitalization(.none)
.autocorrectionDisabled()
.textContentType(.emailAddress)

SecureField(
"password",
text: model.binding(
get: \.password,
set: Action.passwordUpdated
),
text: model.$password,
onCommit: model.action(.login)
)

Expand Down
14 changes: 3 additions & 11 deletions Samples/TicTacToe/Sources/Authentication/LoginWorkflow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import ReactiveSwift
import Workflow
import WorkflowSwiftUI
import WorkflowUI

// MARK: Input and Output
Expand Down Expand Up @@ -45,23 +46,13 @@ extension LoginWorkflow {
enum Action: WorkflowAction {
typealias WorkflowType = LoginWorkflow

case emailUpdated(String)
case passwordUpdated(String)
case login

func apply(toState state: inout LoginWorkflow.State) -> LoginWorkflow.Output? {
switch self {
case .emailUpdated(let email):
state.email = email

case .passwordUpdated(let password):
state.password = password

case .login:
return .login(email: state.email, password: state.password)
}

return nil
}
}
}
Expand All @@ -72,7 +63,8 @@ extension LoginWorkflow {
typealias Rendering = LoginScreen

func render(state: LoginWorkflow.State, context: RenderContext<LoginWorkflow>) -> Rendering {
LoginScreen(
let state = WritableState(state: state, context: context)
return LoginScreen(
actionSink: context.makeSink(),
title: "Welcome! Please log in to play TicTacToe!",
email: state.email,
Expand Down
10 changes: 4 additions & 6 deletions Samples/TicTacToe/Tests/LoginWorkflowTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,14 @@ class LoginWorkflowTests: XCTestCase {
// MARK: Action Tests

func test_action_emailUpdate() {
LoginWorkflow
.Action
SetterAction<LoginWorkflow, String>
.tester(
withState: LoginWorkflow.State(
email: "[email protected]",
password: "password"
)
)
.send(action: .emailUpdated("[email protected]"))
.send(action: .set(\.email, to: "[email protected]"))
.assertNoOutput()
.verifyState { state in
XCTAssertEqual(state.email, "[email protected]")
Expand All @@ -41,15 +40,14 @@ class LoginWorkflowTests: XCTestCase {
}

func test_action_passwordUpdate() {
LoginWorkflow
.Action
SetterAction<LoginWorkflow, String>
.tester(
withState: LoginWorkflow.State(
email: "[email protected]",
password: "password"
)
)
.send(action: .passwordUpdated("drowssap"))
.send(action: .set(\.password, to: "drowssap"))
.assertNoOutput()
.verifyState { state in
XCTAssertEqual(state.email, "[email protected]")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,16 @@ import WorkflowSwiftUI

struct TodoEditScreen: SwiftUIScreen, Equatable {
// The title of this todo item.
var title: String
@Writable var title: String
// The contents, or "note" of the todo.
var note: String

var actionSink: ScreenActionSink<TodoEditWorkflow.Action>
@Writable var note: String

static func makeView(model: ObservableValue<TodoEditScreen>) -> some View {
VStack {
TextField("Title", text: model.binding(
get: \.title,
set: Action.titleChanged
))
TextField("Title", text: model.$title)
.font(.title)

TextEditor(text: model.binding(
get: \.note,
set: Action.noteChanged
))
TextEditor(text: model.$note)
}.padding()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import BackStackContainer
import ReactiveSwift
import Workflow
import WorkflowReactiveSwift
import WorkflowSwiftUI
import WorkflowUI

// MARK: Input and Output
Expand Down Expand Up @@ -61,19 +62,11 @@ extension TodoEditWorkflow {
enum Action: WorkflowAction {
typealias WorkflowType = TodoEditWorkflow

case titleChanged(String)
case noteChanged(String)
case discardChanges
case saveChanges

func apply(toState state: inout TodoEditWorkflow.State) -> TodoEditWorkflow.Output? {
switch self {
case .titleChanged(let title):
state.todo.title = title

case .noteChanged(let note):
state.todo.note = note

case .discardChanges:
// Return the .discard output when the discard action is received.
return .discard
Expand All @@ -82,8 +75,6 @@ extension TodoEditWorkflow {
// Return the .save output with the current todo state when the save action is received.
return .save(state.todo)
}

return nil
}
}
}
Expand Down Expand Up @@ -113,10 +104,11 @@ extension TodoEditWorkflow {
// The sink is used to send actions back to this workflow.
let sink = context.makeSink(of: Action.self)

let state = WritableState(state: state, context: context)

let todoEditScreen = TodoEditScreen(
title: state.todo.title,
note: state.todo.note,
actionSink: .init(sink)
title: state[\.todo.title],
note: state[\.todo.note]
)

let backStackItem = BackStackScreen.Item(
Expand Down
4 changes: 4 additions & 0 deletions Workflow/Sources/ScreenActionSink.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ public struct ScreenActionSink<Value>: Equatable {
ScreenActionSink<T>(Sink { _ in })
}

public static func never() -> ScreenActionSink<Never> {
ScreenActionSink<Never>(Sink { _ in })
}

// MARK: Equatable

public static func == (lhs: ScreenActionSink<Value>, rhs: ScreenActionSink<Value>) -> Bool {
Expand Down
31 changes: 31 additions & 0 deletions Workflow/Sources/SetterAction.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* 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.
*/

public struct SetterAction<WorkflowType: Workflow, Value>: WorkflowAction {
public typealias KeyPath = WritableKeyPath<WorkflowType.State, Value>

private let keyPath: KeyPath
private let value: Value

public static func set(_ keyPath: KeyPath, to value: Value) -> Self {
Self(keyPath: keyPath, value: value)
}

public func apply(toState state: inout WorkflowType.State) -> WorkflowType.Output? {
state[keyPath: keyPath] = value
return nil
}
}
1 change: 1 addition & 0 deletions WorkflowSwiftUI/Sources/ObservableValue+Binding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
#if canImport(UIKit)

import SwiftUI
import Workflow

public extension ObservableValue {
func binding<T>(
Expand Down
4 changes: 3 additions & 1 deletion WorkflowSwiftUI/Sources/SwiftUIScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import WorkflowUI

public protocol SwiftUIScreen: Screen {
associatedtype Content: View
associatedtype Action
associatedtype Action = Never

@ViewBuilder
static func makeView(model: ObservableValue<Self>) -> Content
Expand All @@ -34,6 +34,8 @@ public protocol SwiftUIScreen: Screen {

public extension SwiftUIScreen {
static var isDuplicate: ((Self, Self) -> Bool)? { return nil }

var actionSink: ScreenActionSink<Never> { .never() }
}

public extension SwiftUIScreen where Self: Equatable {
Expand Down
51 changes: 51 additions & 0 deletions WorkflowSwiftUI/Sources/Writable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* 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

@propertyWrapper public struct Writable<Value> {
public let wrappedValue: Value
public let set: (Value) -> Void

public init(value: Value, set: @escaping (Value) -> Void) {
self.wrappedValue = value
self.set = set
}

public var projectedValue: Binding<Value> {
Binding(
get: { wrappedValue },
set: set
)
}
}

extension Writable: Equatable where Value: Equatable {
public static func == (lhs: Writable<Value>, rhs: Writable<Value>) -> Bool {
// TODO: Don't assume setters are equivalent. Use some kind of binding identity?
lhs.wrappedValue == rhs.wrappedValue
}
}

extension Writable: ExpressibleByUnicodeScalarLiteral where Value == String {}

extension Writable: ExpressibleByExtendedGraphemeClusterLiteral where Value == String {}

extension Writable: ExpressibleByStringLiteral where Value == String {
public init(stringLiteral value: String) {
self.init(value: value, set: { _ in })
}
}
44 changes: 44 additions & 0 deletions WorkflowSwiftUI/Sources/WritableState.swift
Original file line number Diff line number Diff line change
@@ -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

@dynamicMemberLookup
public struct WritableState<WorkflowType: Workflow> {
private let state: WorkflowType.State
private let context: RenderContext<WorkflowType>

public init(state: WorkflowType.State, context: RenderContext<WorkflowType>) {
self.state = state
self.context = context
}

public subscript<T>(dynamicMember keyPath: KeyPath<WorkflowType.State, T>) -> T {
state[keyPath: keyPath]
}

public subscript<T>(dynamicMember keyPath: WritableKeyPath<WorkflowType.State, T>) -> Writable<T> {
let sink = context.makeSink(of: SetterAction<WorkflowType, T>.self)
return Writable(
value: state[keyPath: keyPath],
set: { value in sink.send(.set(keyPath, to: value)) }
)
}

public subscript<T>(_ keyPath: WritableKeyPath<WorkflowType.State, T>) -> Writable<T> {
self[dynamicMember: keyPath]
}
}