CollectionView自定义风火轮 layout (一)

最终效果如下所示:

demo.gif

这个效果是我们公司的一个模块的效果, 当时没有由于没有对 collectionView 仔细研究,所以对这个界面的实现机制并不是很熟悉, 到现在已经有段时间了, 这段时间对 collectionView 也加深了解了一些, 于是试着自己写一下试试(当时使我们公司一个大牛写的)

我打算分一下几步来实现这个效果:

  1. 实现圆形布局(这个布局效果在 Apple 的实例代码中有, 具体代码请自行 Google)
  2. 实现圆形的风火轮效果
  3. 对有些需要隐藏的位置进行隐藏

环形布局之前Apple 提供的代码中是直接根据角度计算的每个 Item 的位置, 我们也用同样的思考, 不同的是我们要将角度记录下来, 这个角度是跟 collectionView 的 contentOffset 有关的, 因为当用户在滑动的时候, contentOffset 在更新,这个时候应该重新根据 contentOffset 计算每个 Item 的角度 --- 在心中有个印象

  1. 创建自定义布局
    #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 时, 对应的 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 的旋转
    self.transform = CGAffineTransformMakeRotation(angle);
}
// UICollectionViewLayoutAttributes 实现 <NSCoping> 协议
- (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 对应方向的值

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

好了准备工作基本完成, 接下来开始布局

在这里必须要了解 collectionView 的布局步骤

  1. prepareLayout 每次布局触发时,就会调用该方法
  2. layoutAttributesForElementsInRect:(CGRect)rect 返回在 rect 矩形内的 item 的布局属性数组
  3. layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath 返回在某个 indexPath 的 item 的布局属性

我们需要一个布局属性数组, 来存储所有 item 的布局属性

于是我们在 extension 中添加一个布局属性数组
@interface CircleCollectionViewLayout ()
@property (nonatomic, assign) CGFloat anglePerItem;
@property (nonatomic, copy) NSArray <CircleCollectionViewLayoutAttributes *> *attributesList;
@end

我们直接在layoutAttributesForElementsInRect中返回该数组, 因为我将要在 prepareLayout 中将该数组填充进布局属性的值

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

同理我们直接将某个位置的布局属性从 attributesList 中取出

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

OK, 开始进行布局

- (void)prepareLayout {
    // 调用父类的
    [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 中进行布局, 使布局更接近我们的目标效果

  1. 先形成圆形布局, 这个容易, 我们首先需要调整锚点, 将锚点调整的屏幕中间, 半径我们之间就定义过了, 屏幕宽度减去一个间隙的一半, 我们将目光放在第一个 Item, 要将第一个 item 放在屏幕下方, 同时锚点应该处于屏幕正中间, 所以锚点的 y 值应小于0, 锚点又是相对于自身的高度来的推出锚点的计算公式

     ==> 
     
     CGFloat anchorPointY =  -(self.radius) / self.itemSize.height;
     
     在 for 循环中设置 item 属性的锚点
     attributes.anchorPoint = CGPointMake(0.5, anchorPointY);
    

效果如图所示

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

我们发现整个圆弧向上偏移了, 所以接下来就是调整每个 item 的中心点, 是之下移
同样在 for 循环中, 修改设置 center 的值

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

OK, 圆环效果成功做出, 第一步 OK, 细心的同学发现, 界面上显示的 Item 并不是从0开始, 那么试着将 numberOfItem 改成 8, 此时就是 0~8 显示, 之前之所以不是从零开始, 是因为我们的圆环一次最多显示8个, 而我们的 numberOfItem 有13个, 导致之后的 item 将前面的 item 覆盖

接下来我们实现滑动

滑动是跟 contentOffset 有关, 同时我们还需要设置一个方法

// 当 bounds 改变时, 使当前布局无效, 这便会触发 prepareLayout 进行重新布局
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds {
    return YES;
}

要想达到项目中滑动的效果, 我们需要设置 Item 布局属性的 angle, 并且这个 angle 是与 contentOffset 有关的

先来几条准备知识

  1. 以第0个 item 为起点, 它的角度此时为0度, 当滑动到最后一个 item 时, 我们让最后一个 item 位置与第0个位置重合, 此时第0个 item 总共经过了 -(numberOfItem * anglePerItem), 因为是逆时针转动, 所以是负值
  2. 由 1. 我们得到滑动到最后, 第0个 item 总共偏移了多少角度, 所以我们很容易得到单位偏移的角度, 总偏移角度 * (contentOffset.x 所占的比例)

由以上两点产生两个属性

@interface CircleCollectionViewLayout ()
/**
 *  单位夹角
 */
@property (nonatomic, assign) CGFloat anglePerItem;
/**
 *  布局属性数组
 */
@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 建立联系

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

效果如下

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

可以滑动
接下来, 我们进行最后的完善, 定义两个属性 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;
                // 当小于某个角度是, 将 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) { // 出现时, 逐渐出现
        CGFloat alpha = (M_PI - attributes.angle) / M_PI_4;
        attributes.alpha = alpha;
    }
        [mAttributesList addObject:attributes];
    }
    self.attributesList = [mAttributesList copy];
}

在上面的 prepareLayout 中我们添加了一个 if-else, 目的是当 item 的角度小于某个值时将其隐藏, 因为是逆时针转动, 所以角度是成减小趋势, 当隐藏一个 item 时, 要多布局一个 item, 即 endIndex++, 显示同理, 根据 contentOffset 设置 alpha

demo_3.gif

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

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

如图所示

demo_4.gif

第一部分只完成 collectionView 布局, 在下一部分讲解, 选择 item 进行切换的效果

我觉得这个布局可以优化, 但目前还没来得及, 如果您有更好的方式, 欢迎交流; 如果您有不明白的地方欢迎提问; 如果您有不满意的地方, 欢迎吐槽; 共同学习, 共同进步

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容