iOS
Swift
RxSwift
RxDataSources

RxDataSources+UICollectionViewを使ったサンプルMVVMアプリを作る

概要

  • RxDataSourcesを使ったUICollectionViewの扱いについて書く
  • アーキテクチャはMVVMになぞる

この記事のターゲット

  • Swift による iOS アプリの開発経験が少しだけある(3ヶ月〜1年未満)
  • RxSwift(というかObservable)の概念がなんとなくわかる

完成イメージ

環境

  • Xcode 9.4
  • Swift 4.1
  • Cocoapods 1.5.3
    • R.swift
    • RxDatasources

Note:
:warning: R.swiftを正しく導入しないとこの記事のコードは動きません

Podfile

platform :ios, '11.4'
use_frameworks!

target 'RxCollectionViewSample' do
  pod 'RxDataSources'
  pod 'R.swift'
end

最初に

  • Main.storyboardを使いません。
    • 画面の定義は全てxibファイルを使います

画面の作成

TimelineViewController ArticleDetailViewController ArticleCollectionCell
スクリーンショット 2018-08-14 17.55.42.png スクリーンショット 2018-08-14 17.55.32.png スクリーンショット 2018-08-14 17.55.23.png
UICollectionViewのみ UILabel x2 UILabel x2

セルのコード定義

ArticleCollectionCell.swift
import UIKit

class ArticleCollectionCell: UICollectionViewCell {
    @IBOutlet weak var titleLabel: UILabel!
    @IBOutlet weak var dateLabel: UILabel!

    static let cellHeight: CGFloat = 70.0
    static let cellMargin: CGFloat = 8.0

    func update(title: String, date: Date) {
        titleLabel.text = title
        dateLabel.text = date.description
    }
}

データモデルの定義

Article.swift
import Foundation

struct Article {
    let title: String
    let updatedAt: Date
}

ニュース一覧を出すViewControllerの作成

TimelineViewController.swift
import UIKit
import RxSwift
import RxCocoa
import RxDataSources

typealias TimelineSectionModel = SectionModel<TimelineSection, TimelineItem>

enum TimelineSection {
    case news
}

enum TimelineItem {
    case article(article: Article)
    // case ad(ad: Advertise) ニュース記事の他に広告を挿入したい場合はここに追加する
}

class TimelineViewController: UIViewController {

    @IBOutlet weak var collectionView: UICollectionView!

    private lazy var dataSource = RxCollectionViewSectionedReloadDataSource<TimelineSectionModel>(configureCell: configureCell)

    private lazy var configureCell: RxCollectionViewSectionedReloadDataSource<TimelineSectionModel>.ConfigureCell = { [weak self] (_, tableView, indexPath, item) in
        guard let strongSelf = self else { return UICollectionViewCell() }
        switch item {
        case .article(let article):
            return strongSelf.articleCell(indexPath: indexPath, article: article)
        }
    }

    private var viewModel: TimelineViewModel!
    private let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()
        setupViewController()
        setupCollectionView()
        setupViewModel()
    }

}

extension TimelineViewController {
    private func setupViewController() {
        self.navigationItem.title = "ニュース"
    }

    private func setupCollectionView() {
        collectionView.contentInset.top = ArticleCollectionCell.cellMargin
        collectionView.register(R.nib.articleCollectionCell(), forCellWithReuseIdentifier: R.nib.articleCollectionCell.identifier)
        collectionView.rx.setDelegate(self).disposed(by: disposeBag)
        collectionView.rx.itemSelected
            .map { [weak self] indexPath -> TimelineItem? in
                return self?.dataSource[indexPath]
            }
            .subscribe(onNext: { [weak self] item in
                // セルをタップしたとき、ここが呼ばれる
                guard let item = item else { return }
                switch item {
                case .article(let article):
                    self?.presentArticleDetailViewController(article: article)
                }
            })
            .disposed(by: disposeBag)
    }

    private func setupViewModel() {
        viewModel = TimelineViewModel()
        // ここでdataSourceをdrive(bind)しておくことで、データの更新&Viewの更新をきにしなくてもよくなる
        viewModel.items
            .asDriver()
            .drive(collectionView.rx.items(dataSource: dataSource))
            .disposed(by: disposeBag)
        viewModel.updateItems()
    }

    private func articleCell(indexPath: IndexPath, article: Article) -> UICollectionViewCell {
        if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: R.nib.articleCollectionCell.identifier, for: indexPath) as? ArticleCollectionCell {
            cell.update(title: article.title, date: article.updatedAt)
            return cell
        }
        return UICollectionViewCell()
    }

    private func presentArticleDetailViewController(article: Article) {
        let articleDetailViewController = ArticleDetailViewController(article: article)
        navigationController?.pushViewController(articleDetailViewController, animated: true)
    }
}

extension TimelineViewController: UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
        let item = dataSource[section]
        switch item.model {
        case .news:
            return UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0)
        }
    }

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        let item = dataSource[indexPath]
        switch item {
        case .article:
            let width = collectionView.bounds.width - (ArticleCollectionCell.cellMargin * 2)
            return CGSize(width: width, height: ArticleCollectionCell.cellHeight)
        }
    }
}

一覧画面のViewModelの設定

TimelineViewModel.swift
import RxSwift
import RxCocoa
import RxDataSources

class TimelineViewModel {
    let items = BehaviorRelay<[TimelineSectionModel]>(value: [])

    func updateItems() {
        var sections: [TimelineSectionModel] = []

        let item1 = TimelineItem.article(article: Article(title: "コミックマーケットでスリをした26歳男性逮捕", updatedAt: Date()))
        let item2 = TimelineItem.article(article: Article(title: "27日明け方頃から関東全域に大雨の予想", updatedAt: Date()))
        let item3 = TimelineItem.article(article: Article(title: "夫が知らない 妻の帰省ストレス", updatedAt: Date()))
        let articleSection = TimelineSectionModel(model: .news, items: [item1, item2, item3])
        sections.append(articleSection)

        items.accept(sections)
    }
}

ニュースセルをタップしたあとの記事詳細画面を作成

ArticleDetailViewController.swift
import UIKit

class ArticleDetailViewController: UIViewController {

    @IBOutlet weak var titleLabel: UILabel!
    @IBOutlet weak var dateLabel: UILabel!

    private var article: Article?

    convenience init(article: Article) {
        self.init(nib: R.nib.articleDetailViewController)
        self.article = article
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        self.titleLabel.text = article?.title
        self.dateLabel.text = article?.updatedAt.description
    }
}

AppDelegateの設定

AppDelegate.swift
import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        self.window = UIWindow(frame: UIScreen.main.bounds)
        let navigationController = UINavigationController(rootViewController: TimelineViewController())
        self.window?.rootViewController = navigationController
        self.window?.makeKeyAndVisible()
        return true
    }

}
  • IBOutletやUIの作成がうまくできていればこれで一通り動かせると思います。

カスタマイズする

  • データの追加(セルの追加)は、 TimelineViewModelupdateItems()で行っています。
    • つまり、 updateItems をうまくいじればデータベースから引っ張ってきたデータ、APIからひっぱってきたデータを表示することができます。
    • また、Viewの更新はRxDataSourcesがうまくやってくれているので、TimelineViewController は特に意識しなくても良くなります
    // データをupdateItemsの引数に渡す場合の例
    func updateItems(articles: [Article]) {
        var sections: [TimelineSectionModel] = []

        let articleItems = articles.map { TimelineItem.article(article: $0) }
        let articleSection = TimelineSectionModel(model: .news, items: articleItems)
        sections.append(articleSection)

        items.accept(sections)
    }