LoginSignup
51
45

More than 5 years have passed since last update.

Pinterest風レイアウト(+カラム数動的変更機能)をつくる

Last updated at Posted at 2016-03-31

タイトルの通り、Pinterest風のCollectionViewレイアウトを作成します。
Googleフォトのようにピンチイン・アウトでカラム数を変更できるおまけもつけてみます。

最終的にはこんな感じになります

※ gifなのでカクカクしてますが、実際はもっと滑らかに動きます

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

この状態でアプリを実行すると以下のような挙動になります。
scattered.gif

セルがバラバラに表示されて列数も定まっていません。

では、作成した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)
    }
}

こちらで実行してみると・・・

adaptive.gif

セルが整列しました!!

ピンチイン・アウトで列数を変化させる

最後に列数を動的に変化させる処理を実装してすべて完了となります。

列数を変化させる処理は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()
            }
        }
    }

}

stream.gif

完成!

課題

実際に利用する際はセルの高さ計算の処理などもあるので、パフォーマンス面で何かしらの工夫が必要になるケースもあるかと思います。
適宜キャッシュやestimatedItemSizeなどを利用してプロダクトにあったチューニングを行うことで解決できるかと思います。

ソース

以下に置いてあります
https://github.com/chocoyama/AdaptiveItemSizeLayout

51
45
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
51
45