Binding

Data binding framework (view model binding on MVVM) written using @propertyWrapper and @resultBuilder

Requirement

Swift 5.1+, RxSwift (link)

Usage

Property

The property wrapper used for observable property in view model that can be binded to view property by using RxCocoa.
There are 3 types of wrapper (2 for observable value, and 1 for observable action).

Bindable

One-way binding type of property with Driver as its projectedValue doc, and can use any type for its property type (wrappedValue).

final class ViewModel {
  @Bindable private(set) var name: String = ""
  @Bindable private(set) var description: String = ""
  
  func change() {
      name = "haha"
      description = "description"
  }
}

For the usage in View we could bind it by using the projected value

disposeBag.insert(
  vm.$name.drive(nameLabel.rx.text),
  vm.$description.drive(descLabel.rx.text)
)

Mutable

Two-way binding type of property with BehaviorRelay as its projectedValue, also same as Bindable it can use any type for its property type.

final class ViewModel {
  @Mutable var typedText: String = ""
  @Mutable var selectedTarget: Target = .vidioAdmin
}

For usage in View we cound bind it same as Bindable by using the projected value, but since its type is BehaviorRelay, it can also accept value from view.

disposeBag.insert(
  vm.$typedText.asDriver().drive(textInput.rx.text),
  textInput.rx.text.subscribe(onNext: { vm.$typedText.accept($0) /* or vm.typedText = $0 */ })
)

ViewAction

One parameter observable function, use to trigger view action from view model. Example of action would be show alert, open view controller, dismiss, etc. Note that the wrapped value type has to be a function with single parameter and Void return type and it will have Signal as the projected value.

final class ViewModel {
  @ViewAction var alert: (Message) -> Void
  
  func change() {
      alert(Message("Changed!"))
  }
}

To bind it in view,

disposeBag.insert(
  vm.$alert.emit(onNext: { [weak self] in self?.showAlert($0.textMessage) })
)
ViewAction without argument

Since ViewAction needs the wrapped value to be single argument function, it will be awkward to use Void as the parameter type. For this, we could use another type of ViewAction, ViewAction.NoParam. It’s the same as ViewAction, with exception of wrapped value type has to be no argument function () -> Void.

final class ViewModel {
  @ViewAction.NoParam var dismiss:() -> Void
}

BindingContext

When using RxSwift to bind view and property, the subscription needs to be dispose at some point, this usually be done after the view for the binding has been disposed.
To implement this, usually we use DisposeBag and add the subscriptions to it to let it auto dispose all the subscription when the DisposeBag disposed by the view.

// example
let disposeBag = DisposeBag()
view.rx.text.subscribe(onNext: { /*...*/ }).disposed(by: disposeBag)
// or when there are multiple subscription we use
disposeBag.insert(
  textObservable.subscribe(),
  nameObservable.subscribe()
)

When using binding, a protocol called BindingContext is introduced to provide a context where the binding should be done.
When implementing this protocol, a property disposeBag need to be implemented for it will be used to dispose all subscriptions added inside the context when de-inited.
binding(@BindingDisposables disposables: () -> Disposable) in BindingContext can be used as the scope for the subscriptions.
For any subscriptions done in this function builder, it will be inserted into the disposeBag.

Note: @_functionBuilder is used to implement this behavior proposal doc more learning (apparently, it’s been changed to @functionBuilder in the newer version)

final class ViewController: BindingContext {
  ...
  let disposeBag = DisposeBag() 
  override func viewDidLoad() {
    super.viewDidLoad()
    ...
    binding {
      viewModel.$text.drive(textLabel.rx.text)  // notice we don't add comma here, since it is not needed when using function builder
      viewModel.$description.drive(descLabel.rx.text)
      viewModel.$alert.emit(onNext: { [weak self] in self?.alert($0) })
    }
  }
}

Binding Operators

To simplify the binding, custom operators added to this library.
There are 2 binding operators that can be use to bind the view with view model.

One-way Binding Operator

To handle one-way binding, operator => can be used with left-hand operand to be Driver or Signal(for ViewAction binding).

The right-hand operand for both Driver and Signal can be:

  • ObserverType with value type both optional or not, and it can also be function with one argument.
  • function with one argument (ValueType) -> Void.

binding {
  viewModel.$text => textLabel.rx.text // Driver with Binder as receiver
  viewModel.$description => { print("description: \($0)") } // Driver with function as receiver
  viewModel.$alert => { [weak self] in self?.alert($0) } // Signal with function as receiver
}

Two-way Binding Operator

To handle two-way binding, operator <=> can be used with left-hand operand to be BehaviorRelay and ControlPropertyType as the right-hand operand.

binding {
  // notice in this example text control property is not being used
  // instead it is using custom control property with non optional value
  // since, <=> cannot accept ControlPropertyType with element optional
  // for optional type a new operator will introduced
  viewModel.$inputText <=> textField.rx.nonNullText
}

Optional Operator

This operator is especially used for ControlProperty with default value.
It has to be done this way because of how UIKit was implemented in the past, and RxCocoa has to adapt to it (e.g. text property in text field has String? type).

The operator for this case is re-using the same operator for Nil-coalescing (??) in swift.

binding {
  viewModel.$inputText <=> textField.rx.text ?? "default value"
}

GitHub

View Github