ちょっと話題の記事

iOS6 UICollectionViewのカスタムレイアウトを作成してみる

2012.10.14

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

前回、UICollectionViewを構成するクラス群の役割と、その簡単な利用方法について見ていきました。今回は、前回に続いてカスタムレイアウトコンポーネントを作成したいと思います。

UICollectionViewLayoutのサブクラスを作成

カスタムレイアウトコンポーネントを作成する際には、UICollectionViewLayoutクラスを継承したレイアウトクラスを作成します。UICollectionViewLayoutのサブクラスでは、以下のメソッドをオーバーライドして実装する必要があります。

collectionViewContentSize

UICollectionView内の要素を配置するコンテンツ部のサイズを返すメソッドです。UIScrollViewのcontentSizeプロパティと同じく、スクロール領域の範囲をコントロールします。

layoutAttributesForElementsInRect:

引数で渡されたCGRectの範囲内に表示される要素のUICollectionViewLayoutAttributesの配列を返すメソッドです。一部分しか表示されていない要素もこのメソッドで返します。このメソッドでの戻り値でUICollectionViewLayoutAttributesが返されない要素は画面上に表示されないことから、UICollectionView内部ではこのメソッドの戻り値の情報を利用して、現在の表示領域周辺のみセルなどの要素を配置しているものと思われます。

layoutAttributesForItemAtIndexPath:

引数で指定されたNSIndexPathに対応する要素のレイアウト情報を返すメソッドです。このメソッドで返すのはセルなどのデータを表現する要素だけであり、SupplementaryViewやDecorationViewは対象外です。

layoutAttributesForSupplementaryViewOfKind:atIndexPath:

引数で指定されたNSIndexPathに対応する補助要素のレイアウト情報を返すメソッドです。SupplementaryViewはデータをUIで表現する際に補助的な役割を果たすビジュアルエレメントのことを指します。UICollectionViewFlowLayoutではSupplementaryViewとしてヘッダとフッタが用意されています。このメソッドの第一引数でどちらの要素であるかを示す定数値が渡されてきますので、対応する要素のレイアウト情報を返すよう実装します。作成するカスタムレイアウトがSupplementaryViewをサポートしない場合は、このメソッドを実装する必要はありません。

なお、あくまでSupplementaryViewとしてヘッダとフッタを定義しているのはUICollectionViewFlowLayoutの仕様のようです。現に、ヘッダとフッタを示す定数である、UICollectionElementKindSectionHeaderとUICollectionElementKindSectionFooterはUICollectionViewFlowLayout.hで定義されています。これらのことから考えると、SupplementaryViewはいわゆるヘッダやフッタである必要はなく、実装者の自由に定義されることが想定されているようです。

layoutAttributesForDecorationViewOfKind:atIndexPath:

引数で指定されたNSIndexPathに対応する装飾要素のレイアウト情報を返すメソッドです。DecorationViewとは、特定のデータに紐づかず、単にUIの見た目をよくするために配置されるビジュアルエレメントのことを意味します。DecorationViewについては別の機会に触れたいと思います。

作成するカスタムレイアウトがDecorationViewをサポートしない場合は、このメソッドを実装する必要はありません。

shouldInvalidateLayoutForBoundsChange:

UICollectionViewのコンテンツがスクロールされると、現在表示されているコンテンツ領域の位置情報を引数にこのメソッドが呼び出されます。 このメソッドでは、更新された位置情報からレイアウト処理を再実行すべきか判断して真偽値を返します。ここでYESを返すとinvalideteLayoutメソッドを呼び出したのと同じ扱いになるようです。

サンプルレイアウトクラス

今回は、垂直方向もしくは水平方向に直線上に要素が並ぶ、セクションのヘッダ・フッタをサポートする簡単なレイアウトを作成してみました。ソースコードはGitHubにあげています。

開発環境は下記の通りです。

  • iOS SDK 6.0
  • Xcode 4.5
  • Apple LLVM compiler 4.1

レイアウト処理のライフサイクル

レイアウト処理は画面更新の度に行われるわけではなく、レイアウトの再計算が必要な場合にのみ行われます。UICollectionViewLayoutのinvalidateLayoutメソッドを呼び出すことで、UICollectionViewにレイアウトの再計算が必要であることを伝えることができます。レイアウトの再計算が必要であることが伝えられると、すぐにはレイアウト処理を実行せず、内部的にマーキングしておいて画面更新の前にレイアウト処理が実行されることになります。UIViewに対してsetNeedsLayoutを呼び出すと、画面更新前にlayoutSubviewsが呼び出されるのと同じです。

レイアウト処理が実行された場合、UICollectionViewLayoutサブクラスの以下の3つのメソッドが順番に呼び出されます。

prepareLayout

レイアウト処理が始まると、まず最初にレイアウトクラスのprepareLayoutが呼び出されます。このメソッドは、以降のレイアウト処理で必要となるレイアウト計算を全て事前に実行し、それぞれの要素のレイアウト情報をUICollectionViewLayoutAttributesに格納してキャッシュしておくことが目的です。

このメソッドはレイアウト処理のライフサイクルの上では重要なメソッドとなっていますが、オーバーライドすることが必須ではありません。この理由については後で説明します。

以下は、サンプルのTestCollectionViewLinearLayoutでの実装です。

- (void)prepareLayout
{
    [sectionElements removeAllObjects];
    [headerElements removeAllObjects];
    [footerElements removeAllObjects];
   
    BOOL isVertical = self.scrollDirection == UICollectionViewScrollDirectionVertical;
    BOOL hasHeader = (isVertical && self.headerReferenceSize.height > 0) || (!isVertical && self.headerReferenceSize.width > 0);
    BOOL hasFooter = (isVertical && self.footerReferenceSize.height > 0) || (!isVertical && self.footerReferenceSize.width > 0);
    CGFloat interitemSpacing = self.interitemSpacing;
    CGFloat collectionViewWidth = self.collectionView.bounds.size.width;
    CGFloat collectionViewHeight = self.collectionView.bounds.size.height;
    CGFloat itemWidth = self.itemSize.width;
    CGFloat itemHeight = self.itemSize.height;
    CGFloat currentPosition = 0.0f;
   
    NSInteger sectionCount = [self.collectionView numberOfSections];
    for (int sectionNumber = 0; sectionNumber < sectionCount; sectionNumber++) {
        if (hasHeader) {
            UICollectionViewLayoutAttributes *headerAttr =
                [UICollectionViewLayoutAttributes layoutAttributesForSupplementaryViewOfKind:UICollectionElementKindSectionHeader
                                                                               withIndexPath:[NSIndexPath indexPathForItem:0 inSection:sectionNumber]];
            if (isVertical) {
                headerAttr.frame = CGRectMake(0.0f, currentPosition, collectionViewHeight, self.headerReferenceSize.height);
                currentPosition += self.headerReferenceSize.height + interitemSpacing;
            } else {
                headerAttr.frame = CGRectMake(currentPosition, 0.0f, self.headerReferenceSize.width, collectionViewHeight);
                currentPosition += self.headerReferenceSize.width + interitemSpacing;
            }
           
            [headerElements addObject:headerAttr];
        }
       
        int itemCount = [self.collectionView numberOfItemsInSection:sectionNumber];
        NSMutableArray *elements = [NSMutableArray array];
        for (int itemNumber = 0; itemNumber < itemCount; itemNumber++) {
            UICollectionViewLayoutAttributes *attr =
                [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:[NSIndexPath indexPathForItem:itemNumber inSection:sectionNumber]];
           
            if (isVertical) {
                attr.center = CGPointMake(collectionViewWidth * 0.5, currentPosition + itemHeight * 0.5);
                currentPosition += itemHeight;
            } else {
                attr.center = CGPointMake(currentPosition + itemWidth * 0.5, collectionViewHeight * 0.5);
                currentPosition += itemWidth;
            }
            attr.size = CGSizeMake(itemWidth, itemHeight);
           
            if (itemNumber != itemCount - 1) {
                currentPosition += interitemSpacing;
            }
            [elements addObject:attr];
        }
        [sectionElements addObject:elements];

        if (hasFooter) {
            UICollectionViewLayoutAttributes *footerAttr =
            [UICollectionViewLayoutAttributes layoutAttributesForSupplementaryViewOfKind:UICollectionElementKindSectionFooter
                                                                           withIndexPath:[NSIndexPath indexPathForItem:0 inSection:sectionNumber]];
            if (isVertical) {
                footerAttr.frame = CGRectMake(0.0f, currentPosition + interitemSpacing, collectionViewHeight, self.footerReferenceSize.height);
                currentPosition += self.footerReferenceSize.height + interitemSpacing;
            } else {
                footerAttr.frame = CGRectMake(currentPosition + interitemSpacing, 0.0f, self.footerReferenceSize.width, collectionViewHeight);
                currentPosition += self.footerReferenceSize.width + interitemSpacing;
            }
           
            [footerElements addObject:footerAttr];
        }
    }

    contentSize = currentPosition;
}
[/c]</p>
<p>ここでは全てのセル・ヘッダ・フッタのレイアウト情報をUICollectionViewLayoutAttributesに格納して保持しています。レイアウト情報の格納に配列を使っていますが、NSIndexPathをキーにディクショナリに格納した方がいいかもしれません。</p>

<h4>collectionViewContentSize</h4>
<p>prepareLayoutの次にcollectionViewContentSizeが呼び出されます。</p>
<p>

</p>
<p>ここでは、全ての要素に対してCGRectIntersectsRect関数を利用して矩形範囲と交差しているか判定し、交差している要素を配列に格納して返しています。</p>
<p>なお、SupplementaryViewやDecorationViewに対して、NSIndexPathのitemプロパティをどのように利用するかは実装者に任されています。ここでは無条件に0を指定していますが、仮にヘッダが2つあるレイアウトクラスであれば、インデックスにそれぞれ0と1を指定して識別することができるでしょう。</p>

<p>ここまでの3つのメソッドの呼び出しが、基本的なレイアウト処理のライフサイクルになっています。</p>

<h2 id="toc-2">他の実装メソッド</h2>
<h4>layoutAttributesForItemAtIndexPath:</h4>
<p>NSIndexPathで指定される要素のレイアウト情報を返します。</p>

<p>
- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath
{
    return sectionElements[indexPath.section][indexPath.item];
}

ここでは、キャッシュしてあるレイアウト情報から指定のものを取り出して返しています。

layoutAttributesForSupplementaryViewOfKind:atIndexPath:

layoutAttributesForItemAtIndexPath:と同様に、NSIndexPathで指定されるSupplementaryViewのレイアウト情報を返します。

- (UICollectionViewLayoutAttributes *)layoutAttributesForSupplementaryViewOfKind:(NSString *)kind
                                                                     atIndexPath:(NSIndexPath *)indexPath
{  
    UICollectionViewLayoutAttributes *attr = nil;
    if (kind == UICollectionElementKindSectionHeader) {
        attr = headerElements[indexPath.section];
    } else {
        attr = footerElements[indexPath.section];
    }
   
    return attr;
}

こちらも先ほどと同じように、キャッシュしてあるレイアウト情報から指定のものを取り出して返しています。

shouldInvalidateLayoutForBoundsChange:

- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds
{
    return NO;
}

このレイアウトは、基本的にスクロールによる再レイアウトは必要ありませんので、NOを返しています。

サンプルの実行結果

このサンプルでは、UICollectionViewFlowLayoutと組み合わせて4パターンのレイアウトをアニメーションつきで切り替えるようにしました。

レイアウトの切り替えは、UICollectionViewのsetCollectionViewLayout:animated:にレイアウトクラスのインスタンスをセットするだけです。レイアウト切り替えの際のアニメーションも自動で行ってくれます。

灰色の矩形がセル、薄い紫の矩形がヘッダ、濃い紫の矩形がフッタです。

レイアウト情報の計算とキャッシュ

上のサンプルの実装は、prepareLayoutでレイアウト情報を全て計算してしまい、キャッシュしておく方法を取っています。しかし、非常に要素数が多い場合はメモリの使用量が気になりますし、頻繁にレイアウト処理の再計算をしなければならないようなレイアウトでは、レイアウト情報をキャッシュしておく意味があまりなく、むしろ無駄にメモリを占有するだけとなってしまいます。

そこで、そういったレイアウト情報のキャッシュが適さないレイアウトを実装する場合は、必要になった際に逐一レイアウト処理を行うことがAppleのドキュメントでも推奨されています。もちろん、この場合はprepareLayout呼び出し時にレイアウト計算を行いません。prepareLayoutのオーバーライドが必須となっていないのはこのためです。

なお、ひとつの目安として、要素数が1000を超えた場合にレイアウト情報のキャッシュを見直すことが推奨されているようです。

サンプルソースコードのTestCollectionViewLinearLayoutOnDemandクラスではこのスタイルで実装を行っています。

まとめ

レイアウトに関する処理が完全にUIから分離されているおかげで、レイアウト処理のみを書くだけで簡単にカスタマイズができました。工夫次第でいろいろ遊べそうですね。

参考サイト

iOS Developer Library