[iOS] UICollectionView のレイアウトクラスを作成して「Pinterest」風のレイアウトを実現する

ios_ui

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

こんにちは。モバイルアプリサービス部の平屋です。本記事では、写真共有ウェブサイト「Pinterest」風のレイアウトを iOS アプリで実現するための実装について解説します。

以下の画像のような、「高さの異なるセルが敷き詰められているレイアウト」を作成していきます。

ios-pinterest-layout-01

検証環境

  • OS X El Capitan 10.11.4
  • Xcode 7.3

目次

サンプルアプリについて

本記事で解説するサンプルアプリの要件は以下の通りです。

  • 高さの異なるセルをタテ・ヨコに等間隔で配置する
  • 「セルの高さの合計」が小さいカラムからセルを配置する
    • 「セルの高さの合計」が等しい場合は、左のカラムから配置する
  • アイテムは 2 種類
    • 写真・コメント
    • コメントのみ

コードは以下のリポジトリで公開してます。

ios-pinterest-layout-02

使用するクラスについて

UICollectionView 関連のクラスを整理する

はじめに「UICollectionView」に関連するクラスを簡単に整理してみます。

以下の図は「UICollectionView」に関連するクラスの関係を表したものです。赤い四角で囲った部分は「UITableView」関連のクラス群と同様の役割を持つクラスです。

一方、青い四角で囲った部分はレイアウト処理に関するクラスです。このように、レイアウト処理を担当するクラスが独立している点が「UICollectionView」と「UITableView」の大きな違いになっています。

ios-pinterest-layout-03

各クラスの簡単な説明は以下のとおりです。

クラス・プロトコル名 目的 説明
UICollectionView 最上位のコンテナ機能と管理機能 メインの UI コンポーネントクラス
UICollectionViewCell プレゼンテーション 個々の要素をセルとして表示するための UI コンポーネントクラス
UICollectionViewDelegate コンテンツ管理 選択等の操作を委譲するデリゲートプロトコル
UICollectionViewDataSource コンテンツ管理 表示するデータを提供するためのメソッドを定義するプロトコル
UICollectionViewLayout レイアウト レイアウト処理を行うコンポーネントの抽象クラス
UICollectionViewLayoutAttributes レイアウト レイアウト情報を保持するクラス

カスタムクラスを整理する

本記事で解説するサンプルアプリでは以下のカスタムクラスを実装します。

ios-pinterest-layout-04

クラス名 スーパークラス 説明
SGLPostsViewController UICollectionViewController UICollectionView を管理
SGLPostCell UICollectionViewCell カスタムセル
SGLStaggeredGridLayout UICollectionViewLayout 「Pinterest」風のレイアウトを実現するためのクラス
SGLStaggeredGridLayoutAttributes UICollectionViewLayoutAttributes 写真部分の高さを保持するために作成

SGLStaggeredGridLayout クラスの実装について

SGLStaggeredGridLayout クラスの実装を解説していきます。

SGLStaggeredGridLayout

  • UICollectionViewLayout のサブクラス
  • 主な処理
    • 「Pinterest」風のレイアウトを実現するためのレイアウトを計算する
      • 計算した値は UICollectionView に渡される
      • その値を使用して UICollectionView が View を描画する

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]

ios-pinterest-layout-05

[4] カラムごとの「現在計算対象にしているセルの原点 y」を格納した配列を計算する

ここでは currentCellOriginYList 配列の各要素に初期値 0 を入れるだけです。

currentCellOriginYList 配列に格納される値は、手順 [5] で各セルのレイアウト情報を計算していく中で更新されていきます。例えば、以下のような値をとります。

対象セルのインデックス currentCellOriginYList[0] の値 currentCellOriginYList[1] の値
0 0 0
1 0 0
2 0 42
3 163 42

ios-pinterest-layout-06

[5] 各セルのサイズ・原点座標を計算する

for 文の中で各セルのサイズ・原点座標などを計算していきます。

[6] セルの写真部分・ボディ部分のそれぞれの高さを取得する

SGLStaggeredGridLayoutDelegate のメソッドを使用してセルの「写真」と「ボディ」それぞれの高さを取得します。

ios-pinterest-layout-07

[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 のコンテンツ部分のサイズを返します。今回は上下左右に余白をとるので、余白を差し引いた値がコンテンツ部分のサイズになります。

ios-pinterest-layout-08

contentHeightprepareLayout メソッド内で計算します。

- (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 が用意しています。そのおかげで、レイアウト用のクラスにレイアウト計算処理を書くだけでカスタムレイアウトを実現できます。

今回作成したサンプルアプリのソースコードは以下のリポジトリで公開してますので参考にしてみてください。

参考記事

Developers.IO 内の参考記事・関連記事

AWS Cloud Roadshow 2017 福岡

  • Daisuke Akimoto

    必要があり、Swiftに移植したものを作成し、せっかくなので下記にて公開させてもらいました。
    http://qiita.com/keneo/items/71d58b359e5e551e692d

    特にライセンス上の問題はないと思っていますが、何か問題などあるようであればご連絡ください。

  • shingo hiraya

    Daisuke Akimoto さん

    はい。問題ないと思います!
    > 特にライセンス上の問題はないと思っていますが、何か問題などあるようであればご連絡ください。

    記事中の「参考記事」セクションにも載せていますが、この記事のサンプルアプリは、RAYWENDERLICH の記事を参考にしました。こちらの記事中のコードは Swift で書かれています。

    https://www.raywenderlich.com/107439/uicollectionview-custom-layout-tutorial-pinterest