iOS核心动画--浅析三个动画的实现

动画和绘图是iOS开发中非常重要的部分。我们要实现一个动效,首先就是动画解析,分析动画的路径,然后再考虑具体的代码。这需要一定的经验和Core Animation、Core Graphics等相关知识的基础。本文通过三个动画的demo来分析动画的实现。这几个demo已经放到了github上面,地址是:https://github.com/daixunry/DribbbleAnimationPractice

一、一个有趣的旋转动画###

这个旋转动画,是一个来自于dribbble的设计,非常的可爱,地址是:https://dribbble.com/shots/2064446-Refresh 看到的第一眼,我就打算把它实现了

iOS核心动画--浅析三个动画的实现_第1张图片
refresh.gif

拆解和分析####

这是一个结构比较简单的动画,它的点在于两个箭头不停的旋转,当旋转到一半的时候,会改变箭头的方向。

一般情况下,会使用路径(path)来绘制整个圆弧和箭头,因为这样方便给箭头的方向改变添加动画;一共需要两组动画,一组是整个视图旋转,另一组是修改箭头方向的动画,这个动画可以在用于绘制箭头的CAShapeLayer上添加CAKeyframeAnimation,对layer的path属性做动画。

代码实现####

详细的代码可见
https://github.com/daixunry/DribbbleAnimationPractice。

首先是绘制的思路:

    1、两段圆弧,分别是2个CAShapeLayer;

    2、两个箭头,也分别是2个CAShapeLayer;
        
    3、创建一个view,把这几个CAShapeLayer作为子layer添加进来;
        
    4、给这几个CAShapeLayer添加path属性,也就是绘制的路径。

绘制完成之后开始做动画:

第一个动画是给整个视图做旋转,非常简单,方法有很多种,我这里面用的是CABasicAnimation,对它们共同的父layer的transform.rotation.z属性做动画。layer的transform是一个CATransform3D类型的属性,rotation.z就是绕着Z轴旋转。

第二个是修改箭头layer的path动画。

    CAKeyframeAnimation *aniChangePath1 = [CAKeyframeAnimation animationWithKeyPath:@"path"];
    aniChangePath1.values = @[(__bridge id)_startArrowPath1.CGPath,(__bridge id)_endArrowPath1.CGPath,(__bridge id)_endArrowPath1.CGPath];
    aniChangePath1.keyTimes = @[@(0.45),@.75,@.95];
    aniChangePath1.autoreverses = YES;
    aniChangePath1.repeatCount = NSIntegerMax;
    aniChangePath1.duration = 1;

我们可以观察箭头变换动画:整个先旋转半圈,然后箭头才开始变换方向,然后又变回来。那么CAKeyframeAnimation的values,路径初始值我们设定为箭头的初始path,在keyTimes设定先维持0.45s,也就是等旋转一段时间后才开始变化箭头的路径,这个也可以通过设置动画的beginTime实现,方法比较多,不多鳌述。我们设置autoreverses属性为YES,就是动画完成之后自动反转,在回到初始状态,往复循环。

就这样,这个简单的动画就实现了。但是其实动画的实现方式很多,这个方式也是可以优化的。

优化####

CAReplicatorLayer:复制图层。这个图层非常有趣,这里简单的介绍一下。它可以实现一种效果:复制它的subLayer,并做一些有规律的调整,我们看一些属性:

    //指定实体的个数,如果为10,那么它的每个subLayer会被复制,都拥有10个实例
    instanceCount  

    //修改复制出来的layer的动画时钟的延迟
    //通俗的讲,假如delay为1s,那么就会有这样的效果,如果我往它的sublayer添加一个动画,
    //这个sublayer的第一个副本的动画开始时间会是它本身1s之后执行,第二个副本的动画就是2s之后,依次类推 
    instanceDelay               

    //这个属性是CATransform3D类型,用来依次设置每一个复制的layer副本的偏移量
    //举个例子,假如该属性的值是CATransform3DMakeTranslation(100, 0, 0),第一个subLayer的x坐标假如是0,
    //第二个sublayer的x坐标就是100,依次类推
    instanceTransform           

    //同上面的其他属性相似,设置颜色的偏差
    instanceColor               
    ...

看到这里,我们就大概能猜到如何优化上面这个动画。对,我们利用复制图层,减少绘图和动画一半的代码量。设置复制图层的核心代码就是:

    _parLayer = [CAReplicatorLayer layer];
    _parLayer.frame = self.bounds;
    _parLayer.instanceCount = 2;
    _parLayer.instanceTransform = CATransform3DMakeRotation(M_PI, 0, 0, 1);

instanceCount为2,就是存在2个sublayer的实例,instanceTransform的值是沿着z轴旋转180度,我们往这个图层只添加一个圆弧和箭头,通过180度的翻转复制,就可以完成绘制。复制图层有一个关键的地方是,你往原始的图层中添加动画效果,复制出来的副本可以实时的复制这些动画效果。

二、网易新闻个人页面的水波效果###

网易新闻客户端的这个水波效果出来很久了,我考虑了很长时间该如何实现,但是都没有很好的办法,幸好在一个动画牛人kittenyang的帮助下,知道了实现这个动画最关键的点。他的blog上面有许多优秀的动画案例,非常值的学习,blog的地址是http://kittenyang.com/

iOS核心动画--浅析三个动画的实现_第2张图片
waveGif.gif

这个动画的关键点就是正余弦函数。在听到这个的时候,我非常的震惊,原因是正余弦我们当初在高中的时候学习的知识,不过从来没有想过这些高中书本的知识竟然运用到了实际,非常的佩服kittenyang,同时感觉非常羞愧的,高中的知识都还给老师了,连正余弦的公式都忘记了。不熟悉的同学也可以去复习一下。

正弦型函数解析式:y=Asin(ωx+φ)+h
各常数值对函数图像的影响:
φ(初相位):决定波形与X轴位置关系或横向移动距离(左加右减)
ω:决定周期(最小正周期T=2π/|ω|)
A:决定峰值(即纵向拉伸压缩的倍数)
h:表示波形在Y轴的位置关系或纵向移动距离(上加下减)

拆解和分析####

好了,我们还是来拆解一下这个动画吧。两个波浪是两个正弦函数的效果叠加。首先我们看看该如何绘制一个波的曲线,如下图:

iOS核心动画--浅析三个动画的实现_第3张图片
001

我们知道,计算机不可能绘制出一条完美的曲线,如果放大到像素的级别,可以看到这些曲线其实都是栅格的像素点组成。我们只能最大化的接近曲线,达到肉眼无法分辨的程度。如果想绘制出来一条正弦函数曲线,可以沿着假想的曲线绘制许多个点,然后把点逐一用直线连在一起,如果点足够多,就可以得到一条满足需求的曲线,这也是一种微分的思想。而这些点的位置可以通过正弦函数的解析式求得。

如果要绘制上面这个曲线,可以观察:波的峰值是1,周期是2π,初相位是0,h位移也是0。那么计算各个点的坐标公式就是y = sin(x);获得各个点的坐标之后,使用CGPathAddLineToPoint这个函数,把这些点逐一连成线,就可以得到最后的路径。

接下来问题来了,我们已经绘制了一条静态的曲线,如何让它形成一个流动的波呢?

可以这么思考:初始的曲线如上面所示,1s之后,希望曲线能成为下个形态:

iOS核心动画--浅析三个动画的实现_第4张图片
002

接着,2s、3s...,曲线分别在不停的变化,如下图:

iOS核心动画--浅析三个动画的实现_第5张图片
003

那么随着时间的流逝,这个曲线在不停的起伏变化,就形成了波动的效果。我们认真的想想,波动其实就是每一个点的y坐标都在不停的做着周期变化,想要实现上图1s之后的曲线形态,需要设置上面公式中的φ常量(初相位),假如φ是π/2,那么y=sin(x+φ)在x=0位置的时候,y的值就不在是0,而是1,就得到一条变化的曲线。通过上面的分析,我们知道,需要建立一个时间和φ的函数。

我们可以创建一个定时器(当然做动画我们肯定不会使用计时器,这里举个例子,下面详解),假设每秒让φ自增π/2,这样第4s的时候,φ等于2π(一个周期),y=sin(x+2π)和y=sin(x)等效,又回到了初初始状态,这样就完成了一个波动周期,往下继续加下去,不停的往复这个波动周期动画。

如果我们希望波动的非常剧烈,也就是波流速很快,那么我们可以让初相位随着时间的函数波动更快,就可以实现了。

代码实现####

把上面的原理落实到我们需要制作的动画上面。首先要总结出一个公式,确定正弦型函数解析式:y=Asin(ωx+φ)+h中各个常数的值。这里需要注意UIKit的坐标系统y轴是向下延伸。

    1、我们的容器高度是100,我希望波的整体高度,固定在容器的一个相对的位置。
      这里设置h = 30;也就是说,当Asin(ωx+φ)计算为0的时候,这个时候y的位置是30;
    2、决定波起伏的高度,我们设置波峰是5,波峰越大,曲线越陡峭;
    3、决定波的宽度和周期,比如,我们可以看到上面的例子中是一个周期的波曲线,
      一个波峰、一个波谷,如果我们想在0到2π这个距离显示2个完整的波曲线,那么周期就是π。
      我们这里设置波的宽度是容器的宽度_waveWidth,希望能展示2.5个波曲线,周期就是_waveWidth/2.5。
      那么ω常量就可以这样计算:2.5*M_PI/_waveWidth。
    4、一共有两个波曲线,形成一个落差,也就是设置不同的φ(初相位),我们这里设置落差是M_PI/4。
    5、时间和初相位的函数关系:我们在计时器的函数中一直调用_offset += _speed;
      可以看到,如果我们设置波的速度speed越大,波的震动将会越快。
    
    最后我们的公式如下:
    CGFloat y = _waveHeight*sinf(2.5*M_PI*i/_waveWidth + 3*_offset*M_PI/_waveWidth + M_PI/4) + _h;
    这些参数都可以自己调整,得到一个符合要求的效果。

现在我们解决了项目中最有难度的问题,剩下的事情就非常简单了。两个波是两个CAShapeLayer。我们使用CADisplayLink而不是计时器来驱动动画,因为CADisplayLink触发的时机是每隔一帧运行一次,而NSTimer不是很精确,会有阻塞的情况,照成动画卡顿的现象。

- (void)wave
{
    _link = [CADisplayLink displayLinkWithTarget:self selector:@selector(doAni)];
    [_link addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
}

- (void)doAni
{
    _offset += _speed;
    //设置第一条波曲线的路径
    CGMutablePathRef pathRef = CGPathCreateMutable();
    //起始点
    CGFloat startY = _waveHeight*sinf(_offset*M_PI/_waveWidth);
    CGPathMoveToPoint(pathRef, NULL, 0, startY);
    //第一个波的公式
    for (CGFloat i = 0.0; i < _waveWidth; i ++) {
        CGFloat y = 1.1*_waveHeight*sinf(2.5*M_PI*i/_waveWidth + _offset*M_PI/_waveWidth) + _h;
        CGPathAddLineToPoint(pathRef, NULL, i, y);
    }
    CGPathAddLineToPoint(pathRef, NULL, _waveWidth, 40);
    CGPathAddLineToPoint(pathRef, NULL, 0, 40);
    CGPathCloseSubpath(pathRef);
    //设置第一个波layer的path
    _layer.path = pathRef;
    _layer.fillColor = [UIColor lightGrayColor].CGColor;
    CGPathRelease(pathRef);
    
    //设置第二条波曲线的路径
    CGMutablePathRef pathRef2 = CGPathCreateMutable();
    CGFloat startY2 = _waveHeight*sinf(_offset*M_PI/_waveWidth + M_PI/4);
    CGPathMoveToPoint(pathRef2, NULL, 0, startY2);
    //第二个波曲线的公式
    for (CGFloat i = 0.0; i < _waveWidth; i ++) {
        CGFloat y = _waveHeight*sinf(2.5*M_PI*i/_waveWidth + 3*_offset*M_PI/_waveWidth + M_PI/4) + _h;
        CGPathAddLineToPoint(pathRef2, NULL, i, y);
    }
    CGPathAddLineToPoint(pathRef2, NULL, _waveWidth, 40);
    CGPathAddLineToPoint(pathRef2, NULL, 0, 40);
    CGPathCloseSubpath(pathRef2);
    
    _layer2.path = pathRef2;
    _layer2.fillColor = [UIColor lightGrayColor].CGColor;
    CGPathRelease(pathRef2);
}

我们可以看到,两个波曲线不但初相位不同,形成一个落差,而且相位随着时间的改变速度也不同,带来两个波的流速不同的视觉差异。CADisplayLink每帧都会调用wave方法,wave不停的改变着offset的值,也就是改变着初相位,最后形成了波动动画。

三、3D sideBar###

这是一个一度比较火的3D sideBar效果,我先是在微博上看到有人在发这个gif图,私下就抽时间完成了这个效果,后来发现它来自与http://www.raywenderlich.com/87268/3d-effect-taasky-swift ,是一个用swift语言实现的版本,但是跟我的是完全不同的实现思路。

iOS核心动画--浅析三个动画的实现_第6张图片
sideBar3d.gif

  总体而言,这个动画的实现难度最大。因为它对Core Animation和Core Graphics有一定的要求。我们可以来看一下它有哪些困难的地方。

拆解和分析####

要解决的问题总结一下,大体上有下面几个:

1、黄色sidebar区域的3D翻转效果。

2、黄色sidebar区域翻转的同时有阴影渐变效果。

3、黄色sidebar区域翻转的同时,绿色的contentView跟随右推的效果。

4、这个靠手势驱动的动画,在手势结束的时候,继续自动完成翻转动画的任务。

下面我们来逐一解决这些难点:

1、黄色sidebar区域的3D翻转效果:

我们可以尝试一下,自己做一个简单的翻转动画。可能会遇到的问题有:
系统默认的翻转是沿着视图的中心位置,如何沿着sidebar区域的右侧翻转?
如何有3D立体的效果?在翻转的同时,如何保持sidebar区域的左侧一直贴着屏幕的左侧?

沿着sidebar区域的右侧翻转比较简单,设置layer的anchorPoint为(1,0.5)即可。

3D翻转效果,需要这样设置:

    CATransform3D tran = CATransform3DIdentity;
    tran.m34 = -1/500.0;

iOS中的CALayer的3D本质上并不能算真正的3D,而只是3D在二维平面上的投影,m34的值添加了视点来决定平面投影,它的效果就是在投影平面上表现出近大远小,具体的原理可见 http://geeklu.com/2012/07/ios-3d-perspective/ 这一篇blog讲的特别好。

在给sidebar应用翻转变换的时候,需要加上这个效果,例如我们在初始化的时候,需要菜单被折叠起来,那么是这样设置的:

    //contaTran沿Y轴翻转是在tran的基础之上
    CATransform3D contaTran = CATransform3DRotate(tran,-M_PI_2, 0, 1, 0);
    
    //初始的位置是被折叠起来的,也就是上面的contaTran变换是沿着右侧翻转过去,但是我们需要翻转之后的位置是贴着屏幕左侧,于是需要一个位移
    CATransform3D contaTran2 = CATransform3DMakeTranslation(-self.frame.size.width, 0, 0);
    //两个变换的叠加
    _containView.layer.transform = CATransform3DConcat(contaTran, contaTran2);

翻转的同时,保持sidebar区域的左侧一直贴着屏幕的左侧:我们需要创建一个辅助视图,在动画中,创建辅助视图是比较常规的手段。

之所以这么做的原因是,翻转的时候,因为是沿着sidebar的右侧翻转,所以它的右侧是不动的。我们需要添加一个位移,这个位移可以保证翻转的同时,还在往右边移动,保持sidebar的左侧一直贴着屏幕的左侧,制造一种sidebar的左侧不动,右侧移动的效果。这个时候可以利用辅助视图,让这个辅助视图的大小和transform等属性和sidebar一致,再把翻转变换真正应用在sidebar之前,我们先把翻转应用到辅助视图上面,这个时候计算出当前辅助视图的宽度,这个宽度也是sidebar即将要旋转的时候的宽度,利用sidebar的总宽度减去辅助视图的宽度,就是sidebar需要位移的距离。这一点可以亲手实践,细细体会。

代码如下:

    //翻转矩阵
    CATransform3D contaTran = CATransform3DRotate(tran,-M_PI_2 + rota, 0, 1, 0);
    //先应用到辅助视图上面
    _containHelperView.layer.transform = contaTran;
    //根据辅助视图计算sidebar需要的位移矩阵
    CATransform3D contaTran2 = CATransform3DMakeTranslation(_containHelperView.frame.size.width - 100, 0, 0);
    //两个变换的叠加
    _containView.layer.transform = CATransform3DConcat(contaTran, contaTran2);

2、黄色sidebar区域翻转的同时有阴影渐变效果:

这个相对比较容易实现一点,渐变阴影使用单独的CAGradientLayer,添加到sidebar的子图层中,然后做CAGradientLayer的colors动画。

3、黄色sidebar区域翻转的同时,绿色的contentView跟随右推的效果:

经过刚才第一个问题的分析,我们知道,可以利用辅助视图解决这个问题。当前的辅助视图的宽度,就是绿色的contentView的位移距离。

    self.containerView.transform = CGAffineTransformMakeTranslation(_containHelperView.frame.size.width, 0);

4、这个靠手势驱动的动画,在手势结束的时候,继续自动完成翻转动画的任务:

我们设定一个阀值,手势结束的时候,判断当前是把动画做完,还是做撤销动画,比如打开的动作,还没有做到一半,需要添加动画效果,恢复到之前的状态。

代码实现####

其实上面的拆解和分析,已经把核心的代码都讲了,这里简单总结一下。

1、首先就是动画是靠手势来驱动的,根据pan手势的位移,控制动画的进度,例如,我们希望手势移动100point的时候,动画可以做完,那么就使用位移和100point的比率,来计算现在的变换矩阵。

2、根据当前的状态,是打开还是关闭菜单,和进度,来决定渐变阴影的深度,越是接近要打开的状态,阴影就会变浅,消失,越是要折叠起来,阴影越深。

3、菜单的翻转,和内容视图的推移动画的代码上面分析过了。手势结束的时候,做完剩下的动画,因为之前一直在用手势驱动计算变换矩阵,也就是说,并没有在各个视图上添加动画对象,而是不停的改变他们的transform属性。当手势结束的时候,要添加一个动画上来,完成剩余的动作:

    CABasicAnimation *tranAni = [CABasicAnimation animationWithKeyPath:@"transform"];
    tranAni.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseIn];
    tranAni.fromValue = [NSValue valueWithCATransform3D:_containView.layer.transform];
    tranAni.toValue = [NSValue valueWithCATransform3D:tran];
    tranAni.duration = .5;
    [_containView.layer addAnimation:tranAni forKey:@"openForContainAni"];
    _containView.layer.transform = tran;

优化####

我们前一种实现的方法,先是用手势驱动,然后在添加动画的组合方式,显得有一些乱。这里面,我们利用layer的一个属性speed可以进行优化。

layer的speed属性默认值是1,如果设置为2的话,那么动画的速度会提高一倍,如果设置为0的话,动画不会进行,处于停止状态。

layer还有一个属性,timeOffset,用来控制当前视图的状态处于动画的什么位置。举个例子:如果我们的speed设置为0,timeOffset设置为0.5,当前的视图就会呈现动画执行到一半的时候的视图状态。

这样,我们只需要在前期设置好各个视图的动画,把layer的speed设置为0,在根据手势的进度,设置layer的timeOffset。

不过我们需要注意两个问题,一个是手势结束我们需要在设置speed为1的时候,需要获取当前的视图Presentation tree的transform,并且更新到model tree,很简单,代码如下:

    _containerView.layer.transform = [[_containerView.layer.presentationLayer valueForKeyPath:@"transform"] CATransform3DValue];

还有一个问题是,我们给model tree赋值的时候,默认会有隐式动画的效果,我们需要禁止这种行为:

    [CATransaction setDisableActions:YES];

动画分析就说到这里,详细的细节可以参考源码。

你可能感兴趣的:(iOS核心动画--浅析三个动画的实现)