最終效果如下所示:
這個效果是我們公司的一個模塊的效果, 當(dāng)時沒有由于沒有對 collectionView 仔細(xì)研究,所以對這個界面的實現(xiàn)機(jī)制并不是很熟悉, 到現(xiàn)在已經(jīng)有段時間了, 這段時間對 collectionView 也加深了解了一些, 于是試著自己寫一下試試(當(dāng)時使我們公司一個大牛寫的)
我打算分一下幾步來實現(xiàn)這個效果:
- 實現(xiàn)圓形布局(這個布局效果在 Apple 的實例代碼中有, 具體代碼請自行 Google)
- 實現(xiàn)圓形的風(fēng)火輪效果
- 對有些需要隱藏的位置進(jìn)行隱藏
環(huán)形布局之前Apple 提供的代碼中是直接根據(jù)角度計算的每個 Item 的位置, 我們也用同樣的思考, 不同的是我們要將角度記錄下來, 這個角度是跟 collectionView 的 contentOffset 有關(guān)的, 因為當(dāng)用戶在滑動的時候, contentOffset 在更新,這個時候應(yīng)該重新根據(jù) contentOffset 計算每個 Item 的角度 --- 在心中有個印象
- 創(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 的布局步驟
- prepareLayout 每次布局觸發(fā)時,就會調(diào)用該方法
- layoutAttributesForElementsInRect:(CGRect)rect 返回在 rect 矩形內(nèi)的 item 的布局屬性數(shù)組
- 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];
}
如下圖:
看來我們的思路是正確的, 接下來, 我們所需要的就是在 prepareLayout 中進(jìn)行布局, 使布局更接近我們的目標(biāo)效果
-
先形成圓形布局, 這個容易, 我們首先需要調(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);
效果如圖所示
我們發(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)備知識
- 以第0個 item 為起點, 它的角度此時為0度, 當(dāng)滑動到最后一個 item 時, 我們讓最后一個 item 位置與第0個位置重合, 此時第0個 item 總共經(jīng)過了 -(numberOfItem * anglePerItem), 因為是逆時針轉(zhuǎn)動, 所以是負(fù)值
- 由 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;;
效果如下
可以滑動
接下來, 我們進(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
但這是會發(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;
}
如圖所示
第一部分只完成 collectionView 布局, 在下一部分講解, 選擇 item 進(jìn)行切換的效果
我覺得這個布局可以優(yōu)化, 但目前還沒來得及, 如果您有更好的方式, 歡迎交流; 如果您有不明白的地方歡迎提問; 如果您有不滿意的地方, 歡迎吐槽; 共同學(xué)習(xí), 共同進(jìn)步
Demo 地址: https://github.com/X-Liang/CircleCollectionView.git