UICollectionViewでタグが左寄せに並んでいるようなレイアウトを実現する

  • 86
    Like
  • 5
    Comment

イントロダクション

UICollectionViewで、Webページによくあるタグが左寄せに並んでいるレイアウトを実現します。
デフォルトで指定されているレイアウトクラスのUICollectionViewFlowLayoutだと、下のように可変サイズのセルの時にセル間のスペースが行によって変化してしまいます。

そこで独自のレイアウトクラスを作ることで、セル間のスペースが一定で左寄せになっているCollectionViewを作りたいと思います。UIKitに用意されているレイアウトクラスには、UICollectionViewLayoutとそれを継承して作られたUICollectionViewFlowLayoutがあります。今回はUICollectionViewのデフォルトレイアウトクラスであるUICollectionViewFlowLayoutを継承して、左寄せのレイアウトクラスを作ります(セル間のスペースが一定かつ左寄せになってくれれば良いため、UICollectionViewFlowLayoutを使って最低限の実装に抑えます)。

この実装により、最終的には下のようなレイアウトのUICollectionViewが作られます。

UICollectionViewFlowLayoutのライフサイクル

実装の前に、今回必要になる部分だけに絞ってレイアウト決定のライフサイクルを説明します。

UICollectionViewFlowLayout(とその親クラスであるUICollectionViewLayout)は、各IndexPathのセルのレイアウト属性をUICollectionViewLayoutAttributesというクラスで管理します。まずはじめにprepareLayout()というメソッドが呼ばれ、各IndexPathのレイアウト属性を一旦全て計算して、位置やサイズが決定されます。その後、CollectionViewがスクロールされる度にlayoutAttributesForElementsInRect(_:)が呼ばれ、計算した各IndexPathのレイアウト属性から表示領域内のものを返します。セルの挿入や削除などCollectionViewの要素が動的に変わる場合には、layoutAttributesForItemAtIndexPath(_:)が影響を受けるセルに対して呼ばれて、レイアウトがアップデートされいます。

まとめると次のような順番になります。

  1. prepareLayout
    • はじめに呼ばれ、全てのセルのレイアウト属性を事前に計算する
  2. layoutAttributesForElementsInRect(_:)
    • 表示領域が変わるときに呼ばれ、その範囲内のセルのレイアウト属性を配列で返す
  3. layoutAttributesForItemAtIndexPath(_:)
    • セルの要素の追加・削除で呼ばれ、影響を受けるセルのレイアウト属性を再計算して返す

今回の実装を実現するために、layoutAttributesForElementsInRect(:)とlayoutAttributesForItemAtIndexPath(:)をオーバーライドします。表示領域内のレイアウト属性が必要とされるタイミングで、UICollectionViewFlowLayoutがprepareLayout()であらかじめ計算しておいてくれた各セルのAttributesを書き換えて左寄せにして返します。

実装方法

layoutAttributesForElementsInRect(_:)の実装

UICollectionViewFlowLayoutを継承したクラスを作成しましょう。
layoutAttributesForElementsInRect(:)をオーバーライドして、予め決定されている表示領域内のレイアウト属性を取得します。それぞれのレイアウト属性をlayoutAttributesForItemAtIndexPath(:)経由で書き換えます。経由させる理由はセルの要素の追加・削除などでにも対応するためで、必須ではありません。

    override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        // 表示領域のレイアウト属性を取得する。
        guard let attributes = super.layoutAttributesForElementsInRect(rect) else {
            return nil
        }

        // layoutAttributesForItemAtIndexPath(_:)で各レイアウト属性を書き換える       
        var attributesToReturn = attributes.map { $0.copy() as! UICollectionViewLayoutAttributes }
        for (index, attr) in attributes.enumerate() where attr.representedElementCategory == .Cell {
            attributesToReturn[index] = layoutAttributesForItemAtIndexPath(attr.indexPath) ?? UICollectionViewLayoutAttributes()
        }

        // デフォルトのものから書き換えたレイアウト属性を返す。
        return attributesToReturn
    }

layoutAttributesForItemAtIndexPath(_:)の実装

続いて、layoutAttributesForItemAtIndexPath(_:)をオーバーライドして、その中で各セルのレイアウト属性の補正をしていきます。全体的な流れとしては、以下になります。

  1. 先頭要素であればx座標をSection Insetの左端にする
  2. ひとつ前のセルを取得し、現在のセルと同じ行にあるか比較
  3. あればひとつ前のセルからセル間スペース分だけ空けた場所に、なければSection Insetの左端にする

これを全てのセルに対して行うことで左寄せを作っていきます。

    override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes? {
        guard let currentAttributes = super.layoutAttributesForItemAtIndexPath(indexPath)?.copy() as? UICollectionViewLayoutAttributes,
              let viewWidth = collectionView?.frame.width else {
                return nil
        }

        // sectionInsetの値を左端として取得。メソッドの中身は後述
        let sectionInsetsLeft = sectionInsets(at: indexPath.section).left

        // 先頭セルの場合はx座標を左端にして返す
        guard indexPath.item > 0 else {
            currentAttributes.frame.origin.x = sectionInsetsLeft
            return currentAttributes
        }

        // ひとつ前のセルを取得
        let prevIndexPath = NSIndexPath(forItem: indexPath.item - 1, inSection: indexPath.section)
        guard let prevFrame = layoutAttributesForItemAtIndexPath(prevIndexPath)?.frame else {
            return nil
        }

        // 現在のセルの行内にひとつ前のセルが収まっているか比較する。収まっていなければx座標を左端にして返す
        let validWidth = viewWidth - sectionInset.left - sectionInset.right
        let currentColumnRect = CGRectMake(sectionInsetsLeft, currentAttributes.frame.origin.y, validWidth, currentAttributes.frame.height)
        guard CGRectIntersectsRect(prevFrame, currentColumnRect) else {
            currentAttributes.frame.origin.x = sectionInsetsLeft
            return currentAttributes
        }

        // 一つ前のセルからminimumInteritemSpacing分だけ離れた位置にx座標をずらす
        let prevItemTailX = prevFrame.origin.x + prevFrame.width
        currentAttributes.frame.origin.x = prevItemTailX + minimumInteritemSpacing(at: indexPath.section)
        return currentAttributes
    }

上の処理の中で、collectionViewのsectionInsetとminimumInteritemSpacingを必要としていました。UICollectionViewFlowLayoutの子クラスであれば、そのデリゲートメソッドを使って以下のように取得できます。(このレイアウトを使うViewController内でUICollectionViewDelegateFlowLayoutを実装する必要があります。)

    // sectionInsetをdelegateメソッドから取得
    private func sectionInsets(at index: Int) -> UIEdgeInsets {
        guard
            let collectionView = collectionView,
            let delegate = collectionView.delegate as? UICollectionViewDelegateFlowLayout else {
                return self.sectionInset
        }
        return delegate.collectionView?(collectionView, layout: self, insetForSec
ionAtIndex: index) ?? self.sectionInset
    }

    // minimumInteritemSpacingをdelegateメソッドから取得
    private func minimumInteritemSpacing(at index: Int) -> CGFloat {
        guard
            let collectionView = collectionView,
            let delegate = collectionView.delegate as? UICollectionViewDelegateFlowLayout else {
                return self.minimumInteritemSpacing
        }
        return delegate.collectionView?(collectionView, layout: self, minimumInteritemSpacingForSectionAtIndex: index) ?? self.minimumInteritemSpacing
    }

最終的に出来上がるレイアウトクラス

解説したメソッドを含むレイアウトクラス全体の実装は以下になります。

class CollectionViewFlowLayoutLeftAlign: UICollectionViewFlowLayout {
    override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        guard let attributes = super.layoutAttributesForElementsInRect(rect) else {             
            return nil
        }

        var attributesToReturn = attributes.map { $0.copy() as! UICollectionViewLayoutAttributes }
        for (index, attr) in attributes.enumerate() where attr.representedElementCategory == .Cell {
            attributesToReturn[index] = layoutAttributesForItemAtIndexPath(attr.indexPath) ?? UICollectionViewLayoutAttributes()
        }
        return attributesToReturn
    }

    override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes? {
        guard let currentAttributes = super.layoutAttributesForItemAtIndexPath(indexPath)?.copy() as? UICollectionViewLayoutAttributes,
              let viewWidth = collectionView?.frame.width else {
                return nil
        }

        let sectionInsetsLeft = sectionInsets(at: indexPath.section).left

        guard indexPath.item > 0 else {
            currentAttributes.frame.origin.x = sectionInsetsLeft
            return currentAttributes
        }

        let prevIndexPath = NSIndexPath(forItem: indexPath.item - 1, inSection: indexPath.section)
        guard let prevFrame = layoutAttributesForItemAtIndexPath(prevIndexPath)?.frame else {
            return nil
        }
        let validWidth = viewWidth - sectionInset.left - sectionInset.right
        let currentColumnRect = CGRectMake(sectionInsetsLeft, currentAttributes.frame.origin.y, validWidth, currentAttributes.frame.height)
        guard CGRectIntersectsRect(prevFrame, currentColumnRect) else {
            currentAttributes.frame.origin.x = sectionInsetsLeft
            return currentAttributes
        }

        let prevItemTailX = prevFrame.origin.x + prevFrame.width
        currentAttributes.frame.origin.x = prevItemTailX + minimumInteritemSpacing(at: indexPath.section)
        return currentAttributes
    }

    private func sectionInsets(at index: Int) -> UIEdgeInsets {
        guard
            let collectionView = collectionView,
            let delegate = collectionView.delegate as? UICollectionViewDelegateFlowLayout else {
                return self.sectionInset
        }
        return delegate.collectionView?(collectionView, layout: self, insetForSectionAtIndex: index) ?? self.sectionInset
    }

    private func minimumInteritemSpacing(at index: Int) -> CGFloat {
        guard
            let collectionView = collectionView,
            let delegate = collectionView.delegate as? UICollectionViewDelegateFlowLayout else {
                return self.minimumInteritemSpacing
        }
        return delegate.collectionView?(collectionView, layout: self, minimumInteritemSpacingForSectionAtIndex: index) ?? self.minimumInteritemSpacing
    }
}

これをCollectionViewのlayoutへ渡すと、イントロダクションに書いた図のようなレイアウトが実現できます。
sectionInsetやminimumInteritemSpacingを調整する必要がある場合はUICollectionViewDelegateFlowLayoutにdelegateメソッドを実装します。

スクリーンショット 2016-01-30 17.46.31.png

class CollectionViewController: UICollectionViewController, UICollectionViewDelegateFlowLayout {
//...
//(省略)
//...
    func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAtIndex section: Int) -> UIEdgeInsets {
        return UIEdgeInsetsMake(10, 10, 10, 10)
    }

    func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAtIndex section: Int) -> CGFloat {
        return 10
    }

    func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAtIndex section: Int) -> CGFloat {
        return 10
    }
}

以上です。

(追記)
こちらのコードはswift2.2対応のものです。Swift3.x対応版はコメント欄に載っています。

まとめ

CollectionViewは、デフォルトのレイアウトクラスのままだと可変長のセルの時にあまり綺麗にレイアウトしてくれませんが、このレイアウトクラスを使うことで綺麗に左寄せしてくれるようになりました。1から作られたレイアウトクラスは座標計算が非常に読みづらくなりがちですがUICollectionViewFlowLayoutを拡張することで比較的コード量少なく実現できそうです。

参考資料