RxDataSources+UICollectionViewでPinterest風のUIを作ってみる

- 2019.12.09 更新
    - githubのリンクを更新してそのまま動くファイルに変更
    - swift5に変更
    - ViewModelのファイルを分けて中身を変更


タイトルの通りです。
あっちこっちのサイトをうろちょろしまくりましたが以下のサイトを混ぜた感じに落ち着きました

[RxDataSources+UICollectionViewを使ったサンプルMVVMアプリを作る](https://qiita.com/k0uhashi/items/a37fba110ab0dc77f424)
[Pinterest風のUIを作ってみる](https://qiita.com/eKushida/items/8f8965467d098ca05120)

#環境
|名前|バージョン|
|:---|:-----|
|Swift|5|
|RxSwift|5.0.1|
|RxDataSources|4.0.1|
|Kingfisher|5.11.0|


# こんな感じ
3カラムでやったらこんな感じ
参考サイトだとセルの追加と削除ができなかったのでその辺は変えてあります
無限にガッキーとガッキーそっくりアイドルの栗子さんが見れて幸せになれます
<img src="https://i.gyazo.com/9c5d324b87d8da600d929741313c24c6.gif" width=50%>


# ソースコード

## RxCollectionViewModel

まずはビューモデル
テストデータもここに書いてる

```swift:RxCollectionViewModel
import RxCocoa
import RxSwift
import RxDataSources

//テストデータ
let TEST_DATA =
    (urlStrs:["https://sharelifestyle.info/wp-content/uploads/2018/01/Unknown-5.jpeg",
              "https://i1.wp.com/lovemimi-channel.com/wp-content/uploads/2018/09/cc015d525d521f5b6e69f7e6a41d379b.png"]
        ,names: ["","あいうえお\nかきくけこさしすせそ\nたちつてとなにぬねの\nはひふへほ", "まみむめも\nやゆよ\nらりるれろ\nわ\nを\nん\nAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBCCCCCCCCCCCCCCCCDDDDDDDD"])

//もとのデータ構造
struct SampleData {
    let id = UUID().uuidString
    let name: String
    let urlStr: String
}

//RxDatasourcesを利用するために必要なextension
extension SampleData: IdentifiableType, Equatable{
    var identity: String {return id}
    static func == (lhs: SampleData, rhs: SampleData) -> Bool {
        return lhs.identity == rhs.identity
    }
}

//RxDataSourcesで必要なものをprotocolでまとめる
protocol RxDataSourcesModelProtocol {
    //RxDataSourcesで使うデータ
    associatedtype Data:IdentifiableType,Equatable
    //RxDatasourcesで使うセクションのモデル
    typealias SectionModel = AnimatableSectionModel<Int, Data>
    //
    var dataObservable: Observable<[SectionModel]>{get}
}

//protocolを含めてViewModelを実装
class RxCollectionViewModel:RxDataSourcesModelProtocol {

    typealias Data = SampleData
    typealias SectionModel = AnimatableSectionModel<Int, Data>

    var dataObservable: Observable<[SectionModel]> {
        return dataRelay.asObservable()
    }

    private let dataRelay = BehaviorRelay<[SectionModel]>(value: [])

    private func fetch(shouldRefresh: Bool = false, type: Data) {
        var preItems = dataRelay.value.first?.items ?? []
        preItems.append(type)
        let items = shouldRefresh ? [type] : preItems
        let section = 0
        let sectionModel = SectionModel(model: section, items: items)
        dataRelay.accept([sectionModel])
    }

    func add() {
        let name = TEST_DATA.names.randomElement()!
        let urlStr = TEST_DATA.urlStrs.randomElement()!
        let data = Data(name: name, urlStr: urlStr)
        fetch(type: data)
    }

    func remove(item:Data){
        var preItems = dataRelay.value.first?.items ?? []
        guard let index = preItems.firstIndex(of: item) else { return }
        preItems.remove(at: index)
        let section = 0
        let sectionModel = SectionModel(model: section, items: preItems)
        dataRelay.accept([sectionModel])
    }

    func remove(){
        var preItems = dataRelay.value.first?.items ?? []
        guard preItems.count > 0 else {return}
        preItems.removeLast()
        let section = 0
        let sectionModel = SectionModel(model: section, items: preItems)
        dataRelay.accept([sectionModel])
    }
}

```

## RxCollectionViewController

次はコントローラー
`制約`はこんな感じ
[![Screenshot from Gyazo](https://gyazo.com/12861c745e0a0ede0be9232949d36361/raw)](https://gyazo.com/12861c745e0a0ede0be9232949d36361)

ソースはこんな感じ

```swift:RxCollectionViewController

import UIKit
import RxCocoa
import RxSwift
import RxDataSources

class RxCollectionViewController: UIViewController {

    typealias Cell = RxCollectionViewCell
    private let CELL_CLASS = Cell.self
    private let CELL_ID = NSStringFromClass(Cell.self)

    private let viewModel = RxCollectionViewModel()
    private let disposeBag = DisposeBag()

    @IBOutlet weak var button1: UIButton!
    @IBOutlet weak var button2: UIButton!

    @IBOutlet weak var collectionView: UICollectionView!{
        didSet{
            guard let nibName = NSStringFromClass(CELL_CLASS).components(separatedBy: ".").last else{return}
            collectionView.register(UINib(nibName: nibName, bundle: nil), forCellWithReuseIdentifier: CELL_ID)
            if let layout = collectionView.collectionViewLayout as? PinterestLayout {
                layout.delegate = self
            }
        }
    }

    lazy var dataSource = RxCollectionViewSectionedAnimatedDataSource<RxCollectionViewModel.SectionModel>.init(
        animationConfiguration: AnimationConfiguration(insertAnimation: .fade, reloadAnimation: .none, deleteAnimation: .fade),
        configureCell:{[weak self] (dataSource, collectionView, indexPath, item) -> UICollectionViewCell in
            guard
                let wSelf = self,
                let cell = collectionView.dequeueReusableCell(withReuseIdentifier: wSelf.CELL_ID, for: indexPath) as? Cell else {
                    return UICollectionViewCell()
            }
            cell.configCell(text: item.name, urlStr: item.urlStr)
            return cell
    })


    override func viewDidLoad() {
        super.viewDidLoad()

        collectionView.rx.setDelegate(self).disposed(by: self.disposeBag)

        viewModel.dataObservable.bind(to: collectionView.rx.items(dataSource: dataSource)).disposed(by: self.disposeBag)

        viewModel.dataObservable.subscribe(onNext: {[weak self] (data) in
            self?.button2.isEnabled = (data.first?.items.count ?? 0) != 0
        }).disposed(by: self.disposeBag)

        button1.rx.tap.asDriver().drive(onNext: {[weak self] (_) in
            self?.viewModel.add()
        }).disposed(by: disposeBag)

        button2.rx.tap.asDriver().drive(onNext: {[weak self] (_) in
            self?.viewModel.remove()
        }).disposed(by: disposeBag)
    }
}

extension RxCollectionViewController: UICollectionViewDelegate {
}


extension RxCollectionViewController:PinterestLayoutDelegate{

    //セルの高さを返す
    func cellHeight(collectionView: UICollectionView, indexPath: IndexPath, cellWidth: CGFloat) -> CGFloat {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CELL_ID, for: indexPath) as? Cell else{
            return 0
        }

        let item = dataSource[indexPath]

        //この時点でsetNeedsLayout(), layoutIfNeeded()しても高さが求められないので専用メソッドを呼ぶ
        cell.configCell(text: item.name, urlStr: item.urlStr)
        let h = cell.height(cellWidth: cellWidth)

        return h
    }
}




```

## RxCollectionViewCell1
次はセル
`制約`はこんな感じ
[![Screenshot from Gyazo](https://gyazo.com/f47f8be0d966a831346b6b9eb720cdbf/raw)](https://gyazo.com/f47f8be0d966a831346b6b9eb720cdbf)

`ソース`はこんな感じ

```swift:RxCollectionViewCell1
import UIKit

class RxCollectionViewCell1: UICollectionViewCell {

  @IBOutlet weak var imageView: UIImageView!
  @IBOutlet weak var textLabel: UILabel!
  @IBOutlet weak var imageAspectRatioConst: NSLayoutConstraint!

  func configCell(text:String, urlStr:String){
    
    if let url = URL.init(string: urlStr){
      self.imageView.kf.setImage(with: url, placeholder: nil, options: nil, progressBlock: nil) { (_, error, _, _) in
        //        DLog("finish !! \(error.debugDescription)")
      }
    }

    if text.count != 0{
      self.textLabel.text = text
    }

    self.layoutIfNeeded()
  }

  override func awakeFromNib() {
    super.awakeFromNib()
    // Initialization code
  }

  override func prepareForReuse() {
    super.prepareForReuse()
    self.configCell(text: "", urlStr: "")
  }

  //セルの高さを取得する
  func height(cellWidth: CGFloat) -> CGFloat {

    let ivHeight = cellWidth / imageAspectRatioConst.multiplier

    self.textLabel.sizeToFit()
    let lblHeight = self.textLabel.frame.height

    //ラベルの高さ計算こっちの方が速い？？
    //    let text = self.textLabel.text ?? ""
    //    let lblHeight = NSString(string: text).boundingRect(
    //      with: CGSize(width: cellWidth, height: CGFloat(MAXFLOAT)),
    //      options: .usesLineFragmentOrigin,
    //      attributes: [NSAttributedString.Key.font: self.textLabel.font], context: nil).height

    return ceil(ivHeight + lblHeight)
  }
}
```

## PinterestLayout

Pinterestっぽくするキモの部分
`UICollectionViewLayout`を継承したのって初だからこんな感じでいいものか

```swift:PinterestLayout

import UIKit

//MARK: - PinterestLayoutDelegate
protocol PinterestLayoutDelegate {
  func cellHeight(collectionView: UICollectionView,
                  indexPath: IndexPath,
                  cellWidth: CGFloat) -> CGFloat
}

//MARK: - PinterestLayoutAttributes
class PinterestLayoutAttributes: UICollectionViewLayoutAttributes {

  var cellHeight:CGFloat = 0.0

  override func copy(with zone: NSZone?) -> Any {

    let copy = super.copy(with: zone) as! PinterestLayoutAttributes
    copy.cellHeight = cellHeight
    return copy
  }

  override func isEqual(_ object: Any?) -> Bool {
    guard let attributes = object as? PinterestLayoutAttributes,
      attributes.cellHeight == cellHeight else{return false}
    return super.isEqual(object)
  }
}

//MARK: - PinterestLayout
class PinterestLayout: UICollectionViewLayout {

  var delegate: PinterestLayoutDelegate?
  var cache = [(attributes:PinterestLayoutAttributes, height:CGFloat)]()

  let NUMBER_OF_COLUMN = 3    //カラム数
  let CELL_PADDING:CGFloat = 8.0 //セルのパディング

  var contentHeight:CGFloat = 0.0
  var contentWidth: CGFloat {
    let insets = collectionView!.contentInset
    return collectionView!.bounds.width - (insets.left + insets.right)
  }

  //１列分の幅
  var columnWidth:CGFloat{return contentWidth / CGFloat(NUMBER_OF_COLUMN)}
  //セルの幅
  var cellWidth:CGFloat{return columnWidth - CELL_PADDING * 2}

  override class var layoutAttributesClass : AnyClass {
    return PinterestLayoutAttributes.self
  }

  //MARK:- Layout LifeCycle
  // **** レイアウトを決めるのはここがキモですね ***
  //1. レイアウトの事前計算を行う
  override func prepare() {
    super.prepare()

    guard
      let collectionView = self.collectionView,
      collectionView.numberOfSections > 0,
      let delegate = self.delegate
      else{ return }

    //セルが消えていたらキャッシュを削除する
    self.removeCacheIfNeeded()

    //各セルのx,y座標を初期化
    var (xOffsets, yOffsets) = self.initXYOffset()

    //列番号のindexの初期化
    var columnIndex = 0

    for item in 0 ..< collectionView.numberOfItems(inSection: 0) {
      //ループの次のindexに行く前に情報を更新させるメソッド
      func loopNext(height:CGFloat){
        //各列のy座標のoffsetを更新
        yOffsets[columnIndex] = yOffsets[columnIndex] + height
        //列番号を更新
        columnIndex = (columnIndex + 1) % NUMBER_OF_COLUMN
      }

      guard item >= cache.count else{
        loopNext(height: cache[item].height)
        continue
      }

      let indexPath = IndexPath(item: item, section: 0)

      let cellHeight = delegate.cellHeight(collectionView: collectionView, indexPath: indexPath, cellWidth: self.cellWidth)

      let rowHeight = 2*CELL_PADDING + cellHeight

      let frame = CGRect(x: xOffsets[columnIndex],
                         y: yOffsets[columnIndex],
                         width: self.columnWidth,
                         height: rowHeight)

      do{//attributesを設定してキャッシュに登録
        let attributes = PinterestLayoutAttributes(forCellWith: indexPath)
        attributes.cellHeight = cellHeight
        attributes.frame = frame.insetBy(dx: self.CELL_PADDING, dy: self.CELL_PADDING)
        cache.append((attributes, rowHeight))
      }

      do{//次のループのために情報を更新する
        //最終的なコンテンツの高さ
        contentHeight = max(contentHeight, frame.maxY)
        loopNext(height: rowHeight)
      }
    }
  }

  //2. コンテンツのサイズを返す
  override var collectionViewContentSize : CGSize {
    return CGSize(width: contentWidth, height: contentHeight)
  }

  //3. 表示する要素のリストを返す
  override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
    return cache.filter {$0.attributes.frame.intersects(rect)}.map{$0.attributes}
  }
}

//MARK: - private
private extension PinterestLayout{

  func removeCacheIfNeeded(){
    guard
      let collectionView = self.collectionView,
      collectionView.numberOfSections > 0
    else{
      cache.removeAll()
      return
    }

    let numberOfItems = collectionView.numberOfItems(inSection: 0)
    if cache.count - numberOfItems > 0 {
      for _ in 0 ..< cache.count - numberOfItems{
        cache.removeLast()
      }
    }
  }

  func initXYOffset()->([CGFloat], [CGFloat]){
    //x座標のオフセットの初期化
    var xOffsets = [CGFloat]()
    for column in 0 ..< NUMBER_OF_COLUMN {
      xOffsets.append(CGFloat(column) * columnWidth)
    }
    //y座標のオフセットの初期化
    let yOffsets = [CGFloat](repeating: 0, count: NUMBER_OF_COLUMN)

    return (xOffsets, yOffsets)
  }
}
```

# [githubにあるよ](https://github.com/sakiyamaK/RxDataSourcesPinterest)

単独のプロジェクトに分けたのでそのまま実行できるよ

~~ここのソースのRxCollectionViewってやつです~~
~~(もしかしたらfirebase連携のソースもあるからそのままじゃ動かないかもしれない)~~

# 終わり
全然コードの解説がないままですいません
試行錯誤しながらようやく動いたやつを載せただけなのでコメントに書いてる以上のことを解説できるほど理解してない