iOS封装一个轻量级的顶部分类控件

写在前面

过年前后都好久没有写东西了,都在忙新的项目,目前新项目也快完了,所以准备把项目中写的部分控件封装出来,在这两个项目中都用到了顶部滚动的分类视图,所以我想把它封装出来方便以后使用,虽然这类控件网上应该也有不少,但是我觉得在能力范围内还是自己尝试一下,这样才能进步更快,而且怎么说呢,自己写的控件自己用的更顺手嘛,首先来看看效果:

图1:颜色左右渐变 + 底部线条

1.gif

图2:颜色变化 + 背后椭圆

2.gif

图3:颜色变化 + 文字缩放 + 模拟网络刷新

3.gif

如何使用

如上图可见,该控件共有5中效果:包括:底部横条移动,椭圆背景移动,文字缩放,文字颜色变化,和文字颜色左右渐变,五种效果可以叠加使用也可以单一使用; 我给该控件取名:XWCatergoryView,github地址为:一个轻量级的顶部分类控件XWCatergoryView,集成起来也非常简单,步骤如下,

1、导入XWCatergoryView.h头文件

2、如果是当前控制器是被导航控制器管理,也就是说上方有导航栏,必须对当前控制器做如下设置:self.automaticallyAdjustsScrollViewInsets = NO;否则控件显示会有问题

3、初始化该控件,代码和stroyboard都可以,stroyboard的话,直接拖入一个View并修改Class为XWCatergoryView即可;

4、设置数据源titles属性,如果需要设置网络数据可以稍后刷新设置

5、设置与该控件关联的ScrollView(必须)

6、配置相关的属性即可使用,可自定义的属性比较多,请自行去XWCatergoryView.h中查看,更详请见地址中的demo

7、如何刷新:将新的数据源赋给titles ->调用xw_realoadData进行刷新

原理

1、XWCatergoryView的内部的最主要控件是一个collectionView,它的layout是自定义的,因为每个item的大小随着文字变化而变化,所以必须自定义,我会根据设置的itemSpacing和EdgeSpacing结合文字的长度来算出每个item的具体位置,当算出的最大宽度还没有控件的宽度宽的时候,我会自动调整itemSpacing让控件可以均匀分布,就如图3中只有4个item的时候的效果,具体的计算代码如下

- (void)prepareLayout{
    [super prepareLayout];
    _contentWidth = 0;
    //得到预设值的itemSpacing
    _realItemSpacing = _property.itemSpacing;
    //把所有title组合成一个字符串计算所有的文字的宽度
    NSString * allTitles = [_property.titles componentsJoinedByString:@""];
    _totleTitleWidth = [allTitles xw_sizeWithfont:_property.titleFont maxSize:CGSizeMake(MAXFLOAT, MAXFLOAT)].width;
    //计算contentWidth
    _contentWidth = _totleTitleWidth + _property.edgeSpacing * 2 + _realItemSpacing * (_property.data.count - 1);
    //判断是否需要滚动
    _needScroll = _contentWidth > self.collectionView.width;
    //如果不需要滚动,说明如果按用户设置的属性可能无法正确布局,我们自行改变itemSpacing进行均布
    if (!_needScroll) {
        _realItemSpacing = (self.collectionView.bounds.size.width - _totleTitleWidth - _property.edgeSpacing * 2) / (float)(_property.data.count - 1);
        _contentWidth = self.collectionView.width;
    }
    //设置_totleCenterX,辅助计算item的位置
    _totleCenterX = _property.edgeSpacing - _realItemSpacing;
    _attrs = @[].mutableCopy;
    //开始计算每个item的属性确定其size和center
    for (int i = 0; i < _property.data.count; i++) {
        [_attrs addObject:[self layoutAttributesForItemAtIndexPath:[NSIndexPath indexPathForItem:i inSection:0]]];
    }
}


/**计算每个item的大小和位置*/
- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath{
    UICollectionViewLayoutAttributes *attr = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];
    XWCatergoryViewCellModel *model = _property.data[indexPath.item];
    //计算每个item的size
    CGSize size = [model.title xw_sizeWithfont:_property.titleFont maxSize:CGSizeMake(MAXFLOAT, MAXFLOAT)];
    attr.size = size;
    model.cellSize = size;
    //计算每个item的center
    CGFloat centerX = _totleCenterX + _realItemSpacing + size.width / 2.0f;
    _totleCenterX = centerX + size.width / 2.0f;
    CGPoint center = CGPointMake(centerX, self.collectionView.height / 2.0f);
    if (_property.data.count < 2) {
        center = CGPointMake(self.collectionView.width / 2.0f, self.collectionView.height / 2.0f);
    }
    //将计算结果保存在每个item对应的模型中,作用在第三步说明
    model.cellCenter = center;
    attr.center = center;
    return attr;
}

2、每个collectionView的item对应一个模型就是上面代码中的model,模型中有一个ratio属性,item的状态变化相关的属性都和ratio相关,当在滑动或者点击的时候我会修改模型的ratio的值,同时利用插值公式刷新相应的item,这里我没有使用reloadData直接来刷新数据因为这样会导致collectionView重新调用第一步的prepareLayout方法,重新计算,但是对于这里来说是无需的,只有在数据源改变的时候我们才需要重新计算每个item的位置,所以我给每个cell提供了一个刷新的方法,我在更改数据模型的ratio的时候同时调用所有可见cell的这个刷新方法来刷新数据,既保证了重用也修改了状态,采取这种方式在我6s上测试,非常快速滑动时CPU峰值只有20%左右而reloadData达到了60%以上,大家可以自行尝试一下,主要代码如下

/**
 *  先看看最重要的插值公式,其实动画的本质就是插值计算,通过不断的从起始值到终点值的插值,就可以让任何状态随着手势不断改变,这是最重要的概念
 */
- (CGFloat)xwp_interpolationFromValue:(CGFloat)from toValue:(CGFloat)to ratio:(CGFloat)ratio{
    return from + (to - from) * ratio;
}
/**我监听了关联的scrollView的滚动,这个方法在滚动时会不断调用,我计算出ratio调用上面的插值公式对各种状态进行插值,达到效果*/
- (void)xwp_updateWhenScrollViewDidScroll{
    //拖拽和减速的时候才需要进行update,如果是点击触发的滚动不需要,同时该scrollView需要与初始化传入的scrollView相同
    if (!_scrollView.isDragging && !_scrollView.isDecelerating) {
        return;
    }
    //计算拖拽比例,根据其进行插值计算
    CGFloat ratio = _scrollView.contentOffset.x / _scrollView.width;
    //到达一个item正位置的时候需要滚动和修正当前的indexPath,这里有个好处,滑动太快,不会调用这个方法,免得滑动太快滚动太频繁
    if ((int)ratio == ratio) {
        NSIndexPath *indexPath = [NSIndexPath indexPathForItem:ratio inSection:0];
        _lastIndexPath = indexPath;
        [_mainView scrollToItemAtIndexPath:indexPath atScrollPosition:UICollectionViewScrollPositionCenteredHorizontally animated:YES];
    }
    //处理边缘情况,因为用户可能开启bounces, 如果越界直接将bottomLine动画到正确位置
    if (ratio <= 0 || ratio >= _data.count - 1) {
        ratio = (int)ratio;
    }
    //先设置需要操作的模型
    [self xwp_setNeedUpdateModelWithRatio:ratio];
    //处理bottomLine,对其位置进行插值,具体代码见第三步
    [self xwp_interpolationForBottomLineWithRatio:ratio];
    //处理backEllipse,插值,具体代码见第三步
    [self xwp_interpolationForBackEllipseWithRatio:ratio];
    //处理前后两个item, 更改模型同时刷新item的状态,见下面
    [self xwp_interpolationForItemsWithRatio:ratio];

/**滚动时,刷新item,不使用reloadData,因为会触发prepareLayout,这里没必要,只有titles变了才需要prepareLayout,这里我采用了遍历所有模型修改模型属性,同时遍历可见item,调用自己的刷新方法达到目的且保证重用,并且不会触发prepareLayout,性能更好,大家可自行测试*/
- (void)xwp_interpolationForItemsWithRatio:(CGFloat)ratio{
    for (XWCatergoryViewCellModel *model in _data) {
        model.ratio = ratio;
    }
    for (XWCatergoryViewCell *cell in _mainView.visibleCells) {
    //调用cell自己的刷新方法
        [cell xw_updateCell];
    }
}

- (void)xw_updateCell{
    //插值titleColor
    [self xwp_interpolationColor];
    //插值scale
    [self xwp_interpolationScale];
}
- (void)xwp_interpolationColor{
    CGRect titleMaskRect = CGRectZero;
    CGRect colorMaskRect = CGRectZero;
    if (_property.titleColorChangeEable) {
        if (_property.titleColorChangeGradually) {
    //对颜色左右渐变的情况进行插值,如图1的情况,这里使用了两个不同颜色的label,对其mask的path进行不断插值,这是要考虑颜色是从左到右渐变还是从右到左渐变,从而进行相应的计算,稍微复杂一点
            _colorLabel.hidden = NO;
            if (_data.ratio >= _data.index) {
                titleMaskRect = CGRectMake(0, 0, self.width * (1 - _data.valueRatio), self.height);
                colorMaskRect = CGRectMake(self.width * (1 - _data.valueRatio), 0, self.width * _data.valueRatio, self.height);
            }else{
                titleMaskRect = CGRectMake(self.width * _data.valueRatio, 0, self.width * (1 - _data.valueRatio), self.height);
                colorMaskRect = CGRectMake(0, 0, self.width * _data.valueRatio, self.height);
            }
            _titlemaskLayer.path = [UIBezierPath bezierPathWithRect:titleMaskRect].CGPath;
            _colormaskLayer.path = [UIBezierPath bezierPathWithRect:colorMaskRect].CGPath;
            
        }else{
    //对颜色逐渐变化的情况进行插值,关于颜色插值的代码我写了一个分类,大家自行去代码中查看吧
            _colorLabel.hidden = YES;
            _titleLabel.layer.mask = nil;
            _titleLabel.textColor = [UIColor xw_colorWithInterpolationFromValue:_property.titleColor toValue:_property.titleSelectColor ratio:_data.valueRatio];
            
        }
    }else{
        _colorLabel.hidden = YES;
        _titleLabel.layer.mask = nil;
    }
}

- (void)xwp_interpolationScale{
    /**对transform进行插值达到缩放效果*/
    CGFloat scale = [self xwp_interpolationFromValue:1 toValue:_property.scaleRatio ratio:_data.valueRatio];
    //不能单单对titleLabel进行transform变换,因为有可能变化后超出cell大小文字显示不全;
    self.transform  = CGAffineTransformMakeScale(scale, scale);
}

3、对于下方横线bottomLine和背后椭圆backEllipse,在插值的时候我会找出插值前后所对应的两个模型,由于在第一步骤我们在保存中了每个item的大小和位置,所以通过简单的计算就可以得到插值前后这两个控件的位置和大小,这就是第一步模型的作用,具体代码如下:


/**找到插值前后的两个模型*/
- (void)xwp_setNeedUpdateModelWithRatio:(CGFloat)ratio{
    if (!_data.count) return;
    _fromModel = _data[(int)ratio];
    if ((int)ratio == _data.count - 1) {
    //处理最后一个item的情况,防止数组越界
        _toModel = _fromModel;
    }else{
        _toModel = _data[(int)ratio + 1];
    }
}

/**插值bottomLine,通过模型中保存的cellFrame得到插值的起始终止值,计算出x和width即可*/
- (void)xwp_interpolationForBottomLineWithRatio:(CGFloat)ratio{
    if (!_bottomLineEable || !_data.count) return;
    CGFloat x = [self xwp_interpolationFromValue:_fromModel.cellFrame.origin.x toValue:_toModel.cellFrame.origin.x ratio:ratio - (int)ratio];
    CGFloat y = CGRectGetMaxY(_fromModel.cellFrame) + _bottomLineSpacingFromTitleBottom;
    CGFloat width = [self xwp_interpolationFromValue:_fromModel.cellFrame.size.width toValue:_toModel.cellFrame.size.width ratio:ratio - (int)ratio];
    CGFloat height = _bottomLineWidth;
    _bottomLine.frame = CGRectMake(x, y, width, height);
}

/**插值backEllipse,我们是不断的计算椭圆的path路径,这个path也就是在一个比cellFrame稍微大一点的矩形中画椭圆而已,不断插值两个模型的cellFrame,计算这个矩形的大小和位置然后绘制出椭圆路径赋值给backEllipse就可以达到效果了,代码还是很简单的*/
- (void)xwp_interpolationForBackEllipseWithRatio:(CGFloat)ratio{
    if (!_backEllipseEable || !_data.count) return;
    CGFloat x = [self xwp_interpolationFromValue:_fromModel.backEllipseFrame.origin.x toValue:_toModel.backEllipseFrame.origin.x ratio:ratio - (int)ratio];
    CGFloat y = _fromModel.backEllipseFrame.origin.y;
    CGFloat width = [self xwp_interpolationFromValue:_fromModel.backEllipseFrame.size.width toValue:_toModel.backEllipseFrame.size.width ratio:ratio - (int)ratio];
    CGFloat height = _fromModel.backEllipseFrame.size.height;
    CGFloat cornerRadius = _fromModel.backEllipseFrame.size.height / 2.0f;
    UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(x, y, width, height) cornerRadius:cornerRadius];
    _backEllipse.path = path.CGPath;
    [_mainView.layer insertSublayer:_backEllipse atIndex:0];
}

4、如上就是主要的代码了,我觉得业务逻辑还是挺简单的,当然既然是封装还有很多的细节要处理,这些就请大家自行去源代码中查看了

写在最后

这类控件用的很广泛,有需要的可以多多尝试一下,对比一下自己的一些想法,提出更好的建议,我会及时采纳和修改的,最后再复习一遍gitHub的地址:一个轻量级的顶部分类控件XWCatergoryView,希望大家可以多多支持,如果觉得有帮助的话可以给一个star加以鼓励,谢谢!

更新

3月3日更新:支持初始化设置默认选中的index,请设置defaultIndex属性即可
3月4日更新:优化item的size的大小计算,优化item的点击,之前item的size等同于算出来的文字的宽高,但是如果文字过小就不容易点击到item了,所以重新优化了一下,保证每个item之间和上下都没有间隙,手指点击总能触发一个item:

#######更改前:

屏幕快照 2016-03-04 下午8.28.35.png

#######更改后:

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

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,585评论 18 139
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,397评论 25 707
  • 1、窗体 1、常用属性 (1)Name属性:用来获取或设置窗体的名称,在应用程序中可通过Name属性来引用窗体。 ...
    Moment__格调阅读 4,477评论 0 11
  • 我要给我的朋友写一首诗 写他的前半生 用一笔一划的铅笔字 描述些多言的潦草情感 不用逻辑 诗本身就是毫无逻辑 朋友...
    铅迟阅读 315评论 0 0