Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import ComposableArchitecture
import SwiftUI

private let readMe = """
This file demonstrates how to handle two-way bindings in the Composable Architecture using form \
actions.
This file demonstrates how to handle two-way bindings in the Composable Architecture using \
binding actions.

Form actions allow you to eliminate the boilerplate caused by needing to have a unique action \
for every UI control. Instead, all UI bindings can be consolidated into a single `form` action \
that holds onto a `FormAction` value.
Binding actions allow you to eliminate the boilerplate caused by needing to have a unique action \
for every UI control. Instead, all UI bindings can be consolidated into a single `binding` \
action that holds onto a `BindingAction` value.

It is instructive to compare this case study to the "Binding Basics" case study.
"""
Expand All @@ -21,7 +21,7 @@ struct BindingFormState: Equatable {
}

enum BindingFormAction: Equatable {
case form(FormAction<BindingFormState>)
case binding(BindingAction<BindingFormState>)
case resetButtonTapped
}

Expand All @@ -32,19 +32,19 @@ let bindingFormReducer = Reducer<
> {
state, action, _ in
switch action {
case .form(\.stepCount):
case .binding(\.stepCount):
state.sliderValue = .minimum(state.sliderValue, Double(state.stepCount))
return .none

case .form:
case .binding:
return .none

case .resetButtonTapped:
state = .init()
return .none
}
}
.form(action: /BindingFormAction.form)
.binding(action: /BindingFormAction.binding)

struct BindingFormView: View {
let store: Store<BindingFormState, BindingFormAction>
Expand All @@ -56,20 +56,20 @@ struct BindingFormView: View {
HStack {
TextField(
"Type here",
text: viewStore.binding(keyPath: \.text, send: BindingFormAction.form)
text: viewStore.binding(keyPath: \.text, send: BindingFormAction.binding)
)
.disableAutocorrection(true)
.foregroundColor(viewStore.toggleIsOn ? .gray : .primary)
Text(alternate(viewStore.text))
}
.disabled(viewStore.toggleIsOn)

Toggle(isOn: viewStore.binding(keyPath: \.toggleIsOn, send: BindingFormAction.form)) {
Toggle(isOn: viewStore.binding(keyPath: \.toggleIsOn, send: BindingFormAction.binding)) {
Text("Disable other controls")
}

Stepper(
value: viewStore.binding(keyPath: \.stepCount, send: BindingFormAction.form),
value: viewStore.binding(keyPath: \.stepCount, send: BindingFormAction.binding),
in: 0...100
) {
Text("Max slider value: \(viewStore.stepCount)")
Expand All @@ -81,7 +81,7 @@ struct BindingFormView: View {
Text("Slider value: \(Int(viewStore.sliderValue))")
.font(Font.body.monospacedDigit())
Slider(
value: viewStore.binding(keyPath: \.sliderValue, send: BindingFormAction.form),
value: viewStore.binding(keyPath: \.sliderValue, send: BindingFormAction.binding),
in: 0...Double(viewStore.stepCount)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,17 @@ class BindingFormTests: XCTestCase {
)

store.assert(
.send(.form(.set(\.sliderValue, 2))) {
.send(.binding(.set(\.sliderValue, 2))) {
$0.sliderValue = 2
},
.send(.form(.set(\.stepCount, 1))) {
.send(.binding(.set(\.stepCount, 1))) {
$0.sliderValue = 1
$0.stepCount = 1
},
.send(.form(.set(\.text, "Blob"))) {
.send(.binding(.set(\.text, "Blob"))) {
$0.text = "Blob"
},
.send(.form(.set(\.toggleIsOn, true))) {
.send(.binding(.set(\.toggleIsOn, true))) {
$0.toggleIsOn = true
},
.send(.resetButtonTapped) {
Expand Down
12 changes: 12 additions & 0 deletions Sources/ComposableArchitecture/Internal/Deprecations.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
import Combine
import SwiftUI

// NB: Deprecated after 0.13.0:

@available(*, deprecated, renamed: "BindingAction")
public typealias FormAction = BindingAction

extension Reducer {
@available(*, deprecated, renamed: "binding")
public func form(action toFormAction: CasePath<Action, BindingAction<State>>) -> Self {
self.binding(action: toFormAction)
}
}

// NB: Deprecated after 0.10.0:

@available(iOS 13, *)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,49 +64,49 @@ import SwiftUI
/// }
///
/// This is a _lot_ of boilerplate for something that should be simple. Luckily, we can dramatically
/// eliminate this boilerplate using `FormAction`. First, we can collapse all of these
/// field-mutating actions into a single case that holds a `FormAction` generic over the reducer's
/// root `SettingsState`:
/// eliminate this boilerplate using `BindingAction`. First, we can collapse all of these
/// field-mutating actions into a single case that holds a `BindingAction` generic over the
/// reducer's root `SettingsState`:
///
/// enum SettingsAction {
/// case form(FormAction<SettingsState>)
/// case binding(BindingAction<SettingsState>)
/// }
///
/// And then, we can simplify the settings reducer by allowing the `form` method to handle these
/// And then, we can simplify the settings reducer by allowing the `binding` method to handle these
/// field mutations for us:
///
/// let settingsReducer = Reducer<
/// SettingsState, SettingsAction, SettingsEnvironment
/// > {
/// switch action {
/// case .form:
/// case .binding:
/// return .none
/// }
/// }
/// .form(action: /SettingsAction.form)
/// .binding(action: /SettingsAction.binding)
///
/// Form actions are constructed and sent to the store by providing a writable key path from root
/// Binding actions are constructed and sent to the store by providing a writable key path from root
/// state to the field being mutated. There is even a view store helper that simplifies this work.
/// You can derive a binding by specifying the key path and form action case:
/// You can derive a binding by specifying the key path and binding action case:
///
/// TextField(
/// "Display name",
/// text: viewStore.binding(keyPath: \.displayName, send: SettingsAction.form)
/// text: viewStore.binding(keyPath: \.displayName, send: SettingsAction.binding)
/// )
///
/// Should you need to layer additional functionality over your form, your reducer can pattern match
/// the form action for a given key path:
/// Should you need to layer additional functionality over these bindings, your reducer can pattern
/// match the action for a given key path:
///
/// case .form(\.displayName):
/// case .binding(\.displayName):
/// // Validate display name
///
/// case .form(\.enableNotifications):
/// case .binding(\.enableNotifications):
/// // Return an authorization request effect
///
/// Form actions can also be tested in much the same way regular actions are tested. Rather than
/// Binding actions can also be tested in much the same way regular actions are tested. Rather than
/// send a specific action describing how a binding changed, such as `displayNameChanged("Blob")`,
/// you will send a `.form` action that describes which key path is being set to what value, such
/// as `.form(.set(\.displayName, "Blob"))`:
/// you will send a `.binding` action that describes which key path is being set to what value, such
/// as `.binding(.set(\.displayName, "Blob"))`:
///
/// let store = TestStore(
/// initialState: SettingsState(),
Expand All @@ -115,15 +115,15 @@ import SwiftUI
/// )
///
/// store.assert(
/// .send(.form(.set(\.displayName, "Blob"))) {
/// .send(.binding(.set(\.displayName, "Blob"))) {
/// $0.displayName = "Blob"
/// },
/// .send(.form(.set(\.protectMyPosts, true))) {
/// .send(.binding(.set(\.protectMyPosts, true))) {
/// $0.protectMyPosts = true
/// )
/// )
///
public struct FormAction<Root>: Equatable {
public struct BindingAction<Root>: Equatable {
public let keyPath: PartialKeyPath<Root>

fileprivate let set: (inout Root) -> Void
Expand All @@ -150,12 +150,14 @@ public struct FormAction<Root>: Equatable {
)
}

/// Transforms a form action over some root state to some other type of root state given a key
/// Transforms a binding action over some root state to some other type of root state given a key
/// path.
///
/// - Parameter keyPath: A key path from a new type of root state to the original root state.
/// - Returns: A form action over a new type of root state.
public func pullback<NewRoot>(_ keyPath: WritableKeyPath<NewRoot, Root>) -> FormAction<NewRoot> {
/// - Returns: A binding action over a new type of root state.
public func pullback<NewRoot>(
_ keyPath: WritableKeyPath<NewRoot, Root>
) -> BindingAction<NewRoot> {
.init(
keyPath: (keyPath as AnyKeyPath).appending(path: self.keyPath) as! PartialKeyPath<NewRoot>,
set: { self.set(&$0[keyPath: keyPath]) },
Expand All @@ -170,38 +172,39 @@ public struct FormAction<Root>: Equatable {

public static func ~= <Value>(
keyPath: WritableKeyPath<Root, Value>,
formAction: FormAction<Root>
bindingAction: Self
) -> Bool {
keyPath == formAction.keyPath
keyPath == bindingAction.keyPath
}
}

extension Reducer {
/// Returns a reducer that applies `FormAction` mutations to `State` before running this reducer's
/// logic.
/// Returns a reducer that applies `BindingAction` mutations to `State` before running this
/// reducer's logic.
///
/// For example, a settings screen may gather its form actions into a single `FormAction` case:
/// For example, a settings screen may gather its binding actions into a single `BindingAction`
/// case:
///
/// enum SettingsAction {
/// ...
/// case form(FormAction<SettingsState>)
/// case binding(BindingAction<SettingsState>)
/// }
///
/// The reducer can then be enhanced to automatically handle these mutations for you by tacking on
/// the `form` method:
/// the `binding` method:
///
/// let settingsReducer = Reducer<SettingsState, SettingsAction, SettingsEnvironment {
/// ...
/// }
/// .form(action: /SettingsAction.form)
/// .binding(action: /SettingsAction.binding)
///
/// - Parameter toFormAction: A case path from this reducer's `Action` type to a `FormAction` over
/// this reducer's `State`.
/// - Returns: A reducer that applies `FormAction` mutations to `State` before running this
/// - Parameter toBindingAction: A case path from this reducer's `Action` type to a
/// `BindingAction` over this reducer's `State`.
/// - Returns: A reducer that applies `BindingAction` mutations to `State` before running this
/// reducer's logic.
public func form(action toFormAction: CasePath<Action, FormAction<State>>) -> Self {
public func binding(action toBindingAction: CasePath<Action, BindingAction<State>>) -> Self {
Self { state, action, environment in
toFormAction.extract(from: action)?.set(&state)
toBindingAction.extract(from: action)?.set(&state)
return .none
}
.combined(with: self)
Expand All @@ -210,25 +213,25 @@ extension Reducer {

extension ViewStore {
/// Derives a binding from the store that mutates state at the given writable key path by wrapping
/// a `FormAction` with the store's action type.
/// a `BindingAction` with the store's action type.
///
/// For example, a text field binding can be created like this:
///
/// struct State { var text = "" }
/// enum Action { case form(FormAction<State>) }
/// enum Action { case binding(BindingAction<State>) }
///
/// TextField(
/// "Enter text",
/// text: viewStore.binding(keyPath: \.text, Action.form)
/// text: viewStore.binding(keyPath: \.text, Action.binding)
/// )
///
/// - Parameters:
/// - keyPath: A writable key path from the view store's state to a mutable field
/// - action: A function that wraps a form action in the view store's action type.
/// - action: A function that wraps a binding action in the view store's action type.
/// - Returns: A binding.
public func binding<LocalState>(
keyPath: WritableKeyPath<State, LocalState>,
send action: @escaping (FormAction<State>) -> Action
send action: @escaping (BindingAction<State>) -> Action
) -> Binding<LocalState>
where LocalState: Equatable {
self.binding(
Expand Down