动画和绘图是iOS开发中非常重要的部分。我们要实现一个动效,首先就是动画解析,分析动画的路径,然后再考虑具体的代码。这需要一定的经验和Core Animation、Core Graphics等相关知识的基础。本文通过三个动画的demo来分析动画的实现。这几个demo已经放到了github上面,地址是:https://github.com/daixunry/DribbbleAnimationPractice
一、一个有趣的旋转动画###
这个旋转动画,是一个来自于dribbble的设计,非常的可爱,地址是:https://dribbble.com/shots/2064446-Refresh 看到的第一眼,我就打算把它实现了
拆解和分析####
这是一个结构比较简单的动画,它的点在于两个箭头不停的旋转,当旋转到一半的时候,会改变箭头的方向。
一般情况下,会使用路径(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/
这个动画的关键点就是正余弦函数。在听到这个的时候,我非常的震惊,原因是正余弦我们当初在高中的时候学习的知识,不过从来没有想过这些高中书本的知识竟然运用到了实际,非常的佩服kittenyang,同时感觉非常羞愧的,高中的知识都还给老师了,连正余弦的公式都忘记了。不熟悉的同学也可以去复习一下。
正弦型函数解析式:y=Asin(ωx+φ)+h
各常数值对函数图像的影响:
φ(初相位):决定波形与X轴位置关系或横向移动距离(左加右减)
ω:决定周期(最小正周期T=2π/|ω|)
A:决定峰值(即纵向拉伸压缩的倍数)
h:表示波形在Y轴的位置关系或纵向移动距离(上加下减)
拆解和分析####
好了,我们还是来拆解一下这个动画吧。两个波浪是两个正弦函数的效果叠加。首先我们看看该如何绘制一个波的曲线,如下图:
我们知道,计算机不可能绘制出一条完美的曲线,如果放大到像素的级别,可以看到这些曲线其实都是栅格的像素点组成。我们只能最大化的接近曲线,达到肉眼无法分辨的程度。如果想绘制出来一条正弦函数曲线,可以沿着假想的曲线绘制许多个点,然后把点逐一用直线连在一起,如果点足够多,就可以得到一条满足需求的曲线,这也是一种微分的思想。而这些点的位置可以通过正弦函数的解析式求得。
如果要绘制上面这个曲线,可以观察:波的峰值是1,周期是2π,初相位是0,h位移也是0。那么计算各个点的坐标公式就是y = sin(x);获得各个点的坐标之后,使用CGPathAddLineToPoint这个函数,把这些点逐一连成线,就可以得到最后的路径。
接下来问题来了,我们已经绘制了一条静态的曲线,如何让它形成一个流动的波呢?
可以这么思考:初始的曲线如上面所示,1s之后,希望曲线能成为下个形态:
接着,2s、3s...,曲线分别在不停的变化,如下图:
那么随着时间的流逝,这个曲线在不停的起伏变化,就形成了波动的效果。我们认真的想想,波动其实就是每一个点的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语言实现的版本,但是跟我的是完全不同的实现思路。
总体而言,这个动画的实现难度最大。因为它对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];
动画分析就说到这里,详细的细节可以参考源码。