CollectionView自定義風(fēng)火輪 layout (一)

最終效果如下所示:

demo.gif

這個效果是我們公司的一個模塊的效果, 當(dāng)時沒有由于沒有對 collectionView 仔細(xì)研究,所以對這個界面的實現(xiàn)機(jī)制并不是很熟悉, 到現(xiàn)在已經(jīng)有段時間了, 這段時間對 collectionView 也加深了解了一些, 于是試著自己寫一下試試(當(dāng)時使我們公司一個大牛寫的)

我打算分一下幾步來實現(xiàn)這個效果:

  1. 實現(xiàn)圓形布局(這個布局效果在 Apple 的實例代碼中有, 具體代碼請自行 Google)
  2. 實現(xiàn)圓形的風(fēng)火輪效果
  3. 對有些需要隱藏的位置進(jìn)行隱藏

環(huán)形布局之前Apple 提供的代碼中是直接根據(jù)角度計算的每個 Item 的位置, 我們也用同樣的思考, 不同的是我們要將角度記錄下來, 這個角度是跟 collectionView 的 contentOffset 有關(guān)的, 因為當(dāng)用戶在滑動的時候, contentOffset 在更新,這個時候應(yīng)該重新根據(jù) contentOffset 計算每個 Item 的角度 --- 在心中有個印象

  1. 創(chuàng)建自定義布局
    #import <UIKit/UIKit.h>

    @interface CircleCollectionViewLayout : UICollectionViewLayout
    /**
     *   半徑
     */
    @property (nonatomic, assign) CGFloat radius;
    /**
         *  大小
     */
    @property (nonatomic, assign) CGSize itemSize;
    @end
    
    
    
    
    - (instancetype)init {
        if (self = [super init]) {
            [self initial];
        }
        return self;
    }

    - (instancetype)initWithCoder:(NSCoder *)aDecoder {
        if (self = [super initWithCoder:aDecoder]) {
            [self initial];
        }
        return self;
    }

    - (void)initial {
        self.itemSize = CGSizeMake(ItemWidth, ItemHieght);
        self.radius = (CGRectGetWidth([UIScreen mainScreen].bounds))* 0.5f - ItemWidth - RightMargin;
    }

定義好半徑大小之后, 我們還需要個屬性 相鄰兩個 Item之間的夾角是多少度于是我們在 extension 中定義 anglePerItem屬性, 存儲夾角, 并在 initial 中做初始化

    // item 大小 55 * 55
    #define ItemWidth 55
    #define ItemHieght ItemWidth
    #define RightMargin 5
    
    @interface CircleCollectionViewLayout ()
    // 單位夾角
    @property (nonatomic, assign) CGFloat anglePerItem;

    @end
    
    - (void)initial {
        self.itemSize = CGSizeMake(ItemWidth, ItemHieght);
        self.radius = (CGRectGetWidth([UIScreen mainScreen].bounds) - ItemWidth)* 0.5f - RightMargin;
        // 單位夾角為 45度
        self.anglePerItem = M_PI_2 / 2;
    }

我們之前說過, 每個 Item 要有一個 angle, 用來確定在 contentOffset 時, 對應(yīng)的 item 的角度是多少, 所以這個時候我們需要自定義 LayoutAttributes

自定義 LayoutAttributes

#import <UIKit/UIKit.h>

@interface CircleCollectionViewLayoutAttributes : UICollectionViewLayoutAttributes
// 錨點
@property (nonatomic, assign) CGPoint anchorPoint;
// 角度
@property (nonatomic, assign) CGFloat angle;

@end



#import "CircleCollectionViewLayoutAttributes.h"

@implementation CircleCollectionViewLayoutAttributes

- (instancetype)init {
    if (self = [super init]) {
        self.anchorPoint = CGPointMake(0.5, 0.5);
        self.angle = 0;
    }
    return self;
}

- (void)setAngle:(CGFloat)angle {
    _angle = angle;
    
    self.zIndex = angle * 1000000;
    // 將角度同時用做item 的旋轉(zhuǎn)
    self.transform = CGAffineTransformMakeRotation(angle);
}
// UICollectionViewLayoutAttributes 實現(xiàn) <NSCoping> 協(xié)議
- (id)copyWithZone:(NSZone *)zone {
    CircleCollectionViewLayoutAttributes *copyAttributes = (CircleCollectionViewLayoutAttributes *)[super copyWithZone:zone];
    copyAttributes.anchorPoint = self.anchorPoint;
    copyAttributes.angle = self.angle;
    return copyAttributes;
}

@end

回到 Layout 類

因為我們自定義了 Attributes 類, 所以此時要告知 Layout 類, 我們自定義的 Attributes

+ (Class)layoutAttributesClass {
    return [CircleCollectionViewLayoutAttributes class];
}

因為需要用戶去滑動, 又因為 CollectionView 繼承自 ScrollView, 運行滑動的一個必要條件就是 contentSize某一個方向的值大于 scrollView.bounds 對應(yīng)方向的值

- (CGSize)collectionViewContentSize {
    NSInteger numberOfItem = [self.collectionView numberOfItemsInSection:0];
    return CGSizeMake(numberOfItem * ItemWidth , self.collectionView.bounds.size.height);
}

好了準(zhǔn)備工作基本完成, 接下來開始布局

在這里必須要了解 collectionView 的布局步驟

  1. prepareLayout 每次布局觸發(fā)時,就會調(diào)用該方法
  2. layoutAttributesForElementsInRect:(CGRect)rect 返回在 rect 矩形內(nèi)的 item 的布局屬性數(shù)組
  3. layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath 返回在某個 indexPath 的 item 的布局屬性

我們需要一個布局屬性數(shù)組, 來存儲所有 item 的布局屬性

于是我們在 extension 中添加一個布局屬性數(shù)組
@interface CircleCollectionViewLayout ()
@property (nonatomic, assign) CGFloat anglePerItem;
@property (nonatomic, copy) NSArray <CircleCollectionViewLayoutAttributes *> *attributesList;
@end

我們直接在layoutAttributesForElementsInRect中返回該數(shù)組, 因為我將要在 prepareLayout 中將該數(shù)組填充進(jìn)布局屬性的值

- (NSArray<UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect {
    return self.attributesList;
}

同理我們直接將某個位置的布局屬性從 attributesList 中取出

- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath {
    return self.attributesList[indexPath.row];
}

OK, 開始進(jìn)行布局

- (void)prepareLayout {
    // 調(diào)用父類的
    [super prepareLayout];
    // x 始終確保在屏幕中間
    CGFloat centerX = self.collectionView.contentOffset.x + CGRectGetWidth(self.collectionView.bounds) * .5f;
    NSInteger numberOfItem = [self.collectionView numberOfItemsInSection:0];
    
    NSMutableArray *mAttributesList = [NSMutableArray arrayWithCapacity:numberOfItem];
    for (NSInteger index = 0; index < numberOfItem; index++) {
        NSIndexPath *indexPath = [NSIndexPath indexPathForItem:index inSection:0];
        CircleCollectionViewLayoutAttributes *attributes = [CircleCollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];
        attributes.size = self.itemSize;
        attributes.center = CGPointMake(centerX, CGRectGetMidY(self.collectionView.bounds));
        attributes.angle = self.anglePerItem * index;
        [mAttributesList addObject:attributes];
    }
    self.attributesList = [mAttributesList copy];
}

如下圖:

Simulator Screen Shot 2016年3月30日 14.20.41.png

看來我們的思路是正確的, 接下來, 我們所需要的就是在 prepareLayout 中進(jìn)行布局, 使布局更接近我們的目標(biāo)效果

  1. 先形成圓形布局, 這個容易, 我們首先需要調(diào)整錨點, 將錨點調(diào)整的屏幕中間, 半徑我們之間就定義過了, 屏幕寬度減去一個間隙的一半, 我們將目光放在第一個 Item, 要將第一個 item 放在屏幕下方, 同時錨點應(yīng)該處于屏幕正中間, 所以錨點的 y 值應(yīng)小于0, 錨點又是相對于自身的高度來的推出錨點的計算公式

     ==> 
     
     CGFloat anchorPointY =  -(self.radius) / self.itemSize.height;
     
     在 for 循環(huán)中設(shè)置 item 屬性的錨點
     attributes.anchorPoint = CGPointMake(0.5, anchorPointY);
    

效果如圖所示

屏幕快照 2016-03-30 14.33.40.png
Simulator Screen Shot 2016年3月30日 14.43.28.png

我們發(fā)現(xiàn)整個圓弧向上偏移了, 所以接下來就是調(diào)整每個 item 的中心點, 是之下移
同樣在 for 循環(huán)中, 修改設(shè)置 center 的值

    attributes.center = CGPointMake(centerX, CGRectGetMidY(self.collectionView.bounds) + self.radius);

OK, 圓環(huán)效果成功做出, 第一步 OK, 細(xì)心的同學(xué)發(fā)現(xiàn), 界面上顯示的 Item 并不是從0開始, 那么試著將 numberOfItem 改成 8, 此時就是 0~8 顯示, 之前之所以不是從零開始, 是因為我們的圓環(huán)一次最多顯示8個, 而我們的 numberOfItem 有13個, 導(dǎo)致之后的 item 將前面的 item 覆蓋

接下來我們實現(xiàn)滑動

滑動是跟 contentOffset 有關(guān), 同時我們還需要設(shè)置一個方法

// 當(dāng) bounds 改變時, 使當(dāng)前布局無效, 這便會觸發(fā) prepareLayout 進(jìn)行重新布局
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds {
    return YES;
}

要想達(dá)到項目中滑動的效果, 我們需要設(shè)置 Item 布局屬性的 angle, 并且這個 angle 是與 contentOffset 有關(guān)的

先來幾條準(zhǔn)備知識

  1. 以第0個 item 為起點, 它的角度此時為0度, 當(dāng)滑動到最后一個 item 時, 我們讓最后一個 item 位置與第0個位置重合, 此時第0個 item 總共經(jīng)過了 -(numberOfItem * anglePerItem), 因為是逆時針轉(zhuǎn)動, 所以是負(fù)值
  2. 由 1. 我們得到滑動到最后, 第0個 item 總共偏移了多少角度, 所以我們很容易得到單位偏移的角度, 總偏移角度 * (contentOffset.x 所占的比例)

由以上兩點產(chǎn)生兩個屬性

@interface CircleCollectionViewLayout ()
/**
 *  單位夾角
 */
@property (nonatomic, assign) CGFloat anglePerItem;
/**
 *  布局屬性數(shù)組
 */
@property (nonatomic, copy) NSArray <CircleCollectionViewLayoutAttributes *> *attributesList;
/**
 *  單位偏移角度
 */
@property (nonatomic, assign) CGFloat angle;
/**
 *  總偏移角度
 */
@property (nonatomic, assign) CGFloat angleAtExtreme;
@end

//  -M_PI_2的原因是使每個 Item向右偏移 90 度角
- (CGFloat)angle {
    return self.angleAtExtreme * self.collectionView.contentOffset.x / ([self collectionViewContentSize].width - CGRectGetWidth(self.collectionView.bounds)) - M_PI_2;
}

- (CGFloat)angleAtExtreme {
    return [self.collectionView numberOfItemsInSection:0] > 0 ?
    -([self.collectionView numberOfItemsInSection:0]) * self.anglePerItem : 0;
}

修改 prepareLayout 中布局屬性的 angle, 使之與 contentOffset 建立聯(lián)系

attributes.angle = self.anglePerItem * index +  self.angle;;

效果如下

Simulator Screen Shot 2016年3月30日 14.45.21.png

可以滑動
接下來, 我們進(jìn)行最后的完善, 定義兩個屬性 startIndex, endIndex

- (void)prepareLayout {
    [super prepareLayout];
    CGFloat centerX = self.collectionView.contentOffset.x + CGRectGetWidth(self.collectionView.bounds) * .5f;
    NSInteger numberOfItem = [self.collectionView numberOfItemsInSection:0];
    CGFloat anchorPointY =  -(self.radius) / self.itemSize.height;
    
    self.startIndex = 0, self.endIndex = [self.collectionView numberOfItemsInSection:0] - 1;
    
    NSMutableArray *mAttributesList = [NSMutableArray arrayWithCapacity:numberOfItem];
    self.endIndex = self.startIndex + 7;
    for (NSInteger index = self.startIndex; index < self.endIndex; index++) {
        NSIndexPath *indexPath = [NSIndexPath indexPathForItem:index inSection:0];
        CircleCollectionViewLayoutAttributes *attributes = [CircleCollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];
        attributes.size = self.itemSize;
        attributes.center = CGPointMake(centerX, CGRectGetMidY(self.collectionView.bounds) + self.radius);
        attributes.anchorPoint = CGPointMake(0.5, anchorPointY);
        attributes.angle = self.anglePerItem * index + self.angle;
                // 當(dāng)小于某個角度是, 將 item 逐漸隱藏, 同時多布局一個 item, endIndex++
    if (attributes.angle <= -(M_PI * 2) / 3) {
        self.endIndex++;
        CGFloat alpha = (((M_PI * 2) / 3 + M_PI / 8.0) + attributes.angle)/(M_PI/8.0);
        attributes.alpha = alpha;

        if (self.endIndex >= numberOfItem) {
            self.endIndex = numberOfItem;

        }
    } else if (attributes.angle > (M_PI_2) + M_PI_2 * .5) { // 出現(xiàn)時, 逐漸出現(xiàn)
        CGFloat alpha = (M_PI - attributes.angle) / M_PI_4;
        attributes.alpha = alpha;
    }
        [mAttributesList addObject:attributes];
    }
    self.attributesList = [mAttributesList copy];
}

在上面的 prepareLayout 中我們添加了一個 if-else, 目的是當(dāng) item 的角度小于某個值時將其隱藏, 因為是逆時針轉(zhuǎn)動, 所以角度是成減小趨勢, 當(dāng)隱藏一個 item 時, 要多布局一個 item, 即 endIndex++, 顯示同理, 根據(jù) contentOffset 設(shè)置 alpha

demo_3.gif

但這是會發(fā)現(xiàn), 最后一個 item 可以被滑動的不見, 我們只需要調(diào)整一個地方即可, 及第0個 item 的總偏移量, 因為他是根據(jù)個數(shù), 讓其減去5個 item, 此時便可達(dá)到效果, 需要確保總數(shù) > 5

- (CGFloat)angleAtExtreme {
    return [self.collectionView numberOfItemsInSection:0] > 0 ?
        -([self.collectionView numberOfItemsInSection:0] - 5) * self.anglePerItem : 0;
}

如圖所示

demo_4.gif

第一部分只完成 collectionView 布局, 在下一部分講解, 選擇 item 進(jìn)行切換的效果

我覺得這個布局可以優(yōu)化, 但目前還沒來得及, 如果您有更好的方式, 歡迎交流; 如果您有不明白的地方歡迎提問; 如果您有不滿意的地方, 歡迎吐槽; 共同學(xué)習(xí), 共同進(jìn)步

Demo 地址: https://github.com/X-Liang/CircleCollectionView.git

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容