preface

In the last article about TCA, we saw the operation mode of a Feature in TCA through an overview, and tried to implement a minimal Feature and its test. In this article, we will take a closer look at the handling of Binding in TCA and the use of Environment to decouple dependencies from the Reducer.

If you want to follow along, you can either use the final state of the exercise from the previous article or get the starting code from here.

The binding

The difference between binding and normal state

In the last article, we solved the problem of state-driven UI by implementing the “click button” -> “Send Action” -> “Update State” -> “Trigger UI update” process. However, in addition to simply “updating the UI by State”, SwiftUI also supports @binding to the control in the opposite direction, allowing the UI to change the State without going through our code. In SwiftUI, we can find this pattern on almost any control that represents both state and accepts input, such as TextField that accepts the String Binding

, Toggle accepts Bool Binding Binding

etc.

When we give a state to another view via Binding, this view has the ability to change and directly change the state. In fact, this violates the stipulation in TCA that the state can only be changed in the Reducer. For bindings, TCA adds a way for the View Store to convert state to a “special binding relationship.” Let’s try changing the Text that displays numbers in the Counter example to a TextField that accepts direct input.

Implement a single binding in TCA

First, add the corresponding ability to accept a string value to set count to CounterAction and counterReducer:

enum CounterAction {
  case increment
  case decrement
+ case setCount(String)
  case reset
}

let counterReducer = Reducer<Counter, CounterAction, CounterEnvironment> {
  state, action, _ in
  switch action {
  // ...
+ case .setCount(let text):
+ if let value = Int(text) {
+ state.count = value
+}
+ return .none
  // ...
}.debug()
Copy the code

Next, replace the original Text in the body with the following TextField:

var body: some View {
  WithViewStore(store) { viewStore in
    // ...
- Text("\(viewStore.count)")
+ TextField(
+ String(viewStore.count),
+ text: viewStore.binding(
+ get: { String($0.count) },
+ send: { CounterAction.setCount($0) }
+)
+)
+ .frame(width: 40)
+ .multilineTextAlignment(.center)
      .foregroundColor(colorOfCount(viewStore.count))
  }
}
Copy the code

The viewStore.binding method takes get and send parameters, which are generic functions related to the current View Store and the binding View type. After specialization (converting generics to concrete types in this context) :

  • get: (Counter) -> StringResponsible for the object View (hereTextField) provide data.
  • send: (String) -> CounterActionIs responsible for converting the newly sent value from the View into an action that the View Store can understand and sending it to triggercounterReducer.

After receiving the setCount event from binding, we returned to the standard TCA loop using reducer to update the state and drive the UI.

In traditional SwiftUI, when we get a Binding for a state with the $sign, we are actually calling its projectedValue. Viewstore. binding internally wraps the View Store itself into an ObservedObject, and then sets the input GET and send to binding using a custom projectedValue. Internally, it maintains state through internal storage and hides this detail; Externally, it sends state changes through action. Capturing the change, updating it accordingly, and finally setting the new state to binding again via GET is something the developer needs to ensure.

Simplify the code

Do a little refactoring: The binding get is now a String generated from $0.count, and the state. Count set needs to be converted from String to Int. We extracted the part related to Mode and View expression and put it into an extension of Counter to be used as View Model:

extension Counter {
  var countString: String {
    get { String(count) }
    set { count = Int(newValue) ?? count }
  }
}
Copy the code

Replace the reducer with countString:

let counterReducer = Reducer<Counter, CounterAction, CounterEnvironment> {
  state, action, _ in
  switch action {
  // ...
  case .setCount(let text):
- if let value = Int(text) {
- state.count = value
-}
+ state.countString = text
    return .none
  // ...
}.debug()
Copy the code

In Swift 5.2, KeyPath can already be used as a function, so we can think of the type \Counter. CountString as (Counter) -> String. In Swift 5.3, enum case can also be used as a function. It can be considered that counteraction. setCount has type (String) -> CounterAction. Both satisfy exactly two parameters of a binding, so you can further simplify the creation of the binding:

// ...
  TextField(
    String(viewStore.count),
    text: viewStore.binding(
- get: { String($0.count) },
+ get: \.countString,
- send: { CounterAction.setCount($0) }
+ send: CounterAction.setCount)) / /...Copy the code

Finally, don’t forget to add tests for.setcount!

If there are multiple binding values in a Feature, we need to add an action each time and send it in a binding. This is generic template code, with @BindableState and BindableAction designed in TCA to make multiple bindings easier to write. Specifically, there are three steps:

  1. forStateTo add variables that need to be bound to the UI@BindableState.
  2. willActionDeclared asBindableActionAnd then add a “special” casebinding(BindingAction<Counter>)
  3. Handle this in the Reducer.bindingAnd add.binding()The call.

It’s faster to write it in code:

// 1
struct MyState: Equatable {
+ @BindableState var foo: Bool = false
+ @BindableState var bar: String = ""} / / 2- enum MyAction {
+ enum MyAction: BindableAction {
+ case binding(BindingAction
      
       )
      
}

// 3
let myReducer = //...
  // ...
+ case .binding:
+ return .none
}
+ .binding()
Copy the code

After this we can bind the View with a $fetch Value equivalent to the standard SwiftUI:

struct MyView: View {
  let store: Store<MyState, MyAction>
  var body: some View {
    WithViewStore(store) { viewStore in
+ Toggle("Toggle!" , isOn: viewStore.binding(\.$foo))
+ TextField("Text Field!" , text: viewStore.binding(\.$bar))}}}Copy the code

This way, even if there are multiple binding values, we only need to use a.binding action. This code works because BindableAction requires a function signed BindingAction

-> Self and named binding:

public protocol BindableAction {
  static func binding(_ action: BindingAction<State>) -> Self
}
Copy the code

Again, taking advantage of Swift’s new feature that uses enum case as a function, the code can be very simple and elegant.

Environmental values

Number game

So let’s go back to the Counter example. Now that there is a way to enter numbers, why not make a game of guessing numbers?

Guess the number: the program randomly selects a number between 100 and 100. The user enters a number and the program determines whether the number is a random number. If not, “too big” or “too small” is returned as feedback, and the user is asked to try to guess the next number.

The easiest way to do this is to add an attribute to Counter that holds the random number:

struct Counter: Equatable {
  var count: Int = 0
+ let secret = Int.random(in: -100 ... 100).
}
Copy the code

Check the relationship between count and secret and return the answer:

extension Counter {
  enum CheckResult {
    case lower, equal, higher
  }
  
  var checkResult: CheckResult {
    if count < secret { return .lower }
    if count > secret { return .higher }
    return .equal
  }
}
Copy the code

With this model, we can display a Label representing the result in the view by using checkResult:

struct CounterView: View {
  let store: Store<Counter, CounterAction>
  var body: some View {
    WithViewStore(store) { viewStore in
      VStack {
+ checkLabel(with: viewStore.checkResult)
        HStack {
          Button("-") { viewStore.send(.decrement) }
          // ...
  }
  
  func checkLabel(with checkResult: Counter.CheckResult) -> some View {
    switch checkResult {
    case .lower:
      return Label("Lower", systemImage: "lessthan.circle")
        .foregroundColor(.red)
    case .higher:
      return Label("Higher", systemImage: "greaterthan.circle")
        .foregroundColor(.red)
    case .equal:
      return Label("Correct", systemImage: "checkmark.circle")
        .foregroundColor(.green)
    }
  }
}
Copy the code

In the end, we get a UI that looks like this:

External dependencies

The Reset button can Reset the guess to zero, but it doesn’t restart the game, which is a bit boring. Let’s try changing the Reset button to the New Game button.

In UI and CounterAction we have defined the.reset behavior to do some renaming:

enum CounterAction {
  // ...
- case reset
+ case playNext
}

struct CounterView: View {
  // ...
  var body: some View {
    // ...
- Button("Reset") { viewStore.send(.reset) }
+ Button("Next") { viewStore.send(.playNext) }}}Copy the code

Then handle this situation in the counterReducer,

struct Counter: Equatable {
  var count: Int = 0
- let secret = Int.random(in: -100 ... 100).
+ var secret = Int.random(in: -100 ... 100).
}

let counterReducer = Reducer<Counter, CounterAction, CounterEnvironment> {
  // ...
- case .reset:
+ case .playNext:
    state.count = 0
+ state.secret = Int.random(in: -100 ... 100).
    return .none
  // ...
}.debug()
Copy the code

Run the app and observe the output from Reducer Debug (). Everything is normal! Too good.

Running tests on Cmd + U at any time is a habit everyone should get into, and this is when we can find that the test compilation failed. The final task is to fix the original.reset test, which is also very simple:

func testReset() throws {
- store.send(.reset) { state in
+ store.send(.playNext) { state in
    state.count = 0
  }
}
Copy the code

However, the test results will most likely fail!

This is because.playNext now not only resets count, but also randomly generates new secret. The TestStore will compare the state at the end of the send closure with the real state from the Reducer operation and declare that the former has no proper secret set, so they are not equal and therefore the test fails.

We need a stable way to ensure success.

Use environment values to resolve dependencies

In TCA, reducer must be a pure function to ensure testability: That is, a combination of the same inputs (state, action, and environment) must give the same inputs (in this case state and effect, which we will touch on in a later article).

let counterReducer = // ... {

  state, action, _ in 
  // ...
  case .playNext:
    state.count = 0
    state.secret = Int.random(in: -100 . 100)
    return .none
  / /...
}.debug()
Copy the code

When working with.playnext, int. random apparently cannot guarantee that the same result will be given every time the reducer is called, which is why it became untestable. The concept of Environment in TCA is designed to correspond to such external dependencies. If there is dependent on external condition inside the reducer situation (such as Int here. The random, using random seed SystemRandomNumberGenerator) is selected automatically, we can reduce the injection through the Environment, Let the actual app and unit tests use different environments.

First, update the CounterEnvironment to add an attribute that holds the method for randomly generating ints.

struct CounterEnvironment {
+ var generateRandom: (ClosedRange<Int>) -> Int
}
Copy the code

The compiler now wants us to add generateRandom in place of CounterEnvironment(). We can create a CounterEnvironment directly with Int. Random at build time:

CounterView(
  store: Store(
    initialState: Counter(),
    reducer: counterReducer,
- environment: CounterEnvironment()
+ environment: CounterEnvironment(
+ generateRandom: { Int.random(in: $0) }
+)))Copy the code

A more common and concise approach is to define a set of environments for CounterEnvironment and pass them to the appropriate place:

struct CounterEnvironment {
  var generateRandom: (ClosedRange<Int>) -> Int
  
+ static let live = CounterEnvironment(
+ generateRandom: Int.random
+)
}

CounterView(
  store: Store(
    initialState: Counter(),
    reducer: counterReducer,
- environment: CounterEnvironment()
+ environment: .live))Copy the code

Now, in the Reducer, we can use the injected environment values to achieve the same result as the original:

let counterReducer = // ... {
- state, action, _ in
+ state, action, environment in
  // ...
  case .playNext:
    state.count = 0
- state.secret = Int.random(in: -100 ... 100).
+ state.secret = environment.generateRandom(-100 ... 100).
    return .none
  // ...
}.debug()
Copy the code

All set, back to the original purpose – to make sure the test passes smoothly. In the test target, create a.test environment in a similar way:

extension CounterEnvironment {
  static let test = CounterEnvironment(generateRandom: { _ in 5})}Copy the code

Now, when generating TestStore, use.test, then generate the appropriate Counter as the new state on the assertion, and the test should pass smoothly:

store = TestStore( initialState: Counter(count: Int.random(in: -100... 100)), reducer: counterReducer,- environment: CounterEnvironment()
+ environment: .test
)

store.send(.playNext) { state in
- state.count = 0
+ state = Counter(count: 0, secret: 5)
}
Copy the code

In the closure of store.send, we now directly set a new Counter for state and specify all the expected properties. You can also split two lines and write state.count = 0 and state.secret = 5, and the test will pass. Either option is acceptable, but in cases where complexity is involved, full assignments are preferred: in the test, we want to compare the expected and actual states using assertions rather than re-implementing the logic in the Reducer. This can lead to confusion because if the test fails you need to check whether the reducer is a problem or a problem caused by the operational state in the test code.

Other common dependencies

Except for random series, all external dependencies that break the reducer pure function feature with changes in calling Environment (including time, location, various external states, etc.) should be included in the category of Environment. Common examples are UUID generation, getting the current Date, getting a run queue (such as the main queue), using Core Location to get the current Location, the network framework responsible for sending network requests, etc.

Some of them can be done synchronously, such as in.random in the example; Others take time to get results, such as getting location information and sending network requests. For the latter, we tend to convert it to an Effect. We’ll talk more about Effect in the next article.

practice

If you haven’t updated the code along with this article, you can find the starting code for the following exercise here. The reference implementation can be found here.

Add a Slider

Using a keyboard and a plus and minus sign to control Counter is good enough, but adding a Slider is more fun. Please add a Slider to CounterView to control our number guessing game along with TextField and “+” “-” Button.

The expected UI would look something like this:

Don’t forget to write the test!

Improve Counter and record more information

We need to update the Counter model for further functionality development. First, each puzzle adds some meta information, such as the puzzle ID:

Add the upper and lower attributes to Counter, and then make it satisfy the Identifiable:

- struct Counter: Equatable {
+ struct Counter: Equatable, Identifiable {var count: Int = 0 var secret = Int.random(in: -100 ... 100).+ var id: UUID = UUID()
  }
Copy the code

Update your ID whenever you start a new game. And don’t forget to write the test!