PSCollectionView是一個實現較簡潔的仿Pinterest瀑布流iOS版實現,使用UIScrollView做容器,每列列寬固定,高度可變,使用方式類似UITableView。
其效果如圖:
一.基本原理
其基本實現原理爲:
- 列數固定,根據列數每列存儲一個當前列的高度值。
- 每次插入數據塊時,在當前最小高度的列裏插入,然後更新當前列的高度爲原有高度加上當前數據模塊高度
- 重複2直到所有數據模塊插入完畢
- 調整容器(UIScrollView)的高度爲各列最大的高度值。
二.具體實現
1. 相關數據結構
公共屬性
@property (nonatomic, retain) UIView *headerView;
@property (nonatomic, retain) UIView *footerView;
@property (nonatomic, retain) UIView *emptyView;
@property (nonatomic, retain) UIView *loadingView;
@property (nonatomic, assign, readonly) CGFloat colWidth;
@property (nonatomic, assign, readonly) NSInteger numCols;
@property (nonatomic, assign) NSInteger numColsLandscape;
@property (nonatomic, assign) NSInteger numColsPortrait;
@property (nonatomic, assign) id collectionViewDelegate;
@property (nonatomic, assign) id collectionViewDataSource;
-
headerView,footerView,emptyView,loadingView分別對應列表頭部,尾部,空白時,正在加載時要顯示的視圖。
numColsLandscape,numColsPortrait爲橫屏和豎屏時的列數。 - colWidth,numCols爲只讀屬性,根據當前的視圖方向,視圖總大小,橫屏和豎屏時的列數計算得出。
- collectionViewDelegate,collectionViewDataSource爲Delegate和數據源。
私有屬性
@property (nonatomic, assign, readwrite) CGFloat colWidth;
@property (nonatomic, assign, readwrite) NSInteger numCols;
@property (nonatomic, assign) UIInterfaceOrientation orientation;
@property (nonatomic, retain) NSMutableSet *reuseableViews;
@property (nonatomic, retain) NSMutableDictionary *visibleViews;
@property (nonatomic, retain) NSMutableArray *viewKeysToRemove;
@property (nonatomic, retain) NSMutableDictionary *indexToRectMap;
私有屬性將colWidth,numCols定義爲readwrite,便於內部賦值操作。orientation爲當前視圖的方向,從UIApplication的statusBarOrientation屬性獲取。reuseableViews數據集存儲可重用的的數據塊視圖,在數據塊移除可見範圍時將其放入reuseableViews中,當DataSource調用dequeueReusableView時,從reuseableViews取出一個返回。visibleViews字典存儲當前可見的數據塊視圖,key爲數據塊索引,當容器滾動時,將移除可見範圍的數據塊視圖從visibleViews中移除,並放入reuseableViews中;當存在應該顯示的數據塊視圖,但還未放入容器視圖時,則從DataSource獲取新的數據塊視圖,加入到容器視圖中,同時將其加入到visibleViews中。viewKeysToRemove數組在遍歷visibleViews時存儲應該移除的數據塊視圖Key。indexToRectMap數據字典存儲每個數據塊(不管可不可見)在容器中的位置,將CGRect轉換爲NSString(NSStringFromCGRect)作爲Value存儲,Key爲數據塊的索引。
2.視圖更新方式
- 在reloadData或視圖方向發生變化時,需要重新計算所有數據塊的位置並重新加載,見relayoutViews方法。
- 當滑動容器時,UIScrollView會調用其layoutSubviews方法,若方向未變化,則不需要重新加載所有數據塊,僅僅需要移除非可見的數據塊,載入進入可見範圍的數據塊,見removeAndAddCellsIfNecessary方法.
#pragma mark - DataSource
- (void)reloadData {
[self relayoutViews];
}
#pragma mark - View
- (void)layoutSubviews {
[super layoutSubviews];
UIInterfaceOrientation orientation = [UIApplication sharedApplication].statusBarOrientation;
if (self.orientation != orientation) {
self.orientation = orientation;
[self relayoutViews];
} else {
[self removeAndAddCellsIfNecessary];
}
}
3.relayoutViews方法
relayoutViews會將現有的所有數據塊視圖清除,重新從DataSource獲取數據,重新計算所有數據塊視圖的位置,容器的高度等。
- 首先遍歷可見數據塊視圖字典visibleViews,將所有數據塊視圖放入reuseableViews中,並清空visibleViews,indexToRectMap。
- 將emptyView,loadingView從容器視圖中移除。
- 從DataSource獲取數據塊的個數numViews。
- 若headerView不爲nil,則將headerView視圖加入到容器,更新top索引。
- 若numViews不爲0,則依次計算每個數據塊的位置。使用colOffsets存儲每一列的當前高度,每次增加數據塊時將其添加到高度最小的列中,所處的列確定後,其orig座標就確定了,寬度固定,再從DataSource獲取此數據塊的高度,那麼當前數據塊的frame位置就確定了,將其轉換爲NSString(使用setObject:NSStringFromCGRect)存儲到indexToRectMap字典中,以數控塊索引爲key;同時將當前列的高度更新,再繼續處理下一數據塊,還是加入到高度最小的列中,直至所有數據塊處理完畢。
- 這時的總高度即最高列的高度。
- 若numViews爲0,則將emptyView增加到容器中,總高度則爲添加emptyView的高度。
- 若footerView不爲nil,則將footerView加入到容器中.
- 這時的總高度totalHeight即爲最終容器內容的總高度,將其賦值的UIScrollView的contentSize屬性。
- 這時headerView和footView已加入到容器中,但所有的數據塊只是計算了其應該處於的位置,並未實際放入容器中,調用removeAndAddCellsIfNecessary將當前可見的數據塊視圖加入到容器中。
- (void)relayoutViews {
self.numCols = UIInterfaceOrientationIsPortrait(self.orientation) ? self.numColsPortrait : self.numColsLandscape;
// Reset all state
[self.visibleViews enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
PSCollectionViewCell *view = (PSCollectionViewCell *)obj;
[self enqueueReusableView:view];
}];
[self.visibleViews removeAllObjects];
[self.viewKeysToRemove removeAllObjects];
[self.indexToRectMap removeAllObjects];
if (self.emptyView) {
[self.emptyView removeFromSuperview];
}
[self.loadingView removeFromSuperview];
// This is where we should layout the entire grid first
NSInteger numViews = [self.collectionViewDataSource numberOfViewsInCollectionView:self];
CGFloat totalHeight = 0.0;
CGFloat top = kMargin;
// Add headerView if it exists
if (self.headerView) {
self.headerView.top = kMargin;
top = self.headerView.top;
[self addSubview:self.headerView];
top += self.headerView.height;
top += kMargin;
}
if (numViews > 0) {
// This array determines the last height offset on a column
NSMutableArray *colOffsets = [NSMutableArray arrayWithCapacity:self.numCols];
for (int i = 0; i < self.numCols; i++) {
[colOffsets addObject:[NSNumber numberWithFloat:top]];
}
// Calculate index to rect mapping
self.colWidth = floorf((self.width - kMargin * (self.numCols + 1)) / self.numCols);
for (NSInteger i = 0; i < numViews; i++) {
NSString *key = PSCollectionKeyForIndex(i);
// Find the shortest column
NSInteger col = 0;
CGFloat minHeight = [[colOffsets objectAtIndex:col] floatValue];
for (int i = 1; i < [colOffsets count]; i++) {
CGFloat colHeight = [[colOffsets objectAtIndex:i] floatValue];
if (colHeight < minHeight) {
col = i;
minHeight = colHeight;
}
}
CGFloat left = kMargin + (col * kMargin) + (col * self.colWidth);
CGFloat top = [[colOffsets objectAtIndex:col] floatValue];
CGFloat colHeight = [self.collectionViewDataSource heightForViewAtIndex:i];
if (colHeight == 0) {
colHeight = self.colWidth;
}
if (top != top) {
// NaN
}
CGRect viewRect = CGRectMake(left, top, self.colWidth, colHeight);
// Add to index rect map
[self.indexToRectMap setObject:NSStringFromCGRect(viewRect) forKey:key];
// Update the last height offset for this column
CGFloat test = top + colHeight + kMargin;
if (test != test) {
// NaN
}
[colOffsets replaceObjectAtIndex:col withObject:[NSNumber numberWithFloat:test]];
}
for (NSNumber *colHeight in colOffsets) {
totalHeight = (totalHeight < [colHeight floatValue]) ? [colHeight floatValue] : totalHeight;
}
} else {
totalHeight = self.height;
// If we have an empty view, show it
if (self.emptyView) {
self.emptyView.frame = CGRectMake(kMargin, top, self.width - kMargin * 2, self.height - top - kMargin);
[self addSubview:self.emptyView];
}
}
// Add footerView if exists
if (self.footerView) {
self.footerView.top = totalHeight;
[self addSubview:self.footerView];
totalHeight += self.footerView.height;
totalHeight += kMargin;
}
self.contentSize = CGSizeMake(self.width, totalHeight);
[self removeAndAddCellsIfNecessary];
}
4.removeAndAddCellsIfNecessary方法
removeAndAddCellsIfNecessary根據當前容器UIScrollView的contentOffset,將用戶不可見的數據塊視圖從容器中移除,將用戶可見的數據塊視圖加入到容器中。
-
獲得當前容器的可見部分。
CGRect visibleRect = CGRectMake(self.contentOffset.x, self.contentOffset.y, self.width, self.height);
- 逐個遍歷visibleViews中的視圖,使用CGRectIntersectsRect方法判斷其frame與容器可見部分visibleRect是否有交集,若沒有,則將其從visibleViews中去除,並添加到reuseableViews中。
- 對visibleViews剩餘的數據塊視圖排序,獲得其最小索引(topIndex)和最大索引(bottomIndex)。
- 將topIndex和bottomIndex分別向上和向下擴充bufferViewFactor*numCols個數據塊索引。
- 從topIndex開始到bottomIndex判斷索引對應的數據塊視圖的位置是否在容器的visibleRect範圍內,以及其是否在visibleViews中。若其應該顯示,而且不在visibleViews中,則向DataSource請求一個新的數據塊視圖,加到容器視圖中,同時添加到visibleViews中。
這樣新的ScrollView可見區域就可以被數據塊填充滿。
- (void)removeAndAddCellsIfNecessary {
static NSInteger bufferViewFactor = 5;
static NSInteger topIndex = 0;
static NSInteger bottomIndex = 0;
NSInteger numViews = [self.collectionViewDataSource numberOfViewsInCollectionView:self];
if (numViews == 0) return;
// Find out what rows are visible
CGRect visibleRect = CGRectMake(self.contentOffset.x, self.contentOffset.y, self.width, self.height);
// Remove all rows that are not inside the visible rect
[self.visibleViews enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
PSCollectionViewCell *view = (PSCollectionViewCell *)obj;
CGRect viewRect = view.frame;
if (!CGRectIntersectsRect(visibleRect, viewRect)) {
[self enqueueReusableView:view];
[self.viewKeysToRemove addObject:key];
}
}];
[self.visibleViews removeObjectsForKeys:self.viewKeysToRemove];
[self.viewKeysToRemove removeAllObjects];
if ([self.visibleViews count] == 0) {
topIndex = 0;
bottomIndex = numViews;
} else {
NSArray *sortedKeys = [[self.visibleViews allKeys] sortedArrayUsingComparator:^(id obj1, id obj2) {
if ([obj1 integerValue] < [obj2 integerValue]) {
return (NSComparisonResult)NSOrderedAscending;
} else if ([obj1 integerValue] > [obj2 integerValue]) {
return (NSComparisonResult)NSOrderedDescending;
} else {
return (NSComparisonResult)NSOrderedSame;
}
}];
topIndex = [[sortedKeys objectAtIndex:0] integerValue];
bottomIndex = [[sortedKeys lastObject] integerValue];
topIndex = MAX(0, topIndex - (bufferViewFactor * self.numCols));
bottomIndex = MIN(numViews, bottomIndex + (bufferViewFactor * self.numCols));
}
// NSLog(@"topIndex: %d, bottomIndex: %d", topIndex, bottomIndex);
// Add views
for (NSInteger i = topIndex; i < bottomIndex; i++) {
NSString *key = PSCollectionKeyForIndex(i);
CGRect rect = CGRectFromString([self.indexToRectMap objectForKey:key]);
// If view is within visible rect and is not already shown
if (![self.visibleViews objectForKey:key] && CGRectIntersectsRect(visibleRect, rect)) {
// Only add views if not visible
PSCollectionViewCell *newView = [self.collectionViewDataSource collectionView:self viewAtIndex:i];
newView.frame = CGRectFromString([self.indexToRectMap objectForKey:key]);
[self addSubview:newView];
// Setup gesture recognizer
if ([newView.gestureRecognizers count] == 0) {
PSCollectionViewTapGestureRecognizer *gr = [[[PSCollectionViewTapGestureRecognizer alloc] initWithTarget:self action:@selector(didSelectView:)] autorelease];
gr.delegate = self;
[newView addGestureRecognizer:gr];
newView.userInteractionEnabled = YES;
}
[self.visibleViews setObject:newView forKey:key];
}
}
}
5.select方法
其定義了一個UITapGestureRecognizer的子類PSCollectionViewTapGestureRecognizer來檢測每個數據塊的點擊操作。
從DataSource獲取到一個新的數據塊視圖時,會檢測裏面是否已包含gesture recognizer對象,若沒有則新創建一個PSCollectionViewTapGestureRecognizer對象放入,將delegate設爲自身。
// Setup gesture recognizer
if ([newView.gestureRecognizers count] == 0) {
PSCollectionViewTapGestureRecognizer *gr = [[[PSCollectionViewTapGestureRecognizer alloc] initWithTarget:self action:@selector(didSelectView:)] autorelease];
gr.delegate = self;
[newView addGestureRecognizer:gr];
newView.userInteractionEnabled = YES;
}
手勢識別檢測到點擊時會向Delegate詢問此點是否可接受(gestureRecognizer:shouldReceiveTouch:),若手勢識別對象是PSCollectionViewTapGestureRecognizer類型,則是我們添加進去的。若該點所屬的數據塊視圖可見,則接受此點,若不可見,則忽略。若手勢識別對象不是PSCollectionViewTapGestureRecognizer對象,就不是我們放入的,則一直返回YES。
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch {
if (![gestureRecognizer isMemberOfClass:[PSCollectionViewTapGestureRecognizer class]]) return YES;
NSString *rectString = NSStringFromCGRect(gestureRecognizer.view.frame);
NSArray *matchingKeys = [self.indexToRectMap allKeysForObject:rectString];
NSString *key = [matchingKeys lastObject];
if ([touch.view isMemberOfClass:[[self.visibleViews objectForKey:key] class]]) {
return YES;
} else {
return NO;
}
}
當檢測到點擊操作時,調用didSelectView:方法,在其中調用delegate的collectionView:didSelectView:atIndex:方法,傳遞參數爲self對象,選擇的數據塊視圖以及選擇的數據塊索引;
- (void)didSelectView:(UITapGestureRecognizer *)gestureRecognizer {
NSString *rectString = NSStringFromCGRect(gestureRecognizer.view.frame);
NSArray *matchingKeys = [self.indexToRectMap allKeysForObject:rectString];
NSString *key = [matchingKeys lastObject];
if ([gestureRecognizer.view isMemberOfClass:[[self.visibleViews objectForKey:key] class]]) {
if (self.collectionViewDelegate && [self.collectionViewDelegate respondsToSelector:@selector(collectionView:didSelectView:atIndex:)]) {
NSInteger matchingIndex = PSCollectionIndexForKey([matchingKeys lastObject]);
[self.collectionViewDelegate collectionView:self didSelectView:(PSCollectionViewCell *)gestureRecognizer.view atIndex:matchingIndex];
}
}
}
這種方式還存在各種問題
- 若DataSource返回的數據塊視圖中已加入自己的UITapGestureRecognizer對象,則[newView.gestureRecognizers count]就不爲0,在判斷時PSCollectionView內部定義的PSCollectionViewTapGestureRecognizer就不會加入, 這樣選擇數據塊視圖的操作就不會觸發。
- 實現的gestureRecognizer:shouldReceiveTouch:方法對非PSCollectionViewTapGestureRecognizer的對象直接返回YES。這樣,如果子類化PSCollectionView重寫gestureRecognizer:shouldReceiveTouch:方法時,如果調用super的此方法,則會直接返回,不會執行自己的定製化操作;若不調用super的此方法,則選擇功能就會出差錯。
6.重用數據塊視圖機制
NSMutableSet *reuseableViews;
中存儲可複用的數據塊視圖。dequeueReusableView從reuseableViews中任取一個視圖返回,enqueueReusableView將數據塊視圖放入reuseableViews中。
#pragma mark - Reusing Views
- (PSCollectionViewCell *)dequeueReusableView {
PSCollectionViewCell *view = [self.reuseableViews anyObject];
if (view) {
// Found a reusable view, remove it from the set
[view retain];
[self.reuseableViews removeObject:view];
[view autorelease];
}
return view;
}
- (void)enqueueReusableView:(PSCollectionViewCell *)view {
if ([view respondsToSelector:@selector(prepareForReuse)]) {
[view performSelector:@selector(prepareForReuse)];
}
view.frame = CGRectZero;
[self.reuseableViews addObject:view];
[view removeFromSuperview];
}
代碼:
PSCollectionView.h
PSCollectionView.m
PSCollectionViewCell.h
PSCollectionViewCell.m
git工程:
https://github.com/ptshih/PSCollectionView
三.使用方法
創建PSCollectionView對象
self.collectionView = [[[PSCollectionView alloc] initWithFrame:self.view.bounds] autorelease];
self.collectionView.delegate = self;
self.collectionView.collectionViewDelegate = self;
self.collectionView.collectionViewDataSource = self;
self.collectionView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
設置列數
// Specify number of columns for both iPhone and iPad
if (isDeviceIPad()) {
self.collectionView.numColsPortrait = 4;
self.collectionView.numColsLandscape = 5;
} else {
self.collectionView.numColsPortrait = 2;
self.collectionView.numColsLandscape = 3;
}
添加header,footer,empty,loader等視圖
UIView *loadingLabel = ...
self.collectionView.loadingView = loadingLabel;
UIView *emptyView = ...
self.collectionView.emptyView = emptyView;
UIView *headerView = ...
self.collectionView.headerView = headerView;
UIView *footerView = ...
self.collectionView.footerView = footerView;
實現Delegate和DataSource
- (PSCollectionViewCell *)collectionView:(PSCollectionView *)collectionView viewAtIndex:(NSInteger)index {
NSDictionary *item = [self.items objectAtIndex:index];
// You should probably subclass PSCollectionViewCell
PSCollectionViewCell *v = (PSCollectionViewCell *)[self.collectionView dequeueReusableView];
if (!v) {
v = [[[PSCollectionViewCell alloc] initWithFrame:CGRectZero] autorelease];
}
[v fillViewWithObject:item]
return v;
}
- (CGFloat)heightForViewAtIndex:(NSInteger)index {
NSDictionary *item = [self.items objectAtIndex:index];
// You should probably subclass PSCollectionViewCell
return [PSCollectionViewCell heightForViewWithObject:item inColumnWidth:self.collectionView.colWidth];
}
- (void)collectionView:(PSCollectionView *)collectionView didSelectView:(PSCollectionViewCell *)view atIndex:(NSInteger)index {
// Do something with the tap
}
四.其他瀑布流實現
1.WaterflowView
2.上拉刷新瀑布流將PSCollectionView與EGOTableViewPullRefresh結合,增加上拉/下拉刷新效果。
3.瀑布效果,不同的實現方式
參考:
PSCollectionView
When does layoutSubviews get called?
Overriding layoutSubviews when rotating UIView
iPhone開發筆記 – 瀑布流佈局
瀑布流佈局淺析
說說瀑布流式網站裏那些可人的小細節
EGOTableViewPullRefresh