タイトルの通り、Pinterest風のCollectionViewレイアウトを作成します。
Googleフォトのようにピンチイン・アウトでカラム数を変更できるおまけもつけてみます。
最終的にはこんな感じになります
※ gifなのでカクカクしてますが、実際はもっと滑らかに動きます
ViewControllerの実装
Layoutを適用するViewControllerで最低限必要な処理は以下に示すものだけです。
layoutの初期化
let configuration = AdaptiveItemSizeLayout.Configuration()
let layout = AdaptiveItemSizeLayout(configuration: configuration)
layout.delegate = self
collectionView.setCollectionViewLayout(layout, animated: false)
セルのサイズ指定
func sizeForItemAtIndexPath(indexPath: NSIndexPath) -> CGSize {
return someSize
}
カラム数の変更
incrementColumn()
decrementColumn()
以上です。
では実装を始めていきます。
レイアウトクラスの作成
まずは、レイアウトのクラスを作成しないことには始まらないので、AdaptiveItemSizeLayoutという名前でクラスを作成します。
class AdaptiveItemSizeLayout: UICollectionViewLayout {
}
Configurationの作成
レイアウトクラスができたら、その中にCollectionViewのレイアウトの基本設定を外部から注入できるように設定用のオブジェクトをstructで作ります
今回設定できるようにしているのは以下のプロパティですが、必要に応じてその他のプロパティを増やすと良いかと思います。
- columnCount: 表示するカラム数
- minColumnCount: 最小カラム数
- maxColumnCount: 最大カラム数
- minimuInterItemSpacing: セル同士の水平方向スペースの幅
- minimumLineSpacing: セル同士の鉛直方向のスペースの幅
- sectionInsets: CollectionViewのsectionInsets
また、initではデフォルト値を設定しておき、変更したいものだけ指定するという形をとります。
class AdaptiveItemSizeLayout: UICollectionViewLayout {
struct Configuration {
var columnCount: Int
var minColumnCount: Int
var maxColumnCount: Int
var minimumInterItemSpacing: CGFloat
var minimumLineSpacing: CGFloat
var sectionInsets: UIEdgeInsets
init(
columnCount: Int = 2,
minColumnCount: Int = 1,
maxColumnCount: Int = Int.max,
minimumInterItemSpacing: CGFloat = 5.0,
minimumLineSpacing: CGFloat = 10.0,
sectionInsets: UIEdgeInsets = UIEdgeInsetsZero
) {
self.columnCount = columnCount
self.minColumnCount = minColumnCount
self.maxColumnCount = maxColumnCount
self.minimumInterItemSpacing = minimumInterItemSpacing
self.minimumLineSpacing = minimumLineSpacing
self.sectionInsets = sectionInsets
}
}
}
ViewControllerに適用してもらうプロトコルを用意
AdaptiveItemSizeLayoutableプロトコルを用意します。
ここではsizeForItemAtIndexPathメソッドを定義します。
Layoutはこのデリゲートメソッドからサイズを受け取り、縦横比を維持したまま列数に応じてサイズと座標を動的に計算していくことになります。
また、レイアウトクラスにデリゲートを保持するプロパティをweak属性を付けて作っておきます
protocol AdaptiveItemSizeLayoutable: class {
func sizeForItemAtIndexPath(indexPath: NSIndexPath) -> CGSize
}
class AdaptiveItemSizeLayout: UICollectionViewLayout {
~~
weak var delegate: AdaptiveItemSizeLayoutable?
~~
}
セルのサイズ計算周りの準備
ここが重要な部分ですが、まず方針は以下のようにします。
- 個々のセルのサイズと座標はカラムという単位でまとめる
- セルの配置箇所はそのセルのサイズが計算される時点で一番高さが少ないカラムに配置する
実装は以下の通りです。
- 1つのカラムを表現するColumnクラスを作成。Columnクラスはその列に属する複数のセルのサイズと座標を保持する
- すべてのColumnオブジェクトを管理するColumnContainerクラスを作成する
- レイアウトクラスはprepareLayoutで以下の手順を繰り返し行う
- ColumnContainerにitemSizeを追加する
- ColumnContainerは管理するColumnの中から最も全体の高さが少ないものを呼び出す
- 呼び出したColumnに対してサイズと座標(UICollectionViewLayoutAttributes)を格納する
では実際にクラスを作成します。プロパティとしてColumnインスタンスの配列とColumnContainerのインスタンスをそれぞれを持たせておきます。
class Column {
}
class ColumnContainer {
private var columns = [Column]()
}
class AdaptiveItemSizeLayout: UICollectionViewLayout {
~~
private var columnContainer: ColumnContainer
~~
}
各初期化処理
Column
class Column {
private let configuration: AdaptiveItemSizeLayout.Configuration
private let columnNumber: Int
private var attributesSet = [UICollectionViewLayoutAttributes]() // Columnが管理するUICollectionViewLayoutAttributes配列
private(set) var maxY: CGFloat = 0.0 // カラムの最も下のY座標を示す
// 初期化の引数としてConfigurationオプジェクトと、そのColumnが何番目のカラムなのかを表すcolumnNumberをとる
init(configuration: AdaptiveItemSizeLayout.Configuration, columnNumber: Int) {
self.configuration = configuration
self.columnNumber = columnNumber
}
}
ColumnContainer
class ColumnContainer {
private var columns = [Column]()
private let configuration: AdaptiveItemSizeLayout.Configuration
// 初期化の引数としてConfigurationオブジェクトをとる
init(configuration: AdaptiveItemSizeLayout.Configuration) {
self.configuration = configuration
columns = [Column]()
// Configurationオブジェクトから表示カラム数を取り出して、その分のColumnオブジェクトの配列を作って初期化する
(0..<configuration.columnCount).forEach{
let column = Column(configuration: configuration, columnNumber: $0)
self.columns.append(column)
}
}
}
AdaptiveItemSizeLayout
class AdaptiveItemSizeLayout: UICollectionViewLayout {
~~
private var columnContainer: ColumnContainer
private var configuration = Configuration()
init(configuration: Configuration? = nil) {
if let configuration = configuration {
self.configuration = configuration
}
// ColumnContainerオブジェクトの初期化を行います。
self.columnContainer = ColumnContainer(configuration: self.configuration)
super.init()
}
required init?(coder aDecoder: NSCoder) {
// ColumnContainerオブジェクトの初期化を行います。
self.columnContainer = ColumnContainer(configuration: self.configuration)
super.init(coder: aDecoder)
}
}
計算処理の実装
前準備としてConfigurationに計算型プロパティやメソッドを作っておきます。
struct Configuration {
~~
var atMaxColumn: Bool {
return (columnCount == maxColumnCount)
}
var atMinColumn: Bool {
return (columnCount == minColumnCount)
}
var totalSpace: Int {
return columnCount - 1
}
// 列数やマージンなどの設定値をもとにカラムの幅を決定する
var itemWidth: CGFloat {
let totalHorizontalInsets = sectionInsets.left + sectionInsets.right
let totalInterItemSpace = minimumInterItemSpacing * CGFloat(totalSpace)
let itemWidth = (UIScreen.mainScreen().bounds.width - totalHorizontalInsets - totalInterItemSpace) / CGFloat(columnCount)
return itemWidth
}
// サイズの縦横比を維持した状態で、カラムの幅に応じた高さを返す
func itemHeight(rawItemSize rawItemSize: CGSize) -> CGFloat {
let itemHeight = rawItemSize.height * itemWidth / rawItemSize.width
return itemHeight
}
}
ようやくですが、計算の処理を実装していきます。
各オブジェクトが担う役割は以下となっているので、それに沿う形でメソッドを作っていきます
- AdaptiveItemSizeLayout: delegate先からサイズ情報を取得する。ColumnContainerにサイズの保存を委譲し、適切なColumnに値を受け渡してもらう
- ColumnContainer: すべてのColumnを管理し、Layoutクラスから渡されたサイズ情報を適切なColumnに振り分ける
- Column: ColumnContainerから渡された情報をもとにサイズ調整や座標の決定を行う。管理するすべてのセルのUICollectionViewLayoutAttributesを管理する
AdaptiveItemSizeLayout
class AdaptiveItemSizeLayout {
~~
override func prepareLayout() {
super.prepareLayout()
guard let collectionView = collectionView else { return }
reset()
for section in (0..<collectionView.numberOfSections()) {
for item in (0..<collectionView.numberOfItemsInSection(section)) {
let indexPath = NSIndexPath(forItem: item, inSection: section)
let itemSize = delegate?.sizeForItemAtIndexPath(indexPath) ?? CGSize.zero
columnContainer.addAttributes(indexPath, itemSize: itemSize)
}
}
}
override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return columnContainer.all.flatMap{ $0.getAttributes(rect) }
}
override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes? {
return columnContainer.all.flatMap{ $0.getAttributes(indexPath) }.first
}
override func collectionViewContentSize() -> CGSize {
let width = collectionView?.bounds.width ?? CGFloat.min
let height = columnContainer.bottom
return CGSize(width: width, height: height)
}
private func reset() {
columnContainer.reset()
}
}
ColumnContainer
class ColumnContainer {
~~
// 全てのカラムの中から最も高さのあるカラムの最大Y座標(+sectionInsets)を返す
var bottom: CGFloat {
let bottomItem = columns.sort{ $0.0.maxY < $0.1.maxY }.last
if let maxY = bottomItem?.maxY {
return maxY + configuration.sectionInsets.bottom
} else {
return CGFloat.min
}
}
var all: [Column] {
return columns
}
// 次にサイズを格納すべきColumnオブジェクトを返す
var next: Column? {
let sortedColumns = columns.sort{ $0.0.maxY < $0.1.maxY }
return sortedColumns.first
}
func reset() {
let count = columns.count
columns = [Column]()
(0..<count).forEach{
let column = Column(configuration: configuration, columnNumber: $0)
self.columns.append(column)
}
}
// カラムにデータを保存する
func addAttributes(indexPath: NSIndexPath, itemSize: CGSize) {
next?.addAttributes(indexPath, itemSize: itemSize)
}
}
Column
class Column {
~~
// セルのX座標を返す
private var originX: CGFloat {
var x = configuration.sectionInsets.left
if columnNumber != 0 {
x += (configuration.itemWidth + configuration.minimumInterItemSpacing) * CGFloat(columnNumber)
}
return x
}
// セルのX座標を返す
private var originY: CGFloat {
return (attributesSet.count == 0) ? configuration.sectionInsets.top : maxY + configuration.minimumLineSpacing
}
// Configurationに応じて表示サイズと座標を計算して保持する
func addAttributes(indexPath: NSIndexPath, itemSize: CGSize) {
let attributes = UICollectionViewLayoutAttributes(forCellWithIndexPath: indexPath)
attributes.frame = CGRect(x: originX, y: originY, width: configuration.itemWidth, height: configuration.itemHeight(rawItemSize: itemSize))
maxY = attributes.frame.maxY
attributesSet.append(attributes)
}
// 保持しているattributesをNSIndexPathをもとに検索する
func getAttributes(indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes? {
return attributesSet.filter{
$0.indexPath.section == indexPath.section && $0.indexPath.item == indexPath.item
}.first
}
// 保持しているattributesをCGRectをもとに検索する
func getAttributes(rect: CGRect) -> [UICollectionViewLayoutAttributes] {
return attributesSet.filter{ CGRectIntersectsRect($0.frame, rect) }
}
}
以上でレイアウトの計算処理はすべて実装完了です!
正しくレイアウトされるかViewControllerにレイアウトを適用してみましょう!
ViewControllerにレイアウトを適用する
まず、以下にレイアウトを適用していない状態のViewControllerを示します。
class ViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
@IBOutlet weak var collectionView: UICollectionView!
override func viewDidLoad() {
super.viewDidLoad()
}
func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 1000
}
func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCellWithReuseIdentifier("AdaptiveSizeCollectionViewCell", forIndexPath: indexPath)
cell.backgroundColor = randomColor
return cell
}
func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize {
return randomSize
}
private var randomColor: UIColor {
let r = (CGFloat(arc4random_uniform(255)) + 1) / 255
let g = (CGFloat(arc4random_uniform(255)) + 1) / 255
let b = (CGFloat(arc4random_uniform(255)) + 1) / 255
return UIColor(red: r, green: g, blue: b, alpha: 1.0)
}
private var randomSize: CGSize {
let min: CGFloat = 150.0
let max: CGFloat = 300.0
let diff = UInt32(max - min)
let width = CGFloat(arc4random_uniform(diff)) + min
let height = CGFloat(arc4random_uniform(diff)) + min
return CGSize(width: width, height: height)
}
}
セルがバラバラに表示されて列数も定まっていません。
では、作成したAdaptiveItemSizeLayoutをViewControllerに適用してみましょう。
変更後のコードは以下のようになります。先ほどの実装からほとんど変更をせずにレイアウトを適用できることがわかるかと思います。
class ViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource, AdaptiveItemSizeLayoutable {
@IBOutlet weak var collectionView: UICollectionView!
var layout = AdaptiveItemSizeLayout()
override func viewDidLoad() {
super.viewDidLoad()
initLayout()
}
func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 1000
}
func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCellWithReuseIdentifier("AdaptiveSizeCollectionViewCell", forIndexPath: indexPath)
cell.backgroundColor = randomColor
return cell
}
private func initLayout() {
layout.delegate = self
collectionView.setCollectionViewLayout(layout, animated: false)
}
func sizeForItemAtIndexPath(indexPath: NSIndexPath) -> CGSize {
return randomSize
}
private var randomColor: UIColor {
let r = (CGFloat(arc4random_uniform(255)) + 1) / 255
let g = (CGFloat(arc4random_uniform(255)) + 1) / 255
let b = (CGFloat(arc4random_uniform(255)) + 1) / 255
return UIColor(red: r, green: g, blue: b, alpha: 1.0)
}
private var randomSize: CGSize {
let min: CGFloat = 150.0
let max: CGFloat = 300.0
let diff = UInt32(max - min)
let width = CGFloat(arc4random_uniform(diff)) + min
let height = CGFloat(arc4random_uniform(diff)) + min
return CGSize(width: width, height: height)
}
}
こちらで実行してみると・・・
セルが整列しました!!
ピンチイン・アウトで列数を変化させる
最後に列数を動的に変化させる処理を実装してすべて完了となります。
列数を変化させる処理はAdaptiveItemSizeLayoutableのProtocolExtensionで実装します。
layoutプロパティとcollectionViewプロパティをプロトコルに追加します
protocol AdaptiveItemSizeLayoutable: class {
var layout: AdaptiveItemSizeLayout { get set }
var collectionView: UICollectionView! { get }
func sizeForItemAtIndexPath(indexPath: NSIndexPath) -> CGSize
}
プロトコルを採用するのがUIViewControllerのサブクラスであることを前提に振る舞いを定義します
extension AdaptiveItemSizeLayoutable where Self: UIViewController {
}
実装するのは以下のメソッドです
- reloadLayout: レイアウトを更新
- incrementColumn: カラム数を増加させる
- decrementColumn: カラム数を減少させる
extension AdaptiveItemSizeLayoutable where Self: UIViewController {
func reloadLayout() {
let layout = AdaptiveItemSizeLayout(configuration: self.layout.configuration)
layout.delegate = self
collectionView.setCollectionViewLayout(layout, animated: true) { [weak self] (result) in
self?.collectionView.reloadData()
self?.layout = layout
}
}
func incrementColumn() -> Bool {
guard !layout.configuration.atMaxColumn else { return false }
layout.configuration.columnCount += 1
reloadLayout()
return true
}
func decrementColumn() -> Bool {
guard !layout.configuration.atMinColumn else { return false }
layout.configuration.columnCount -= 1
reloadLayout()
return true
}
}
ProtocolExtensionを定義したので、あとはViewControllerが適切なタイミングでincrementColumn() or decrementColumn()を呼び出すだけです。
今回のケースではピンチジェスチャーが実行された際にこの処理を呼び出します
class ViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource, AdaptiveItemSizeLayoutable {
~~
@IBAction func didRecognizedPinchGesture(sender: UIPinchGestureRecognizer) {
if case .Ended = sender.state {
if sender.scale > 1.0 {
decrementColumn()
} else if sender.scale < 1.0 {
incrementColumn()
}
}
}
}
完成!
課題
実際に利用する際はセルの高さ計算の処理などもあるので、パフォーマンス面で何かしらの工夫が必要になるケースもあるかと思います。
適宜キャッシュやestimatedItemSizeなどを利用してプロダクトにあったチューニングを行うことで解決できるかと思います。
ソース
以下に置いてあります
https://github.com/chocoyama/AdaptiveItemSizeLayout