这次让我们来实现一个非常有意思的弹性视图效果,如图
这个例子主要是让大家对CADisplayLink有一个更深的理解,即CADisplayLink的“同步屏幕刷新”这个功能除了用来实现自定义的帧动画外,它更精妙的一些用法,帮助大家拓宽思路。
在我们这个例子中使用的一个拓展思路是:CADisplayLink可以用来监听系统动画效果的每一帧。有些动画效果我们自己去写的话可能难以实现,而系统也没有现成的动画可以使用,但是系统实现了一些和我们要的效果有些关联的动画,我们就可以使用CADisplayLink来监听系统的这些动画在每一帧都干了什么,再使用这些信息来辅助实现我们要的动画效果。
这段描述现在看起来可能会造成困惑,没关系,我们先实现这个例子,这样大家就明白我要表达什么意思了。
首先要说明的是,整个实践篇的目的在于“如何用已学会的姿势实现各种各样的效果”,所以我会在这里说明大量和思路相关的内容,让大家了解如何把你在原理篇和技巧篇中学到的零星的姿势点汇聚到一起,变成得力的工具,这样才能举一反三,使得大家今后遇到除此之外的效果,也能有实现的方向去思考,而不是看完这篇博客后,只会实现这篇博客介绍的效果。
所以我很可能不会像其他的博客那样,直接讲【实现的过程】,我会花大量的篇幅讲【思考的过程】。当然如果您觉得这样看起来比较没有体验(因为“显而易见的废话”可能确实会比较多,而我要确保每一步大家都能看懂并能想透),那可以直接跳过思路到详细设计,跳过思路的这部分对于您直接参考详细设计和实现部分的内容没有太大的影响。
那么就让我们开始吧(不知道为什么,看英文文章然后用中文做笔记多了以后,自己写博客也有一股子硬翻译的味道,但这确实是我的原创博客并不是翻译老外的(捂脸)!
拿到这个效果,首先仍然是想法对动画效果进行分解。在考虑思路的时候通常是由抽象到具体进行思考,我们通常通过直观的观察对效果有一个最基本的认识,然后再具体根据分解的效果来拆分更细的技术。推荐大家在思路不清晰的时候把思考的过程通过文字记录下来,这种操作有一个术语叫talk to your dog,原意是把你的问题【说出来】描述给你的狗听,虽然你的狗并不能听懂,但是你在描述问题的过程中往往能更好的理解问题,这是你在组织语言时收到来自大脑的回报,对找到解决方案有极大帮助,大家可以试一试。
所以首先直观描述的话,这个动画效果就是给视图的4条边添加弹性动画的效果。
然后我们再更具体一点的描述,可以这样拆分效果:
1、边从直线变形成曲线
2、变形的过程是具有弹性的效果
这样我们又进一步对上面的1进行分析:实际上边是从直线变形成二阶贝塞尔曲线。
那么既然是要变形成为二阶贝赛尔曲线(只有一个控制点),根据观察,贝赛尔曲线的起点和终点都固定不变(视图四个角上的点固定没有随着动画移动),肯定在变形的过程中只有控制点在(垂直于边的方向上)移动,当控制点移动的时候,每一帧根据新的控制点的位置重绘贝赛尔曲线就会有类似的动画效果了。根据2,控制点仿佛是黏在一个弹簧上跟着弹簧一起来回摆动,有一个弹性动画的效果。
分析到这一步,可能一些读者脑子里已经有了完整的对于这个效果的解决方案了。如果还没有,没关系,我们继续分析。
先总结一下我们已经分析出来的思路:
1、动画整体效果是四个边从直线变形成二阶贝塞尔曲线。
2、动画过程中实际上是贝赛尔曲线的控制点在垂直于边的方向上来回移动。
3、控制点的移动效果是具有弹性效果的。
4、在控制点移动的过程中根据新的控制点位置每帧重绘边的形状。
我们只要能实现以上四点,这个动画就能实现了。我们一个一个来看。
首先是1,动画整体效果是四个边从直线变形成二阶贝塞尔曲线。那么至少我们首先要想办法能绘制出二阶贝塞尔曲线,在看过我技巧篇中贝赛尔曲线那一篇博客后,应该很容易想到,使用CAShapeLayer+UIBezierPath就能轻松绘制了。不过值得一想的是,怎样让CAShapeLayer+UIBezierPath【成为】一个普通UIView的四条边呢?换一种说法的话会更清晰:也就是这个UIView的形状是由这个CAShapeLayer决定的。这样一说,看过我技巧篇讲解蒙版那一篇博客的朋友应该一下就能想到了吧:使用这个CAShapeLayer作为UIView的蒙版!也就是:
view.layer.mask = shapeLayer;
这样,我们的1就基本解决了,还差一步:“变形”。变形的问题在接下来的思路2中来解决。
好的我们马上来看2,动画过程中实际上是贝赛尔曲线的控制点在垂直于边的方向上来回移动。注意大家在分解思路考虑每一步时,不要去想其他步骤上的内容,比如这里的2,我们只关心控制点是这样垂直移动的,不要去考虑3的问题“在移动过程中怎么每帧重绘贝塞尔曲线啊”,我们只在这里考虑“如何移动控制点”,剩下的问题等到了那一步再考虑。
所以这里我们一下子在脑子里想到的肯定是考虑怎么给这个【控制点】加动画。因为控制点实际上是一个抽象的东西,在我们眼里它就是一个CGPoint,它不像一个视图那样看得见摸得着,这种抽象的虚无的玩意,一个CGPoint,它咋个加动画嘛。遇到这种问题,我们从结果入手,总之不管过程怎样,我们终究得想办法【加动画】对吧,那现在就像你抱怨的那样,我们直接调用系统原生动画API只能给一个看得见的玩意,比如CALayer啊,UIView啊加动画,好吧,既然我们无法改变环境,那就适应环境吧!我们既然只能给一个UIView加动画,而又必须要加动画,那就…没错,那就把一个UIView当做控制点就行了呀。但是控制点是看不见的呀,你弄一个UIView在那动,到时候效果出来不是很怪么,那就让这个UIView的背景色为透明就行了呗。但是我们的控制点就是一个CGPoint呀,那就取这个UIView的center给控制点赋值就OK咯。
这就是talk to your dog,把这些该死的问题用实在的语言描述出来而不是在脑子里空想,很容易就能简化问题见招拆招。
那总结一下咯,2如何解决呢,我们用比较小的UIView来模拟这些个二阶贝塞尔曲线的控制点,给UIView加动画就相当于控制点在移动了,而这些UIView的背景色是透明的,在贝赛尔曲线扭来扭去的时候用户根本就看不见控制点其实也在那摆来摆去,只有我们开发者知道,这是我们的小秘密!然后呢在这个控制点摆来摆去的时候我们可以取它的center作为那个真正的、抽象的控制点来使用。
我们已经解决一半了!并且现在我们的大脑已经热身完毕,正在飞速运转,赶紧趁现在一鼓作气搞定这些该死的问题!
看到弹性效果这几个字,作为老司机的笔者(当然读者您也有可能是一名老司机),一下子就想到了系统自带的弹性效果动画。没错我们的UIKit有自带的spring动画效果,想到这里似乎又小小的兴奋了一下呢,赶紧写一个简单的效果出来:
- (void)viewDidLoad {
[super viewDidLoad];
UIView * controlPointView = [[UIView alloc] initWithFrame:CGRectMake(300, 200, 10, 10)];
controlPointView.backgroundColor = [UIColor blackColor];
[self.view addSubview:controlPointView];
[UIView animateWithDuration:2 delay:0 usingSpringWithDamping:0.1 initialSpringVelocity:5 options:0 animations:^{
// 弹性地向下偏移20个像素
controlPointView.frame = CGRectOffset(controlPointView.frame, 0, 20);
} completion:^(BOOL finished) {
}];
}
效果是这样的:
关于这个方法的两个参数:damping和initial velocity,damping表示阻尼系数,initial velocity表示初速度,想象一个物体在弹簧上振动,如果没有初速度的话肯定是动不起来的。
我会在这篇文章末尾通过推导阻尼振动的运动力学方程来更加详细的说明这几个参数在动画中的意义,以加深各位对这几个参数的理解,感兴趣的朋友可以前往观看(包含的数学姿势有微分学基础、微分方程基础、牛顿第二定律,不掌握这些姿势也能看懂大概)。
回到思路上来,当我们看到上图的这个动画效果,再结合思路2,是不是我们的实现思路又更进一步了呢?思路2中说明了,我们需要使用一个UIView来模拟贝赛尔曲线的控制点,通过给这个UIView添加动画然后取其center的值来作为控制点的值。而要添加的动画是一个弹性效果的动画,而由思路3,我们知道了要给2中的用来模拟控制点的UIView添加的动画就是系统的这个自带的spring动画。
看到这个“每帧重绘”,首先要明白的是,动画本身就是“每帧重绘”的,所以你要实现某个“每帧重绘”的效果,先考虑系统自带的动画能不能达到这个效果。在我们这个情景中,我们要每帧重绘的是边的形状,更具体的说,每帧重绘CAShapeLayer的UIBezierPath。所以我们首先看看“每帧重绘CAShapeLayer的UIBezierPath”可不可以有什么系统自带的动画可以实现的,答案是有的,因为CAShapeLayer的path属性本身就是Animatable的,参考技巧篇中的UIBezierPath这一篇博客,里面还实现了一个使用CABasicAnimation来每帧重绘path的动画效果。
OK,既然我们可以直接给path添加动画,那么是不是可以直接使用CABasicAnimation来解决3呢?“在控制点移动的过程中根据新的控制点位置”这个条件明确的回答了我们:NO!因为CABasicAnimation的动画是要求我们传入插值的条件:ease函数+from+to+duration,然后系统自己来计算每一帧如何绘制,而在“在控制点移动的过程中根据新的控制点位置”这个条件下我们只能自己计算每一帧如何绘制,CABasicAnimation帮不了我们任何忙了。
好吧,那我们自己计算每一帧如何绘制,我咋个绘制呢?我们的主角到这里就要闪亮登场了,登登登登,CADisplayLink了解一下,当然如果你之前阅读了我的技巧篇的第一篇文章讲解CADisplayLink和线性插值以及基于缓冲函数的非线性插值,那么到这里应该能非常熟练的使用了。
CADisplayLink可以让我们同步屏幕的刷新,每当屏幕刷新的时候,系统会回调一个我们提供的回调方法,在这个回调方法中我们就可以想办法实现“每帧重绘”了,因为这个回调方法就是“每帧调用”的,所以只要在方法中实现重绘就好了。
现在我们解决了如何每帧重绘,这里就只剩最后一个问题了:根据新的控制点位置每帧重绘边的形状。
那么这个问题也可以分解,其实就是两步:1、每帧获取控制点当前的位置;2、根据这个控制点的位置重绘边的形状。所谓重绘边的形状,其实就是生成一个新的贝赛尔曲线重新赋值给作为蒙版的CAShapeLayer。
那么更进一步,我们可以在回调方法中尝试写一下伪代码(注意一下presentationLayer的用法):
// 这个方法是我们向CADisplayLink提供的回调方法
- (void)onDisplayLink
{
// 这里是每帧重绘的地方
// 获取新的控制点,一共有四个:
// 注意我们这是在弹性动画进行的过程中去获取点的值,还记得原理篇里面讲CALayer的模型层和展示层吗,动画过程中实际上只有presentationLayer在重绘,modelLayer也就是我们直接通过layer去取的值已经在终点等着了,所以为了取到动画过程中的layer的属性的实时的值,这里只能去取它presentationLayer的值
CGPoint control1 = self.topControlPointView.layer.presentationLayer.position;
CGPoint control2 = ...
CGPoint control3 = ...
CGPoint control4 = ...
// 根据新的控制点赋值
UIBezierPath * topPath = [UIBezierPath bezierPath];
// 从四边形的左上角拉一条二阶贝塞尔曲线到右上角
[topPath moveToPoint:leftTopPoint];
[topPath addQuadCurveToPoint:rightTopPoint controlPoint:control1];
// 把剩下三条线像这样画好,然后进行赋值
UIBezierPath * leftPath = ...
UIBezierPath * bottomPath = ...
UIBezierPath * rightPath = ...
UIBezierPath * path = [UIBezierPath bezierPath];
[path appendPath:topPath];
[path appendPath:leftPath];
[path appendPath:bottomPath];
[path appendPath:rightPath];
self.maskLayer.path = path.CGPath;
self.maskLayer.path = [self pathForMaskLayer];
}
我们这个效果中最核心的代码就实现好了。最后,我们来把上面1234四条思路结合起来,重新整理一下,然后简单的架构一下,就可以开始着手实现代码了。
我们冗长的思路分析终于结束了,通过这样的分析,我们找到了一条实现这个效果的路,并且关键的地方的代码都能写的出来,接下来我们就可以开始进行编码的详细设计,也就是在开始编写一些模块之前,在脑中或者在文档中把具体的类、属性、方法、方法之间的调用、调用流程等逻辑理一下,然后再动手编码。
首先根据我们的思路,考虑一下,我们需要一个UIView作为动画的主体,一个CAShapeLayer作为这个UIView的蒙版,然后需要4个UIView模拟四条边的控制点,这些是我们在思路上面已经能直接想到的。除此之外,我们其实还需要指定一些更深的细节:阻尼振动的振幅,这个是因为主体视图本身的frame肯定要比蒙版围成的矩形要大的,如果一样大,那在四条边振动的时候,凸出去的那部分内容就是空的了,具体看我的灵魂画板的解释。
如图,黑色的框是静止时我们所看到的内容,红色的框是动画过程中可能达到的最大振幅,所以我们的主体视图至少要能显示到最大振幅的地方,也就是蓝色框所表示的范围。
这样我们主体视图的frame就会设为蓝色框的部分,而黑色框部分的frame需要另外进行计算。如何计算呢?肯定需要先确定振幅了,这样我们可以先声明出所需的所有属性:
@interface ViewController ()
@property (nonatomic, strong) CAShapeLayer * maskLayer;
@property (nonatomic, strong) CADisplayLink * displayLink;
// 四条边的四个控制点
@property (nonatomic, strong) UIView * topControlPointView;
@property (nonatomic, strong) UIView * leftControlPointView;
@property (nonatomic, strong) UIView * bottomControlPointView;
@property (nonatomic, strong) UIView * rightControlPointView;
// 振幅
@property (nonatomic, assign) CGFloat amplitude;
// 视图静止时的frame(相对于父视图)
@property (nonatomic, assign) CGRect contentsFrame;
// 动画主体视图,因为存在交互事件,这里简单的声明为UIControl
@property (nonatomic, strong) UIControl * animationView;
@end
其中animationView和displayLink需要事先提供好回调方法:
#pragma mark - callback
- (void)touchDown
{
}
- (void)touchUp
{
}
- (void)onDisplayLink
{
}
这里我们将采用面向过程编程的思维来进行代码的编写,面向过程编程实际上就是函数之间的相互调用,把整个模块的业务逻辑拆分的各个小的功能和逻辑,把这些小的块用函数来实现,然后在主流程中迭代调用各个函数,这样模块的整体功能就实现了。说白一点,在你的主业务逻辑的函数中(比如你们在学习c语言的时候的main函数,当然这里我直接写到一个UIViewController里面的话就是viewDidLoad:方法以及上面声明的这三个负责处理交互的方法,因为对于一个UIViewController,它要做的就是处理显示和交互,显示的逻辑写到viewDidLoad:里面,交互的逻辑写到上面这三个方法里面,在这四个主逻辑方法中调用其他小的方法,整个主要业务逻辑就能完全实现了)把你要做的事情全部用函数调用来代替。
举个例子,我们在viewDidLoad中要干嘛呢?肯定是要初始化所有要用到的变量和属性,并且把该绘制的绘制好,那么大概就是两步,所以我们直接在viewDidLoad中写:
- (void)viewDidLoad {
[super viewDidLoad];
// 初始化所需的所有数据和属性
[self initializeDataSource];
// 绘制初始界面
[self initializeAppearance];
}
而至于-initializeDataSource和-initializeAppearance两个方法中干了什么,可以暂时不用管,我们在面向过程编程时采用自顶向下的思维方式,先考虑“架构”,再考虑实现,也就是优先考虑你需要声明和调用哪些方法和函数,(把所有需要的函数和方法都声明好了以后)最后再考虑这些函数里面该如何实现。当然你应该先把它们声明好,免得编译器报错,声明可以直接写到类的extension里面,因为这两个很明显是私有方法,也可以不用声明直接写一个空的实现出来:
- (void)initializeAppearance {}
- (void)initializeDataSource {}
在面向过程编程中,这样的“自顶向下”的思维是很重要的,就像上级下达指令一下,下级一层一层的,把该自己做的做了,不该自己做的,继续往下下达指令,迭代完成后,最上级的指令就实施完毕了。每一级只需考虑自己要做什么,以及要找下一级做什么。
这样viewDidLoad就实现好了(你不需要再管viewDidLoad了,只要你把initializeAppearance和initializeDataSource实现了,就相当于viewDidLoad实现完毕了,这样的思维其实就是面向过程编程的核心思想)。
接下来我们来看负责交互的三个主逻辑方法,我们同样按照面向过程的思维,考虑它们应该调用什么样的函数来实现它们所有的功能。我们的效果是,在动画的视图上按下,四条边开始“膨胀”,松开后,则弹性动画开始,而弹性动画实际上是控制点的弹性动画,真正改变边形状的地方是在displayLink里面。于是这三个方法里面要做的事情就显而易见了:
#pragma mark - callback
- (void)touchDown
{
// 按下,执行控制点的膨胀动画,也就是把控制点移到振幅的位置
// 同时开启displayLink,因为按下的一瞬间就应该开始监听控制点的位置改变了
[self startDisplayLink];
[self prepareForBounceAnimation];
}
- (void)touchUp
{
// 放开,执行控制点的弹性动画
[self bounceWithAnimation];
}
- (void)onDisplayLink
{
// 无论是按下还是放开,只要改变了控制点的位置,就应该根据最新的控制点重绘四条边的形状。
// 调用pathForMaskLayer来计算最新的path并赋值给蒙版layer
self.maskLayer.path = [self pathForMaskLayer];
}
接下来当然就是想办法实现-startDisplayLink、- prepareForBounceAnimation、- bounceWithAnimation和- pathForMaskLayer这几个方法了。
像这样逐步逐步的迭代调用下去,我们整个功能就能实现完毕了。接下来我再把重要的- bounceWithAnimation和- pathForMaskLayer这两个方法讲一下,剩下的就只有一些细节处理了,交给大家自己尝试去实现。
首先是bounceWithAnimation,这个方法是我们的控制点在振幅位置(因为之前调用了prepareForBounceAnimation,把控制点移到了振幅位置)振动回原来的位置。所以只需要给四个控制点加动画即可,然后在动画结束后停止CADisplayLink的监听,这里要注意控制点视图的父视图就是我们的animationView,animationView的frame参考上面我用灵魂画板画出来的东西,所以这四个控制点视图在初始位置的center应该是这样写:
- (void)bounceWithAnimation
{
[UIView animateWithDuration:0.45 delay:0 usingSpringWithDamping:0.15 initialSpringVelocity:5.5 options:0 animations:^{
[self positionControlPoints];
} completion:^(BOOL finished) {
[self stopDisplayLink];
}];
}
// 这个方法可以复用,因为在viewDidLoad里面也需要初始化这四个控制点的位置,在bounceWithAnimation里面的动画也可以调用这个方法来让四个控制点回到初始的位置。
- (void)positionControlPoints
{
self.topControlPointView.center = CGPointMake(CGRectGetMidX(self.animationView.bounds), self.amplitude);
self.leftControlPointView.center = CGPointMake(self.amplitude, CGRectGetMidY(self.animationView.bounds));
self.bottomControlPointView.center = CGPointMake(CGRectGetMidX(self.animationView.bounds), CGRectGetHeight(self.animationView.bounds) - self.amplitude);
self.rightControlPointView.center = CGPointMake(CGRectGetWidth(self.animationView.bounds) - self.amplitude, CGRectGetMidY(self.animationView.bounds));
}
接下来是pathForMaskLayer,这个方法里面根据当前四个控制点视图的center构造四条二阶贝赛尔曲线并合成一条曲线,然后根据这条曲线返回一个CGPathRef:
- (CGPathRef)pathForMaskLayer
{
// 视图可见部分的宽和高
CGFloat width = CGRectGetWidth(self.contentsFrame);
CGFloat height = CGRectGetHeight(self.contentsFrame);
// 获取四个控制点,这里通过四个控制点视图的presentationLayer来获取(参考原理篇讲解CALayer的模型与展示)
CGPoint topControlPoint = CGPointMake(width/2, [self.topControlPointView.layer.presentationLayer position].y - self.amplitude);
CGPoint rightControlPoint = CGPointMake([self.rightControlPointView.layer.presentationLayer position].x - self.amplitude, height/2);
CGPoint bottomControlPoint = CGPointMake(width/2, [self.bottomControlPointView.layer.presentationLayer position].y - self.amplitude);
CGPoint leftControlPoint = CGPointMake([self.leftControlPointView.layer.presentationLayer position].x - self.amplitude, height/2);
// 为一个UIBezierPath对象添加四条二阶贝塞尔曲线,不熟悉的话可以参考技巧篇讲解贝塞尔曲线的内容
UIBezierPath * bezierPath = [UIBezierPath bezierPath];
[bezierPath moveToPoint:CGPointZero];
[bezierPath addQuadCurveToPoint:CGPointMake(width, 0) controlPoint:topControlPoint];
[bezierPath addQuadCurveToPoint:CGPointMake(width, height) controlPoint:rightControlPoint];
[bezierPath addQuadCurveToPoint:CGPointMake(0, height) controlPoint:bottomControlPoint];
[bezierPath addQuadCurveToPoint:CGPointZero controlPoint:leftControlPoint];
return bezierPath.CGPath;
}
详细设计结束后,我们就可以直接把具体代码填进去了,注意一些细节即可。
#import "ViewController.h"
@interface ViewController ()
{
// contentsFrame基于animationView坐标系的frame
CGRect _privateContentsFrame;
}
@property (nonatomic, strong) CAShapeLayer * maskLayer;
@property (nonatomic, strong) CADisplayLink * displayLink;
@property (nonatomic, strong) UIView * topControlPointView;
@property (nonatomic, strong) UIView * leftControlPointView;
@property (nonatomic, strong) UIView * bottomControlPointView;
@property (nonatomic, strong) UIView * rightControlPointView;
// 振幅
@property (nonatomic, assign) CGFloat amplitude;
//
@property (nonatomic, assign) CGRect contentsFrame;
// 用来做动画效果测试的视图
@property (nonatomic, strong) UIControl * animationView;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
[self initializeDataSource];
[self initializeAppearance];
}
#pragma mark - callback
- (void)touchDown
{
// 按下,执行控制点的膨胀动画,也就是把控制点移到振幅的位置
// 同时开启displayLink,因为按下的一瞬间就应该开始监听控制点的位置改变了
if (!self.displayLink.paused) {
return;
}
[self startDisplayLink];
[self prepareForBounceAnimation];
}
- (void)touchUp
{
// 放开,执行控制点的弹性动画
[self bounceWithAnimation];
}
- (void)onDisplayLink
{
// 无论是按下还是放开,只要改变了控制点的位置,就应该根据最新的控制点重绘四条边的形状。
// 调用pathForMaskLayer来计算最新的path并赋值给蒙版layer
self.maskLayer.path = [self pathForMaskLayer];
}
#pragma mark - private methods
- (void)prepareForBounceAnimation
{
[UIView animateWithDuration:0.5 delay:0 usingSpringWithDamping:0.9 initialSpringVelocity:1.5 options:0 animations:^{
self.topControlPointView.frame = CGRectOffset(self.topControlPointView.frame, 0, -self.amplitude);
self.leftControlPointView.frame = CGRectOffset(self.leftControlPointView.frame, -self.amplitude, 0);
self.bottomControlPointView.frame = CGRectOffset(self.bottomControlPointView.frame, 0, self.amplitude);
self.rightControlPointView.frame = CGRectOffset(self.rightControlPointView.frame, self.amplitude, 0);
} completion:^(BOOL finished) {
}];
}
- (void)bounceWithAnimation
{
[UIView animateWithDuration:0.45 delay:0 usingSpringWithDamping:0.15 initialSpringVelocity:5.5 options:0 animations:^{
[self positionControlPoints];
} completion:^(BOOL finished) {
[self stopDisplayLink];
}];
}
- (void)startDisplayLink
{
self.displayLink.paused = NO;
}
- (void)stopDisplayLink
{
self.displayLink.paused = YES;
}
- (CGPathRef)pathForMaskLayer
{
// 视图可见部分的宽和高
CGFloat width = CGRectGetWidth(self.contentsFrame);
CGFloat height = CGRectGetHeight(self.contentsFrame);
// 获取四个控制点,这里通过四个控制点视图的presentationLayer来获取(参考原理篇讲解CALayer的模型与展示)
CGPoint topControlPoint = CGPointMake(width/2, [self.topControlPointView.layer.presentationLayer position].y - self.amplitude);
CGPoint rightControlPoint = CGPointMake([self.rightControlPointView.layer.presentationLayer position].x - self.amplitude, height/2);
CGPoint bottomControlPoint = CGPointMake(width/2, [self.bottomControlPointView.layer.presentationLayer position].y - self.amplitude);
CGPoint leftControlPoint = CGPointMake([self.leftControlPointView.layer.presentationLayer position].x - self.amplitude, height/2);
// 为一个UIBezierPath对象添加四条二阶贝塞尔曲线,不熟悉的话可以参考技巧篇讲解贝塞尔曲线的内容
UIBezierPath * bezierPath = [UIBezierPath bezierPath];
[bezierPath moveToPoint:CGPointZero];
[bezierPath addQuadCurveToPoint:CGPointMake(width, 0) controlPoint:topControlPoint];
[bezierPath addQuadCurveToPoint:CGPointMake(width, height) controlPoint:rightControlPoint];
[bezierPath addQuadCurveToPoint:CGPointMake(0, height) controlPoint:bottomControlPoint];
[bezierPath addQuadCurveToPoint:CGPointZero controlPoint:leftControlPoint];
return bezierPath.CGPath;
}
/**
* 通过contentsFrame和interval确定自己的frame
*/
- (void)updateFrame
{
CGFloat x = self.contentsFrame.origin.x - self.amplitude;
CGFloat y = self.contentsFrame.origin.y - self.amplitude;
CGFloat width = self.contentsFrame.size.width + 2 * self.amplitude;
CGFloat height = self.contentsFrame.size.height + 2 * self.amplitude;
self.animationView.frame = CGRectMake(x, y, width, height);
_privateContentsFrame = CGRectMake(self.amplitude, self.amplitude, CGRectGetWidth(self.contentsFrame), CGRectGetHeight(self.contentsFrame));
self.maskLayer.frame = _privateContentsFrame;
}
- (void)initializeDataSource
{
self.contentsFrame = CGRectMake(80, 80, 200, 80);
self.amplitude = 15;
}
- (void)initializeAppearance
{
[self updateFrame];
[self.view addSubview:self.animationView];
for (UIView * view in @[self.topControlPointView, self.leftControlPointView, self.bottomControlPointView, self.rightControlPointView]) {
// view.backgroundColor = [UIColor yellowColor];
view.frame = CGRectMake(0, 0, 5, 5);
[self.animationView addSubview:view];
}
[self positionControlPoints];
self.animationView.layer.mask = self.maskLayer;
}
/**
* 把四个控制点还原到起始位置(在初始化的时候也要调用,让它们一开始就在起始位置)
*/
- (void)positionControlPoints
{
self.topControlPointView.center = CGPointMake(CGRectGetMidX(self.animationView.bounds), self.amplitude);
self.leftControlPointView.center = CGPointMake(self.amplitude, CGRectGetMidY(self.animationView.bounds));
self.bottomControlPointView.center = CGPointMake(CGRectGetMidX(self.animationView.bounds), CGRectGetHeight(self.animationView.bounds) - self.amplitude);
self.rightControlPointView.center = CGPointMake(CGRectGetWidth(self.animationView.bounds) - self.amplitude, CGRectGetMidY(self.animationView.bounds));
}
#pragma mark - getter
- (UIView *)topControlPointView
{
if (!_topControlPointView) {
_topControlPointView = [[UIView alloc] init];
}
return _topControlPointView;
}
- (UIView *)leftControlPointView
{
if (!_leftControlPointView) {
_leftControlPointView = [[UIView alloc] init];
}
return _leftControlPointView;
}
- (UIView *)bottomControlPointView
{
if (!_bottomControlPointView) {
_bottomControlPointView = [[UIView alloc] init];
}
return _bottomControlPointView;
}
- (UIView *)rightControlPointView
{
if (!_rightControlPointView) {
_rightControlPointView = [[UIView alloc] init];
}
return _rightControlPointView;
}
- (UIControl *)animationView
{
if (!_animationView) {
_animationView = ({
UIControl * view = [[UIControl alloc] initWithFrame:CGRectMake(80, 80, 200, 80)];
view.backgroundColor = [UIColor blackColor];
[view addTarget:self action:@selector(touchDown) forControlEvents:UIControlEventTouchDown];
[view addTarget:self action:@selector(touchUp) forControlEvents:UIControlEventTouchUpInside];
// 用户按下后发生手势响应中断的情况,比如按下后还没放开呢,突然来了个电话。
[view addTarget:self action:@selector(touchUp) forControlEvents:UIControlEventTouchCancel];
// 用户按下后,保持按住的状态把手指移到视图外部再放开的情况
[view addTarget:self action:@selector(touchUp) forControlEvents:UIControlEventTouchUpOutside];
view;
});
}
return _animationView;
}
- (CAShapeLayer *)maskLayer
{
if (!_maskLayer) {
_maskLayer = ({
CAShapeLayer * layer = [CAShapeLayer layer];
layer.fillColor = [UIColor redColor].CGColor;
layer.backgroundColor = [UIColor clearColor].CGColor;
layer.strokeColor = [UIColor clearColor].CGColor;
layer.frame = _privateContentsFrame;
layer.path = [UIBezierPath bezierPathWithRect:layer.bounds].CGPath;
layer;
});
}
return _maskLayer;
}
- (CADisplayLink *)displayLink
{
if (!_displayLink) {
_displayLink = ({
CADisplayLink * displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(onDisplayLink)];
displayLink.paused = YES;
[displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];
[displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:UITrackingRunLoopMode];
displayLink;
});
}
return _displayLink;
}
@end
我把这个效果进行了封装,大家可以把效果添加到任意的视图上,代码地址在这个git仓库里:
DHBounceEffect
代码是我很早就写好的了,所以这个封装版本的代码可能和博客里面的有些许不太一样,就是一些设计上的不同而已,主要的代码是一模一样的。
这里简单对阻尼振动的动力学方程做一个介绍,了解微分方程和经典物理学的朋友可以用来当做理解UIKit的spring动画方法中的damping、initial velocity这两个参数的参考,不感兴趣的同学当然可以略过。
首先考虑一个物体在弹簧上做阻尼振动,该物体受到的合力由两个力叠加:一是弹簧本身的弹力 f弹=−kx f 弹 = − k x ,一是阻尼力(空气阻力或者滑动摩擦力或者液体阻力等等),阻尼力与物体振动的速度成正比,也就是物体振动的过程中速度越快,受到的阻尼力就越大: f=−Cv f = − C v ,其中k为弹性系数,C为阻尼力系数。
那么根据牛顿第二定律有:
由于阻尼振动整个过程的速度和加速度都在无时无刻变化着,所以速度和加速度我们可以用微分来表示,也就是
那么带入牛顿第二定律的方程就得到
因为我们想要得到物体振动的运动力学方程,也就是物体的振动的位移和时间的关系函数: x(t) x ( t ) ,而这里恰好函数的一阶导数为: x′(t)=dxdt x ′ ( t ) = d x d t ,二阶导数为 x″(t)=d2xdt2 x ″ ( t ) = d 2 x d t 2 ,所以上述方程就是一个微分方程了:
这个微分方程的解就是我们要得到的 x(t) x ( t ) 的函数表达式。
这是一个二阶线性常系数齐次方程,解微分方程的过程这里就不赘述了,我们可以通过这个微分方程的通解得到弹簧形变量与时间的函数:
其中
这就是动力学方程了,其中 A和φ A 和 φ 为待定常数,δ就是方法要传入的第一个参数damping,以及另一个变量ω,只要我们确定了这四个系数,那么整个弹簧阻尼振动的运动就可以用这个方程来描述了。现在我们来看如何确定 A、φ和ω A 、 φ 和 ω
首先由 x=Ae−δtcos(ωt+φ) x = A e − δ t cos ( ω t + φ ) 计算x对t求导得到速度和时间的函数
令 x(0)=x0,v(0)=v0 x ( 0 ) = x 0 , v ( 0 ) = v 0 ,也就是在我们的动画过程中,考虑动画开始的那一刻,也就是t=0的时候,初速度为 v0 v 0 ,弹簧形变量为 x0 x 0 。
那么可以计算出
而初速度 v0 v 0 就是我们方法传入的第二个参数,可以作为已知量来使用,那么我们使用 v0 v 0 反解出 A A 和 φ φ :首先 v0x0=−δ+ωtanφ v 0 x 0 = − δ + ω tan φ 消除A得到
然后把 A A 和 φ φ 带入x(t)得到运动力学方程为
这样我们就知道动画的物体在每个时刻的x位置。这里的x指的是弹簧的形变量,因为最终物体静止后的位置弹性势能肯定为0,也就是弹簧形变量为0的位置,所以x指的就是物体当前位置与物体最终停止的位置的位移。
在这个函数的表达式中存在除了自变量t以外的其他系数,这些系数通过spring动画的方法参数–duration(持续时间)、damping(阻尼系数)、initial velocity(初速度)来确定。
δ就是damping, v0 v 0 就是初速度, x0 x 0 是运动物体的初位移,也就是弹簧的初形变量,比如你要让一个视图从80开始阻尼振动到100结束, x0 x 0 就是100-80=20,因为100的位置是弹簧原长的位置,80就是弹簧形变量为20的位置,ω由δ和duration确定。
这样我们的运动力学最终的方程就能确定下来了。
顺便一提,阻尼振动停止的时刻是物体机械能为0的时刻,我们可以先求出动能和势能: