iOS
Swift
RxSwift

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

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

RxDataSources+UICollectionViewを使ったサンプルMVVMアプリを作る
Pinterest風のUIを作ってみる

環境

名前 バージョン
Swift 4.2
RxSwift 4.4.0
RxDataSources 3.1.0

こんな感じ

3カラムでやったらこんな感じ
参考サイトだとセルの追加と削除ができなかったのでその辺は変えてあります
無限にガッキーとガッキーそっくりアイドルの栗子さんが見れて幸せになれます

ソースコード

RxCollectionViewController

まずはコントローラー
制約はこんな感じ
Screenshot from Gyazo

ソースはこんな感じ
ここにViewModelテストデータも書かれてます

RxCollectionViewController
import UIKit
import RxCocoa
import RxSwift
import RxDataSources
import RxKingfisher


//テストデータ
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?fit=1280%2C853&ssl=1"]
    ,names: ["あいうえお\nかきくけこさしすせそ\nなにぬねの\nはひふへほ", "まみむめも"])

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

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

//RxDatasourcesで使うmodel
typealias SampleSectionModel2 = AnimatableSectionModel<Int, SampleData2>

//MARK: - ViewModel
class SampleViewModel2 {

  typealias Data = SampleData2
  typealias SectionModel = SampleSectionModel2

  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.index(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])
  }
}


//MARK: - ViewController
class RxCollectionViewController: UIViewController {

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

  private let viewModel = SampleViewModel2()
  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<SampleSectionModel2>.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

ソースはこんな感じ

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を継承したのって初だからこんな感じでいいものか

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

  //1列分の幅
  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にあるよ

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

終わり

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