Edited at

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連携のソースもあるからそのままじゃ動かないかもしれない)


終わり

全然コードの解説がないままですいません

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