UICollectionViewでWaterfallを実現してみた

UICollectionViewでWaterfallを実現してみたスターも多かったので、chiahsien/CHTCollectionViewWaterfallLayoutを参考にWaterfallレイアウトをのコードリーディングをしてみました。最後は自分なりの実装を貼り付けてみます。

Waterfallレイアウトとは滝のように流れるレイアウトのことです(だと思います)。CollectionView自体ちゃんと使っていなかったので、そこからの勉強になります。

WaterfallレイアウトはPinterestのようなビューです。

基礎知識

CollectionView

dataSourceのrequiredは、

  • collectionView:numberOfItemsInSection:
  • collectionView:cellForItemAtIndexPath:

この辺りは、UITableViewと同じなので楽ですね。

CollectionViewLayout

UICollectionViewとUITableViewの最大の違いはこれでレイアウトを自由に作成できるところです。

下記のメソッドをオーバーライドして利用する。

  • collectionViewContentSize
  • layoutAttributesForElementsInRect:
  • layoutAttributesForItemAtIndexPath:
  • layoutAttributesForSupplementaryViewOfKind:atIndexPath: (if your layout – supports supplementary views)
  • layoutAttributesForDecorationViewOfKind:atIndexPath: (if your layout supports decoration views)
  • shouldInvalidateLayoutForBoundsChange:

UICollectionViewLayoutAttributes

レイアウト情報を格納するクラスです。CollectionViewが領域を表示した際にこれが返されてビューが決定されるようです。

レイアウト作成部分のリーディング

prepareLayoutはレイアウトを準備するメソッドです。この部分を愚直ですが1行1行リーディングしていこうと思います。

[super prepareLayout];

親クラスのメソッドを呼び出す。

NSInteger numberOfSections = [self.collectionView numberOfSections];
if (numberOfSections == 0) {
    return;
}

セクション数を指定します。セクションが0の場合はなにもしないということですね。

 self.delegate = (id  )self.collectionView.delegate;
  NSAssert([self.delegate conformsToProtocol:@protocol(CHTCollectionViewDelegateWaterfallLayout)], @"UICollectionView's delegate should conform to CHTCollectionViewDelegateWaterfallLayout protocol");
  NSAssert(self.columnCount > 0, @"UICollectionViewWaterfallLayout's columnCount should be greater than 0");

CHTCollectionViewDelegateWaterfallLayoutに対応しているself.collectionview.delegateをself.delegateにセットしています。このdelegateの招待は呼び出し元のUIViewControllerのサブクラスなどになると思います。

// Initialize variables
NSInteger idx = 0;

[self.headersAttribute removeAllObjects];
[self.footersAttribute removeAllObjects];
[self.unionRects removeAllObjects];
[self.columnHeights removeAllObjects];
[self.allItemAttributes removeAllObjects];
[self.sectionItemAttributes removeAllObjects];

for (idx = 0; idx < self.columnCount; idx++) {
  [self.columnHeights addObject:@(0)];
}

idxは使いまわす変数のようです。次に、クラスに確保している変数を初期化しています。self.columnHeights@(0)で初期化しています。

// Create attributes
CGFloat top = 0;
UICollectionViewLayoutAttributes *attributes;

for (NSInteger section = 0; section < numberOfSections; ++section) {

ここからレイアウトを作りこんでいきます。まず、topattributesを宣言しています。そして、sectionの数だけ次に行う処理を繰り返します。

/*
 * 1. Get section-specific metrics (minimumInteritemSpacing, sectionInset)
 */
CGFloat minimumInteritemSpacing;
if ([_delegate respondsToSelector:@selector(collectionView:layout:minimumInteritemSpacingForSectionAtIndex:)]) {
  minimumInteritemSpacing = [_delegate collectionView:self.collectionView
                                               layout:self
             minimumInteritemSpacingForSectionAtIndex:section];
} else {
  minimumInteritemSpacing = self.minimumInteritemSpacing;
}

UIEdgeInsets sectionInset;
if ([_delegate
     respondsToSelector:@selector(collectionView:layout:insetForSectionAtIndex:)]) {
  sectionInset = [_delegate collectionView:self.collectionView
                                    layout:self
                    insetForSectionAtIndex:section];
} else {
  sectionInset = self.sectionInset;
}

CGFloat width = self.collectionView.frame.size.width
    - sectionInset.left - sectionInset.right;

CGFloat itemWidth = floorf((width - (self.columnCount - 1)
                            * self.minimumColumnSpacing) / self.columnCount);

セクションで利用する値を決定していきます。まず、minimumInteritemSpacingを決定します。アイテム間のスペースです。デリゲートがあればそちらから利用し、なければ、自身の値を利用します。動的に変更できるようになっています。

次に、sectionInsetを決定します。これも同様にデリゲートを確認して、なければ自身の値を利用します。

widthを決定します。collectionViewの幅からsectionInsetのleftとrightを差し引いて算出しています。

itemWidthを決定します。カラム数から1引いたものにminimumColumSpacingを掛けて、widthから引いたものをカラム数で割ります。その値以下の最大の整数値を計算してfloorで返しています。

widthが300でカラム数が3でminimumColumSpacingが10の場合、(300 - (3 - 1) * 10) / 3となり、返却されるのは93.0となります。

/*
 * 2. Section header
 */
CGFloat headerHeight;
if ([self.delegate respondsToSelector:@selector(collectionView:layout:heightForHeaderInSection:)]) {
  headerHeight = [self.delegate collectionView:self.collectionView layout:self heightForHeaderInSection:section];
} else {
  headerHeight = self.headerHeight;
}

if (headerHeight > 0) {
  attributes = [UICollectionViewLayoutAttributes layoutAttributesForSupplementaryViewOfKind:CHTCollectionElementKindSectionHeader withIndexPath:[NSIndexPath indexPathForItem:0 inSection:section]];
  attributes.frame = CGRectMake(0, top, self.collectionView.frame.size.width, headerHeight);

  self.headersAttribute[@(section)] = attributes;
  [self.allItemAttributes addObject:attributes];

  top = CGRectGetMaxY(attributes.frame);
}

top += sectionInset.top;
for (idx = 0; idx < self.columnCount; idx++) {
  self.columnHeights[idx] = @(top);
}

headerの高さを算出します。デリゲート優先で値をセットします。

headerHeightが0以上の場合、UICollectionViewLayoutAttributesのインスタンスを作成し、セットしていきます。indexPathはitem:0で指定します。サイズを横幅いっぱいに指定します。

self.headerAttributsのセクション番号にセットします。。今後もこの処理が出てきますが、これはlayoutAttributesForSupplementaryViewOfKindメソッドで求められた時に利用するために保持しています。さらに、self.allItemAttributesにもセットします。self.allItemAttributeslayoutAttributesForElementsInRect:で利用するために保持します。

topをheaderのMaxYで更新しています。次から作成するビューはこのtopをみて位置を決定します。topにsectionInsetのtopを加えて、self.columnHeights[idx]にそれぞれのcolumnのところにセットしています。これは続くセルの中身を決定するために利用されます。

/*
 * 3. Section items
 */
NSInteger itemCount = [self.collectionView numberOfItemsInSection:section];
NSMutableArray *itemAttributes = [NSMutableArray arrayWithCapacity:itemCount];

// Item will be put into shortest column.
for (idx = 0; idx < itemCount; idx++) {
  NSIndexPath *indexPath = [NSIndexPath indexPathForItem:idx inSection:section];
  NSUInteger columnIndex = [self shortestColumnIndex];
  CGFloat xOffset = sectionInset.left + (itemWidth + self.minimumColumnSpacing) * columnIndex;
  CGFloat yOffset = [self.columnHeights[columnIndex] floatValue];
  CGSize itemSize = [self.delegate collectionView:self.collectionView layout:self sizeForItemAtIndexPath:indexPath];
  CGFloat itemHeight = 0;
  if (itemSize.height > 0 && itemSize.width > 0) {
    itemHeight = floorf(itemSize.height * itemWidth / itemSize.width);
  }
    
  attributes = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];
  attributes.frame = CGRectMake(xOffset, yOffset, itemWidth, itemHeight);
  [itemAttributes addObject:attributes];
  [self.allItemAttributes addObject:attributes];
  self.columnHeights[columnIndex] = @(CGRectGetMaxY(attributes.frame) +
      minimumInteritemSpacing);
}

[self.sectionItemAttributes addObject:itemAttributes];

セルのレイアウトを決定していきます。アイテムの数を取得して、同じ数だけのUICollectionViewLayoutAttributesの配列を作成します。

itemの数だけfor文で処理を行います。

  1. indexPathを作成
  2. 現時点で一番短いカラム番号を取得
  3. xのoffsetを決定
  4. yのoffsetを決定
  5. itemのサイズを決定。この際、delegateで取得したアイテムのサイズをレイアウト作成時に出来上がったwidthに合わせて倍率変更しています。
  6. UICollectionViewLayoutAttributesのインスタンスを作成
  7. 先ほど作成したxとyとサイズをattributesにセット
  8. itemAttributes配列にセット
  9. self.allItemAttributsにitemAttributesをセット
  10. 今回利用したカラムの高さを更新
  11. 最後に、self.sectionItemAttributesにitemAttributesをセットしています。
/*
 * 4. Section footer
 */
CGFloat footerHeight;
NSUInteger columnIndex = [self longestColumnIndex];
top = [self.columnHeights[columnIndex] floatValue]
    - minimumInteritemSpacing + sectionInset.bottom;

if ([self.delegate respondsToSelector:@selector(collectionView:layout:heightForFooterInSection:)]) {
  footerHeight = [self.delegate collectionView:self.collectionView layout:self heightForFooterInSection:section];
} else {
  footerHeight = self.footerHeight;
}

if (footerHeight > 0) {
  attributes = [UICollectionViewLayoutAttributes layoutAttributesForSupplementaryViewOfKind:CHTCollectionElementKindSectionFooter withIndexPath:[NSIndexPath indexPathForItem:0 inSection:section]];
  attributes.frame = CGRectMake(0, top, self.collectionView.frame.size.width, footerHeight);

  self.footersAttribute[@(section)] = attributes;
  [self.allItemAttributes addObject:attributes];

  top = CGRectGetMaxY(attributes.frame);
}

for (idx = 0; idx < self.columnCount; idx++) {
  self.columnHeights[idx] = @(top);
}

フッターのレイアウトを決定していきます。基本的にヘッダーを作成した時と同じ考え方でやります。作成前にtopを更新します。今回は、長い方のカラムの値を利用します。

} // end of for (NSInteger section = 0; section < numberOfSections; ++section)

ここまででレイアウト作成処理は完了です。

// Build union rects
idx = 0;
NSInteger itemCounts = [self.allItemAttributes count];
while (idx < itemCounts) {
  CGRect rect1 = ((UICollectionViewLayoutAttributes *)self.allItemAttributes[idx]).frame;
  idx = MIN(idx + unionSize, itemCounts) - 1;
  CGRect rect2 = ((UICollectionViewLayoutAttributes *)self.allItemAttributes[idx]).frame;
  [self.unionRects addObject:[NSValue valueWithCGRect:CGRectUnion(rect1, rect2)]];
  idx++;
}

最後に、self.unionRectsを設定します。先ほど用意したself.allItemAttributesを利用して、返却するレイアウトの範囲を設定します。本レイアウトクラスの冒頭にconst NSInteger unionSize = 20;で宣言しているサイズを利用してそのサイズごと一つ一つのunionRectを作成していき、self.unionRectsにセットします。

ここまでやってやっとprepareLayoutの処理が終わりました。

レイアウト作成部分以外のLayoutのリーディング

#pragma mark - Init
- (void)commonInit {
  _columnCount = 2;
  _minimumColumnSpacing = 10;
  _minimumInteritemSpacing = 10;
  _headerHeight = 0;
  _footerHeight = 0;
  _sectionInset = UIEdgeInsetsZero;
}

- (id)init {
  if (self = [super init]) {
    [self commonInit];
  }
  return self;
}

- (id)initWithCoder:(NSCoder *)aDecoder {
  if (self = [super initWithCoder:aDecoder]) {
    [self commonInit];
  }
  return self;
}

initして利用する変数を初期化。

- (void)setSectionInset:(UIEdgeInsets)sectionInset {
  if (!UIEdgeInsetsEqualToEdgeInsets(_sectionInset, sectionInset)) {
    _sectionInset = sectionInset;
    [self invalidateLayout];
  }
}

パブリックなプロパティはオーバーライドして、invalidateLayoutを行うようにしている。invalidateLayoutが呼ばれると、prepareLayoutが再度呼び出される。

その他学んだこと

willAnimateRotationToInterfaceOrientation: duration:の時に、

- (void)updateLayoutForOrientation:(UIInterfaceOrientation)orientation {
  CHTCollectionViewWaterfallLayout *layout =
    (CHTCollectionViewWaterfallLayout *)self.collectionView.collectionViewLayout;
  layout.columnCount = UIInterfaceOrientationIsPortrait(orientation) ? 2 : 3;
}

を挟んでおくと、レイアウトの変更が簡単。layout.columnCountは、

- (void)setColumnCount:(NSInteger)columnCount {
  if (_columnCount != columnCount) {
    _columnCount = columnCount;
    [self invalidateLayout];
  }
}

このようにオーバーライドされている。[self invalidateLayout]を挿入するためにオーバーライドしていると思う。コードが見難くなるからinvalidateLayoutは外から実行してもよいと思うけどどうだろう。

- (NSUInteger)shortestColumnIndex {
  __block NSUInteger index = 0;
  __block CGFloat shortestHeight = MAXFLOAT;

  [self.columnHeights enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) {
    CGFloat height = [obj floatValue];
    if (height < shortestHeight) {
      shortestHeight = height;
      index = idx;
    }
  }];

  return index;
}

短い方を見つける処理。シンプルで良い。

自分なりの実装

自分なりの実装をしようと思いましたが、主要な処理はほとんどコピペになってしまいました。結構勉強になったし、これを軸に育てていこうと思い、一応、Githubに上げました。今回作成したのはバージョン1ということで、v1.0とタグ付けしています。

Pocket
LINEで送る

You may also like...