Unexpected Binding Behavior

I'm relatively new to Swift and TCA, so I'm not totally sure if what I'm seeing is expected behavior or not.

The core thing that I'm trying to do is to format a string that is typed in to a TextField and have the contents of that TextField respect the formatting. Specifically, I want to format the input to a TextField as a phone number.

My view looks more or less like

                TextField(
                    "Phone Number",
                    text: viewStore.binding(
                        get: {
                            return $0.phoneNumber
                        },
                        send: FormAction.phoneNumberChanged
                    )
                )

and my reducer looks like

    case let .phoneNumberChanged(phoneNumberStr):
        if phoneNumberStr.starts(with: "+1"), phoneNumberStr.count <= 3 {
            // Condition A
            return .none
        }
        
        state.phoneNumber = PartialFormatter().formatPartial(phoneNumberStr)
        return .none

However, when the action fires, I noticed that when Condition A occurs, the state displayed by the text field and the state of my application diverge. I would have expected the value that is displayed to be the get value of the viewStore.binding, but that does not seem to be the case.

Am I missing something about SwiftUI, Bindings, or TCA? Is this behavior expected?

I've included a complete pathological example, based on Binding Basics, of how to introduce this divergent state below. Notice that as you type in to the TextField, it displays what you type, but State remains unchanged. The behavior expected is that you should not be able to type into the TextField.

// Based on https://github.com/pointfreeco/swift-composable-architecture/blob/master/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Basics.swift

import ComposableArchitecture
import SwiftUI

// The state for this screen holds a bunch of values that will drive
struct BindingBasicsState: Equatable {
  var text = ""
}

enum BindingBasicsAction {
  case textChange(String)
}

struct BindingBasicsEnvironment {}

let bindingBasicsReducer = Reducer<
  BindingBasicsState, BindingBasicsAction, BindingBasicsEnvironment
> {
  state, action, _ in
  switch action {
  case let .textChange(text):
//    state.text = text
    return .none
  }
}

struct BindingBasicsView: View {
  let store: Store<BindingBasicsState, BindingBasicsAction>

  var body: some View {
    WithViewStore(self.store) { viewStore in
      Form {
        Section(header: Text("Example")) {
          HStack {
            TextField(
              "Type here",
              text: viewStore.binding(get: { $0.text }, send: BindingBasicsAction.textChange)
            )
            .disableAutocorrection(true)
            Text(viewStore.text)
          }
        }
      }
    }
    .navigationBarTitle("Bindings basics")
  }
}

struct BindingBasicsView_Previews: PreviewProvider {
  static var previews: some View {
    NavigationView {
      BindingBasicsView(
        store: Store(
          initialState: BindingBasicsState(),
          reducer: bindingBasicsReducer,
          environment: BindingBasicsEnvironment()
        )
      )
    }
  }
}

This is expected behaviour from TCA.
This is how it works on Vanilla SwiftUI.

If you want to test it, here I adapted the snippet to use Vanilla.

struct BindingBasicsView: View {
    @State var text: String = ""
    
    var body: some View {
        Form {
            Section(header: Text("Example")) {
                HStack {
                    TextField(
                        "Type here",
                        text: Binding(
                            get: { text },
                            set: { _ in
                                //Notice that we do nothing with new text coming in
                            }
                        )
                    )
                    .disableAutocorrection(true)
                    Text(text)
                }
            }
        }
        .navigationBarTitle("Bindings basics")
    }
}

struct BindingBasicsView_Previews: PreviewProvider {
    static var previews: some View {
        NavigationView {
            BindingBasicsView()
        }
    }
}

So, basically if text field is active first responder doesn't read the state. As soon as you resign from responder(press enter for example), it will read state and empty the text field.

To fix your issue with phone number, you just need to put
state.phoneNumber = phoneNumberStr
when Condition A occurs.

N.B. get: \.phoneNumber looks way nicer than

get: {
                            return $0.phoneNumber
                        },
Terms of Service

Privacy Policy

Cookie Policy