UICollectionView の Layout で悩んだら

こんにちは、サービス開発部の氏です。
主にiOSのクックパッドアプリの開発を担当しています。

UICollectionViewLayout みなさん使ってますか?
UICollectionView でレイアウトを組む際、実際触り始めると実装するための選択肢が複数あり、どれが最適なのか悩ましい場面に遭遇する人もいるのではないかと思います。
今回は、自分が業務で触れた際に得た知見について軽くお話したいと思います。

UICollectionVIewLayout とは

UICollectionView は Cellのサイズや余白等のレイアウトを管理するため、プロパティとして、 UICollectionViewLayout を所持しています。
この UICollectionViewLayout に手をいれることによって、レイアウトを好きな形に変更することができます。

レイアウトを組み立てるときの複数の選択肢

実際に UICollectionViewLayout をいじろうとすると、大きく分けて三つの選択肢が出てきます。

  1. UICollectionViewFlowLayout を調整する
  2. UICollectionViewDelegateFlowLayout を実装する
  3. UICollectionViewLayout (Custom) を作成する

つづけて、各Layoutで出来ること、出来ないことを挙げていきたいと思います。
どんなレイアウトの組み上げ方をすればよいか等、判断に困った際の参考にしていただければ幸いです。

1. UICollectionViewFlowLayout を調整する

一つ目は UICollectionViewFlowLayout をそのまま利用する方法です。
InterfaceBuilderで UICollectionView を設置すると、初期値としてこの UICollectionViewFlowLayout が設定されています。
UICollectionViewFlowLayout では、CellやHeader/FooterのSize等がプロパティとして用意されており、それを変更するだけで良い感じに組み上げてくれます。

let flowLayout = UICollectionViewFlowLayout()  
let margin: CGFloat = 3.0  
flowLayout.itemSize = CGSize(width: 100.0, height: 100.0)  
flowLayout.minimumInteritemSpacing = margin  
flowLayout.minimumLineSpacing = margin  
flowLayout.sectionInset = UIEdgeInsets(top: margin, left: margin, bottom: margin, right: margin)  
let collectionViewController = CollectionViewController(collectionViewLayout: flowLayout)  

ですが、Cellの大きさを決める itemSize では、動的な変更が行なえません。
全てのCellを同じ大きさで表示するのであれば、UICollectionViewFlowLayout を利用すると良いでしょう。

2. UICollectionViewDelegateFlowLayout を実装する

二つ目は UICollectionViewDelegateFlowLayout を実装する方法です。 UICollectionViewDelegateFlowLayoutUICollectionViewDelegate を継承した Protocolになっており、各種便利メソッドが用意されています。
基本的には、 UICollectionViewFlowLayout のプロパティと同等なものが準備されています。

extension CollectionViewController: UICollectionViewDelegateFlowLayout {  

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {  
        if indexPath.row % 3 == 0 {  
            return CGSize(width: 100.0, height: 100.0)  
        }  
         return CGSize(width: 60.0, height: 60.0)  
    }  

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {  
        return UIEdgeInsets(top: margin, left: margin, bottom: margin, right: margin)  
    }  

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {  
        return margin  
    }  

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {  
        return margin  
    }  
}  

このレイアウトの利点としては、 indexPath の情報を参照出来るため、動的なCellのサイズ変更が可能です。
ですが、Protocolとして用意されているものでしか変更を行うことが出来ないため、アニメーションを伴う変化には余り適していないと思います。

3. UICollectionViewLayout (Custom) を作成する

最後は UICollectionViewLayout を継承した独自レイアウトを作成する手段です。

自由にレイアウトを組める反面、今までに挙げた2通りの様に良しなにレイアウトを組んでもらえません。
CellやSectionなど各要素の配置先を計算する必要があり手間がかかりますが、その分動的なサイズ変更やレイアウト変更を好きなように行うことができます。(自分で書くので当然ですが…)

UICollectionViewLayout を継承して利用するには、下記の処理を実装する必要があります。

collectionViewContentSize: CGSize

UICollectionViewcontentSize を返します。
UICollectionView は、この contentSize をもとにスクロール量を判断します。
その為、ここでは表示させたい要素に応じた正確な contentSize を返さないと思った通りの位置までスクロールをしてくれません。

layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?

IndexPath に応じたCellの UICollectionViewLayoutAttributes を返します。
UICollectionViewLayoutAttributesIndexPath に応じたセルのレイアウト属性です。
この layoutAttributes にCellのサイズと座標を指定しておくと、指定通りの座標に表示されます。
この中でCellのサイズ計算等を行う場合、時間がかかる処理などがあるとカクつきの原因となります。
よくある方法として、prepare() でレイアウト情報を先に計算して配列などに用意しておきここではその情報を返すだけとするケースが多いです。

layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?

範囲内に含まれる UICollectionReusableView (cellやsupplementary view)の UICollectionViewLayoutAttributes の配列を返します。
基本的には、その範囲に含まれる layoutAttributesForItem(at indexPath: IndexPath) を取得してくる形になるでしょう。

アニメーションについて

レイアウトを作っていると、アニメーションを求められるケースがそれなりにあるかと思います。
レイアウト変更時のアニメーションは下記の様な形でアニメーションを行うことができます。

collectionView.setCollectionViewLayout(newLayout, animated: true)  

他には、Cellの生成時や削除時のレイアウト属性を返すメソッドがあり、それを実装することで insertItems, deleteItems でもアニメーションをさせる事ができます。

  • initialLayoutAttributesForAppearingItem(at itemIndexPath: IndexPath)
  • finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath)

また、「UICollectionViewController, UINavigationController の組み合わせでのみ」という制限がありますが、
UICollectionViewControlleruseLayoutToLayoutNavigationTransitions の値を true にしてpushさせると UINavigationBar と連携した遷移が可能です。

let viewController = UICollectionViewController(collectionViewLayout: newLayout)  
viewController.useLayoutToLayoutNavigationTransitions = true  
navigationController?.pushViewController(viewController, animated: true)  

UICollectionViewController 以外への遷移アニメーションは、 UIViewControllerAnimatedTransitioning を実装してあげると良いでしょう。

まとめ

UICollectionView のレイアウトを作るに当たって、ほとんどのケースでは UICollectionViewFlowLayoutUICollectionDelegateFlowLayout で事足りるかと思います。

独自レイアウトを採用するケースとしては、行ベース、グリッドベース以外のレイアウトが必要なケースや、各要素のレイアウトが頻繁に変化する場合に必要になってきます。(カバーフローのようなものだったり)

UICollectionView はiOS10から prefetchUICollectionViewFlowLayoutAutomaticSize 等の新しい機能も追加され、表現の幅も増えています。

クックパッド内で利用するのはまだ少し先かもしれませんが、ユーザーがより良い体験を提供できるよう常に心がけていきたいですね。