写在前面
过年前后都好久没有写东西了,都在忙新的项目,目前新项目也快完了,所以准备把项目中写的部分控件封装出来,在这两个项目中都用到了顶部滚动的分类视图,所以我想把它封装出来方便以后使用,虽然这类控件网上应该也有不少,但是我觉得在能力范围内还是自己尝试一下,这样才能进步更快,而且怎么说呢,自己写的控件自己用的更顺手嘛,首先来看看效果:
图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
网友评论
'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>,请问如何解决,谢谢
_contentWidth = _totleTitleWidth + _property.edgeSpacing * 2 + _realItemSpacing * (_property.data.count - 1);
objc_setAssociatedObject(self, "fullItem", @(fullItem), OBJC_ASSOCIATION_ASSIGN);
这个地方为什么要用关联对象呢?
麻烦博主哥哥解答一下