×

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

96
wazrx
2016.02.25 09:35* 字数 1406

写在前面

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

图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
iOS学习笔记
Web note ad 1