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:
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.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
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With