- 2019.12.09 更新
- githubのリンクを更新してそのまま動くファイルに変更
- swift5に変更
- ViewModelのファイルを分けて中身を変更
タイトルの通りです。
あっちこっちのサイトをうろちょろしまくりましたが以下のサイトを混ぜた感じに落ち着きました
RxDataSources+UICollectionViewを使ったサンプルMVVMアプリを作る
Pinterest風のUIを作ってみる
環境
名前 | バージョン |
---|---|
Swift | 5 |
RxSwift | 5.0.1 |
RxDataSources | 4.0.1 |
Kingfisher | 5.11.0 |
こんな感じ
3カラムでやったらこんな感じ
参考サイトだとセルの追加と削除ができなかったのでその辺は変えてあります
無限にガッキーとガッキーそっくりアイドルの栗子さんが見れて幸せになれます
ソースコード
RxCollectionViewModel
まずはビューモデル
テストデータもここに書いてる
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
ソースはこんな感じ
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
ソース
はこんな感じ
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連携のソースもあるからそのままじゃ動かないかもしれない)
終わり
全然コードの解説がないままですいません
試行錯誤しながらようやく動いたやつを載せただけなのでコメントに書いてる以上のことを解説できるほど理解してない