[iOS] UICollectionView のレイアウトクラスを作成して「Pinterest」風のレイアウトを実現する
はじめに
こんにちは。モバイルアプリサービス部の平屋です。本記事では、写真共有ウェブサイト「Pinterest」風のレイアウトを iOS アプリで実現するための実装について解説します。
以下の画像のような、「高さの異なるセルが敷き詰められているレイアウト」を作成していきます。
検証環境
- OS X El Capitan 10.11.4
- Xcode 7.3
目次
サンプルアプリについて
本記事で解説するサンプルアプリの要件は以下の通りです。
- 高さの異なるセルをタテ・ヨコに等間隔で配置する
- 「セルの高さの合計」が小さいカラムからセルを配置する
- 「セルの高さの合計」が等しい場合は、左のカラムから配置する
- アイテムは 2 種類
- 写真・コメント
- コメントのみ
コードは以下のリポジトリで公開してます。
使用するクラスについて
UICollectionView 関連のクラスを整理する
はじめに「UICollectionView」に関連するクラスを簡単に整理してみます。
以下の図は「UICollectionView」に関連するクラスの関係を表したものです。赤い四角で囲った部分は「UITableView」関連のクラス群と同様の役割を持つクラスです。
一方、青い四角で囲った部分はレイアウト処理に関するクラスです。このように、レイアウト処理を担当するクラスが独立している点が「UICollectionView」と「UITableView」の大きな違いになっています。
各クラスの簡単な説明は以下のとおりです。
クラス・プロトコル名 | 目的 | 説明 |
---|---|---|
UICollectionView | 最上位のコンテナ機能と管理機能 | メインの UI コンポーネントクラス |
UICollectionViewCell | プレゼンテーション | 個々の要素をセルとして表示するための UI コンポーネントクラス |
UICollectionViewDelegate | コンテンツ管理 | 選択等の操作を委譲するデリゲートプロトコル |
UICollectionViewDataSource | コンテンツ管理 | 表示するデータを提供するためのメソッドを定義するプロトコル |
UICollectionViewLayout | レイアウト | レイアウト処理を行うコンポーネントの抽象クラス |
UICollectionViewLayoutAttributes | レイアウト | レイアウト情報を保持するクラス |
カスタムクラスを整理する
本記事で解説するサンプルアプリでは以下のカスタムクラスを実装します。
クラス名 | スーパークラス | 説明 |
---|---|---|
SGLPostsViewController | UICollectionViewController | UICollectionView を管理 |
SGLPostCell | UICollectionViewCell | カスタムセル |
SGLStaggeredGridLayout | UICollectionViewLayout | 「Pinterest」風のレイアウトを実現するためのクラス |
SGLStaggeredGridLayoutAttributes | UICollectionViewLayoutAttributes | 写真部分の高さを保持するために作成 |
SGLStaggeredGridLayout クラスの実装について
SGLStaggeredGridLayout クラスの実装を解説していきます。
SGLStaggeredGridLayout
- UICollectionViewLayout のサブクラス
- 主な処理
- 「Pinterest」風のレイアウトを実現するためのレイアウトを計算する
- 計算した値は UICollectionView に渡される
- その値を使用して UICollectionView が View を描画する
- 「Pinterest」風のレイアウトを実現するためのレイアウトを計算する
prepareLayout
レイアウト処理が始まると、まずこのメソッドが呼び出されます。 このメソッド内では、以下の処理を行います。
- UICollectionView 上に配置する要素の位置と大きさを計算する
- コンテンツ領域の大きさを計算する
これらの処理はレイアウトクラスの処理の中で中心となる処理です。
- (void)prepareLayout { // [1] レイアウト情報をキャッシュ済みの場合は処理を終了する if (self.cachedAttributes.count > 0) { return; } NSInteger column = 0; // [2] セルの幅を計算する CGFloat totalHorizontalMargin = (kCellMargin * (kNumberOfColumns - 1)); CGFloat cellWidth = (self.contentWidth - totalHorizontalMargin) / kNumberOfColumns; // [3] 「セルの原点 x」の配列を計算する NSMutableArray<NSNumber *> *cellOriginXList = [NSMutableArray new]; for (NSInteger i = 0; i < kNumberOfColumns; i++) { CGFloat originX = i * (cellWidth + kCellMargin); [cellOriginXList addObject:@(originX)]; } // [4] カラムごとの「現在計算対象にしているセルの原点 y」を格納した配列を計算する NSMutableArray<NSNumber *> *currentCellOriginYList = [NSMutableArray new]; for (NSInteger i = 0; i < kNumberOfColumns; i++) { [currentCellOriginYList addObject:@(0.0f)]; } // [5] 各セルのサイズ・原点座標を計算する for (NSInteger item = 0; item < [self.collectionView numberOfItemsInSection:0]; item++) { NSIndexPath *indexPath = [NSIndexPath indexPathForItem:item inSection:0]; // [6] セルの写真部分・ボディ部分のそれぞれの高さを取得する CGFloat imageHeight = [self.delegate collectionView:self.collectionView heightForImageAtIndexPath:indexPath width:cellWidth]; CGFloat bodyHeight = [self.delegate collectionView:self.collectionView heightForBodyAtIndexPath:indexPath width:cellWidth]; CGFloat cellHeight = imageHeight + bodyHeight; // [7] セルの frame を作成する CGRect cellFrame = CGRectMake(cellOriginXList[column].floatValue, currentCellOriginYList[column].floatValue, cellWidth, cellHeight); // [8] SGLStaggeredGridLayoutAttributes オブジェクトを作成して、cachedAttributes プロパティに格納する SGLStaggeredGridLayoutAttributes *attributes = [SGLStaggeredGridLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath]; attributes.imageHeight = imageHeight; attributes.frame = cellFrame; [self.cachedAttributes addObject:attributes]; // [9] UICollectionView のコンテンツの高さを計算して contentHeight プロパティに格納する self.contentHeight = MAX(self.contentHeight, CGRectGetMaxY(cellFrame)); // [10] 次のセルの原点 y を計算する currentCellOriginYList[column] = @(currentCellOriginYList[column].floatValue + cellHeight + kCellMargin); // [11] 次のカラムを決める __block NSInteger nextColumn = 0; __block NSNumber *minOriginY = @(MAXFLOAT); [currentCellOriginYList enumerateObjectsUsingBlock:^(NSNumber *originY, NSUInteger index, BOOL *stop) { if ([originY compare:minOriginY] == NSOrderedAscending) { minOriginY = originY; nextColumn = index; } }]; column = nextColumn; } }
[1] レイアウト情報をキャッシュ済みの場合は処理を終了する
prepareLayout
メソッド内で計算した「レイアウト情報」はこの後の手順 [8] で cachedAttributes
プロパティに格納します。既に計算済みの場合は何もしません。
[2] セルの幅を計算する
「コンテンツ部分の幅」から「セル間のマージンの合計」を引いて「カラム数」で割ったものがセルの幅になります。
[3] 「セルの原点 x」の配列を計算する
セルの 原点 x を計算して cellOriginXList
配列に格納します。手順 [5] で使用します。
セルの間にマージンを 10pt とるので、「カラム数:2」かつ「320 x 568pt のスクリーン」の場合の配列の具体的な値は以下の通りです。
- [0, 155]
[4] カラムごとの「現在計算対象にしているセルの原点 y」を格納した配列を計算する
ここでは currentCellOriginYList
配列の各要素に初期値 0 を入れるだけです。
currentCellOriginYList
配列に格納される値は、手順 [5] で各セルのレイアウト情報を計算していく中で更新されていきます。例えば、以下のような値をとります。
対象セルのインデックス | currentCellOriginYList[0] の値 | currentCellOriginYList[1] の値 |
---|---|---|
0 | 0 | 0 |
1 | 0 | 0 |
2 | 0 | 42 |
3 | 163 | 42 |
[5] 各セルのサイズ・原点座標を計算する
for 文の中で各セルのサイズ・原点座標などを計算していきます。
[6] セルの写真部分・ボディ部分のそれぞれの高さを取得する
SGLStaggeredGridLayoutDelegate
のメソッドを使用してセルの「写真」と「ボディ」それぞれの高さを取得します。
[7] セルの frame を作成する
ここまでで計算した値を使用して frame
を作成します。
[8] SGLStaggeredGridLayoutAttributes オブジェクトを作成して、cachedAttributes に格納する
SGLStaggeredGridLayoutAttributes
オブジェクトを作成し、セルの frame と imageHeight を格納します。そして、SGLStaggeredGridLayoutAttributes
オブジェクトを cachedAttributes
プロパティに格納します。
cachedAttributes
プロパティに格納したオブジェクトは、layoutAttributesForElementsInRect:
または layoutAttributesForItemAtIndexPath
メソッドで使用します。
また、imageHeight
プロパティに格納した値はセルのクラス側で使用します。
[9] UICollectionView のコンテンツの高さを計算して contentHeight プロパティに格納する
必要に応じて contentHeight
プロパティの値を更新します。
「現在計算対象にしているセルの下端の y座標」>「contentHeight」の場合、contentHeight
プロパティの値が更新されます。
[10] 次のセルの原点 y を計算する
currentCellOriginYList
配列の対象要素の値を更新します。
[11] 次のカラムを決める
「セルの合計の高さ」が小さいカラムが次のカラムになります。currentCellOriginYList
配列の最小値を計算して次のカラムを決めます。
collectionViewContentSize
prepareLayout
メソッドの次に呼ばれます。オーバーライド必須のメソッドです。
collectionView のコンテンツ部分のサイズを返します。今回は上下左右に余白をとるので、余白を差し引いた値がコンテンツ部分のサイズになります。
contentHeight
は prepareLayout
メソッド内で計算します。
- (CGSize)collectionViewContentSize { return CGSizeMake(self.contentWidth, self.contentHeight); }
layoutAttributesForElementsInRect:
collectionViewContentSize
メソッド の次に呼ばれます。オーバーライド必須です。
引数で渡された CGRect の範囲内に表示される要素の UICollectionViewLayoutAttributes
(またはそのサブクラス) の配列を返します。UICollectionViewLayoutAttributes
は UICollectionView 上に表示される要素のレイアウト情報を保持するクラスであり、要素の位置やサイズなどを保持します。
- UICollectionViewLayoutAttributes
- プロパティ
- frame
- center
- size
- ...
- プロパティ
本記事のサンプルアプリでは追加の情報を保持したいので、UICollectionViewLayoutAttributes
のサブクラスである SGLStaggeredGridLayoutAttributes
を使用します。
SGLStaggeredGridLayoutAttributes
オブジェクトは、prepareLayout
メソッド内で生成して cachedAttributes
プロパティに格納しておきます。layoutAttributesForElementsInRect
メソッド内では、該当する SGLStaggeredGridLayoutAttributes
オブジェクトを cachedAttributes
プロパティから取り出すだけです。
- (NSArray<__kindof UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect { NSMutableArray<__kindof UICollectionViewLayoutAttributes *> *layoutAttributes = [NSMutableArray new]; for (SGLStaggeredGridLayoutAttributes *attributes in self.cachedAttributes) { if (CGRectIntersectsRect(attributes.frame, rect)) { [layoutAttributes addObject:attributes]; } } return [layoutAttributes copy]; }
layoutAttributesForItemAtIndexPath:
オーバーライド必須のメソッドです。
引数で渡された NSIndexPath に対応する要素の UICollectionViewLayoutAttributes
(またはそのサブクラス) を返します。
layoutAttributesForElementsInRect:
メソッド内での実装と同様に、該当する SGLStaggeredGridLayoutAttributes
オブジェクトを cachedAttributes
プロパティから、取り出します。
- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath { return self.cachedAttributes[indexPath.item]; }
shouldInvalidateLayoutForBoundsChange:
オーバーライド必須のメソッドです。
UICollectionView をスクロールしたタイミングでレイアウトを無効をする場合は YES を返します。今回は不要なので NO を返します。
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds { return NO; }
SGLStaggeredGridLayout クラスの実装の解説は以上になります。
他のクラスの実装について
SGLStaggeredGridLayout 以外のクラスについては簡単な説明だけ載せておきます。
SGLPostsViewController
- UICollectionViewController のサブクラス
- 主な処理
- UICollectionView に表示するデータを提供する
- UICollectionView の contentInset を設定する
- SGLStaggeredGridLayoutDelegate のメソッドを実装
SGLPostCell
- UICollectionViewCell のサブクラス
- 主な処理
- 「写真」と「ボディ」それぞれの高さを計算する
SGLStaggeredGridLayoutAttributes
- UICollectionViewLayoutAttributes のサブクラス
- 主な処理
- 「写真」部分の高さを保持する
まとめ
本記事では、UICollectionView のレイアウトクラスを作成して「Pinterest」風のレイアウトを実現する実装を紹介しました。
UICollectionView 関連のクラスは「プレゼンテーション」「コンテンツ管理」「レイアウト」という風に役割が明確に分かれており、それぞれが連携する仕組みを UIKit が用意しています。そのおかげで、レイアウト用のクラスにレイアウト計算処理を書くだけでカスタムレイアウトを実現できます。
今回作成したサンプルアプリのソースコードは以下のリポジトリで公開してますので参考にしてみてください。
参考記事
- Collection View プログラミングガイド - Apple Developer
- UICollectionView Custom Layout Tutorial: Pinterest - RAYWENDERLICH