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) {
ここからレイアウトを作りこんでいきます。まず、topとattributesを宣言しています。そして、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.allItemAttributesはlayoutAttributesForElementsInRect:で利用するために保持します。
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文で処理を行います。
- indexPathを作成
- 現時点で一番短いカラム番号を取得
- xのoffsetを決定
- yのoffsetを決定
- itemのサイズを決定。この際、delegateで取得したアイテムのサイズをレイアウト作成時に出来上がったwidthに合わせて倍率変更しています。
- UICollectionViewLayoutAttributesのインスタンスを作成
- 先ほど作成したxとyとサイズをattributesにセット
- itemAttributes配列にセット
- self.allItemAttributsにitemAttributesをセット
- 今回利用したカラムの高さを更新
- 最後に、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とタグ付けしています。