风火轮是怎么练成的

之前使用CollectionView一直使用默认的UICollectionViewLayout,殊不知CollectionViewLayout还可以玩出花,比如:

看了几篇文章之后,便想跟着CollectionView自定义风火轮也撸个风火轮出来,如图:
alt
开始之前,还是先了解一下自定义UICollectionViewLayout的相关知识,你可以看自定义CollectionViewLayout这篇教程。


coding

下载我们的初始工程

CircleLayout为我们自定义的布局,现在里面什么都没有,运行我们的项目。

界面是一片空白,尽管我们在ViewController中配置了数据源也返回了相对应的Cell视图,但是CollectionView不知道怎么去显示它们,接下来我们开始我们的风火轮之旅。

打开CircleLayout.m,我们需要重写以下几个方法:

  1. prepareLayout 每次布局触发时,就会调用该方法
  2. layoutAttributesForElementsInRect:(CGRect)rect 返回在 rect 矩形内的 item 的布局属性数组
  3. layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath 返回在某个 indexPath 的 item 的布局属性
  4. 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)设置每个图片的旋转,在这里是为了看清楚有多个图片重叠在一起,否则你只能看到最后一个,因为它们重叠在一起

enter image description here

接下来,我们需要将布局变成圆形排列,借用一张图

enter image description here

将图片排列在圆弧上,便可得到一个圆形的布局,为了计算方便,我加多了一个圆

enter image description here

外面的圆上的点就是图片的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  
  1. 保证后面的cell覆盖在前面的cell上,因为角度用弧度表示,我们将其扩大 1,000,000倍来确保相邻的值不会被四舍五入成同一个 zIndex 值,zIndex 是 Int 型的
  2. 当设置角度(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 按照圆形来布局了,但是滑动的过程中…等一下,发生了什么?它们被移出了屏幕而不是旋转!?

enter image description here

改善滑动

不管怎么说,我们至少完成了第一步圆形布局,yes!
enter image description here

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,运行,可以滑动了
enter image description here

接下来,我们继续,将风火轮升级成以下效果:

enter image description here

需要将图片按照逆时针排序,并且更改第一张图片的位置:

enter image description here ====>enter image description here

修改代码如下:

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;  
  }

-代表逆时针

运行,如图,已经成功变换了位置

enter image description here

我们将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;
 }
  1. 图片逆时针旋转,当图片的转角等于30度时
    alpha=((M_PI / 6 + M_PI/6) + attributes.angle)/(M_PI/6)=1
    如果继续逆时针转动,alpha会在M_PI/6的转动周期内从1->0,实现了渐变隐藏
  2. 图片顺时针旋转,也是同理

再次运行

enter image description here

但这是会发现, 最后一个 item 可以被滑动的不见, 我们只需要调整一个地方即可, 及第0个 item 的总偏移量, 因为他是根据个数, 让其减去7个 item, 此时便可达到效果, 需要确保总数 > 7

enter image description here

写了一天,大多数都是在别人博客已经有的东西,自己拿过来再重新敲了一遍,还有个必须优化的,layoutAttributesForElementsInRect应该返回可是范围内的cell布局,不应该返回全部,这个后续会完善。本文demo全部代码在这