Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Best practice for binding controls in UITableViewCell to ViewModel using RxSwift

Tags:

ios

mvvm

rx-swift

I'm in the process of migrating an existing app using MVC that makes heavy use of the delegation pattern to MVVM using RxSwift and RxCocoa for data binding.

In general each View Controller owns an instance of a dedicated View Model object. Let's call the View Model MainViewModel for discussion purposes. When I need a View Model that drives a UITableView, I generally create a CellViewModel as a struct and then create an observable sequence that is converted to a driver that I can use to drive the table view.

Now, let's say that the UITableViewCell contains a button that I would like to bind to the MainViewModel so I can then cause something to occur in my interactor layer (e.g. trigger a network request). I'm not sure what is the best pattern to use in this situation.

Here is a simplified example of what I've started out with (see 2 specific question below code example):

Main View Model:

class MainViewModel {

   private let buttonClickSubject = PublishSubject<String>()   //Used to detect when a cell button was clicked.

   var buttonClicked: AnyObserver<String> {
      return buttonClickSubject.asObserver()
   }

   let dataDriver: Driver<[CellViewModel]>

   let disposeBag = DisposeBag()

   init(interactor: Interactor) {
      //Prepare the data that will drive the table view:
      dataDriver = interactor.data
                      .map { data in
                         return data.map { MyCellViewModel(model: $0, parent: self) }
                      }
                      .asDriver(onErrorJustReturn: [])

      //Forward button clicks to the interactor:
      buttonClickSubject
         .bind(to: interactor.doSomethingForId)
         .disposed(by: disposeBag)
   }
}

Cell View Model:

struct CellViewModel {
   let id: String
   // Various fields to populate cell

   weak var parent: MainViewModel?

   init(model: Model, parent: MainViewModel) {
      self.id = model.id
      //map the model object to CellViewModel

      self.parent = parent
   }
}

View Controller:

class MyViewController: UIViewController {
    let viewModel: MainViewModel
    //Many things omitted for brevity

    func bindViewModel() {
        viewModel.dataDriver.drive(tableView.rx.items) { tableView, index, element in
            let cell = tableView.dequeueReusableCell(...) as! TableViewCell
            cell.bindViewModel(viewModel: element)
            return cell
        }
        .disposed(by: disposeBag)
    }
}

Cell:

class TableViewCell: UITableViewCell {
    func bindViewModel(viewModel: MyCellViewModel) {
        button.rx.tap
            .map { viewModel.id }       //emit the cell's viewModel id when the button is clicked for identification purposes.
            .bind(to: viewModel.parent?.buttonClicked)   //problem binding because of optional.
            .disposed(by: cellDisposeBag)
    }
}

Questions:

  1. Is there a better way of doing what I want to achieve using these technologies?
  2. I declared the reference to parent in CellViewModel as weak to avoid a retain cycle between the Cell VM and Main VM. However, this causes a problem when setting up the binding because of the optional value (see line .bind(to: viewModel.parent?.buttonClicked) in TableViewCell implemenation above.
like image 890
Dragonspell Avatar asked Oct 28 '19 19:10

Dragonspell


Video Answer


1 Answers

The solution here is to move the Subject out of the ViewModel and into the ViewController. If you find yourself using a Subject or dispose bag inside your view model, you are probably doing something wrong. There are exceptions, but they are pretty rare. You certainly shouldn't be doing it as a habit.

class MyViewController: UIViewController {
    var tableView: UITableView!
    var viewModel: MainViewModel!
    private let disposeBag = DisposeBag()

    func bindViewModel() {
        let buttonClicked = PublishSubject<String>()
        let input = MainViewModel.Input(buttonClicked: buttonClicked)
        let output = viewModel.connect(input)
        output.dataDriver.drive(tableView.rx.items) { tableView, index, element in
            var cell: TableViewCell! // create and assign
            cell.bindViewModel(viewModel: element, buttonClicked: buttonClicked.asObserver())
            return cell
        }
        .disposed(by: disposeBag)
    }
}

class TableViewCell: UITableViewCell {
    var button: UIButton!
    private var disposeBag = DisposeBag()
    override func prepareForReuse() {
        super.prepareForReuse()
        disposeBag = DisposeBag()
    }

    func bindViewModel<O>(viewModel: CellViewModel, buttonClicked: O) where O: ObserverType, O.Element == String {
        button.rx.tap
            .map { viewModel.id }    //emit the cell's viewModel id when the button is clicked for identification purposes.
            .bind(to: buttonClicked) //problem binding because of optional.
            .disposed(by: disposeBag)
    }
}

class MainViewModel {

    struct Input {
        let buttonClicked: Observable<String>
    }

    struct Output {
        let dataDriver: Driver<[CellViewModel]>
    }

    private let interactor: Interactor

    init(interactor: Interactor) {
        self.interactor = interactor
    }

    func connect(_ input: Input) -> Output {
        //Prepare the data that will drive the table view:
        let dataDriver = interactor.data
            .map { data in
                return data.map { CellViewModel(model: $0) }
            }
            .asDriver(onErrorJustReturn: [])

        //Forward button clicks to the interactor:
        _ = input.buttonClicked
            .bind(to: interactor.doSomethingForId)
        // don't need to put in dispose bag because the button will emit a `completed` event when done.

        return Output(dataDriver: dataDriver)
    }
}

struct CellViewModel {
    let id: String
    // Various fields to populate cell

    init(model: Model) {
        self.id = model.id
    }
}
like image 92
Daniel T. Avatar answered Oct 26 '22 23:10

Daniel T.