欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

iOS CoreAnimation专题——实战篇(一)惊艳的进度条效果实现

程序员文章站 2024-02-05 20:48:22
...

终于到实战篇了,中间拖的有点久,主要是事情太多了,且这一篇有大量的抽象内容需要用文字来描述,真真是不好写啊,不过好歹是写完了,噢噢噢噢~

本篇的代码我进行了封装并放进了这个git仓库中,需要的同学自取。
https://github.com/DHUsesAll/DHProgressView

圆形渐变进度条

“那个,DH呀,你过来一下,这是我们的新需求,我们要做一个这样的效果出来。”产品经理把我叫了过去,并向我出示了美工sama刚完成的效果图,一个非常骚气的进度条效果,如图。

iOS CoreAnimation专题——实战篇(一)惊艳的进度条效果实现

啥玩意儿啊,咋回事儿啊,这咋整啊,旁边安卓兄弟也是一脸吃了翔的表情。

以上场景很可能就是大家工作的时候会遇到的情况,那么,如果真的遇到了,如图的进度条效果,你们能实现吗?

在学习了实战篇和技巧篇的内容后,我相信你也有了一些实现思路了,这里似乎缺了最后一块拼图:渐变效果的实现。

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];
}

效果:

iOS CoreAnimation专题——实战篇(一)惊艳的进度条效果实现

你可以随意修改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;

}

运行一下:

iOS CoreAnimation专题——实战篇(一)惊艳的进度条效果实现

似乎有了雏形了,但是有几个问题需要解决一下。首先是我的四条边怎么被啃了一部分?显然是gradientLayer的边长不够长,但是其边长刚好等于我们的gradientLayer路径圆形半径的两倍啊:

gradientLayer.frame = CGRectMake(80, 200, radius * 2, radius * 2);

要理解这个原因,又需要考验大家的抽象思维能力了,当然这里其实还是很好理解的。首先在脑海中想象出一条线,随便什么形状都可以,也可以是一个圆形,但是注意,它的宽为0,也就是它只是一条抽象的线,并没有实体(你可以想象成很细很细的虚线)。然后想象一只毛笔,去描这条虚线,落笔的点肯定在虚线上(毛笔的笔尖还是挺细的),那么我们用力画的时候,毛笔的笔尖被挤压,所以画出来的线是有宽度的,那么这个描出来的实线和那条抽象的线(虚线)之间存在怎样的位置关系呢?是不是一半在虚线的一端一半在虚线的另一端的(或者说是虚线沿着实线的路径把实线拆成了相等的两半)?所以我们的shapeLayer渲染出来的圆,它真正的外接矩形的边长要加上两个【线宽的一半】,应该是(半径+线宽/2)*2。

iOS CoreAnimation专题——实战篇(一)惊艳的进度条效果实现

实际上我们的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;
}

iOS CoreAnimation专题——实战篇(一)惊艳的进度条效果实现

这样就对了吧。嗯,和原图对比一下,好像还是有哪里不对。这时你就会发现我们之前的思路又要起作用了。我们按照思路的实现方式,是以shapeLayer作为gradientLayer的蒙版来实现的,那么请看着原图效果,在脑海中再次想想象出我们真正需要绘制的那个gradientLayer的模样是怎样的。我们一起来想象一下,嗯,我想象出来是这样的,你想出来了吗?

iOS CoreAnimation专题——实战篇(一)惊艳的进度条效果实现

为什么我能想出来是这个样子呢,沿着思路的核心:以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;
}

iOS CoreAnimation专题——实战篇(一)惊艳的进度条效果实现

感觉已经可以开香槟庆祝了!

第二个坑

眼睛尖的同学肯定能看见,诶,这个底部好像有条缝诶!

iOS CoreAnimation专题——实战篇(一)惊艳的进度条效果实现

这又是什么原因造成的呢?仔细看上面第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;
}

iOS CoreAnimation专题——实战篇(一)惊艳的进度条效果实现

已经几近完美,几乎没有瑕疵了。

添加动画

动画实际上就比较简单了,你可以在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];

π23π2而不是0到2π,自己下来结合技巧篇介绍shapeLayer和贝塞尔曲线那一篇想想为什么是这样吧。

高光渐变进度条

“那个,DH呀,你过来一下,我们又要做一个这样的进度条效果。”产品经理把我叫了过去,并向我展示了美工sama的脑洞。

iOS CoreAnimation专题——实战篇(一)惊艳的进度条效果实现

这又是什么鬼啊啊啊!

不过经历了第一轮的洗礼,我的动画实现能力有了一个飞跃,这个动画虽然看起来很炫酷,但是仔细分解一下,似乎是完全可以用我们之前学习过的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 CoreAnimation专题——实战篇(一)惊艳的进度条效果实现

好像有点感觉了也。现在我们要做的,就是如何让它动起来,还能一直循环循环的动下去且不违和。

动起来

动什么

首先我们来解决如何让这个高光动起来,在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];
}

iOS CoreAnimation专题——实战篇(一)惊艳的进度条效果实现

瞧啊,动起来了!至少我们朝这个方向去做肯定是没问题的,需要改善一下动画的实现逻辑就行了。

循环滚动且不违和

第一步,我们要找到滚动极限(动画结束)的地方,然后让这个地方的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];

iOS CoreAnimation专题——实战篇(一)惊艳的进度条效果实现

进度条动画

最后就是添加进度条动画了。这个看起来挺简单的诶,给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];
}

运行一下:

iOS CoreAnimation专题——实战篇(一)惊艳的进度条效果实现

诶,怎么是从中间开始往两边延伸的?和我们想象中的不一样啊,难道不应该从左边开始往右边延伸吗。

这里要注意的是,我们之前可能习惯了使用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];

运行一下:

iOS CoreAnimation专题——实战篇(一)惊艳的进度条效果实现

瞧啊,这效果是不是很骚?

细心的同学可能这里会发现一个问题,我们实现的效果中,高光的长度看起来是跟着进度条长度一起进行了缩放,当进度条还比较短的时候,高光看起来也比较短。而原图中整个进度条动画的过程中,高光的长度都保持不变。这又是怎么一肥四呢。

其实在动画的过程中,每一帧都是对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。

总结

在这一篇中,我们讲解了两个看起来比较复杂的进度条动画实现,大家下来可以对该效果进行封装。这里除了硬功夫以外,我希望大家还能学到一些软功夫,即我们遇到一些问题的时候应该如何去思考,如何根据现有的姿势解决看起来麻烦的问题,如何转变自己的思维,从全新的角度思考问题,希望大家能有所收获。