Binding model and view: how to observe object properties

Viewed 1324

I have a view structured like a form that creates a model object. I am trying to bind the form elements (UIControl) to the model properties, so that the views auto-update when their corresponding model property is changed, and the model update when the controls are changed (two way binding). The model can change without the view knowing because multiple views can be linked to one same model property.

Approach 1: Plain Swift

My problem is the following: to observe changes to the model properties, I tried to use KVO in Swift, and specifically the observe(_:changeHandler:) method.

class Binding<View: NSObject, Object: NSObject, ValueType> {
    weak var object: Object?
    weak var view: View?

    var objectToViewObservation: NSKeyValueObservation?
    var viewToObjectObservation: NSKeyValueObservation?

    private var objectKeyPath: WritableKeyPath<Object, ValueType>
    private var viewKeyPath: WritableKeyPath<View, ValueType>

    init(betweenObject objectKeyPath: WritableKeyPath<Object, ValueType>,
         andView viewKeyPath: WritableKeyPath<View, ValueType>) {
        self.objectKeyPath = objectKeyPath
        self.viewKeyPath = viewKeyPath
    }

    override func bind(_ object: Object, with view: View) {
        super.bind(object, with: view)
        self.object = object
        self.view = view

        // initial value from object to view
        self.view![keyPath: viewKeyPath] = self.object![keyPath: objectKeyPath]

        // object --> view
        objectToViewObservation = object.observe(objectKeyPath) { _, change in
            guard var view = self.view else {
                // view doesn't exist anymore
                self.objectToViewObservation = nil
                return
            }

            guard let value = change.newValue else { return }
            view[keyPath: self.viewKeyPath] = value
        }

        // view --> object
        viewToObjectObservation = view.observe(viewKeyPath) { _, change in
            guard var object = self.object else {
                // object doesn't exist anymore
                self.viewToObjectObservation = nil
                return
            }

            guard let value = change.newValue else { return }
            object[keyPath: self.objectKeyPath] = value
        }
    }
}

However some of the properties of my model have types CustomEnum, CustomClass, Bool?, and ClosedRange<Int>, and to use observe I had to mark them as @objc dynamic, which yielded the error:

Property cannot be marked @objc because its type cannot be represented in Objective-C

Approach 2: Using RxSwift rx.observe

I turned to RxSwift and the rx.observe method thinking I could work around this problem, but the same thing happened (at runtime this time).

// In some generic bridge class between the view and the model
func bind(to object: SomeObjectType) {
    object.rx
        .observe(SomeType.self, "someProperty")
        .flatMap { Observable.from(optional: $0) }
        .bind(to: self.controlProperty)
        .disposed(by: disposeBag)
}

Approach 3: Using RxSwift BehaviorRelays?

This is my first experience with RxSwift, and I know I should be using BehaviorRelay for my model, however I don't want to change all my model properties as my model object is working with other framework. I could try to implement a bridge then, to transform model properties into BehaviorRelay, but I would come across the same problem: how to listen for model changes.

In this question, there were no answer as to how to listen for property changes without refactoring all model properties to RxSwift's Variable (currently deprecated).

Approach 4: Using didSet Swift property observer?

The didSet and willSet property observers in plain Swift would allow listening for changes, however this would require to mark all the properties in the model with these observers, which I find quite inconvenient, since my model object has a lot of properties. If there is a way to add these observers at runtime, this would solve my problem.


I believe that what I am trying to achieve is quite common, having a set of views that modify a model object, however I can't find a way to properly link the model to the view, so that both auto-update when needed.

Basically, I'm looking for an answer to one of the following questions:

  • Is there something I overlooked, is there a better way to achieve what I want?
  • or How to overcome the "Property cannot be marked @objc" problem?
  • or How to bridge my model object to BehaviorRelay without changing my model?
  • or How to add didSet observers at runtime?
1 Answers

You said:

I believe that what I am trying to achieve is quite common, having a set of views that modify a model object, however I can't find a way to properly link the model to the view, so that both auto-update when needed.

Actually it's not at all common. One idea you don't mention is to wrap your entire model into a Behavior Relay. Then the set of views can modify your model object.

Each of your views, in turn, can observe the model in the behavior relay and update accordingly. This is the basis of, for example, the Redux pattern.

You could also use your approach #3 and use property wrappers to make the code a bit cleaner:

@propertyWrapper
struct RxPublished<Value> {
    private let relay: BehaviorRelay<Value>
    public init(wrappedValue: Value) {
        self.relay = BehaviorRelay(value: wrappedValue)
    }

    var wrappedValue: Value {
        get { relay.value }
        set { relay.accept(newValue) }
    }

    var projectedValue: Observable<Value> {
        relay.asObservable()
    }
}

But understand that the whole reason you are having this problem is not due to Rx itself, but rather due to the fact that you are trying to mix paradigms. You are increasing the complexity of your code. Hopefully, it's just a temporary increase during a refactoring.


Old Answer

You said you want to make it "so that the views auto-update when their corresponding model property is changed, and the model update when the controls are changed (two way binding)."

IMO, that way of thinking about the problem is incorrect. Better would be to examine each output independently of all other outputs and deal with it directly. In order to explain what I mean, I will use the example of converting °F to °C and back...

This sounds like a great reason to use 2-way binding but let's see?

// the chain of observables represents a view model
celsiusTextField.rx.text           // • this is the input view
    .orEmpty                       // • these next two convert
    .compactMap { Double($0) }     //   the view into an input model
    .map { $0 * 9 / 5 + 32 }       // • this is the model
    .map { "\($0)" }               // • this converts the model into a view
    .bind(to: fahrenheitTextField) // • this is the output view
    .disposed(by: disposeBag)

fahrenheitTextField.rx.text
    .orEmpty
    .compactMap { Double($0) }
    .map { ($0 - 32) * 5 / 9 }
    .map { "\($0)" }
    .bind(to: celsiusTextField.rx.text)
    .disposed(by: disposeBag)

The above code handles the two-way communication between the text fields without two-way binding. It does this by using two separate view models (The view model is the code between the text Observable and the text Observer as described in the comments.)

We can see a lot of duplication. We can DRY it up a bit:

extension ControlProperty where PropertyType == String? {
    func viewModel(model: @escaping (Double) -> Double) -> Observable<String> {
        orEmpty
            .compactMap { Double($0) }
            .map(model)
            .map { "\($0)" }
    }
}

You may prefer a different error handling strategy than what I used above. I was striving for simplicity since this is an example.

The key though is that each observable chain should be centered on a particular effect. It should combine all the causes that contribute to that effect, implement some sort of logic on the inputs, and then emit the needed output for that effect. If you do this to each output individually you will find that you don't need two-way binding at all.

Related