之前使用CollectionView一直使用默认的UICollectionViewLayout,殊不知CollectionViewLayout还可以玩出花,比如:
看了几篇文章之后,便想跟着CollectionView自定义风火轮也撸个风火轮出来,如图:
开始之前,还是先了解一下自定义UICollectionViewLayout的相关知识,你可以看自定义CollectionViewLayout这篇教程。
coding
下载我们的初始工程
CircleLayout为我们自定义的布局,现在里面什么都没有,运行我们的项目。
界面是一片空白,尽管我们在ViewController中配置了数据源也返回了相对应的Cell视图,但是CollectionView不知道怎么去显示它们,接下来我们开始我们的风火轮之旅。
打开CircleLayout.m,我们需要重写以下几个方法:
- prepareLayout 每次布局触发时,就会调用该方法
- layoutAttributesForElementsInRect:(CGRect)rect 返回在 rect 矩形内的 item 的布局属性数组
- layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath 返回在某个 indexPath 的 item 的布局属性
- collectionViewContentSize,返回滚动范围
CircleLayout.m
@interface CircleLayout()
@end
@implementation CircleLayout
-(void)prepareLayout{
[super prepareLayout];
}
-(NSArray<UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect{
return nil;
}
-(UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath{
return nil;
}
-(CGSize)collectionViewContentSize{
return CGSizeZero;
}
-(BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds {
return YES;
}
@end
ok,开始撸我们的布局,先定义图片的宽高,边距
#define ItemWidth 55
#define ItemHieght ItemWidth
#define RightMargin 5
again,在prepareLayout中开始计算我们的布局,并且存在一个数组中
- (void)prepareLayout{
[super prepareLayout];
CGFloat centerX = self.collectionView.contentOffset.x + CGRectGetWidth(self.collectionView.bounds) * .5f;
CGFloat centerY = self.collectionView.contentOffset.y + CGRectGetHeight(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];
UICollectionViewLayoutAttributes *attributes = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];
attributes.size = CGSizeMake(ItemWidth, ItemHieght);
attributes.center = CGPointMake(centerX,centerY);
attributes.transform = CGAffineTransformMakeRotation(M_PI/18.0 * index);
[mAttributesList addObject:attributes];
}
self.attributesList = [mAttributesList copy];
}
将布局中心点设置为屏幕的中心,CGAffineTransformMakeRotation(M_PI/18.0 * index)
设置每个图片的旋转,在这里是为了看清楚有多个图片重叠在一起,否则你只能看到最后一个,因为它们重叠在一起
接下来,我们需要将布局变成圆形排列,借用一张图
将图片排列在圆弧上,便可得到一个圆形的布局,为了计算方便,我加多了一个圆
外面的圆上的点就是图片的center
,好了,现在让脑袋飞回高中,回想一下当年的圆(终于觉得当年学的东西有点作用了):
圆点坐标:(x0,y0)
半径:r
角度:a0
则圆上任一点为:(x1,y1)
x1 = x0 + r cos(ao 3.14 /180 )
y1 = y0 + r sin(ao 3.14 /180 )
好了,万事俱备,只欠代码了,先定义几个属性
@property (nonatomic, assign) CGFloat radius;
@property (nonatomic, assign) CGSize itemSize;
@property (nonatomic, assign) CGFloat anglePerItem;///< 单位夹角
self.itemSize = CGSizeMake(ItemWidth, ItemHieght);
self.radius = (CGRectGetWidth([UIScreen mainScreen].bounds) - ItemWidth * 2 - RightMargin * 2) * 0.5f;
self.anglePerItem = M_PI / 4.0;
下一步,实现collectionViewContentSize
来声明你的 collectionView 的内容有多大:
- (CGSize)collectionViewContentSize{
NSInteger numberOfItem = [self.collectionView numberOfItemsInSection:0];
return CGSizeMake(ItemWidth * numberOfItem, self.collectionView.bounds.size.height);
}
自定义布局属性
除了新建一个新的布局子类,你还要新建一个继承自UICollectionViewLayoutAttributes的类来存储角位置:
@interface CircleLayoutAttributes : UICollectionViewLayoutAttributes
@property (nonatomic, assign) CGFloat angle;
@end
@implementation CircleLayoutAttributes
- (instancetype)init {
if (self = [super init]) {
self.angle = 0;
}
return self;
}
- (void)setAngle:(CGFloat)angle {
_angle = angle;
//1
self.zIndex = angle * 1000000;
//2
self.transform = CGAffineTransformMakeRotation(angle);
}
// UICollectionViewLayoutAttributes 实现 <NSCoping> 协议
- (id)copyWithZone:(NSZone *)zone {
CircleLayoutAttributes *copyAttributes = (CircleLayoutAttributes *)[super copyWithZone:zone];
copyAttributes.angle = self.angle;
return copyAttributes;
}
@end
- 保证后面的cell覆盖在前面的cell上,因为角度用弧度表示,我们将其扩大 1,000,000倍来确保相邻的值不会被四舍五入成同一个 zIndex 值,zIndex 是 Int 型的
- 当设置角度(angle)的时候,在内部设置其 transform 旋转 angle 弧度
我们使用了自定义布局属性,所以我们要告诉布局,喂,以前那老板跑了,换了个新的了:
+ (Class)layoutAttributesClass{
return [CircleLayoutAttributes class];
}
将我们原来的属性数组更改为:
@property (nonatomic, copy ) NSArray<CircleLayoutAttributes *> *attributesList;
好了,继续回到我们的prepareLayout
,我们需要修改for循环里面的代码:
for (NSInteger index = 0; index < numberOfItem; index++) {
NSIndexPath *indexPath = [NSIndexPath indexPathForItem:index inSection:0];
CircleLayoutAttributes *attributes = [CircleLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];
attributes.size = self.itemSize;
attributes.center = CGPointMake(
centerX + self.radius * cosf(self.anglePerItem*index),
centerY + self.radius * sinf(self.anglePerItem*index)
);
attributes.angle = self.anglePerItem * index;
[mAttributesList addObject:attributes];
}
运行程序,你会看到所有的 cell 按照圆形来布局了,但是滑动的过程中…等一下,发生了什么?它们被移出了屏幕而不是旋转!?
改善滑动
不管怎么说,我们至少完成了第一步圆形布局,yes!
在CircleLayout
中,加入以下代码:
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds {
return YES;
}
该方法返回 YES 告知 collectionView 在滑动时布局失效,然后它会调用prepareLayout
,进而使用更新后的角位置重新计算 cell 的布局,滑动过程中,contentOffset.x从 0 到collectionViewContentSize.width - CGRectGetWidth(collectionView.bounds)变化,假若第一个图片从当前位置转了一圈回到当前位置,刚好是 2*M_PI
,我们可以得出如下:
偏移的度数 = 滚动的总度数 / contentOffset.x所占的比例
CGFloat offsetWidth = self.collectionView.contentSize.width - self.collectionView.bounds.size.width
offsetAngle = angleTotal * (contentOffset.x / offsetWidth)
即,offsetAngle为contentOffset.x每变化一个单位所需要偏移的度数
加入以下代码:
- (CGFloat)offsetAngle{
NSInteger numberOfItems = [self.collectionView numberOfItemsInSection:0];
if (numberOfItems > 0) {
NSInteger lastItem = numberOfItems;
//滚动的总度数
CGFloat angleTotal = lastItem * self.anglePerItem;
//计算collectionView滑动的距离
CGFloat offsetWidth = self.collectionView.contentSize.width - self.collectionView.bounds.size.width;
//偏移的度数 = 滚动的总度数 / contentOffsetX所占的比例,即 result = angleTotal * (contentOffsetX / offsetWidth)
//(contentOffsetX / offsetWidth)为单位偏移量所占的比例
return angleTotal * (self.collectionView.contentOffset.x / offsetWidth);
}
return 0;
}
然后将prepareLayout
中的代码:
attributes.center = CGPointMake(
centerX + self.radius * cosf(self.anglePerItem*index),
centerY + self.radius * sinf(self.anglePerItem*index) );
attributes.angle = self.anglePerItem * index;
替换为
attributes.center = CGPointMake(
centerX + self.radius * cosf(self.anglePerItem*index + self.offsetAngle),
centerY + self.radius * sinf(self.anglePerItem*index + self.offsetAngle) );
attributes.angle = self.anglePerItem * index + self.offsetAngle;
将滑动的contentOffset.x
引起的偏移角度加上,ok,运行,可以滑动了
接下来,我们继续,将风火轮升级成以下效果:
需要将图片按照逆时针排序,并且更改第一张图片的位置:
====>
修改代码如下:
CGFloat changeAngle = M_PI + M_PI_4;
attributes.center = CGPointMake(
centerX + self.radius * cosf(-(self.anglePerItem*index + self.offsetAngle - changeAngle)),
centerY + self.radius * sinf(-(self.anglePerItem*index + self.offsetAngle - changeAngle)) );
attributes.angle = -(self.anglePerItem * index + self.offsetAngle - changeAngle);
返回的offsetAngle也需要修改为:
- (CGFloat)offsetAngle{
NSInteger numberOfItems = [self.collectionView numberOfItemsInSection:0];
if (numberOfItems > 0) {
NSInteger lastItem = numberOfItems;
//滚动的总度数
CGFloat angleTotal = lastItem * self.anglePerItem;
//计算collectionView滑动的距离
CGFloat offsetWidth = self.collectionView.contentSize.width - self.collectionView.bounds.size.width;
//偏移的度数 = 滚动的总度数 / contentOffsetX所占的比例,即 result = angleTotal * (contentOffsetX / offsetWidth)
//(contentOffsetX / offsetWidth)为单位偏移量所占的比例
return -angleTotal * (self.collectionView.contentOffset.x / offsetWidth);
}
return 0;
}
-
代表逆时针
运行,如图,已经成功变换了位置
我们将cell的个数改多一点,会发现,后面的cell盖住了前面的cell,所以,我们需要按需隐藏不相关的cell
if (attributes.angle <= - M_PI / 6) {
CGFloat alpha = ((M_PI / 6 + M_PI/6) + attributes.angle)/(M_PI/6);
attributes.alpha = alpha;
} else if (attributes.angle > M_PI + M_PI/4) {
CGFloat alpha = (M_PI + M_PI/4 + M_PI/6 - attributes.angle) / (M_PI/6);
attributes.alpha = alpha;
}
- 图片逆时针旋转,当图片的转角等于30度时
alpha=((M_PI / 6 + M_PI/6) + attributes.angle)/(M_PI/6)=1
如果继续逆时针转动,alpha
会在M_PI/6
的转动周期内从1->0,实现了渐变隐藏 - 图片顺时针旋转,也是同理
再次运行
但这是会发现, 最后一个 item 可以被滑动的不见, 我们只需要调整一个地方即可, 及第0个 item 的总偏移量, 因为他是根据个数, 让其减去7个 item, 此时便可达到效果, 需要确保总数 > 7
写了一天,大多数都是在别人博客已经有的东西,自己拿过来再重新敲了一遍,还有个必须优化的,layoutAttributesForElementsInRect
应该返回可是范围内的cell布局,不应该返回全部,这个后续会完善。本文demo全部代码在这