最近接到的一個需求里匙监,需要實現(xiàn)一個如下滑動效果的banner:
自然而然的可以想到用UICollectionView來實現(xiàn)此效果斟冕,而主要的難點便在于需要自定義UICollectionViewLayout來設置滑動時的動態(tài)布局屬性。
關于UICollectionViewLayout
我們可以先了解下UICollectionView中不同類的依賴關系
UICollectionView在初始化的時候,需要指定一個對應的collectionViewLayout的實例充甚。當UICollectionView在進行布局顯示的時候,會向對應的UICollectionViewLayout獲取所有內容的布局信息霸褒,包括cell的所有布局信息和追加視圖伴找、裝飾視圖的布局信息。UICollectionViewLayout就是掌管所有布局信息的類废菱。
UICollectionViewLayout類中需要包含一個UICollectionViewLayoutAttributes的列表技矮,其對應了UICollectionView中每一個cell(UICollectionViewCell)以及追加視圖、裝飾視圖(UICollectionReusableView)布局的所有信息昙啄。而重寫UICollectionViewLayout的過程就是我們自主的去控制這些布局信息的過程穆役。
在圖中還有一個UICollectionViewFlowLayout,它是由官方實現(xiàn)的一個流水布局Layout,繼承自UICollectionViewLayout梳凛,一般我們實現(xiàn)常規(guī)的UICollectionView布局直接使用它比較方便。
既然要自定義UICollectionView的layout梳杏,那么了解下UICollectionViewLayout中需要重載的常用方法吧:
-(void)prepareLayout;
系統(tǒng)在進行l(wèi)ayout布局前會調用這個方法韧拒,該方法一般用于初始化需要的布局變量屬性等。
-(CGSize)collectionViewContentSize;
這里我們需要自己計算collectionView的contentSize大小并返回十性。
-(NSArray *)layoutAttributesForElementsInRect:(CGRect)rect;
返回給定rect中所有的item的布局屬性(UICollectionViewLayoutAttributes)數(shù)據(jù)叛溢,其中包括cell,追加視圖和裝飾視圖
-(UICollectionViewLayoutAttributes )layoutAttributesForItemAtIndexPath:(NSIndexPath )indexPath;
返回對應于indexPath位置的cell的布局屬性,該方法內需要我們自己根據(jù)indexPath來設置對應cell的布局屬性
-(UICollectionViewLayoutAttributes )layoutAttributesForSupplementaryViewOfKind:(NSString )kind atIndexPath:(NSIndexPath *)indexPath;
返回對應indexPath的位置的追加視圖的布局屬性劲适,如果沒有追加視圖可不重載
-(UICollectionViewLayoutAttributes * )layoutAttributesForDecorationViewOfKind:(NSString)decorationViewKind atIndexPath:(NSIndexPath )indexPath;
返回對應indexPath的位置的裝飾視圖的布局屬性楷掉,如果沒有裝飾視圖可不重載
-(BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds;
返回YES表示一旦滑動就重新計算布局信息,包括重新調用prepareLayout霞势。
- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity;
這個方法用于設置滑動停止時的位置烹植,proposedContentOffset代表將要停止的位置,velocity代表現(xiàn)在滑動的速度
自定義UICollectionViewLayout
了解各個重載方法的大致作用愕贡,現(xiàn)在就按順序來試著實現(xiàn)上述banner的滑動效果吧草雕。
自定義步驟
-
創(chuàng)建一個類繼承自UICollectionViewLayout并添加需要的屬性,我添加了一些需要用到的布局屬性固以,這里可以參考UICollectionViewFlowLayout對外的屬性來編寫墩虹。
在YXBannerLayout.h文件中嘱巾,添加的屬性用于外部調整collectionView的布局參數(shù)
@interface YXBannerLayout : UICollectionViewLayout //同一行兩個item之間的距離 @property (nonatomic, assign) NSInteger itemSpace; //collectionView的section內容與collectionView邊緣的間距(上,下诫钓,左旬昭,右) @property (nonatomic, assign) UIEdgeInsets sectionInset; //每一個item的長寬 @property (nonatomic, assign) CGSize itemSize; @end
在YXBannerLayout.m文件中
@interface YXBannerLayout () // 每一個cell對應的布局信息的數(shù)組,在init方法內進行初始化 @property (nonatomic, strong) NSMutableArray *attributesArray; @end
-
重載prepareLayout進行布局信息初始化
- (void)prepareLayout { //別忘記先調用super方法 [super prepareLayout]; //獲取section為0時cell的個數(shù)菌湃,這里只考慮一個section的情況 NSInteger itemCount = [self.collectionView numberOfItemsInSection:0]; //清除歷史布局數(shù)據(jù)稳懒,attributesArray已在init中初始化 [self.attributesArray removeAllObjects]; //為每一個cell創(chuàng)建一個attributes并存入數(shù)組 //這里調用的便是下面的 layoutAttributesForItemAtIndexPath for (int i = 0; i < itemCount; i++) { UICollectionViewLayoutAttributes *attributes = [self layoutAttributesForItemAtIndexPath:[NSIndexPath indexPathForItem:i inSection:0]]; [self.attributesArray addObject:attributes]; } }
該方法內主要是獲取collectionview的cell數(shù)量,然后根據(jù)數(shù)量依次調用layoutAttributesForItemAtIndexPath創(chuàng)建相應數(shù)目的布局屬性并保存慢味。
-
重載layoutAttributesForItemAtIndexPath設置各個cell的初始布局信息
- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath { //根據(jù)indexPath創(chuàng)建item的attributes UICollectionViewLayoutAttributes *attributes = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath]; //根據(jù)當前的index計算item?左上角起始點的x,y值 CGFloat itemX = self.sectionInset.left + (self.itemSpace + _itemSize.width) * indexPath.row; CGFloat itemY = self.sectionInset.top; //設置attributes的frame attributes.frame = CGRectMake(itemX, itemY, _itemSize.width, _itemSize.height); return attributes; }
在該方法中主要是設置各個item在collectionView中的frame场梆,光根據(jù)各個布局屬性設置好對應item的frame基本上就可以滿足尋常的展示需求了,然而我們的需求還需要進行額外的形變纯路,這個后面再細說或油。不過在此之前,我們還需要根據(jù)設置的items的frame自己計算并返回collectionView的contentsize驰唬。
-
重載collectionViewContentSize并計算返回contentSize
為了方便返回contentsize顶岸,在類中我直接添加了個屬性
@property (nonatomic, assign) CGFloat contentWidth;
接著在第3步的 layoutAttributesForItemAtIndexPath 方法中計算并保存contentWidth的值
... attributes.frame = CGRectMake(itemX, itemY, _itemSize.width, _itemSize.height); // 根據(jù)當前item的初始點的x,item的寬度叫编,以及item的間距計算當前的contentWidth _contentWidth = itemX + _itemSize.width + self.itemSpace;
最后就比較簡單了
- (CGSize)collectionViewContentSize { // 寬度最后還是要加上最后一個item距離collectionView右邊緣的距離 return CGSizeMake(_contentWidth + self.sectionInset.right, self.collectionView.frame.size.height); }
-
重載layoutAttributesForElementsInRect進行布局屬性變更
在這里我們可以完成這個Banner的滑動效果辖佣。
從動畫可以看出,在滑動的同時搓逾,往中間靠攏的item逐步放大卷谈,往兩邊的移動的item逐步縮小,并且左邊的item往旁邊移動的時候其會向右平移霞篡,這樣才能在最后和中間的item相重合世蔗。由此要實現(xiàn)的便是根據(jù)距離來計算各個item放大縮小的比例,同時設置左邊item的陪平移的距離朗兵。
//返回rect范圍內item的attributes - (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect { // 計算停留的中心點 CGFloat centerX = self.collectionView.contentOffset.x + self.sectionInset.left + self.itemSize.width*0.5; // 最小的間距值 CGFloat minDelta = MAXFLOAT; NSInteger minIndex = 0; NSInteger index = 0; //遍歷所有item的布局信息污淋,計算和centerX的差值大小,并保存距離最近的item的index余掖,用于獲取前一個需要平移的item for (UICollectionViewLayoutAttributes *attrs in _attributesArray) { // cell的中心點x 和 collectionView最中心點的x值 的間距 CGFloat delta = ABS(attrs.center.x - centerX); // 根據(jù)item邊緣與中心點的距離來計算縮放比例寸爆,距離中心點越近,展示比例越大 CGFloat scale = 1 - (delta-self.itemSize.width*0.5)*0.15 / self.itemSize.width; //限制最小縮放比例與最大比例 scale = (scale>0.88) ?scale : 0.88; scale = (scale>1) ?1 :scale; // 設置縮放比例盐欺,這里用的形變方法會按照原始frame進行形變 attrs.transform = CGAffineTransformMakeScale(scale, scale); //計算最靠近中心點的item的index if (ABS(minDelta) > ABS(attrs.center.x - centerX)) { minDelta = attrs.center.x - centerX; minIndex = index; } index ++; } //假如最靠近中心點的item前面還有item赁豆,則需要對前面一個item進行向右平移,使之與中間item有重合 if (minIndex>=1) { UICollectionViewLayoutAttributes *preAttr = _attributesArray[minIndex-1]; CGFloat delta = ABS(preAttr.center.x - centerX); //進行向右平移的形變找田,這里用的形變方法會在傳入形變的基礎上疊加形變 preAttr.transform = CGAffineTransformTranslate(preAttr.transform, (delta-self.itemSize.width*0.5)*0.3 , 0); //調整zIndex歌憨,使其處于中間item的下面 preAttr.zIndex = -1; } return self.attributesArray; }
該方法中進行的形變均是基于與中心點距離的線性變換,較為簡單墩衙,可以在坐標軸中作圖幫助理解务嫡。
相信大家也會注意到這里還調整了UICollectionViewLayoutAttributes得zIndex屬性甲抖,這個屬性代表view的層級關系,其值越大心铃,代表其展示的層級越高准谚,層級高的view能覆蓋層級低的view。這里將左邊的view的zIndex設置為-1去扣,讓其始終居于中間item的下面柱衔。要注意的是在這里設置zIndex并不會影響其他item的zIndex,因為每次運行到這里的時候愉棱,都會先調用layoutAttributesForItemAtIndexPath重置所有item的布局信息唆铐,包括zIndex,這樣在顯示的時候能保證只有左邊的item的view層級被調整到下層奔滑。
當然艾岂,滾動重新布局必須依賴為了shouldInvalidateLayoutForBoundsChange的返回,只有返回真的時候才能在滑動的同時不斷的更新縮放比例以及平移距離朋其。
//返回YES表示一旦滑動就重新計算所有布局信息 - (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds { return YES; }
到這里基本上實現(xiàn)了滑動縮放以及重疊的效果王浴,但是有個細節(jié)還有待考察,目前的banner不會停在item居于最中央的位置梅猿,不具有翻頁的效果氓辣。
滑動效果
實現(xiàn)翻頁效果
存在的問題
為了實現(xiàn)類滑動翻頁效果,在每次滑動之后都能停在一個恰當?shù)奈恢酶を荆覀冃枰剌dtargetContentOffsetForProposedContentOffset 方法钞啸,指定滑動停止時的位置。那么現(xiàn)在有幾個問題需要先考慮一下
-
怎么判斷是該滑往前一個item的frame還是滑往下一個item的frame癞松,或者停在當前item爽撒?
最開始的時候我是判斷速度velocity的正負來決定滑向哪一個頁面,后來發(fā)現(xiàn)這樣判斷會出現(xiàn)超出滑動意向預期的滑動效果响蓉,比如你想滑到下一頁,但是滑動結束的時候手指往回不小心勾了一下哨毁,就會出現(xiàn)往回滑的表現(xiàn)枫甲。所以這里做的優(yōu)化就是判斷中間的item往哪個方向偏移了,這樣偏移的方向就是滑動翻頁的方向扼褪。
-
這里的翻頁是一頁翻了多少想幻?
這里的翻頁并不是真的翻了一個屏幕寬度,因為每翻一頁都是一個item居中话浇,所以它只是翻了一個item的原始寬度脏毯,并不能設置collectionView的page屬性來實現(xiàn)。
-
怎么獲取需要滑到的contentOffset值呢幔崖?
因為翻頁效果每翻一次是一個item的寬度食店,因此這里我們可以根據(jù)要滑到的item的index計算出contentOffset值渣淤。而首先需要知道滑動前處于中間的item的index,這里我的做法是在collectionView里面實現(xiàn)scrollViewWillBeginDragging來獲取在滑動將要開始時居中item的index吉嫩,將其傳遞給layout价认,然后在targetContentOffsetForProposedContentOffset方法中使用。這依賴于scrollViewWillBeginDragging是在將要開始拖拽的時候調用自娩,后者是在拖拽結束的時候調用用踩。
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
// 獲取當前可見的items列表
NSArray<UICollectionViewCell *> *cells = [self.collectionView visibleCells];
if (!cells || cells.count==0) {
return;
}
//計算item停靠中心位置
CGFloat centerX = self.collectionView.contentOffset.x + _layout.sectionInset.left + _layout.itemSize.width*0.5;
NSInteger index = 0;
CGFloat minDelta = MAXFLOAT;
//獲取距離中心點最近的Item的index
for (NSInteger i=0; i<cells.count ; i++) {
UICollectionViewCell *cell = cells[I];
if (minDelta > ABS(cell.center.x - centerX)) {
minDelta = ABS(cell.center.x - centerX);
index = I;
}
}
NSIndexPath *indexPath = [self.collectionView indexPathForCell:cells[index]];
_layout.currentIndex = indexPath.row;
}
解決方案的實現(xiàn)
解決好以上幾個問題之后忙迁,便可以輕松的寫出滑到適當位置的邏輯
- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity {
CGFloat leftX = self.collectionView.contentOffset.x + self.sectionInset.left;
NSInteger itemCount = [self.collectionView numberOfItemsInSection:0];
//獲取滑動前居中的item
UICollectionViewCell *cell = [self.collectionView cellForItemAtIndexPath:[NSIndexPath indexPathForRow:_currentIndex inSection:0]];
//根據(jù)滑動前居中的item的位置來判斷需要滑到的item的index
//不要忘記第一個item和最后一個item的情況
if (leftX > cell.frame.origin.x && _currentIndex+1 < itemCount) {
_currentIndex += 1;
}else if(leftX < cell.frame.origin.x && _currentIndex-1 >= 0){
_currentIndex -= 1;
}
//設置目標位置的contentOffset
proposedContentOffset.x = (_itemSpace + _itemSize.width) * _currentIndex;
return proposedContentOffset;
}
看起來已經(jīng)完美了脐彩,然而運行的結果有些差強人意。在給banner翻頁的時候姊扔,假如以很慢的速度滑動惠奸,banner也會以很慢的速度慢騰騰的滑到下一頁,banner翻頁的速度完全取決于用戶滑動的速度旱眯,這離達到視覺大大的要求還是有一段距離的晨川。
那么怎么解決呢,也許可以直接不使用該方法的減速停止機制删豺,而是直接設置collectionView的contentOffset共虑。這樣的效果會怎么樣呢?
//設置目標停止位置和當前所在的位置一致呀页,提前結束減速滑動效果
proposedContentOffset.x = self.collectionView.contentOffset.x;
//直接設置目標位置的contentOffset
self.collectionView.contentOffset = CGPointMake((_itemSpace + _itemSize.width) * _currentIndex, self.collectionView.contentOffset.y);
運行一遍果然是沒有減速過程妈拌,但是直接瞬移到了目標位置,所以我們離結果只差一個滑動動畫而已蓬蝶。
proposedContentOffset.x = self.collectionView.contentOffset.x;
//動畫滑動到指定位置
[self.collectionView scrollRectToVisible:CGRectMake((_itemSpace + _itemSize.width) * _currentIndex, 0, self.collectionView.frame.size.width, self.collectionView.frame.size.height) animated:YES];
到這里尘分,我們已經(jīng)通過自定義UICollectionViewLayout完全實現(xiàn)了Banner的滑動特效,但是在上面的 targetContentOffsetForProposedContentOffset 中其實還存在著一個Bug丸氛,它也會導致某種情況下的滑動結果超出預期培愁。
UICollectionViewCell *cell = [self.collectionView cellForItemAtIndexPath:[NSIndexPath indexPathForRow:_currentIndex inSection:0]];
相信大家都知道,collectionView中的cell在滑出屏幕的時候就會被回收缓窜,那么這時候通過index獲取到的cell便是nil定续,這樣在滑動的時候便會出現(xiàn)無腦滑到下一頁的情況。復現(xiàn)操作就是對banner從左邊緣滑到右邊緣禾锤,這樣可以觀察到觸發(fā)bug之后的表現(xiàn)私股。怎么解決呢,我們可以用自己保存的UICollectionViewLayoutAttributes來進行判斷恩掷。
UICollectionViewLayoutAttributes *attr = _attributesArray[_currentIndex];
if (leftX > attr.frame.origin.x && _currentIndex+1 < itemCount) {
_currentIndex += 1;
}else if(leftX < attr.frame.origin.x && _currentIndex-1 >= 0){
_currentIndex -= 1;
}
修改后的完整代碼如下:
- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity {
CGFloat leftX = self.collectionView.contentOffset.x + self.sectionInset.left;
UICollectionViewLayoutAttributes *attr = _attributesArray[_currentIndex];
//根據(jù)滑動前居中的item的位置來判斷需要滑到的item的index
//不要忘記第一個item和最后一個item的情況
if (leftX > attr.frame.origin.x && _currentIndex+1 < _attributesArray.count) {
_currentIndex += 1;
}else if(leftX < attr.frame.origin.x && _currentIndex-1 >= 0){
_currentIndex -= 1;
}
//設置目標停止位置和當前所在的位置一致倡鲸,提前結束減速滑動效果
proposedContentOffset.x = self.collectionView.contentOffset.x;
//動畫滑動到指定位置
[self.collectionView scrollRectToVisible:CGRectMake((_itemSpace + _itemSize.width) * _currentIndex, 0, self.collectionView.frame.size.width, self.collectionView.frame.size.height) animated:YES];
return proposedContentOffset;
}
到這里我們已經(jīng)通過自定義UICollectionViewLayout的方式實現(xiàn)了想要的banner效果,如果有什么錯漏的話黄娘,還請聯(lián)系指正峭状。