Data Binding with MVVM on iOS part 2: KeyPath and Type Erasure

Tonny
Dev Genius
Published in
8 min readJul 12, 2020

--

This story is about how I implement one-way and two-way data binding with Swift KeyPath. Before reading this story, you may need to check out part1 to know which data-flow strategy I’m taking.

Why KeyPath?

\TypeName.path

1. Dynamic and Consistency

Key-path expressions provide a dynamic and consistent way to access and update data. The type can be any concrete or generic type, including normal struct and UI Components which are the two important endpoints of data binding.

var model = User()
var view = UITextField()
model[keyPath: \User.name] = “Tonny”
view[keyPath: \UITextField.text] = “Tonny”
view[keyPath: \UITextField.text] = model[keyPath: \User.name]
model[keyPath: \User.name] = view[keyPath: \UITextField.text]

It also has the ability to access or update the embedded property.

model[keyPath: \User.address.country] = “NZ”view[keyPath: \UITextField.text] = model[keyPath: \User.address.country]

2. Abstract How as What

When implementing a form UI, there is numerous line of code to listen to view’s change and update the related model. The task is simply about how to update the model from view and update view from the model. Key-path expression is an abstract way to treat those HOW as WHAT, which reduces the duplicated listening and updating code.

  • How to update property?
func textChanged(field: UITextFeild) {
let tag = field.tag
let text = field.text

if tag == 1 {
user.name = text
}else if tag == 2 {
user.email = text
}else if tag == 3 {
user.phone = text
}
}
  • What’s the property to be updated?
func textChanged(field: UITextFeild) {
let propertyKeyPath = keyPathMapping[field.tag]

user[keyPath: propertyKeyPath] = value
}

One way data binding

One way data binding first initials the presenting of view, and then helps view to follow the model’s change constantly. There are several ways to listen and notify change, such as didSet, KVO, or RxSwift. I simply put all update logic in one viewModel, which is the only place responsible for the model update. When the ViewModel update model, it updates view subsequently.

//initial views
lbl[keyPath: \UILabel.text] = user[keyPath: \User.info]
imgView[keyPath: \UIImageView.image] = user[keyPath: \User.image]
textField[keyPath: \UITextField.text] = user[keyPath: \User.name]
//help view to follow model's change
func update<V>(_ modelKeyPath: WritableKeyPath<User, V>, _ value: V){

model[keyPath: modelKeyPath] = value

let viewKeyPath = mapping[modelKeyPath]
view[keyPath: viewKeyPath] = value
}

All the view components in one way binding have their own unique keyPath to initialize and update the view.

let viewKeyPathes = [
UILabel: \UILabel.text,
UIImageView: \UIImageView.image,
]

Two-way data binding

Two-way data binding takes one more step further than one-way data binding. It not only listens to the model’s change but also listens to the view’s change and update the model accordingly. The most common usage of two-way data binding of UI component is UITextField. And addTarget(_: action: for:) method is ready to listen to view’s change.

//initial views, same as above one way binding
//help view to follow model’s change, same as above one way binding
//help model to follow view’s change
field.addTarget(self, #selector(textChanged), for: .editingChanged)
func textChanged() { user[keyPath: \User.name] = field[keyPath: \UITextField.text]
}

To support other UIControls, such as UIButton, UISteper, UISwitch, UISlide, The listener should be generic.

let event = type(of: view).bindingEvent
view.addTarget(self, #selector(viewChanged), for: event)
func viewChanged(view: UIControl) { let modelKeyPath = mapping[view.tag]
let viewKeyPath = type(of: view).bindingKeyPath

model[keyPath: modelKeyPath] = view[keyPath: viewKeyPath]
}

All the view components in two way binding not only have their own keyPath but also have the related event trigger.

let viewKeyPathes = [
UITextField: \UITextField.text,
UISteper: \UIStepper.value,
UISlider: \UISlider.value,
UISwitch: \UISwitch.isOn,
UIButton: \UIButton.isSelected,
]
let viewEvents = [
UITextField: UIControl.Event.editingChanged,
UISteper: UIControl.Event.valueChanged,
UISlider: UIControl.Event.valueChanged,
UISwitch: UIControl.Event.valueChanged,
UIButton: UIControl.Event.touchUpInside,
]

View and Model’s KeyPath Mapping

The data binding describes the relationship between the view’s appearance and the model’s property. With Swift KeyPath, it essentially is
about view’ keypath and model’s keypath. And what we need is just to record the mapping of view and model’s keypath so the binding can automatically happen when the view’s event-triggered or model changed. Considering not to reference view strongly, not to customized view with inheritance, the view’s tag would be the best idea to record the mapping.

let mapping = [
txtFieldTag: \User.name,
labelTag: \User.email,
buttonTag: \User.isAdmin,
switchTag: \User.isTermsSelected,
stepperTag: \User.degree
]

Please note that the tag of view should be unique in a mapping.

Data flow

The role of KeyPath plays in data flow as the following diagram shows:

data flow

Issues with KeyPath

When I implement the data binding with KeyPath, I came across two issues.

Issue#1. Failed to update the model with a value of type not match
Given a model’s Int property and a value retrieved by \UIStepper.value which is Double, the update fails because the type does not match.

Type not match
Type not match

Issue#2. The generic Value type of KeyPath<Root, Value> is erased in the collection.
If all model’s keypath put into a dictionary or an array, the Value type of keypath is erased. When WritableKeyPath<User, String?> and WritableKeyPath<User, Bool> put into the dictionary, the type will be lost as PartialKeyPath<User>, and PartialKeyPath does not support to update model anymore. The update fails because the value type is not known.

Type Erasure

Type erasure is a programming paradigm in languages with closure or block features. It’s a mechanism to erase type initiatively, but store it for later use, especially applied in the collection. It seems to lose the type, but actually it saves the information of type. Here are some examples:

  • When the two WritableKeyPath put into an array, the type will be lost as PartialKeyPath<User>, and PartialKeyPath does not support to update model anymore. But with Type Erasure technique, it cheats the compiler successfully and can accept any type, but update the model only when the type matches.
struct AnyWritableKeyPath<Root> {    let update: (inout Root, Any) -> ()    init<Value>(_ kp: WritableKeyPath<Root, Value>) {
update = {
guard let value = $1 as? Value else {
return
}

$0[keyPath: kp] = value
}
}
}
let array = [AnyWritableKeyPath(\User.name), AnyWritableKeyPath(\User.likeKiwi)]array[0].update(&user, dataWithUnknowType)
  • When closures stored in a collection, the type’s information gets lost. And we have to erase it initiatively, but save it for later.
let stringClosure: (String) -> () = {
//...
}
let intClosure: (Int) -> () = {
//...
}
struct AnyClosure {
let invoke: (Any)->()
init<T>(_ closure: @escaping (T) -> ()) {
invoke = { v in
if let v = v as? T {
closure(v)
}
}
}
}
[AnyClosure(stringClosure), AnyClosure(intClosure)].forEach {
$0.invoke("a")
}
  • AnyView has references on the wrapped view’s property, function, and type information.
class AnyView {
let referToTag: Int
weak var weakReference: UIView?
let referToFunction: (Any, Selector, UIControl.Event)->() init<V: UIView>(_ view: V) {
referToTag = view.tag
weakReference = view
referToFunction = {
if let v = view as? UIControl {
v.addTarget($0, action: $1, for: $2)
}
}
}
}
[AnyView(UIView()), AnyView(UIButton())].forEach {
$0.referToFunction(viewModel, action, event)
}

Note that Type Erasure is a trick to anti strong type feature, sometimes it is not safe without type constraint. Do not abuse it.

Data Binding in Action

First, define two-way bindable view, it has keypath presents the appearance and event to update the model.

TwoWayBinding describes the binding of view and model’s property, it uses a closure to store the model’s property type. With Swift KeyPath, It supports model update from view, views update from the model and update them both.

Two-way data binding

The mapping of view and model’s property now peacefully stored in a collection without type lost.

After bindings are defined, ViewModel takes the rest job, it works as Controller as the above data flow diagram shows. It is responsible for notifying the view’s change to the model and updating the model and view subsequently.

Data Binding ViewModel

With the help of an infix operator, the bindings become briefly and expressive.

So far so good, but there are some cases you may need to format the data when displaying in the view, or need to cast the type because the type of view’s keyPath does not match with the type of model’s property. So the operators need to be improved like this.

uiLabel  <- \.text + { "This is $0"}
uiSteper <~ \.age + { Double($0) } + { Int($0) }

After a couple of days playing with KeyPath, I create a demo Form app. It focuses on one way and two-way data binding including features:

  • Infix operator to bind view and model’s keypath
label <- model's keyPath  //one way
field <-> model's keyPath //two way
  • Data bindings with KeyPath for UILabel, UIImageView, UITextField, UISwitch, UIButton, UISlider, UIStepper.
uiLabel     <-  \.name
uiTextField <-> \.email
  • Data bindings with Closure for Any View, it supports data format from view to model or vice versa.
uiLabel  <~  (\.text, {
$0.text = "A prefix $1"
})
uiActivityIndicatorView <~ (\.isLoading, { view, aBool in
if aBool {
view.startAnimating()
}else {
view.stopAnimating()
}
})
uiSteper <~> (\.aInt, { view, aInt in
view.value = Double(aInt)
}, { view, _ in
return Int(view.value)
})
  • Multiple views binding to one Model’s property
uiTextField <-> \email
uiLable <~ (\.email, { $0.text = "Your email: $1" })
  • Unbind view with the model

What’s your idea about a standard form in iOS? And how do you implement a similar data binding in iOS? Welcome to comment and have fun in Swift.

--

--