自定義UICollectionView佈局(類似集五福)

一直以來想學習怎麼樣去自定義UICollectionViewLayout,但總是感覺太難,一直以來,都是看了一點點就放棄了。但其實任何事,只要去做了,就會發現,其實遠沒有想像的那麼難。所以以後我遇事也要多動手。

廢話說在前面

我之前嘗試過去寫這樣一個關卡選擇的功能,但是總是寫不出來,後來同事用一個UIScrollView簡單的寫了一個,但是效果完全不給力,不但動畫很生硬,而且沒有複用機制。當關卡一多的時候,就會有很明顯的卡頓,所以被我否決了。後來我想到了利用第三方庫iCarousel來實現,但是並沒有我所需要的效果,雖然可以自定義,但老實說我確實沒有那種研究精神,而且我發現iCarousel與xib的結合使用似乎不太好。但出於簡單省時的目的,我還是簡單的修改了iCarousel的代碼得到我需要的效果,但是同事卻發現了其他的問題,於是最終還是決定自己實現一個,當然要實現這樣的功能,當然是通過自定義UICollectionViewLayout來實現。本來我是在網上找資料,但是不知道是本性還是怎麼,看一點就不想看下去了。我感覺沒有一篇詳細說明每個步驟的博文,所以決定把自己的實現過程紀錄下來,供和我有同樣需求的朋友參考。

效果展示

上面廢話有點多,還是直接一點,上效果圖吧。 
效果展示 
效果雖然很簡單,但基本也能概括自定義UICollectionViewLayout的必要步驟吧。

CustomCarCollectionViewFlowLayout類的定義

CustomCarCollectionViewFlowLayout其實如果繼承自UICollectionViewFlowLayout會很簡單的實現該效果,但是我之所以讓其繼承自UICollectionViewLayout的原因主要是有兩點:1、我自己想利用這次機會好好學習一下自定義。2、繼承自UICollectionViewFlowLayout很多方面都不好控制,而繼承自UICollectionViewLayout完全自由,定義如下:

@interface CustomCardCollectionViewFlowLayout : UICollectionViewLayout

@property(nonatomic, assign) CGFloat internalItemSpacing;
@property(nonatomic, assign) CGSize itemSize;
@property(nonatomic, assign) UIEdgeInsets sectionEdgeInsets;
@property(nonatomic, assign) CGFloat scale;
@property(nonatomic, assign) NSInteger currentItemIndex;
@property(nonatomic, assign) id<CustomCardCollectionViewFlowLayoutDelegate> delegate;

@end
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

類說明

屬性說明

internalItemSpacing

@property(nonatomic, assign) CGFloat internalItemSpacing;
  • 1
  • 1

這個屬性其實是參考了UICollectionViewFlowLayout裏面的minimumInterItemSpacing,該屬性表示每個Cell之間的間隔,不過UICollectionViewFlowLayout裏的是指最小的,可變的。而internalItemSpacing則不可變

itemSize

@property(nonatomic, assign) CGSize itemSize;
  • 1
  • 1

這個也是參考的UICollectionViewFlowLayout,該屬性表示每個Cell的大小

sectionEdgeInsets

@property(nonatomic, assign) UIEdgeInsets sectionEdgeInsets;
  • 1
  • 1

還是參考的UICollectionViewFlowLayout,該屬性表示每個section之間的間距

scale

@property(nonatomic, assign) CGFloat scale;
  • 1
  • 1

即表示左邊或右邊的Cell的縮放係數,當Cell走到最左邊或最右邊的時候將會被縮放成指定的大小。

currentItemIndex

@property(nonatomic, assign) NSInteger currentItemIndex;
  • 1
  • 1

表示當前在中央的Cell在UICollectionView中的索引,只有當Cell處於最中間的時候纔會設置。

代理定義

CustomCardCollectionViewFlowLayoutDelegate的定義如下:

@class CustomCardCollectionViewFlowLayout;
@protocol CustomCardCollectionViewFlowLayoutDelegate <NSObject>

@optional
-(void)scrolledToTheCurrentItemAtIndex:(NSInteger)itemIndex;

@end
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

該代理中的方法,就是當UICollectionView滾動停止後,當前所在中間的Cell的索引,參考效果圖上的關卡指示(1/16)。

代碼說明

下面我將一步步按照自己的編寫順序來說明該功能的實現。

prepareLayout

prepareLayout是一個必須要實現的方法,該方法的功能是爲佈局提供一些必要的初始化參數,我的代碼如下:

-(void)prepareLayout {
    [super prepareLayout];

    _itemsCount = [self.collectionView numberOfItemsInSection:0];

    if(_internalItemSpacing == 0)
        _internalItemSpacing = 5;

    if(_sectionEdgeInsets.top == 0 && _sectionEdgeInsets.bottom == 0 && _sectionEdgeInsets.left == 0 && _sectionEdgeInsets.right == 0)
        _sectionEdgeInsets = UIEdgeInsetsMake(0, ([UIScreen mainScreen].bounds.size.width - self.itemSize.width) / 2, 0, ([UIScreen mainScreen].bounds.size.width - self.itemSize.width) / 2);

//    UITapGestureRecognizer* tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(collectionViewTapped:)];
//    [tapGesture setDelegate:self];
//    [self.collectionView addGestureRecognizer:tapGesture];

    return ;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

首先是獲取collectionView中共有多少個Cell,因爲該功能一般只有一個section,所以我直接獲取了section 0的數量。

其次是爲該效果設置一些默認參數,如果用戶沒有提供值的話,將使用這些默認值。。

在最後有一個註釋的UITapGestureRecognizer,這個手勢本來是用來實現,點擊兩邊的Cell能自動將點擊的Cell滾動到中央。但是最後發現和UICollectionView的點擊事件衝突了,導致滑動起來很吃力,到目前爲止我還沒想到更好的解決辦法,於是只能暫時註釋,慢慢想辦法解決。也不妨將該手勢的執行方法說一說。

手勢處理

雖然手勢不能使用,但還是可以拿來講一講,裝裝逼。其中有兩段代碼,一段是我處理手勢衝突寫的,但似乎效果不理想,最終還是沒啓用。

-(void)collectionViewTapped:(UIGestureRecognizer*)recognizer {
    CGPoint location = [recognizer locationInView:self.collectionView];
    NSIndexPath* indexPath = [self.collectionView indexPathForItemAtPoint:location];

    if(indexPath == nil)
        return ;

    if(_currentItemIndex == indexPath.item) {
        if([self.collectionView.delegate respondsToSelector:@selector(collectionView:didSelectItemAtIndexPath:)])
            [self.collectionView.delegate collectionView:self.collectionView didSelectItemAtIndexPath:indexPath];
    }
    else {
        _currentItemIndex = indexPath.item;
        [self.collectionView setContentOffset:CGPointMake(indexPath.item * (_internalItemSpacing + _itemSize.width), 0) animated:YES];

        if([self.delegate respondsToSelector:@selector(scrolledToTheCurrentItemAtIndex:)])
            [self.delegate scrolledToTheCurrentItemAtIndex:_currentItemIndex];
    }

    return ;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

該方法是先獲取到點擊點在collectionView中的座標,然後對應到點擊的Cell的indexPath,當點擊的Cell位於中間的話,則調用原來collectionView的didSelectItemAtIndexPath:方法,否則,調用setContentOffset方法,設置將點擊的Cell設置到中間位置。

還有一個是處理手勢衝突的,如下:

-(BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch {
    CGPoint location = [touch locationInView:self.collectionView];
    NSIndexPath* indexPath = [self.collectionView indexPathForItemAtPoint:location];

    if(indexPath == nil || indexPath.item == _currentItemIndex)
        return NO;
    return YES;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

該方法是UIGestureRecognizerDelegate裏的代理方法,同樣的先獲取到點擊的indexPath,如果沒有點擊在Cell上或者點擊了中間項,就不響應點擊手勢。但是效果並不理想,目前項目緊張也暫時不去考慮這麼多了。

collectionViewContentSize

顧名思義,該方法也是一個必寫的方法,該方法返回了collectionView的contentSize,我的代碼如下:

-(CGSize)collectionViewContentSize {
    CGFloat contentWidth = _sectionEdgeInsets.left + _sectionEdgeInsets.right + _itemsCount * _itemSize.width + (_itemsCount - 1) * _internalItemSpacing;
    CGFloat contentHeight = _sectionEdgeInsets.top + _sectionEdgeInsets.bottom + self.collectionView.frame.size.height;
    return CGSizeMake(contentWidth, contentHeight);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 1
  • 2
  • 3
  • 4
  • 5

那麼collectionView的contentSize應該是多少呢?根據代碼,我們知道height可以設置爲0,因爲需要縱向滾動,橫向呢? 
contentSize
從圖中我們可以很明顯的看出來,寬度應該是 『左邊間距 + Cell數 * Cell寬度 + (Cell數 - 1) * Cell間距 + 右邊間距』。

layoutAttributesForItemAtIndexPath:方法

該方法也是一個必須要實現的方法,該方法是爲每個Cell返回一個對應的Attributes,我們需要在該Attributes中設置對應的屬性,如Frame等,代碼如下:

-(UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath {
    UICollectionViewLayoutAttributes* attr = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];

    attr.size = _itemSize;
    attr.frame = CGRectMake((int)indexPath.row * (_itemSize.width + _internalItemSpacing) + _sectionEdgeInsets.left, (self.collectionView.bounds.size.height - _itemSize.height) / 2 + _sectionEdgeInsets.top, attr.size.width, attr.size.height);

    return attr;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

首先調用”layoutAttributesForCellWithIndexPath:類方法創建一個Attributes,然後設置對應cell的frame,最後再返回該Attributes。

layoutAttributesForElementsInRect:

該方法是爲在一個rect中的Cell返回Attributes,我們必須在該方法中做相應的處理,才能實現相應的效果。代碼如下:

-(NSArray<UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect {
    NSMutableArray* attributes = [NSMutableArray array];

    CGRect visiableRect = CGRectMake(self.collectionView.contentOffset.x, 0, self.collectionView.bounds.size.width, self.collectionView.bounds.size.height);
    CGFloat centerX = self.collectionView.contentOffset.x + [UIScreen mainScreen].bounds.size.width / 2;

    for (NSInteger i=0 ; i < _itemsCount; i++) {
        NSIndexPath* indexPath = [NSIndexPath indexPathForItem:i inSection:0];
        UICollectionViewLayoutAttributes* attr = [self layoutAttributesForItemAtIndexPath:indexPath];
        [attributes addObject:attr];

        if(CGRectIntersectsRect(attr.frame, visiableRect) == false)
            continue ;
        CGFloat xOffset = fabs(attr.center.x - centerX);

        CGFloat scale = 1 - (xOffset * (1 - _scale)) / (([UIScreen mainScreen].bounds.size.width + self.itemSize.width) / 2 - self.internalItemSpacing);
        attr.transform = CGAffineTransformMakeScale(scale, scale);
    }

    return attributes;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

首先,調用我們實現的layoutAttributesForItemAtIndexPath:方法,爲每個Cell設置一個Attributes,然後遍歷Attributes集,如果Cell沒有和當前返回的rect相交,那麼我們不用去處理,因爲反正我們也看不到。否則設置scale,至於scale的計算,數學能力強的很容易寫出來,我搞了好久,因爲從小到大,數學就TM菜的一逼。最後設置transform進行縮放。

shouldInvalidateLayoutForBoundsChange:

該方法中,需要返回YES,當滾動的時候,重新生成對應屬性

-(BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds {
    return YES;
}
  • 1
  • 2
  • 3
  • 1
  • 2
  • 3

targetContentOffsetForProposedContentOffset:withScrollingVelocity:

該方法的作用是當UICollectionView停止滾動時,用戶希望停止在哪個位置上,對於該方法,我的代碼如下所示:

-(CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity {

    NSInteger itemIndex = (NSInteger)(self.collectionView.contentOffset.x / (_itemSize.width + _internalItemSpacing));
    CGFloat xOffset = itemIndex * (_internalItemSpacing + _itemSize.width);
    CGFloat xOffset_1 = (itemIndex + 1) * (_internalItemSpacing + _itemSize.width);

    if(fabs(proposedContentOffset.x - xOffset) > fabs(xOffset_1 - proposedContentOffset.x)) {
        _currentItemIndex = itemIndex + 1;
        if([self.delegate respondsToSelector:@selector(scrolledToTheCurrentItemAtIndex:)])
            [self.delegate scrolledToTheCurrentItemAtIndex:_currentItemIndex];
        return CGPointMake(xOffset_1, 0);
    }

    _currentItemIndex = itemIndex;
    if([self.delegate respondsToSelector:@selector(scrolledToTheCurrentItemAtIndex:)])
        [self.delegate scrolledToTheCurrentItemAtIndex:_currentItemIndex];
    return CGPointMake(xOffset, 0);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

首先,我根據偏移量計算出對應的當前Cell的index,然後分別獲取到當前Cell和下一個Cell的偏移量,然後判斷屏幕中央隔哪邊比較近,就將哪一個調整到中間。最後修改中央Cell的index,調用結束之後的代理方法。

使用方法

至此,自定義UICollectionViewLayout就已經全部結束,其使用方法也和UICollectionViewFlowLayout差不多,我的使用代碼如下:

((CustomCardCollectionViewFlowLayout*)self.m_pCollectionView.collectionViewLayout).itemSize = CGSizeMake(UI_IOS_WINDOW_WIDTH - 80, UI_IOS_WINDOW_HEIGHT - 64 - 40 - 105);

((CustomCardCollectionViewFlowLayout*)self.m_pCollectionView.collectionViewLayout).scale = 0.85f;

((CustomCardCollectionViewFlowLayout*)self.m_pCollectionView.collectionViewLayout).delegate = self;
  • 1
  • 2
  • 3
  • 4
  • 5
  • 1
  • 2
  • 3
  • 4
  • 5

關於UICollectionView的部分是在xib中設置的,沒有相關代碼,因爲很多默認值都是根據我的項目需要來設置的,所以我這裏只設置了itemSize和scale縮放係數這兩個參數。效果就如上圖所示。。

結束語

其實自定義一個佈局真的不算太難,最難的點在於數學模型的建立,但是只要有決心,相信自己,用心鑽研,也一定能搞定難題。雖然我不是這種人,但是原本我覺得很難,但這次無可奈何的情況下,只能硬着頭皮去做,結果發現是我自己把事情想複雜了。所以最後再來一句:不要想,就是幹。

代碼鏈接

github代碼鏈接

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