iOS CoreAnimation专题——实战篇(一)惊艳的进度条效果实现
终于到实战篇了,中间拖的有点久,主要是事情太多了,且这一篇有大量的抽象内容需要用文字来描述,真真是不好写啊,不过好歹是写完了,噢噢噢噢~
本篇的代码我进行了封装并放进了这个git仓库中,需要的同学自取。
https://github.com/DHUsesAll/DHProgressView
圆形渐变进度条
“那个,DH呀,你过来一下,这是我们的新需求,我们要做一个这样的效果出来。”产品经理把我叫了过去,并向我出示了美工sama刚完成的效果图,一个非常骚气的进度条效果,如图。
啥玩意儿啊,咋回事儿啊,这咋整啊,旁边安卓兄弟也是一脸吃了翔的表情。
以上场景很可能就是大家工作的时候会遇到的情况,那么,如果真的遇到了,如图的进度条效果,你们能实现吗?
在学习了实战篇和技巧篇的内容后,我相信你也有了一些实现思路了,这里似乎缺了最后一块拼图:渐变效果的实现。
CAGradientLayer
在iOS的绘图体系中,CALayer扮演了重要的角色。之前也提到过,系统为我们提供了大量的CALayer的子类供我们使用,如果我们想要实现颜色渐变的效果,那么我们可以使用其中一个子类:CAGradientLayer。
NOTE:事实上除了CAGradientLayer,你还可以使用CoreGraphics在上下文中绘制渐变效果,然后渲染到一个CALayer上面,但是其基于C语言的API比较繁琐,除非是非常复杂的、对性能要求较高的场景,否则还是推荐直接使用CoreAnimation的API来进行绘制。
FYI:使用CAGradientLayer只能实现线性渐变的效果,而CoreGraphics的绘制能绘制更多的渐变效果,大家如果感兴趣可以去查阅相关文档。
要知道CAGradientLayer是如何工作的,只需要打开PhotoShop,看看里面的“线性渐变工具”就行了。
CAGradientLayer的关键属性
- colors
colors属性表示了参与渐变的颜色,是一个CGColorRef数组,所以这里我们要使用桥接把CGColorRef转换为id类型放进NSArray中。
- startPoint、endPoint
startPoint和endPoint确定了一个向量,表示渐变的方向、渐变开始的地方和渐变结束的地方。就像你们在PS中使用线性渐变工具一样,需要拉一条线,表示了颜色插值的开始和结束。这里CAGradientLayer使用的是线性插值算法,如果你从左往右拉了一条线,则CAGradientLayer会从colors属性中的第一个颜色,从左往右地渐变为最后一个颜色,待会来看效果。要注意的是它们的x,y取值范围是[0,1],是一个相对值,(0,0)表示layer的左上角,(1,1)表示layer的右下角。
- locations
locations可以理解为参与渐变的颜色的终点值所占的比例,其count值需要和colors的count值一致(不一致也没关系,但是效果可能就和你想要的不一样了)。在PS中你可以在设置渐变色的时候拖动游标来设置每个颜色的location。
我们先令startPoint和endPoint所确定的向量为向量
a⃗ 。locations数组中的元素是整型NSNumber,表示每个颜色位于a⃗ 上的位置,其取值范围是[0,1],0表示向量起点,1表示向量终点。默认的locations属性是nil,则每个颜色平均分配进度。举个例子:渐变色为红->黄->绿,a⃗ =(1,0) (向量的标准表达式,还记得吗),locations为@[@0,@0.3,@1],则表示把红色放在最左边,黄色放在距离红色0.3 * layer宽的位置,蓝色放在最右边,那么蓝色距离黄色就是(1-0.3) * layer宽。此时三个颜色把整个layer分成了三段,红色到黄色的这一段渐变占了整个layer左边起百分之三十的部分,剩下的百分之七十就是从黄色渐变为蓝色。
构造CAGradientLayer
我们来把上面的例子用代码实现出来看一看效果。
为了方便我们对颜色进行桥接,先来一个宏定义:
#define CGColorToNSObject(x) (__bridge id)x.CGColor
然后在ViewDidLoad中构造一个CAGradientLayer:
- (void)viewDidLoad {
[super viewDidLoad];
// 所有的layer都使用便利构造
CAGradientLayer * layer = [CAGradientLayer layer];
layer.frame = CGRectMake(100, 200, 250, 40);
// 渐变颜色为红->黄->绿
layer.colors = @[CGColorToNSObject([UIColor redColor]),CGColorToNSObject([UIColor yellowColor]), CGColorToNSObject([UIColor greenColor])];
// 起始点在左上角,结束点在右上角,则相当于从左往右画了一条线,所以确定了渐变方向是从左到右
// 这里随意修改它们两个的y值,只要保持一致,都能确定渐变方向为从左往右
layer.startPoint = CGPointMake(0, 0);
layer.endPoint = CGPointMake(1, 0);
// 设置locations
layer.locations = @[@0,@0.3,@1];
[self.view.layer addSublayer:layer];
}
效果:
你可以随意修改location和startPoint还有endPoint的值再运行看看,加深对这几个属性的理解。
回到主题
好了,我们的最后一块拼图终于有了,是时候开始拼图了。
我们要的圆形进度条效果似乎是把刚刚画的那个条形的渐变layer给掰弯了过来,这是我们直观上的第一反应,如果你朝这个方向思考,很可能你就会陷入想办法对这个条形layer去变形来达到效果,这显然是非常困难且不太好实现的。
我们的思路需要转变过来,实际上我们在实现动画效果的时候,需要把整个效果进行拆分,绘图也是一样的,不要第一眼看上去像什么就想当然朝着一个方向去思考。比如这个圆形进度条,大家要把”圆形“和”渐变“分开看。圆形的效果是如何实现的?渐变的效果是如何实现的?
思路
看到圆形,要绘制圆形的话第一个是不是应该想到CAShapeLayer?这时你脑海中应该会自动出现一个圆形的CAShapeLayer,那我们如何把这个shapeLayer填充成渐变的颜色呢?我们唯一所知的渐变的技术就是CAGradientLayer,显然我们最终需要使用一个CAGradientLayer来绘制渐变色,那这时你脑海中在刚才那个圆形的CAShapeLayer旁边又有了一坨CAGradientLayer,渐变色就是刚才的红->黄->绿。然后在脑海中,你把CAShapeLayer和CAGradientLayer慢慢的融合到一起,CAGradientLayer上面出现了一个CAShapeLayer圆环,我们要的圆形进度条效果,似乎就是把CAGradientLayer上面”抠“一个CAShapeLayer的形状下来,咦,这个效果好耳熟,似乎在哪里听说过。
蒙版!
gradientLayer.mask = shapeLayer;
That’s it !
想到这里,你兴奋不已,开始动起手来!然而你写到一半,似乎发现了一些问题。没关系,我们先实现第一个版本的效果来看看。
第一个坑
按照我们的思路,我们需要一个CAGradientLayer和一个圆形的CAShapeLayer。其中CAGradientLayer是一个正方形(其实无所谓,只要足够大,最终长什么样都是由它的蒙版来决定的,这里我们还是设定为正方形,且边长和CAShapeLayer的直径相等),CAShapeLayer需要设定一个半径和线宽(参考技巧篇第二篇文章讲解CAShapeLayer)。
// 前排声明一下线宽和半径等
static const CGFloat radius = 150;
static const CGFloat lineWidth = 20;
把刚才实现的CAGradientLayer改成给予半径和线宽的frame
- (void)viewDidLoad {
[super viewDidLoad];
CAGradientLayer * gradientLayer = [CAGradientLayer layer];
gradientLayer.frame = CGRectMake(80, 200, radius * 2, radius * 2);
gradientLayer.colors = @[CGColorToNSObject([UIColor redColor]),CGColorToNSObject([UIColor yellowColor]), CGColorToNSObject([UIColor greenColor])];
// 改为从上到下的渐变
gradientLayer.startPoint = CGPointMake(0, 0);
gradientLayer.endPoint = CGPointMake(0, 1);
[self.view.layer addSublayer:gradientLayer];
// 构造shapeLayer的渲染路径
// 这里要注意由于shapeLayer作为gradientLayer的蒙版,所以圆心是基于gradientLayer的坐标系
UIBezierPath * path = [UIBezierPath bezierPathWithArcCenter:CGPointMake(radius, radius) radius:radius startAngle:0 endAngle:2 * M_PI clockwise:YES];
CAShapeLayer * shapeLayer = [CAShapeLayer layer];
// 设置蒙版内容,为什么要这么设置,查阅技巧篇第三篇关于蒙版的内容
// shapeLayer的描线内容为蒙版内容,所以要设置描线颜色,随便什么颜色都可以
shapeLayer.strokeColor = [UIColor redColor].CGColor;
// 不要填充内容,因为默认的填充颜色为黑色,所以这里要设置为无色
shapeLayer.fillColor = [UIColor clearColor].CGColor;
shapeLayer.lineWidth = lineWidth;
shapeLayer.path = path.CGPath;
gradientLayer.mask = shapeLayer;
}
运行一下:
似乎有了雏形了,但是有几个问题需要解决一下。首先是我的四条边怎么被啃了一部分?显然是gradientLayer的边长不够长,但是其边长刚好等于我们的gradientLayer路径圆形半径的两倍啊:
gradientLayer.frame = CGRectMake(80, 200, radius * 2, radius * 2);
要理解这个原因,又需要考验大家的抽象思维能力了,当然这里其实还是很好理解的。首先在脑海中想象出一条线,随便什么形状都可以,也可以是一个圆形,但是注意,它的宽为0,也就是它只是一条抽象的线,并没有实体(你可以想象成很细很细的虚线)。然后想象一只毛笔,去描这条虚线,落笔的点肯定在虚线上(毛笔的笔尖还是挺细的),那么我们用力画的时候,毛笔的笔尖被挤压,所以画出来的线是有宽度的,那么这个描出来的实线和那条抽象的线(虚线)之间存在怎样的位置关系呢?是不是一半在虚线的一端一半在虚线的另一端的(或者说是虚线沿着实线的路径把实线拆成了相等的两半)?所以我们的shapeLayer渲染出来的圆,它真正的外接矩形的边长要加上两个【线宽的一半】,应该是(半径+线宽/2)*2。
实际上我们的gradientLayer是路径(线宽为0或者说忽略线宽)的外接矩形而不是shapeLayer绘制内容的外接矩形,就像上图所显示的那样,白色的虚线是shapeLayer的路径,但是渲染出来后shapeLayer所占的内容真正的半径比它的抽象路径要大半个线宽,以上。
我们对gradientLayer的边长进行修改,注意gradientLayer的大小改变后,路径的圆心也要跟着改变。
- (void)viewDidLoad {
[super viewDidLoad];
// 先计算边长
CGFloat length = radius * 2 + lineWidth;
CAGradientLayer * gradientLayer = [CAGradientLayer layer];
gradientLayer.frame = CGRectMake(80, 200, length, length);
gradientLayer.colors = @[CGColorToNSObject([UIColor redColor]),CGColorToNSObject([UIColor yellowColor]), CGColorToNSObject([UIColor greenColor])];
// 改为从上到下的渐变
gradientLayer.startPoint = CGPointMake(0, 0);
gradientLayer.endPoint = CGPointMake(0, 1);
[self.view.layer addSublayer:gradientLayer];
// 构造shapeLayer的渲染路径
// 这里要注意由于shapeLayer作为gradientLayer的蒙版,所以圆心是基于gradientLayer的坐标系
UIBezierPath * path = [UIBezierPath bezierPathWithArcCenter:CGPointMake(length/2, length/2) radius:radius startAngle:0 endAngle:2 * M_PI clockwise:YES];
CAShapeLayer * shapeLayer = [CAShapeLayer layer];
// 设置蒙版内容,为什么要这么设置,查阅技巧篇第三篇的内容
// shapeLayer的描线内容为蒙版内容,所以要设置描线颜色,随便什么颜色都可以
shapeLayer.strokeColor = [UIColor redColor].CGColor;
// 不要填充内容,因为默认的填充颜色为黑色,所以这里要设置为无色
shapeLayer.fillColor = [UIColor clearColor].CGColor;
shapeLayer.lineWidth = lineWidth;
shapeLayer.path = path.CGPath;
gradientLayer.mask = shapeLayer;
}
这样就对了吧。嗯,和原图对比一下,好像还是有哪里不对。这时你就会发现我们之前的思路又要起作用了。我们按照思路的实现方式,是以shapeLayer作为gradientLayer的蒙版来实现的,那么请看着原图效果,在脑海中再次想想象出我们真正需要绘制的那个gradientLayer的模样是怎样的。我们一起来想象一下,嗯,我想象出来是这样的,你想出来了吗?
为什么我能想出来是这个样子呢,沿着思路的核心:以shapeLayer作为gradientLayer的蒙版,所以我们自己写的这个版本和原图的区别就在于gradientLayer不一样。哪里不一样呢?原图左边是绿色到黄色的渐变,右边是红色到黄色的渐变,啊,这样一说,是不是一下子就把实现思路给说出来了,我们可以用两个gradientLayer拼起来,左边那个是自上向下的绿色到黄色的渐变,右边那个是自上向下的红色到黄色的渐变。所以我希望大家能学习到一些解决问题的思维方式。
诶等等,好像还有一个问题,既然我们要用到两个gradientLayer,那shapeLayer是不是也要两个呢?那到时候动画也要做一个协同啊,还要判断是不是过了百分之50……放飞你的思维,我们根本不需要两个shapeLayer,gradientLayer确实是两个,但是它们两个可以放到同一个layer上面啊,然后把shapeLayer作为这个承载了两个gradientLayer的layer的蒙版不就行了。有点拗口没看懂?多看几遍,还看不懂就看下面的代码吧!
接下来终于可以向着原图更近一步了!
- (void)viewDidLoad {
[super viewDidLoad];
// 左面半边的gradientLayer
CAGradientLayer * leftLayer = [CAGradientLayer layer];
leftLayer.colors = @[(__bridge id)[UIColor greenColor].CGColor,(__bridge id)[UIColor yellowColor].CGColor];
leftLayer.startPoint = CGPointMake(0, 0);
leftLayer.endPoint = CGPointMake(0, 1);
leftLayer.frame = CGRectMake(0, 0, CGRectGetWidth(self.containerLayer.frame)/2, CGRectGetHeight(self.containerLayer.frame));
// 右面半边的gradientLayer
CAGradientLayer * rightLayer = [CAGradientLayer layer];
rightLayer.colors = @[(__bridge id)[UIColor redColor].CGColor,(__bridge id)[UIColor yellowColor].CGColor];
rightLayer.startPoint = CGPointMake(0, 0);
rightLayer.endPoint = CGPointMake(0, 1);
rightLayer.frame = CGRectMake(CGRectGetWidth(self.containerLayer.frame)/2, 0, CGRectGetWidth(self.containerLayer.frame)/2, CGRectGetHeight(self.containerLayer.frame));
// 作为被蒙版的容器layer
// 这样就相当于容器layer的绘制内容就是leftLayer+rightLayer的内容,然后设置好蒙版,效果就有了
CALayer * containerLayer = [CALayer layer];
containerLayer.frame = CGRectMake(0, 0, radius*2+lineWidth, radius*2+lineWidth);
containerLayer.position = self.view.center;
[containerLayer addSublayer:leftLayer];
[containerLayer addSublayer:rightLayer];
[self.view.layer addSublayer:containerLayer];
// 蒙版
CAShapeLayer * maskLayer = [CAShapeLayer layer];
UIBezierPath * path = [UIBezierPath bezierPathWithArcCenter:CGPointMake(radius+lineWidth/2, radius+lineWidth/2) radius:radius startAngle:-M_PI_2 endAngle:M_PI_2 * 3 clockwise:YES];
maskLayer.path = path.CGPath;
maskLayer.lineWidth = lineWidth;
maskLayer.strokeColor = [UIColor blackColor].CGColor;
maskLayer.fillColor = [UIColor clearColor].CGColor;
maskLayer.backgroundColor = [UIColor clearColor].CGColor;
containerLayer.mask = maskLayer;
}
感觉已经可以开香槟庆祝了!
第二个坑
眼睛尖的同学肯定能看见,诶,这个底部好像有条缝诶!
这又是什么原因造成的呢?仔细看上面第6张图(完整的双gradientLayer那张图),你就会发现,我们实现的颜色渐变方向是垂直方向,而其实真正要的效果渐变方向是“沿着路径的方向”。下面这个表格表示了底端左右两边颜色渐变导致了“缝”出现的原因。
左边颜色 | 右边颜色 |
---|---|
绿绿黄 | 红红黄 |
绿黄 | 红黄 |
黄 | 黄 |
虽然最底部确实都是黄色,但是再往上走几个像素,左右的颜色就不一样了。那么实际上这个问题我们是不能百分之百完全解决的,只能靠耍点小聪明来把误差降到最低。当然最理想的情况就是我们把那条缝周围都用黄色去填充就行了,但是这个“周围”大小是多少呢,至少我是懒得去算了,所以我干脆就这样做:
左右两个layer的高度减掉一个线宽,然后用一个线宽的高度的黄色layer去填补。
- (void)viewDidLoad {
[super viewDidLoad];
// 左面半边的gradientLayer,高度减掉了一个线宽
CAGradientLayer * leftLayer = [CAGradientLayer layer];
leftLayer.colors = @[(__bridge id)[UIColor greenColor].CGColor,(__bridge id)[UIColor yellowColor].CGColor];
leftLayer.startPoint = CGPointMake(0, 0);
leftLayer.endPoint = CGPointMake(0, 1);
leftLayer.frame = CGRectMake(0, 0, CGRectGetWidth(self.containerLayer.frame)/2, CGRectGetHeight(self.containerLayer.frame) - lineWidth);
// 右面半边的gradientLayer,高度减掉了一个线宽
CAGradientLayer * rightLayer = [CAGradientLayer layer];
rightLayer.colors = @[(__bridge id)[UIColor redColor].CGColor,(__bridge id)[UIColor yellowColor].CGColor];
rightLayer.startPoint = CGPointMake(0, 0);
rightLayer.endPoint = CGPointMake(0, 1);
rightLayer.frame = CGRectMake(CGRectGetWidth(self.containerLayer.frame)/2, 0, CGRectGetWidth(self.containerLayer.frame)/2, CGRectGetHeight(self.containerLayer.frame) - lineWidth);
// 底部的补偿layer,高度为一个线宽
CALayer * bottomLayer = [CALayer layer];
bottomLayer.frame = CGRectMake(0, CGRectGetHeight(self.containerLayer.frame)-lineWidth, CGRectGetWidth(self.containerLayer.frame), lineWidth);
bottomLayer.backgroundColor = [UIColor yellowColor].CGColor;
// 作为被蒙版的容器layer
// 这样就相当于容器layer的绘制内容就是leftLayer+rightLayer的内容,然后设置好蒙版效果就有了
CALayer * containerLayer = [CALayer layer];
containerLayer.frame = CGRectMake(0, 0, radius*2+lineWidth, radius*2+lineWidth);
containerLayer.position = self.view.center;
[containerLayer addSublayer:leftLayer];
[containerLayer addSublayer:rightLayer];
[containerLayer addSublayer:bottomLayer];
[self.view.layer addSublayer:containerLayer];
// 蒙版
CAShapeLayer * maskLayer = [CAShapeLayer layer];
UIBezierPath * path = [UIBezierPath bezierPathWithArcCenter:CGPointMake(radius+lineWidth/2, radius+lineWidth/2) radius:radius startAngle:-M_PI_2 endAngle:M_PI_2 * 3 clockwise:YES];
maskLayer.path = path.CGPath;
maskLayer.lineWidth = lineWidth;
maskLayer.strokeColor = [UIColor blackColor].CGColor;
maskLayer.fillColor = [UIColor clearColor].CGColor;
maskLayer.backgroundColor = [UIColor clearColor].CGColor;
containerLayer.mask = maskLayer;
}
已经几近完美,几乎没有瑕疵了。
添加动画
动画实际上就比较简单了,你可以在touchEnd事件里面添加动画,这样做的话就要把maskLayer作为属性,这样才能全局访问。
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
CABasicAnimation * animation = [CABasicAnimation animation];
animation.keyPath = @"strokeEnd";
animation.duration = 2;
animation.fromValue = @0;
self.maskLayer.strokeEnd = 1;
[self.maskLayer addAnimation:animation forKey:nil];
}
之前忘了提一点,我们圆形路径的path是这样的:
UIBezierPath * path = [UIBezierPath bezierPathWithArcCenter:CGPointMake(radius+lineWidth/2, radius+lineWidth/2) radius:radius startAngle:-M_PI_2 endAngle:M_PI_2 * 3 clockwise:YES];
是
高光渐变进度条
“那个,DH呀,你过来一下,我们又要做一个这样的进度条效果。”产品经理把我叫了过去,并向我展示了美工sama的脑洞。
这又是什么鬼啊啊啊!
不过经历了第一轮的洗礼,我的动画实现能力有了一个飞跃,这个动画虽然看起来很炫酷,但是仔细分解一下,似乎是完全可以用我们之前学习过的API实现的。
首先普通的进度条效果(慢慢变长)就不说了,这个很简单,重点是进度条的高光效果和高光还能无限制的跑来跑去的效果。按照我们上次实现圆形进度条的思路,我们先拆分效果。
第一眼看过去,那我们肯定核心的技术就是CAGradientLayer来实现渐变效果。那这个动画…先不管那么多了,甩一个效果出来先。没错,如果实在没有什么思路,先把能写的写了,至少先看到点效果,然后说不定就有新的思路了呢。
- (void)viewDidLoad {
[super viewDidLoad];
CAGradientLayer * layer = [CAGradientLayer layer];
layer.frame = CGRectMake(20, 200, 300, 20);
layer.colors = @[CGColorToNSObject([UIColor cyanColor]),CGColorToNSObject([UIColor yellowColor]), CGColorToNSObject([UIColor cyanColor])];
layer.startPoint = CGPointMake(0, 0);
layer.endPoint = CGPointMake(1, 0);
[self.view.layer addSublayer:layer];
}
好像有点感觉了也。现在我们要做的,就是如何让它动起来,还能一直循环循环的动下去且不违和。
动起来
动什么
首先我们来解决如何让这个高光动起来,在iOS开发中,实现动画的第一步都要考虑:动什么?在动画的过程中,肯定去改变了CALayer的某个属性(这里是CAGradientLayer),那这个属性是哪个?或者说,如果我们要声明一个CABasicAnimation对象,那么它的keyPath是什么?所以,这里的高光进度条,我们应该让它的什么属性动起来呢?
这就考验了你对于CoreAnimation各个API的熟悉程度了。这个效果,我粗略的看了一眼,马上就想到了,肯定要动CAGradientLayer的locations属性。然后战战兢兢的点进locations的定义里面去看看注释,哈,果然是Animatable的(关于Animatable属性的具体内容,可以看我原理篇的第二篇文章),这样我们就可以为locations添加动画了,让locations这个属性动起来!
为locations添加动画
由于locations属性默认是nil,所以在为它添加动画前,我们需要给它一直新的非nil的值。
- (void)viewDidLoad {
[super viewDidLoad];
CAGradientLayer * layer = [CAGradientLayer layer];
layer.frame = CGRectMake(20, 200, 300, 20);
layer.colors = @[CGColorToNSObject([UIColor cyanColor]),CGColorToNSObject([UIColor yellowColor]), CGColorToNSObject([UIColor cyanColor])];
// 横向渐变,渐变向量为(1,0)
layer.startPoint = CGPointMake(0, 0);
layer.endPoint = CGPointMake(1, 0);
layer.locations = @[@0,@0.5,@1];
[self.view.layer addSublayer:layer];
CABasicAnimation * animation = [CABasicAnimation animation];
animation.keyPath = @"locations";
animation.duration = 2;
animation.fromValue = @[@0,@0.5,@1];
animation.toValue = @[@0,@0.8,@2];
// 重复次数无穷大
animation.repeatCount = CGFLOAT_MAX;
[layer addAnimation:animation forKey:nil];
}
瞧啊,动起来了!至少我们朝这个方向去做肯定是没问题的,需要改善一下动画的实现逻辑就行了。
循环滚动且不违和
第一步,我们要找到滚动极限(动画结束)的地方,然后让这个地方的appearance(我找不到哪个中文词比较合适。。)和动画开始的时候的appearance一样。
为什么这样做呢,因为我们动画无限播放肯定是设置了动画的repeatCount属性为无限大,这个属性的效果是当动画执行完成后重新执行动画(更准确的解释请参考我的原理篇第四篇)。那么当我们动画结束一次后,就会马上重新开始动画,但是这个重新开始的过程不能让人感受到,不然就会有顿一下或者闪一下的感觉。如何不让人感受到呢,那就是结束的那一瞬间和开始的那一瞬间长得一样。
这里的思想比较抽象,希望大家能理解吧,如果你曾经实现过无线轮播广告图,那这个思想应该比较容易能理解了。
我就直接上完整的代码咯,大家可以结合代码再次理一理实现思路。
- (void)viewDidLoad {
[super viewDidLoad];
CAGradientLayer * layer = [CAGradientLayer layer];
layer.frame = CGRectMake(20, 200, 300, 20);
// 结合无限轮播广告图的思路,在左边和右边多预备一块
layer.colors = @[CGColorToNSObject([UIColor cyanColor]),CGColorToNSObject([UIColor yellowColor]), CGColorToNSObject([UIColor cyanColor]), CGColorToNSObject([UIColor yellowColor]),CGColorToNSObject([UIColor cyanColor])];
layer.startPoint = CGPointMake(0, 0);
layer.endPoint = CGPointMake(1, 0);
layer.locations = @[@-1,@-0.5,@0,@0.5,@1];
[self.view.layer addSublayer:layer];
CABasicAnimation * animation = [CABasicAnimation animation];
animation.keyPath = @"locations";
animation.duration = 2;
animation.fromValue = @[@-1,@-0.5,@0,@0.5,@1];
// toValue即动画结束的地方,其appearance和fromValue处的appearance要长得一样
animation.toValue = @[@0,@0.5,@1,@1.5,@2];
animation.repeatCount = CGFLOAT_MAX;
[layer addAnimation:animation forKey:nil];
进度条动画
最后就是添加进度条动画了。这个看起来挺简单的诶,给layer的长度加一个动画不就行了吗?
先来试试看,首先要注意的是,frame并不是Animatable的属性,所以使用CoreAnimation对CALayer的长度进行修改,只能对bounds进行动画。
- (void)viewDidLoad {
[super viewDidLoad];
CAGradientLayer * layer = [CAGradientLayer layer];
layer.frame = CGRectMake(20, 200, 300, 20);
layer.colors = @[CGColorToNSObject([UIColor cyanColor]),CGColorToNSObject([UIColor yellowColor]), CGColorToNSObject([UIColor cyanColor]), CGColorToNSObject([UIColor yellowColor]),CGColorToNSObject([UIColor cyanColor])];
layer.startPoint = CGPointMake(0, 0);
layer.endPoint = CGPointMake(1, 0);
layer.locations = @[@-1,@-0.5,@0,@0.5,@1];
[self.view.layer addSublayer:layer];
CABasicAnimation * animation = [CABasicAnimation animation];
animation.keyPath = @"locations";
animation.duration = 2;
animation.fromValue = @[@-1,@-0.5,@0,@0.5,@1];
animation.toValue = @[@0,@0.5,@1,@1.5,@2];
animation.repeatCount = CGFLOAT_MAX;
[layer addAnimation:animation forKey:nil];
CABasicAnimation * boundsAnimation = [CABasicAnimation animation];
boundsAnimation.keyPath = @"bounds";
boundsAnimation.duration = 5;
// 长度从0开始
boundsAnimation.fromValue = [NSValue valueWithCGRect:CGRectMake(0, 0, 0, 20)];
// 根据modelLayer和presentationLayer的关系,我们不设置toValue则系统会自动把modelLayer的值作为toValue
[layer addAnimation:boundsAnimation forKey:nil];
}
运行一下:
诶,怎么是从中间开始往两边延伸的?和我们想象中的不一样啊,难道不应该从左边开始往右边延伸吗。
这里要注意的是,我们之前可能习惯了使用UIView的block动画直接给frame添加动画,我们从一个frame比如(x,y,0,h)变为(x,y,w,h),那整个插值过程中x,y的值是不会变的,只有width会变,所以动画过程中看起来是左边固定(x不变)然后往右延伸。
而我们使用CoreAnimation,对bounds进行动画的话,有一点可能大家忽略掉了,那就是frame是由bounds+position决定的(详见原理篇第一篇)。所以在bounds动画过程中,position是不变的,即layer的中心点保持不变而宽度变大,所以动画看起来就是固定中心点往两边延伸。左边固定而往右延伸的效果实际上position也是不停在修改的(x,y不变而width改变的话position.x也就改变了),所以为了达到这样的效果,必须在给bounds添加动画的同时给position也添加动画以达到给frame直接添加动画的效果(frame由bounds+position决定)。
我们再在之前的代码后面添加一个position的动画:
CABasicAnimation * positionAnimation = [CABasicAnimation animation];
positionAnimation.keyPath = @"position";
positionAnimation.duration = 5;
positionAnimation.fromValue = [NSValue valueWithCGPoint:CGPointMake(20, 210)];
[layer addAnimation:positionAnimation forKey:nil];
运行一下:
瞧啊,这效果是不是很骚?
细心的同学可能这里会发现一个问题,我们实现的效果中,高光的长度看起来是跟着进度条长度一起进行了缩放,当进度条还比较短的时候,高光看起来也比较短。而原图中整个进度条动画的过程中,高光的长度都保持不变。这又是怎么一肥四呢。
其实在动画的过程中,每一帧都是对layer的属性进行新的赋值而已,这在我们讲CADisplayLink那一篇进行了详细的数学分析。既然如此,layer的宽在每一帧都是一个新的值,那么CAGradientLayer的渐变效果肯定会根据这个新的值重新进行渲染,我们设置的locations和colors决定了CAGradientLayer如何渲染,整个动画过程中虽然locations也在跟着改变,但是由于locations各个元素之间的间隔不变,所以每个颜色所占的比例是不变的,也就是说在整个动画过程中,颜色的分布规则一定是所有颜色平均分配整个宽度(比例不变且我们设置了各个颜色之间的间隔相等),且colors的数量不变,而动画过程中宽度在变,那么总结起来,我问你:N个颜色,颜色间隔(或者说就是每个颜色所占的宽度)为x,这N个颜色在总宽度w上平均分配,那么就有x = w/N。那么当w变大的时候,若N保持不变,x是不是会变大呢?对吧,这样的话,在动画过程中,w变大,高光的那一块就会随着动画一起变宽。
原图中高光的宽度是一直保持不变的,这就说明,在动画过程中,gradientLayer的宽度(上面公式中的w)是不变的,所以,真正添加动画的layer并不是gradientLayer,那么究竟是谁在动画呢?
我把完整代码贴出来大家自己品一品其中的奥义:
- (void)viewDidLoad {
[super viewDidLoad];
CAGradientLayer * layer = [CAGradientLayer layer];
layer.frame = CGRectMake(20, 200, 300, 20);
layer.colors = @[CGColorToNSObject([UIColor cyanColor]),CGColorToNSObject([UIColor yellowColor]), CGColorToNSObject([UIColor cyanColor]), CGColorToNSObject([UIColor yellowColor]),CGColorToNSObject([UIColor cyanColor])];
layer.startPoint = CGPointMake(0, 0);
layer.endPoint = CGPointMake(1, 0);
layer.locations = @[@-1,@-0.5,@0,@0.5,@1];
[self.view.layer addSublayer:layer];
CABasicAnimation * animation = [CABasicAnimation animation];
animation.keyPath = @"locations";
animation.duration = 2;
animation.fromValue = @[@-1,@-0.5,@0,@0.5,@1];
animation.toValue = @[@0,@0.5,@1,@1.5,@2];
animation.repeatCount = CGFLOAT_MAX;
[layer addAnimation:animation forKey:nil];
// 关键!
// 用一个新的layer作为gradientLayer的蒙版,然后给这个蒙版添加动画
// 蒙版的内容逐渐变宽的话,gradientLayer的内容看起来也就是逐渐变宽的效果
CALayer * mask = [CALayer layer];
mask.frame = layer.bounds;
mask.backgroundColor = [UIColor redColor].CGColor;
layer.mask = mask;
// 动画实际上是加给这个蒙版的
CABasicAnimation * boundsAnimation = [CABasicAnimation animation];
boundsAnimation.keyPath = @"bounds";
boundsAnimation.duration = 5;
// 长度从0开始
boundsAnimation.fromValue = [NSValue valueWithCGRect:CGRectMake(0, 0, 0, 20)];
// 根据modelLayer和presentationLayer的关系,我们不设置toValue则系统会自动把modelLayer的值作为toValue
[mask addAnimation:boundsAnimation forKey:nil];
CABasicAnimation * positionAnimation = [CABasicAnimation animation];
positionAnimation.keyPath = @"position";
positionAnimation.duration = 5;
// 这里要注意参考系为gradientLayer本身
positionAnimation.fromValue = [NSValue valueWithCGPoint:CGPointMake(20, 10)];
[mask addAnimation:positionAnimation forKey:nil];
}
这一波操作是不是666。
总结
在这一篇中,我们讲解了两个看起来比较复杂的进度条动画实现,大家下来可以对该效果进行封装。这里除了硬功夫以外,我希望大家还能学到一些软功夫,即我们遇到一些问题的时候应该如何去思考,如何根据现有的姿势解决看起来麻烦的问题,如何转变自己的思维,从全新的角度思考问题,希望大家能有所收获。