如何使用 MVVM 和 RxSwift 编辑/删除 UICollectionView 单元格

Posted

技术标签:

【中文标题】如何使用 MVVM 和 RxSwift 编辑/删除 UICollectionView 单元格【英文标题】:How edit/delete UICollectionView cells using MVVM and RxSwift 【发布时间】:2019-01-04 22:18:34 【问题描述】:

我试图了解如何使用对象列表和 UICollectionView 实现 MVVM。我不明白如何实现用户迭代 -> 模型流。

我设置了一个test application,模型只是一个带有 Int 的类,而视图是一个 UICollectionViewCell,它显示了一个具有相应 Int 值的文本,并有加号、减号和删除按钮来增加、减少和删除分别是一个元素。 每个条目看起来像: 我想知道使用 MVVM 和 RxSwift 更新/删除单元格的最佳方式。

我有一个随机生成的 Int 值列表

let items: [Model]

只有 Int 值的模型

class Model 
    var number: Int

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

ViewModel 类,它只保存模型并具有 Observable

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

我不明白怎么做的是如何将按钮连接到相应的动作,然后更新模型和视图。

Cell 的 ViewModel 是否对此负责?它应该是接收点击事件,更新模型然后是视图的那个吗?

在删除情况下,单元格的删除按钮需要从数据列表中删除当前模型。如果不将所有内容混合在一起,如何做到这一点?

【问题讨论】:

【参考方案1】:

这里是 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)

对于上面的例子,我假设:

countersObservable&lt;[(id: UUID, value: Int)]&gt; 类型,来自视图控制器的视图模型。 values 的类型为 PublishSubject&lt;(id: UUID, value: Int)&gt;,并被输入到视图控制器的视图模型中。 deletes 的类型为 PublishSubject&lt;UUID&gt;,并被输入到视图控制器的视图模型中。

视图控制器的视图模型的构造遵循与单元相同的模式:

输入:

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)
                    
                
        
    

【讨论】:

感谢您的解释和提出更改建议的请求 :) !【参考方案2】:

我是这样做的:

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)
    

请注意,一个常见的错误是只在 Cell 中创建 DisposeBag 一次,这会在触发操作时造成混乱。

DisposeBag 必须在每次重用 Cell 时重新创建

可以在here 找到完整的工作示例。

【讨论】:

以上是关于如何使用 MVVM 和 RxSwift 编辑/删除 UICollectionView 单元格的主要内容,如果未能解决你的问题,请参考以下文章

传递数据 MVVM 和 RxSwift

如何在MVVM架构中使用RxSwift发送参数来查看模型?

我应该如何访问有关用于使用RxSwift和MVVM填充表格视图的数组的数据

RxSwift MVVM 如何使用项目管理器设置视图模型?

MVVM + RXSwift+ Coordinator 如何设置数据?

如何在 MVVM-C RxSwift 中实现 firebase 身份验证