Rui Rodrigues
Rui Rodrigues

Reputation: 413

How edit/delete UICollectionView cells using MVVM and RxSwift

I am trying to understand how to implement MVVM with a list of objects and an UICollectionView. I am not understanding how to implement the User iteration -> Model flow.

I have setup a test application, the Model is just a class with an Int, and the View is an UICollectionViewCell which shows a text with the corresponding Int value and have plus, minus and delete buttons to increment, decrease and remove a element respectively. Each entry looks like: I would like to know the best way to use MVVM and RxSwift the update/remove a cell.

I have a list random generated Int values

let items: [Model]

the Model which just have the Int value

class Model {
    var number: Int

    init(_ n: Int = 0) {
        self.number = n
    }
}

The ViewModel class which just hold the Model and has an Observable

class ViewModel {

    var value: Observable<Model>

    init(_ model: Model) {
        self.value = Observable.just(model)
    }
}

And the Cell

class Cell : UICollectionViewCell {
    class var identifier: String { return "\(self)" }

    var bag = DisposeBag()

    let label: UILabel
    let plus: UIButton
    let minus: UIButton
    let delete: UIButton
....
    var viewModel: ViewModel? = nil {
        didSet {
        ....
            viewModel.value
                .map({ "number is \($0.number)" })
                .asDriver(onErrorJustReturn: "")
                .drive(self.label.rx.text)
                .disposed(by: self.bag)
        ....
        }
    }
}

What I don't understand clearly how to do is how to connect the buttons to the corresponding action, update the model and the view afterwards.

Is the Cell's ViewModel responsible for this? Should it be the one receiving the tap event, updating the Model and then the view?

In the remove case, the cell's delete button needs to remove the current Model from the data list. How can this be done without mixing everything all together?

Upvotes: 1

Views: 3516

Answers (2)

Daniel T.
Daniel T.

Reputation: 33967

Here is the project with the updates below in GitHub: https://github.com/dtartaglia/RxCollectionViewTester

The first thing we do is to outline all our inputs and outputs. The outputs should be members of the view model struct and the inputs should be members of an input struct.

In this case, we have three inputs from the cell:

struct CellInput {
    let plus: Observable<Void>
    let minus: Observable<Void>
    let delete: Observable<Void>
}

One output for the cell itself (the label) and two outputs for the cell's parent (presumably the view controller's view model.)

struct CellViewModel {
    let label: Observable<String>
    let value: Observable<Int>
    let delete: Observable<Void>
}

Also we need to setup the cell to accept a factory function so it can create a view model instance. The cell also needs to be able to reset itself:

class Cell : UICollectionViewCell {

    var bag = DisposeBag()

    var label: UILabel!
    var plus: UIButton!
    var minus: UIButton!
    var delete: UIButton!

    // code to configure UIProperties omitted. 

    override func prepareForReuse() {
        super.prepareForReuse()
        bag = DisposeBag() // this resets the cell's bindings
    }

    func configure(with factory: @escaping (CellInput) -> CellViewModel) {
        // create the input object
        let input = CellInput(
            plus: plus.rx.tap.asObservable(),
            minus: minus.rx.tap.asObservable(),
            delete: delete.rx.tap.asObservable()
        )
        // create the view model from the factory
        let viewModel = factory(input)
        // bind the view model's label property to the label
        viewModel.label
            .bind(to: label.rx.text)
            .disposed(by: bag)
    }
}

Now we need to build the view model's init method. This is where all the real work happens.

extension CellViewModel {
    init(_ input: CellInput, initialValue: Int) {
        let add = input.plus.map { 1 } // plus adds one to the value
        let subtract = input.minus.map { -1 } // minus subtracts one

        value = Observable.merge(add, subtract)
            .scan(initialValue, accumulator: +) // the logic is here

        label = value
            .startWith(initialValue)
            .map { "number is \($0)" } // create the string from the value
        delete = input.delete // delete is just a passthrough in this case
    }
}

You will notice that the view model's init method needs more than what is provided by the factory function. The extra info will be provided by the view controller when it creates the factory.

The view controller will have this in its viewDidLoad:

viewModel.counters
    .bind(to: collectionView.rx.items(cellIdentifier: "Cell", cellType: Cell.self)) { index, element, cell in
        cell.configure(with: { input in
            let vm = CellViewModel(input, initialValue: element.value)
            // Remember the value property tracks the current value of the counter
            vm.value
                .map { (id: element.id, value: $0) } // tell the main view model which counter's value this is
                .bind(to: values)
                .disposed(by: cell.bag)

            vm.delete
                .map { element.id } // tell the main view model which counter should be deleted
                .bind(to: deletes)
                .disposed(by: cell.bag)
            return vm // hand the cell view model to the cell
        })
    }
    .disposed(by: bag)

For the above example I assume that:

  • counters is of type Observable<[(id: UUID, value: Int)]> and comes from the view controller's view model.
  • values is of type PublishSubject<(id: UUID, value: Int)> and is input into the view controller's view model.
  • deletes is of type PublishSubject<UUID> and is input into the view controller's view model.

The construction of the view controller's view model follows the same pattern as the one for the cell:

Inputs:

struct Input {
    let value: Observable<(id: UUID, value: Int)>
    let add: Observable<Void>
    let delete: Observable<UUID>
}

Outputs:

struct ViewModel {
    let counters: Observable<[(id: UUID, value: Int)]>
}

Logic:

extension ViewModel {
    private enum Action {
        case add
        case value(id: UUID, value: Int)
        case delete(id: UUID)
    }

    init(_ input: Input, initialValues: [(id: UUID, value: Int)]) {
        let addAction = input.add.map { Action.add }
        let valueAction = input.value.map(Action.value)
        let deleteAction = input.delete.map(Action.delete)
        counters = Observable.merge(addAction, valueAction, deleteAction)
            .scan(into: initialValues) { model, new in
                switch new {
                case .add:
                    model.append((id: UUID(), value: 0))
                case .value(let id, let value):
                    if let index = model.index(where: { $0.id == id }) {
                        model[index].value = value
                    }
                case .delete(let id):
                    if let index = model.index(where: { $0.id == id }) {
                        model.remove(at: index)
                    }
                }
        }
    }
}

Upvotes: 3

Chinh Nguyen
Chinh Nguyen

Reputation: 663

I'm doing it this way:

ViewModel.swift

import Foundation
import RxSwift
import RxCocoa

typealias Model = (String, Int)

class ViewModel {
    let disposeBag = DisposeBag()
    let items = BehaviorRelay<[Model]>(value: [])
    let add = PublishSubject<Model>()
    let remove = PublishSubject<Model>()
    let addRandom = PublishSubject<()>()

    init() {
        addRandom
            .map { _ in (UUID().uuidString, Int.random(in: 0 ..< 10)) }
            .bind(to: add)
            .disposed(by: disposeBag)
        add.map { newItem in self.items.value + [newItem] }
            .bind(to: items)
            .disposed(by: disposeBag)
        remove.map { removedItem in
            self.items.value.filter { (name, _) -> Bool in
                name != removedItem.0
            }
            }
            .bind(to: items)
            .disposed(by: disposeBag)
    }
}

Cell.swift

import Foundation
import Material
import RxSwift
import SnapKit

class Cell: Material.TableViewCell {
    var disposeBag: DisposeBag?
    let nameLabel = UILabel(frame: .zero)
    let valueLabel = UILabel(frame: .zero)
    let removeButton = FlatButton(title: "REMOVE")

    var model: Model? = nil {
        didSet {
            guard let (name, value) = model else {
                nameLabel.text = ""
                valueLabel.text = ""
                return
            }
            nameLabel.text = name
            valueLabel.text = "\(value)"
        }
    }

    override func prepare() {
        super.prepare()
        let textWrapper = UIStackView()
        textWrapper.axis = .vertical
        textWrapper.distribution = .fill
        textWrapper.alignment = .fill
        textWrapper.spacing = 8

        nameLabel.font = UIFont.boldSystemFont(ofSize: 24)
        textWrapper.addArrangedSubview(nameLabel)
        textWrapper.addArrangedSubview(valueLabel)

        let wrapper = UIStackView()
        wrapper.axis = .horizontal
        wrapper.distribution = .fill
        wrapper.alignment = .fill
        wrapper.spacing = 8
        addSubview(wrapper)
        wrapper.snp.makeConstraints { make in
            make.edges.equalToSuperview().inset(8)
        }
        wrapper.addArrangedSubview(textWrapper)
        wrapper.addArrangedSubview(removeButton)
    }
}

ViewController.swift

import UIKit
import Material
import RxSwift
import SnapKit

class ViewController: Material.ViewController {
    let disposeBag = DisposeBag()
    let vm = ViewModel()

    let tableView = UITableView()
    let addButton = FABButton(image: Icon.cm.add, tintColor: .white)

    override func prepare() {
        super.prepare()

        view.addSubview(tableView)
        tableView.snp.makeConstraints { make in
            make.edges.equalToSuperview()
        }

        addButton.pulseColor = .white
        addButton.backgroundColor = Color.red.base
        view.layout(addButton)
            .width(48)
            .height(48)
            .bottomRight(bottom: 16, right: 16)
        addButton.rx.tap
            .bind(to: vm.addRandom)
            .disposed(by: disposeBag)

        tableView.register(Cell.self, forCellReuseIdentifier: "Cell")
        vm.items
            .bind(to: tableView.rx.items) { (tableView, row, model) in
                let cell = tableView.dequeueReusableCell(withIdentifier: "Cell") as! Cell
                cell.model = model
                cell.disposeBag = DisposeBag()
                cell.removeButton.rx.tap
                    .map { _ in model }
                    .bind(to: self.vm.remove)
                    .disposed(by: cell.disposeBag!)
                return cell
            }
            .disposed(by: disposeBag)
    }
}

Note that a common mistake is creating the DisposeBag inside Cell only once, this will causing confusing when the action got triggered.

The DisposeBag must be re-created every time the Cell got reused.

A complete working example can be found here.

Upvotes: 2

Related Questions