Exploring two-way databinding solutions in UIKit

It’s quite common to have to build some kind of (reusable) component to edit some piece of state. For example, let’s say we have a User model, and we want to build a form to edit a user. With SwiftUI we have the @Binding property wrapper that makes it really easy to create a two-way databinding between a form field and a model, but in the UIKit world it’s slightly less easy.

You can image that we’d build a UITableView with a UITableViewCell for every field of the user that we want to edit. Every cell has a UITextField, which is instantiated with the current value of the field we want to edit. And when the value is changed, we of course want to update the field on the user model.

Let’s start off with a version without two-way databinding to set a baseline. Simplified, it can look something like this:

import UIKit

struct User {
  var firstName: String
  var lastName: String
}

class TextFieldCell {
  let textField = UITextField()

  init(value: String) {
    textField.text = value
  }
}

class MyViewController {
  var user = User(firstName: "Kevin", lastName: "Renskers")
  var nameTextField: TextFieldCell

  init() {
    nameTextField = TextFieldCell(value: user.firstName)
  }
}

We create a TextFieldCell for the user’s firstName field and instantiate it with the current value of the first name (“Kevin”). There is no databinding going on at all; if you’d edit the text inside the UITextField, nothing happens to the actual user model, its name is forever stuck to be “Kevin”.

We need to introduce a way to communicate changes back to the model. A simple way is to use a closure:

class TextFieldCell {
  let textField = UITextField()
  private let onUpdate: (String) -> Void

  init(initialValue: String, onUpdate: @escaping (String) -> Void) {
    self.onUpdate = onUpdate
    textField.text = initialValue
    textField.addTarget(self, action: #selector(updated), for: .valueChanged)
  }

  @objc func updated() {
    onUpdate(textField.text ?? "")
  }
}

class MyViewController {
  var user = User(firstName: "Kevin", lastName: "Renskers")
  var nameTextField: TextFieldCell!

  init() {
    nameTextField = TextFieldCell(initialValue: user.firstName) { newName in
      self.user.firstName = newName
    }
  }
}

let vc = MyViewController()
print(vc.user.firstName) // prints "Kevin"

vc.nameTextField?.textField.text = "Bob"
vc.nameTextField?.updated()

print(vc.user.firstName) // prints "Bob" 🎉

I’m calling the updated function by hand in the second to last line since programmatically changing the text value of a UITextField doesn’t trigger the valueChanged action.

We start off with a user called “Kevin”, update the value to “Bob”, and the user’s firstName property now holds “Bob”. It feels a bit iffy to have to pass in the user.firstName and also do the user.firstName = newName dance - it would be much nicer if this could be combined into one.

One improvement that we can make is to use KeyPaths.

class TextFieldCell<Model> {
  let textField = UITextField()
  private let model: Model
  private let keyPath: ReferenceWritableKeyPath<Model, String>

  init(model: Model, keyPath: ReferenceWritableKeyPath<Model, String>) {
    self.model = model
    self.keyPath = keyPath
    textField.text = model[keyPath: keyPath]
    textField.addTarget(self, action: #selector(updated), for: .valueChanged)
  }

  @objc func updated() {
    model[keyPath: keyPath] = textField.text ?? ""
  }
}

class MyViewController {
  var user = User(firstName: "Kevin", lastName: "Renskers")
  var nameTextField: TextFieldCell<MyViewController>!

  init() {
    nameTextField = TextFieldCell(model: self, keyPath: \.user.firstName)
  }
}

Creating an instance of TextFieldCell is now a lot simpler, as you don’t have to give an initial value and also an onUpdate closure. Instead we give a ReferenceWritableKeyPath.

However, it can be slightly disorienting to work with this, due to the usage of ReferenceWritableKeyPath. For example since the User model is a struct (a value type), I need to pass in the view controller itself as the model, with the keyPath \.user.firstName.

Can we use SwiftUI’s Binding inside UIKit? Yes we can, provided that we are building an iOS 13+ app of course.

class TextFieldCell {
  let textField = UITextField()
  private let value: Binding<String>

  init(value: Binding<String>) {
    self.value = value
    textField.text = value.wrappedValue
    textField.addTarget(self, action: #selector(updated), for: .valueChanged)
  }

  @objc func updated() {
    value.wrappedValue = textField.text ?? ""
  }
}

class MyViewController {
  var user = User(firstName: "Kevin", lastName: "Renskers")
  var nameTextField: TextFieldCell!

  init() {
    nameTextField = TextFieldCell(value: 
      Binding(
        get: { self.user.firstName }, 
        set: { self.user.firstName = $0 }
      )
    )
  }
}

It solves the ReferenceWritableKeyPath weirdness, but now we’re back to needing to give both a getter and a setter, so it’s not an ideal solution either. It can be improved by also using @State, but at the cost of turning the User model into a class:

class User {
  var firstName: String
  var lastName: String

  init(firstName: String, lastName: String) {
    self.firstName = firstName
    self.lastName = lastName
  }
}

class TextFieldCell {
  let textField = UITextField()
  private let value: Binding<String>

  init(value: Binding<String>) {
    self.value = value
    textField.text = value.wrappedValue
    textField.addTarget(self, action: #selector(updated), for: .valueChanged)
  }

  @objc func updated() {
    value.wrappedValue = textField.text ?? ""
  }
}

class MyViewController {
  @State var user = User(firstName: "Kevin", lastName: "Renskers")
  var nameTextField: TextFieldCell!

  init() {
    nameTextField = TextFieldCell(value: $user.firstName)
  }
}

How about we use Combine, with a PassthroughSubject, instead?

class TextFieldCell {
  let textField = UITextField()
  private let subject: PassthroughSubject<String, Never>
  private var cancellable: AnyCancellable?

  init(subject: PassthroughSubject<String, Never>) {
    self.subject = subject
    textField.addTarget(self, action: #selector(updated), for: .valueChanged)

    cancellable = subject.sink {
      self.textField.text = $0
    }
  }

  @objc func updated() {
    subject.send(textField.text ?? "")
  }
}

class MyViewController {
  var user = User(firstName: "Kevin", lastName: "Renskers")
  var nameTextField: TextFieldCell!
  private var cancellable: AnyCancellable?

  init() {
    let subject = PassthroughSubject<String, Never>()
    subject.send(user.firstName)
    cancellable = subject.assign(to: \.user.firstName, on: self)
    nameTextField = TextFieldCell(subject: subject)
  }
}

It works, but at the cost of even more boilerplate. Definitely not an improvement!

And using @Published gets us back to needing to both initialize and then observe a value:

class TextFieldCell {
  let textField = UITextField()
  @Published var value = ""

  init() {
    textField.addTarget(self, action: #selector(updated), for: .valueChanged)
  }

  @objc func updated() {
    value = textField.text ?? ""
  }
}

class MyViewController {
  var user = User(firstName: "Kevin", lastName: "Renskers")
  var nameTextField: TextFieldCell!
  private var cancellable: AnyCancellable?

  init() {
    nameTextField = TextFieldCell()

    nameTextField.value = user.firstName

    cancellable = nameTextField.$value.sink { [weak self] value in
      self?.user.firstName = value
    }
  }
}

So honestly at that point you might as well just use the closure method.

At the moment my solution of choice is the ReferenceWritableKeyPath when I have a class as the model (or a class ViewModel for example that holds a value type model). If that is not possible and it’s an iOS 13+ app, then the Binding approach would work pretty well too. But it feels like there is no really nice ideal solution with the same ease of use as SwiftUI.