しおメモ

雑多な技術系ブログです。

TableViewのアニメーションでRxDataSourcesを使ってみた

RxDataSourcesはRxSwiftやRxCocoaの補助的なライブラリで、 UITableViewUICollectionViewのDataSourceの追加、削除などの変更を検知して、RxSwiftに橋渡ししてくれます。

特に、混み行ったことをやろうとすると、これらの更新の判定ロジックは複雑になりがちなので、そこの部分の負担を減らすことができます。

はじめに

今回は、MVVMでTable Viewの場合を例とします。 解説用に、シンプルな例を用意しました👇

github.com

MVVMではないですが、RxDataSourcesのGitHubにもサンプルがあります。

RxDataSources/Examples/Example at master · RxSwiftCommunity/RxDataSources

DataSourceの種類

DataSourceは2種類用意されています。

  • RxTableViewSectionedReloadDataSource
  • RxTableViewSectionedAnimatedDataSource

前者は、アニメーションをしないTable View向けで、更新があった際は単純に、TableView.reloadData()が呼ばれます。
一方後者は、追加・削除・移動に対して個別のアニメーション(UITableView.RowAnimation)を設定することができ、それらの更新イベントに対して、設定したアニメーションがビューの更新時に反映されます。

Modelの実装

CellのModel

RxDataSourcesは、モデルにわり振られたidentityを用いて差分を確認して、更新の有無を確認します。
そのため、IdentifiableTypeEquatableに適合している必要があります。

例では、dateはボタンを押した時刻をミリ秒まで文字列としているため、厳密には一意でないですが、これをidentityとします。

// DateListCell.swift

struct DateListCell {
    let date: String
}

extension DateListCell: IdentifiableType {
    typealias Identity = String
    var identity: Identity {
        return date
    }
}

extension DateListCell: Equatable {}

SectionModelとDataSourceの対応

Table Viewの場合、Sectionのモデルのプロトコルが用意されているので、それを実装します。

Model 対応するDataSource
SectionModel RxTableViewSectionedReloadDataSource
AnimatableSectionModelType RxTableViewSectionedAnimatedDataSource

AnimatableSectionModelTypeの方で実装してみます。

// DateListTableSection.swift

struct DateListTableSection: AnimatableSectionModelType {
    typealias Item = DateListCell
    typealias Identity = UInt

    var identity: UInt {
        return 0
    }
    var items: [Item]

    init(items: [Item]) {
        self.items = items
    }

    init(original: DateListTableSection, items: [Item]) {
        self = original
        self.items = items
    }
}

Itemはセクション内のセルのモデルなので、DateListCellを指定します。
Identityはセクションの識別子なので、実際は0でなくセクションのタイトルやIndexPath.rowを返すと良いと思います。

ViewModelの実装

先ほど定義したDateListTableSectionは、BehaviorRelayで持ちます。
また、ViewからのアクションはPublishRelayで受けます。

// DateListViewModel.swift

final class DateListViewModel: DateListViewModeling {
    private var tableSections: [DateListTableSection]
    private let disposeBag = DisposeBag()
    private(set) var sectionModelRelay: BehaviorRelay<[DateListTableSection]>
    let addingItemRequestRelay = PublishRelay<Date>()
    let removingItemRequestRelay = PublishRelay<IndexPath>()

    init() {
        tableSections = [DateListTableSection(items: [])]
        sectionModelRelay = BehaviorRelay<[DateListTableSection]>(value: tableSections)

        addingItemRequestRelay
            .subscribe(onNext: onAddingItem)
            .disposed(by: disposeBag)
        removingItemRequestRelay
            .subscribe(onNext: onRemovingItem)
            .disposed(by: disposeBag)
    }
}

extension DateListViewModel {
    private func onAddingItem(date: Date) {
        tableSections[0].items.append(
            DateListCell(date: date.string)
        )
        sectionModelRelay.accept(self.tableSections)
    }

    private func onRemovingItem(indexPath: IndexPath) {
        tableSections[indexPath.section].items.remove(at: indexPath.row)
        sectionModelRelay.accept(self.tableSections)
    }
}

ViewControllerの実装

RxTableViewSectionedAnimatedDataSourceを用意します。
UITableViewDataSourcetableView(_:cellForRowAt:)configureCellにあたります。

セルの追加や削除等があった際に、そのセクションがリロードされます。
その際のRowAnimationは、animationConfigurationで設定できます。

// DateListViewController.swift

extension DateListViewController {
    typealias DataSource = RxTableViewSectionedAnimatedDataSource<DateListTableSection>

    class func dataSource() -> DataSource {
        return DataSource(
            animationConfiguration: AnimationConfiguration(
                insertAnimation: .fade,
                reloadAnimation: .fade,
                deleteAnimation: .right
            ),
            configureCell: ({ (datasource, tableView, indexPath, dateCell) -> UITableViewCell in
                guard let dateListViewCell = tableView.dequeueReusableCell(withIdentifier: Identifiers.cell.rawValue) else {
                    fatalError("Failed to dequeue a cell")
                }

                dateListViewCell.textLabel?.text = dateCell.date
                return dateListViewCell
            }))
    }
}

viewDidLoadViewModelと結びつけます。

// DateListViewController.swift

class DateListViewController: UIViewController {
    // ...
    private(set) lazy var dataSource: DataSource = DateListViewController.dataSource()

    override func viewDidLoad() {
        super.viewDidLoad()

        viewModel.sectionModelRelay.asObservable()
            .bind(to: dateTableView.rx.items(dataSource: dataSource))
            .disposed(by: disposeBag)
        addButton.rx.tap
            .subscribe(onNext: { [weak self] in
                self?.viewModel.addingItemRequestRelay.accept(Date())
            })
            .disposed(by: self.disposeBag)
        dateTableView.rx.itemSelected
            .subscribe(onNext: { [weak self] indexPath in
                self?.viewModel.removingItemRequestRelay.accept(indexPath)
            })
            .disposed(by: self.disposeBag)
    }
}

考え中

identityの変更がない際は、Animatedの方ではリロードがされないので、
そのような場合にもビューをいじって、アニメーションを動かしたいときは、工夫が必要かもしれません。