DEV Community

Cover image for Three ways to react to @State event changes in SwiftUI
Calin-Cristian Ciubotariu
Calin-Cristian Ciubotariu

Posted on • Originally published at calincrist.com

Three ways to react to @State event changes in SwiftUI

(... or how I learned to implement an equivalent of "onChange" on SwiftUI controls)

After almost a year since SwiftUI was released, I decided to give it a go. I started to get my hands dirty by implementing basic UI controls (like Slider or TextField) and how to manipulate view states.

In short time, I faced the challenge to update a @State variable based on another @State variable changes.

And yes, the property observers that we know (like didSet or willSet) don't work in @State variables.

disappointed

After some research (took longer than I expected), I learned 3 ways to do that:

  1. UI Controls specific callbacks: onEditingChanged
  2. Binding variables
  3. Making use of Combine framework

Below I will describe a specific simple use-case: check if a text field value is matching a predefined word and show that by toggling a switch on/off (the control is called Toggle).

Matching text gif

The UI skeleton code:

struct ContentView: View {

    @State var textValue: String = "Hello"
    @State var enteredTextValue: String = ""
    @State var textsMatch: Bool = false

    var body: some View {
          VStack {
              HStack {
                  Text("Write this word: ")
                  Text(textValue)
              }

              TextField("Write here:", text: $enteredTextValue)
                  .padding(10)
                  .border(Color.green, width: 1)

              Toggle(isOn: $textsMatch) {
                  Text("Matching?")
              }
              .disabled(true)
              .padding()
        }.padding()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
Enter fullscreen mode Exit fullscreen mode

onEditingChanged

According to Apple's Developer Documentation, this callback is available on the inits of 3 controls: TextField, Slider and Stepper.

TextField:
init(_:text:onEditingChanged:onCommit:)
Enter fullscreen mode Exit fullscreen mode
Slider:
init(value:in:onEditingChanged:)
Enter fullscreen mode Exit fullscreen mode
Stepper:
init(_:onIncrement:onDecrement:onEditingChanged:)
Enter fullscreen mode Exit fullscreen mode

What we can do here is enhancing the TextField's init with this parameter:

@State var textValue: String = "Hello"
@State var enteredTextValue: String = ""
@State var textsMatch: Bool = false    

//  ADD THIS
func checkIfTextsMatch(changed: Bool) {
    self.textsMatch = self.textValue == self.enteredTextValue
}

var body: some View {
  VStack {
      HStack {
          Text("Write this word: ")
          Text(textValue)
      }

      TextField("Write here:", 
                text: $enteredTextValue,
                //  USE HERE
                onEditingChanged: self.checkIfTextsMatch)
      .padding(10)
      .border(Color.green, width: 1)

      Toggle(isOn: $textsMatch) {
          Text("Matching?")
      }
      .disabled(true)
      .padding()

  }.padding()
}
Enter fullscreen mode Exit fullscreen mode

A possible downside to this approach is that onEditingChanged gets called after the user presses the return key of the keyboard.

But if you don't want this to happen in "real-time" it's a viable solution.

Binding variables

Binding is a property wrapper type that can read and write a value owned by a source of truth.

This reference enables the view to edit the state of any view that depends on this data.

We can use this to mimic the property observers from UIKit approach (getters/setters).

func checkIfTextsMatch() {
    self.textsMatch = self.textValue == self.enteredTextValue
}

var body: some View {
  let textValueBinding = Binding<String>(get: {
      self.enteredTextValue
  }, set: {
      self.enteredTextValue = $0
      self.checkIfTextsMatch()
  })

  return VStack {
      HStack {
          Text("Write this word: ")
          Text(String(textValue))
      }

      TextField("Write here:", text: textValueBinding)
          .padding(10)
          .border(Color.green, width: 1)
      Text(enteredTextValue)

      Toggle(isOn: $textsMatch) {
          Text("Matching?")
      }
      .disabled(true)
      .padding()
  }.padding()
}
Enter fullscreen mode Exit fullscreen mode

I have to say that I don't particularly like this method as it doesn't look clean to declare bindings and have business inside the rendering section.

Combine framework

The Combine framework is used to customise handling of asynchronous events by combining event-processing operators - in our case to listen to state changes events.

In Combine's vocabulary we have:

- ObservableObject - A type of object with a publisher that emits before the object has changed.

- ObservedObject - declares dependency on a reference type that conforms to the ObservableObject protocol. It's a property wrapper type that subscribes to an observable object and invalidates a view whenever the observable object changes.

- Published - A type that publishes a property marked with an attribute.

This approach is forcing us (in a good way) to have a cleaner code by extracting the business logic out of the view.

Create the view model:

class ContentViewModel: ObservableObject {
    @Published var textValue: String = "Hello"
    @Published var enteredTextValue: String = "" {
        didSet {
            checkIfTextsMatch()
        }
    }
    @Published var textsMatch: Bool = false

    func checkIfTextsMatch() {
        self.textsMatch = textValue == enteredTextValue
    }
}
Enter fullscreen mode Exit fullscreen mode

Use it in the desired view:

struct ContentView: View {

  @ObservedObject var viewModel = ContentViewModel()

  var body: some View {
      VStack {
          HStack {
              Text("Write this word: ")
              Text(String(viewModel.textValue))
          }

          TextField("Write here:", text: $viewModel.enteredTextValue)
              .padding(10)
              .border(Color.green, width: 1)
          Text(viewModel.enteredTextValue)

          Toggle(isOn: $viewModel.textsMatch) {
              Text("Matching?")
          }
          .disabled(true)
          .padding()
      }.padding()
  }

}
Enter fullscreen mode Exit fullscreen mode

I don't know about you, but I have to say that I much prefer the third option as I have more control over the data flow and the code is more maintainable. And I need that in the real-world use cases.

Top comments (2)

Collapse
 
tr3ntg profile image
Trent

Calin, this writeup was great. I was pretty stuck...

Without understanding why it worked, I used method 3 in one part of my codebase (before finding your article) and later tried to adapt the usage of @didset inside of my viewModel on the @State variables. 🤦‍♂️

Still working on the refactor, but this clarified a lot. Thanks for saving me the trouble.

Collapse
 
calin_crist profile image
Calin-Cristian Ciubotariu

Hi, Trent! I am really happy it helped you :)

I was in the same situation as you. It required more internet research than expected (at that time at least).
Either way, I feel SwiftUI has a pretty steep learning curve and searching for specific patterns requires a lot of googling + trial and error.