PSCollectionView瀑布流實現

PSCollectionView是一個實現較簡潔的仿Pinterest瀑布流iOS版實現,使用UIScrollView做容器,每列列寬固定,高度可變,使用方式類似UITableView。
其效果如圖:

一.基本原理

其基本實現原理爲:

  1. 列數固定,根據列數每列存儲一個當前列的高度值。
  2. 每次插入數據塊時,在當前最小高度的列裏插入,然後更新當前列的高度爲原有高度加上當前數據模塊高度
  3. 重複2直到所有數據模塊插入完畢
  4. 調整容器(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爲當前視圖的方向,從UIApplicationstatusBarOrientation屬性獲取。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獲取數據,重新計算所有數據塊視圖的位置,容器的高度等。

  1. 首先遍歷可見數據塊視圖字典visibleViews,將所有數據塊視圖放入reuseableViews中,並清空visibleViews,indexToRectMap。
  2. 將emptyView,loadingView從容器視圖中移除。
  3. 從DataSource獲取數據塊的個數numViews。
  4. 若headerView不爲nil,則將headerView視圖加入到容器,更新top索引。
  5. 若numViews不爲0,則依次計算每個數據塊的位置。使用colOffsets存儲每一列的當前高度,每次增加數據塊時將其添加到高度最小的列中,所處的列確定後,其orig座標就確定了,寬度固定,再從DataSource獲取此數據塊的高度,那麼當前數據塊的frame位置就確定了,將其轉換爲NSString(使用setObject:NSStringFromCGRect)存儲到indexToRectMap字典中,以數控塊索引爲key;同時將當前列的高度更新,再繼續處理下一數據塊,還是加入到高度最小的列中,直至所有數據塊處理完畢。
  6. 這時的總高度即最高列的高度。
  7. 若numViews爲0,則將emptyView增加到容器中,總高度則爲添加emptyView的高度。
  8. 若footerView不爲nil,則將footerView加入到容器中.
  9. 這時的總高度totalHeight即爲最終容器內容的總高度,將其賦值的UIScrollView的contentSize屬性。
  10. 這時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根據當前容器UIScrollViewcontentOffset,將用戶不可見的數據塊視圖從容器中移除,將用戶可見的數據塊視圖加入到容器中。

  1. 獲得當前容器的可見部分。

    CGRect visibleRect = CGRectMake(self.contentOffset.x, self.contentOffset.y, self.width, self.height);
  2. 逐個遍歷visibleViews中的視圖,使用CGRectIntersectsRect方法判斷其frame與容器可見部分visibleRect是否有交集,若沒有,則將其從visibleViews中去除,並添加到reuseableViews中。
  3. 對visibleViews剩餘的數據塊視圖排序,獲得其最小索引(topIndex)和最大索引(bottomIndex)。
  4. 將topIndex和bottomIndex分別向上和向下擴充bufferViewFactor*numCols個數據塊索引。
  5. 從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];
        }
    }
}

這種方式還存在各種問題

  1. 若DataSource返回的數據塊視圖中已加入自己的UITapGestureRecognizer對象,則[newView.gestureRecognizers count]就不爲0,在判斷時PSCollectionView內部定義的PSCollectionViewTapGestureRecognizer就不會加入, 這樣選擇數據塊視圖的操作就不會觸發。
  2. 實現的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.上拉刷新瀑布流PSCollectionViewEGOTableViewPullRefresh結合,增加上拉/下拉刷新效果。
3.瀑布效果,不同的實現方式

參考:
PSCollectionView
When does layoutSubviews get called?
Overriding layoutSubviews when rotating UIView
iPhone開發筆記 – 瀑布流佈局
瀑布流佈局淺析
說說瀑布流式網站裏那些可人的小細節
EGOTableViewPullRefresh

本文出自 清風徐來,水波不興 的博客,轉載時請註明出處及相應鏈接。


From: http://www.winddisk.com/2012/07/28/pscollectionview%E7%80%91%E5%B8%83%E6%B5%81%E5%AE%9E%E7%8E%B0/


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章