Как редактировать / удалять ячейки UICollectionView, используя MVVM и RxSwift - PullRequest
0 голосов
/ 05 января 2019

Я пытаюсь понять, как реализовать MVVM со списком объектов и UICollectionView. Я не понимаю, как реализовать пользовательскую итерацию -> Модель потока.

Я установил тестовое приложение , Модель - это просто класс с Int, а View - это UICollectionViewCell, который показывает текст с соответствующим значением Int и имеет кнопки плюс, минус и delete для увеличивать, уменьшать и удалять элемент соответственно. Каждая запись выглядит так: Я хотел бы знать, как лучше использовать MVVM и RxSwift для обновления / удаления ячейки.

У меня есть список случайно сгенерированных значений Int

let items: [Model]

Модель, которая просто имеет значение Int

class Model {
    var number: Int

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

Класс ViewModel, который просто содержит модель и имеет наблюдаемый

class ViewModel {

    var value: Observable<Model>

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

И клетка

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)
        ....
        }
    }
}

Я не совсем понимаю, как это сделать, как подключить кнопки к соответствующему действию, обновить модель и вид после этого.

Является ли ViewModel Cell ответственным за это? Должен ли он получать событие касания, обновлять модель и затем представление?

В случае удаления кнопка удаления ячейки должна удалить текущую модель из списка данных. Как это можно сделать, не смешивая все вместе?

Ответы [ 2 ]

0 голосов
/ 05 января 2019

Я делаю это так:

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)
    }
}

Обратите внимание, что распространенной ошибкой является создание DisposeBag внутри ячейки только один раз, что приведет к путанице при запуске действия.

DisposeBag должен создаваться заново каждый раз, когда ячейка используется повторно.

Полный рабочий пример можно найти здесь .

0 голосов
/ 05 января 2019

Вот проект с обновлениями ниже в GitHub: https://github.com/dtartaglia/RxCollectionViewTester

Первое, что мы делаем, - обрисовываем в общих чертах все наши входы и выходы. Выходные данные должны быть членами структуры модели представления, а входные данные должны быть членами входной структуры.

В этом случае у нас есть три входа из ячейки:

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

Один выход для самой ячейки (метка) и два выхода для родителя ячейки (предположительно, модель представления контроллера представления.)

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

Также нам нужно настроить ячейку так, чтобы она принимала заводскую функцию, чтобы она могла создавать экземпляр модели представления. Ячейка также должна иметь возможность сброса себя:

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)
    }
}

Теперь нам нужно построить метод init модели представления. Здесь происходит вся настоящая работа.

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
    }
}

Вы заметите, что метод init модели представления нуждается в большем, чем предусмотрено заводской функцией. Дополнительная информация будет предоставлена ​​контроллером представления при создании фабрики.

Контроллер вида будет иметь это в 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)

Для приведенного выше примера я предполагаю, что:

  • counters относится к типу Observable<[(id: UUID, value: Int)]> и относится к модели представления контроллера вида.
  • values имеет тип PublishSubject<(id: UUID, value: Int)> и вводится в модель представления контроллера вида.
  • deletes имеет тип PublishSubject<UUID> и вводится в модель представления контроллера вида.

Конструкция модели представления контроллера представления следует той же схеме, что и для ячейки:

Входы:

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

Выходы:

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

Логика:

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)
                    }
                }
        }
    }
}
...