Let’s break this down. In this post, we will use the example of a cell that represents an item added to a shopping cart. The cell has a stepper style UI, so that multiples of the same product can be added, and also that the number of items can be reduced to zero, removing the item from the shopping cart. A full demo app can be found on GitHub.
Observable streams from custom cell controls
First off, let’s expose the taps from our stepper by using the tap observable on UIButton. We can create a reactive extension on our custom cell to keep this neat and tidy.
final class CartCell: UITableViewCell {
@IBOutlet weak var minusButton: UIButton!
@IBOutlet weak var plusButton: UIButton!
private(set) var disposeBag = DisposeBag()
override func prepareForReuse() {
super.prepareForReuse()
disposeBag = DisposeBag()
}
}
extension Reactive where Base: CartCell {
var incrementTap: ControlEvent<Void> { base.plusButton.rx.tap }
var decrementTap: ControlEvent<Void> { base.minusButton.rx.tap }
}
Binding to the view model
We now have two observable streams from our custom cell which we need to connect as inputs to the business logic our view model. There are lots of variations on how a view model can be implemented. We’ll take a fairly common approach.
private let incrementProductSubject = PublishSubject<CartProduct>()
private let decrementProductSubject = PublishSubject<CartProduct>()
var incrementProduct: AnyObserver<CartProduct> { incrementProductSubject.asObserver() }
var decrementProduct: AnyObserver<CartProduct> { decrementProductSubject.asObserver() }
func bind(_ input: CartInput) -> CartOutput {
let cart = Observable
.merge(
incrementProductSubject.map { CartAction.increment($0) },
decrementProductSubject.map { CartAction.decrement($0) }
)
...
return CartOutput(...)
}
The view model is bound to the view in the view controllers viewDidLoad.
override func viewDidLoad() {
super.viewDidLoad()
configureView()
let output = viewModel.bind(CartInput(addProduct: rx.addProduct, checkout: rx.checkout))
disposeBag = DisposeBag {
output.cart.bind(to: tableView.rx.items(dataSource: dataSource()))
output.cartEmpty.bind(to: tableView.rx.isEmpty(message: "Your cart is empty"))
output.cartTotal.bind(to: amountLabel.rx.text)
output.checkoutVisible.bind(to: rx.isCheckoutVisible)
}
}
The streams from the cells obviously can’t be bound at this point, so we’ll use two private subjects of the view model, safely exposed to the outside world as observers (we don’t want to expose the subjects themselves). When each cell is created, we can then bind these to the observables we exposed on the cell, like so:
configureCell: { _, tableView, indexPath, row in
let cell: CartCell = tableView.dequeueCell(for: indexPath)
cell.bind(viewModel: CartCellViewModel(row: row),
incrementObserver: self.viewModel.incrementProduct,
decrementObserver: self.viewModel.decrementProduct)
return cell
}
extension CartCell {
func bind(viewModel: CartCellViewModel,
incrementObserver: AnyObserver<CartProduct>,
decrementObserver: AnyObserver<CartProduct>) {
rx.incrementTap
.map { viewModel.product }
.bind(to: incrementObserver)
.disposed(by: disposeBag)
rx.decrementTap
.map { viewModel.product }
.bind(to: decrementObserver)
.disposed(by: disposeBag)
}
}
This can seem a little complicated when you first come to tackle the problem, but as demonstrated above, it’s actually quite straight forward. Thanks to @phi161, @vzsg, @freak4pc and @danielt1263 on the RxSwift Slack community for some useful insights that were discussed whilst I was working on something similar. Their thoughts and ideas helped to shape the solution I came up with, from which this article was taken. A full demo project can be found on GitHub.