river inside forest near brown leaf trees

How to bind button taps from custom cells to your view model

RxCocoa provides a useful stream for cell item accessory button taps. But how do you generate your own reactive streams from buttons in custom cells? And how should these streams be connected to your view model logic?

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.

Stay in the Loop

Subscribe to tapdev and never miss a post.

Total
0
Shares
Previous Post
flight sky earth space

Start learning RxSwift today with these 6 steps

Next Post
food healthy leaf pineapple

Check an enum case without a switch statement

Related Posts
woman looking at the map

Convert array types using the map operator in Combine

In reactive programming, it's very common to require a stream of values in the form of an array. A typical use case for this is to populate a list with data. However, the array that is published from the source may need to be transformed to an array of values of a different type for display.
Read More
adult blur books close up

Is it worth learning RxSwift in 2021?

Does this sound like you...you’re a good developer with years of experience building and shipping apps to the App Store. Somehow though, you never quite managed to get onboard the functional reactive programming train in the back half of the “twenty-tens” (2015 - 2019), and now we have Combine. Should you learn RxSwift today?
Read More