美文网首页iOS技术点iOS工作系列专注iOS开发
iOS封装一个轻量级的顶部分类控件

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

作者: wazrx | 来源:发表于2016-02-25 09:35 被阅读3937次

    写在前面

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

    图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

    相关文章

      网友评论

      • cc果冻儿12:你好,我看demo中,添加下方横线,初始位置没有横线,只有滑动一下才会有,点击的话横线还在上面,不是我设置的位置。
      • 65426ed1f254:楼主有个bug不知您发现没,categoryview为了性能有个处理刷新只刷新可见cell这样如果标题很多出现重用的话快速滑动时会出现选中状态文字颜色展示错误,有需要的话我可以发视频给你看。我这边暂时改成reload整个collection来规避
      • 仙道贵生:能不能做成顶部点击 纵向展示的界面
      • 韧风透骨寒:Xcode8环境下 底部线条在文字上面怎么解…… :smile:
      • NateLam:收藏, 未来用到的话有啥问题希望不吝赐教
      • bdf9075670d1:太谢谢楼主了,非常好
      • 070c7851dd88:楼主 俺想和你交朋友
      • lizhi_boy:bottomLinewidth是设置下划线的粗细,而不是设置长短,我要设置下划线的长短请问怎样设置?
      • Luke0407:而且,我用你的Demo也试了一遍,发现ip4s也是崩溃........麻烦楼主回复我一下,3q
        Luke0407:@wazrx 不知道的哎,应该是最新,前天还是昨天刚下的,我加你了
        wazrx:@Luke0407 加我QQ 812800716,你是用的最新的代码?
      • Luke0407:楼主,有bug,我在ip6,ip5上运行都可以,但是在ip4s上push进去,然后pop出来就出现崩溃。
        'NSInternalInconsistencyException', reason: 'An instance 0x7c8cfa00 of class UICollectionView was deallocated while key value observers were still registered with it. Current observation info: <NSKeyValueObservationInfo 0x7b973af0> (
        <NSKeyValueObservance 0x7b995c90: Observer: 0x7b973630, Key path: contentOffset, Options: <New: YES, Old: NO, Prior: NO> Context: 0x0, Property: 0x7b995cc0>,请问如何解决,谢谢
      • c50765594d45:好像没有处理横屏
      • fd6879151c31:能让顶部分类collectionView中的cell在有位移偏移的时候偏移量刚好是整个cell的宽度么?
        fd6879151c31:比如说你上面的“生活家”在移动到最左边的时候 只显示一部分,
        wazrx:@haiwei 不太明白你的意思
      • hanzhansen:有bug,在iPhone5上运行进去二级界面再出来 就会崩溃, 通知是没有移除掉得。
        wazrx:我在iOS7 和iOS8.1的设备上均没有触发崩溃,打印dealloc信息视图和控制器都能正常移除,可以的话,你可以加我QQ:812800716,让我看看你那边的崩溃信息,谢谢
        wazrx:@hanzhansen 谢谢反馈,我去看看
        hanzhansen:@hanzhansen 就是说在iOS9之前 都会出现这中问题。
      • Formutolar:楼楼,我不是很理解这里为什么要减1
        _contentWidth = _totleTitleWidth + _property.edgeSpacing * 2 + _realItemSpacing * (_property.data.count - 1);
        Formutolar:@Formutolar 突然明白了哎
        wazrx:@Formutolar 这是显然的嘛,你想想如果有4个item水平排列着,他们之间的间隔也就是这里的 _realItemSpacing肯定就是4 - 1 = 3嘛 :smile:
      • Candy_M:谢谢分享,我有一个小问题。
        objc_setAssociatedObject(self, "fullItem", @(fullItem), OBJC_ASSOCIATION_ASSIGN);
        这个地方为什么要用关联对象呢?
        麻烦博主哥哥解答一下 :pray:
        Candy_M:@wazrx 嗯嗯。明白啦,谢谢 :blush:
        wazrx:@萌小蒙_oO 这种关联方式其实是在类目中添加属性的一种方法,因为类目本身是不能扩展成员变量的,我的这个类目的左右就是给layout添加一个fullItem的属性,更改这个属性为YES可以方便的让item的size能够和整个collectionView的size相同,不用自己去手动计算itemSize
      • 唐师兄:再次对你表示感谢,简直太及时了
      • 192a0dc74341:嗯嗯,好的,谢谢
      • 192a0dc74341:请问怎么修改item默认的颜色,和选中时的颜色呢?我想默认时是黑色的,选中后变为蓝色,求教
        wazrx:@翻腾的小浪花 修改titleColor和titleSelectColor这两个属性,XWCatergoryView.h文件里面都有说明
      • 8ae158dda3f2:如果在分类条目上面再留一部分空间,上滑分类条目view顶住navigationbar ,使用这个怎么实现
        wazrx:@_NevinZ 812800716
        8ae158dda3f2:@wazrx 你QQ多少,我发你个GIF,文字表述不那么准确
        wazrx:@_NevinZ 没太明白你表达的意思?😓
      • 小神仙:好心人啊!

      本文标题:iOS封装一个轻量级的顶部分类控件

      本文链接:https://www.haomeiwen.com/subject/epuikttx.html