如何自定義UICollectionViewLayout定制自己的Banner

最近接到的一個需求里匙监,需要實現(xiàn)一個如下滑動效果的banner:

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的滑動效果吧草雕。

自定義步驟

  1. 創(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    
    
  2. 重載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ù)目的布局屬性并保存慢味。

  3. 重載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驰唬。

  4. 重載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);
    }
    
  5. 重載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)在有幾個問題需要先考慮一下

  1. 怎么判斷是該滑往前一個item的frame還是滑往下一個item的frame癞松,或者停在當前item爽撒?

    最開始的時候我是判斷速度velocity的正負來決定滑向哪一個頁面,后來發(fā)現(xiàn)這樣判斷會出現(xiàn)超出滑動意向預期的滑動效果响蓉,比如你想滑到下一頁,但是滑動結束的時候手指往回不小心勾了一下哨毁,就會出現(xiàn)往回滑的表現(xiàn)枫甲。所以這里做的優(yōu)化就是判斷中間的item往哪個方向偏移了,這樣偏移的方向就是滑動翻頁的方向扼褪。

  2. 這里的翻頁是一頁翻了多少想幻?

    這里的翻頁并不是真的翻了一個屏幕寬度,因為每翻一頁都是一個item居中话浇,所以它只是翻了一個item的原始寬度脏毯,并不能設置collectionView的page屬性來實現(xiàn)。

  3. 怎么獲取需要滑到的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)系指正峭状。

需要demo的話可以點擊這里克滴。

參考

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市宁炫,隨后出現(xiàn)的幾起案子偿曙,更是在濱河造成了極大的恐慌,老刑警劉巖羔巢,帶你破解...
    沈念sama閱讀 218,682評論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件望忆,死亡現(xiàn)場離奇詭異,居然都是意外死亡竿秆,警方通過查閱死者的電腦和手機启摄,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,277評論 3 395
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來幽钢,“玉大人歉备,你說我怎么就攤上這事》搜啵” “怎么了蕾羊?”我有些...
    開封第一講書人閱讀 165,083評論 0 355
  • 文/不壞的土叔 我叫張陵,是天一觀的道長帽驯。 經(jīng)常有香客問我龟再,道長,這世上最難降的妖魔是什么尼变? 我笑而不...
    開封第一講書人閱讀 58,763評論 1 295
  • 正文 為了忘掉前任利凑,我火速辦了婚禮,結果婚禮上嫌术,老公的妹妹穿的比我還像新娘哀澈。我一直安慰自己,他們只是感情好度气,可當我...
    茶點故事閱讀 67,785評論 6 392
  • 文/花漫 我一把揭開白布割按。 她就那樣靜靜地躺著,像睡著了一般磷籍。 火紅的嫁衣襯著肌膚如雪哲虾。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,624評論 1 305
  • 那天择示,我揣著相機與錄音,去河邊找鬼晒旅。 笑死栅盲,一個胖子當著我的面吹牛,可吹牛的內容都是我干的废恋。 我是一名探鬼主播谈秫,決...
    沈念sama閱讀 40,358評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼扒寄,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了拟烫?” 一聲冷哼從身側響起该编,我...
    開封第一講書人閱讀 39,261評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎硕淑,沒想到半個月后课竣,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,722評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡置媳,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 37,900評論 3 336
  • 正文 我和宋清朗相戀三年于樟,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片拇囊。...
    茶點故事閱讀 40,030評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡迂曲,死狀恐怖,靈堂內的尸體忽然破棺而出寥袭,到底是詐尸還是另有隱情路捧,我是刑警寧澤,帶...
    沈念sama閱讀 35,737評論 5 346
  • 正文 年R本政府宣布传黄,位于F島的核電站杰扫,受9級特大地震影響,放射性物質發(fā)生泄漏尝江。R本人自食惡果不足惜涉波,卻給世界環(huán)境...
    茶點故事閱讀 41,360評論 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望炭序。 院中可真熱鬧啤覆,春花似錦、人聲如沸惭聂。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,941評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽辜纲。三九已至笨觅,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間耕腾,已是汗流浹背见剩。 一陣腳步聲響...
    開封第一講書人閱讀 33,057評論 1 270
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留扫俺,地道東北人苍苞。 一個月前我還...
    沈念sama閱讀 48,237評論 3 371
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親羹呵。 傳聞我的和親對象是個殘疾皇子骂际,可洞房花燭夜當晚...
    茶點故事閱讀 44,976評論 2 355

推薦閱讀更多精彩內容