CoreAnimation5-图层时间和缓冲
图层时间
动画的发生是需要持续一段时间的,所以计时对整个概念来说至关重要。在这一章中,我们来看看CAMediaTiming,看看Core Animation是如何跟踪时间的。
CAMediaTiming协议
CAMediaTiming协议定义了在一段动画内用来控制逝去时间的属性的集合,CALayer和CAAnimation都实现了这个协议,所以时间可以被任意基于一个图层或者一段动画的类控制。
持续和重复
我们在“显式动画”中简单提到过duration(CAMediaTiming的属性之一),duration是一个CFTimeInterval的类型(类似于NSTimeInterval的一种双精度浮点类型),对将要进行的动画的一次迭代指定了时间。
这里的一次迭代是什么意思呢?CAMediaTiming另外还有一个属性叫做repeatCount,代表动画重复的迭代次数。如果duration是2,repeatCount设为3.5(三个半迭代),那么完整的动画时长将是7秒。
duration和repeatCount默认都是0。但这不意味着动画时长为0秒,或者0次,这里的0仅仅代表了“默认”,也就是0.25秒和1次,你可以用一个简单的测试来尝试为这两个属性赋多个值,如清单9.1,图9.1展示了程序的结果。
清单9.1 测试duration和repeatCount
@interface ViewController () @property (nonatomic, weak) IBOutlet UIView *containerView; @property (nonatomic, weak) IBOutlet UITextField *durationField; @property (nonatomic, weak) IBOutlet UITextField *repeatField; @property (nonatomic, weak) IBOutlet UIButton *startButton; @property (nonatomic, strong) CALayer *shipLayer; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //add the ship self.shipLayer = [CALayer layer]; self.shipLayer.frame = CGRectMake(0, 0, 128, 128); self.shipLayer.position = CGPointMake(150, 150); self.shipLayer.contents = (__bridge id)[UIImage imageNamed: @"Ship.png"].CGImage; [self.containerView.layer addSublayer:self.shipLayer]; } - (void)setControlsEnabled:(BOOL)enabled { for (UIControl *control in @[self.durationField, self.repeatField, self.startButton]) { control.enabled = enabled; control.alpha = enabled? 1.0f: 0.25f; } } - (IBAction)hideKeyboard { ?[self.durationField resignFirstResponder]; [self.repeatField resignFirstResponder]; } - (IBAction)start { CFTimeInterval duration = [self.durationField.text doubleValue]; float repeatCount = [self.repeatField.text floatValue]; //animate the ship rotation CABasicAnimation *animation = [CABasicAnimation animation]; animation.keyPath = @"transform.rotation"; animation.duration = duration; animation.repeatCount = repeatCount; animation.byValue = @(M_PI * 2); animation.delegate = self; [self.shipLayer addAnimation:animation forKey:@"rotateAnimation"]; //disable controls [self setControlsEnabled:NO]; } - (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)flag { //reenable controls [self setControlsEnabled:YES]; } @end
图9.1 演示duration和repeatCount的测试程序
创建重复动画的另一种方式是使用repeatDuration属性,它让动画重复一个指定的时间,而不是指定次数。你甚至设置一个叫做autoreverses的属性(BOOL类型)在每次间隔交替循环过程中自动回放。这对于播放一段连续非循环的动画很有用,例如打开一扇门,然后关上它(图9.2)。
图9.2 摆动门的动画
对门进行摆动的代码见清单9.2。我们用了autoreverses来使门在打开后自动关闭,在这里我们把repeatDuration设置为INFINITY,于是动画无限循环播放,设置repeatCount为INFINITY也有同样的效果。注意repeatCount和repeatDuration可能会相互冲突,所以你只要对其中一个指定非零值。对两个属性都设置非0值的行为没有被定义。
清单9.2 使用autoreverses属性实现门的摇摆
@interface ViewController () @property (nonatomic, weak) UIView *containerView; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //add the door CALayer *doorLayer = [CALayer layer]; doorLayer.frame = CGRectMake(0, 0, 128, 256); doorLayer.position = CGPointMake(150 - 64, 150); doorLayer.anchorPoint = CGPointMake(0, 0.5); doorLayer.contents = (__bridge id)[UIImage imageNamed: @"Door.png"].CGImage; [self.containerView.layer addSublayer:doorLayer]; //apply perspective transform CATransform3D perspective = CATransform3DIdentity; perspective.m34 = -1.0 / 500.0; self.containerView.layer.sublayerTransform = perspective; //apply swinging animation CABasicAnimation *animation = [CABasicAnimation animation]; animation.keyPath = @"transform.rotation.y"; animation.toValue = @(-M_PI_2); animation.duration = 2.0; animation.repeatDuration = INFINITY; animation.autoreverses = YES; [doorLayer addAnimation:animation forKey:nil]; } @end
相对时间
每次讨论到Core Animation,时间都是相对的,每个动画都有它自己描述的时间,可以独立地加速,延时或者偏移。
beginTime指定了动画开始之前的的延迟时间。这里的延迟从动画添加到可见图层的那一刻开始测量,默认是0(就是说动画会立刻执行)。
speed是一个时间的倍数,默认1.0,减少它会减慢图层/动画的时间,增加它会加快速度。如果2.0的速度,那么对于一个duration为1的动画,实际上在0.5秒的时候就已经完成了。
timeOffset和beginTime类似,但是和增加beginTime导致的延迟动画不同,增加timeOffset只是让动画快进到某一点,例如,对于一个持续1秒的动画来说,设置timeOffset为0.5意味着动画将从一半的地方开始。
和beginTime不同的是,timeOffset并不受speed的影响。所以如果你把speed设为2.0,把timeOffset设置为0.5,那么你的动画将从动画最后结束的地方开始,因为1秒的动画实际上被缩短到了0.5秒。然而即使使用了timeOffset让动画从结束的地方开始,它仍然播放了一个完整的时长,这个动画仅仅是循环了一圈,然后从头开始播放。
可以用清单9.3的测试程序验证一下,设置speed和timeOffset滑块到随意的值,然后点击播放来观察效果(见图9.3)
清单9.3 测试timeOffset和speed属性
@interface ViewController () @property (nonatomic, weak) IBOutlet UIView *containerView; @property (nonatomic, weak) IBOutlet UILabel *speedLabel; @property (nonatomic, weak) IBOutlet UILabel *timeOffsetLabel; @property (nonatomic, weak) IBOutlet UISlider *speedSlider; @property (nonatomic, weak) IBOutlet UISlider *timeOffsetSlider; @property (nonatomic, strong) UIBezierPath *bezierPath; @property (nonatomic, strong) CALayer *shipLayer; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //create a path self.bezierPath = [[UIBezierPath alloc] init]; [self.bezierPath moveToPoint:CGPointMake(0, 150)]; [self.bezierPath addCurveToPoint:CGPointMake(300, 150) controlPoint1:CGPointMake(75, 0) controlPoint2:CGPointMake(225, 300)]; //draw the path using a CAShapeLayer CAShapeLayer *pathLayer = [CAShapeLayer layer]; pathLayer.path = self.bezierPath.CGPath; pathLayer.fillColor = [UIColor clearColor].CGColor; pathLayer.strokeColor = [UIColor redColor].CGColor; pathLayer.lineWidth = 3.0f; [self.containerView.layer addSublayer:pathLayer]; //add the ship self.shipLayer = [CALayer layer]; self.shipLayer.frame = CGRectMake(0, 0, 64, 64); self.shipLayer.position = CGPointMake(0, 150); self.shipLayer.contents = (__bridge id)[UIImage imageNamed: @"Ship.png"].CGImage; [self.containerView.layer addSublayer:self.shipLayer]; //set initial values [self updateSliders]; } - (IBAction)updateSliders { CFTimeInterval timeOffset = self.timeOffsetSlider.value; self.timeOffsetLabel.text = [NSString stringWithFormat:@"%0.2f", imeOffset]; float speed = self.speedSlider.value; self.speedLabel.text = [NSString stringWithFormat:@"%0.2f", speed]; } - (IBAction)play { //create the keyframe animation CAKeyframeAnimation *animation = [CAKeyframeAnimation animation]; animation.keyPath = @"position"; animation.timeOffset = self.timeOffsetSlider.value; animation.speed = self.speedSlider.value; animation.duration = 1.0; animation.path = self.bezierPath.CGPath; animation.rotationMode = kCAAnimationRotateAuto; animation.removedOnCompletion = NO; [self.shipLayer addAnimation:animation forKey:@"slide"]; } @end
图9.3 测试时间偏移和速度的简单的应用程序
fillMode
对于beginTime非0的一段动画来说,会出现一个当动画添加到图层上但什么也没发生的状态。类似的,removeOnCompletion被设置为NO的动画将会在动画结束的时候仍然保持之前的状态。这就产生了一个问题,当动画开始之前和动画结束之后,被设置动画的属性将会是什么值呢?
一种可能是属性和动画没被添加之前保持一致,也就是在模型图层定义的值(见第七章“隐式动画”,模型图层和呈现图层的解释)。
另一种可能是保持动画开始之前那一帧,或者动画结束之后的那一帧。这就是所谓的填充,因为动画开始和结束的值用来填充开始之前和结束之后的时间。
这种行为就交给开发者了,它可以被CAMediaTiming的fillMode来控制。fillMode是一个NSString类型,可以接受如下四种常量:
kCAFillModeForwards
kCAFillModeBackwards
kCAFillModeBoth
kCAFillModeRemoved
默认是kCAFillModeRemoved,当动画不再播放的时候就显示图层模型指定的值剩下的三种类型向前,向后或者即向前又向后去填充动画状态,使得动画在开始前或者结束后仍然保持开始和结束那一刻的值。
这就对避免在动画结束的时候急速返回提供另一种方案(见第八章)。但是记住了,当用它来解决这个问题的时候,需要把removeOnCompletion设置为NO,另外需要给动画添加一个非空的键,于是可以在不需要动画的时候把它从图层上移除。
层级关系时间
在第三章“图层几何学”中,你已经了解到每个图层是如何相对在图层树中的父图层定义它的坐标系的。动画时间和它类似,每个动画和图层在时间上都有它自己的层级概念,相对于它的父亲来测量。对图层调整时间将会影响到它本身和子图层的动画,但不会影响到父图层。另一个相似点是所有的动画都被按照层级组合(使用CAAnimationGroup实例)。
对CALayer或者CAGroupAnimation调整duration和repeatCount/repeatDuration属性并不会影响到子动画。但是beginTime,timeOffset和speed属性将会影响到子动画。然而在层级关系中,beginTime指定了父图层开始动画(或者组合关系中的父动画)和对象将要开始自己动画之间的偏移。类似的,调整CALayer和CAGroupAnimation的speed属性将会对动画以及子动画速度应用一个缩放的因子。
全局时间和本地时间
CoreAnimation有一个全局时间的概念,也就是所谓的马赫时间(“马赫”实际上是iOS和Mac OS系统内核的命名)。马赫时间在设备上所有进程都是全局的--但是在不同设备上并不是全局的--不过这已经足够对动画的参考点提供便利了,你可以使用CACurrentMediaTime函数来访问马赫时间:
CFTimeInterval time = CACurrentMediaTime();
这个函数返回的值其实无关紧要(它返回了设备自从上次启动后的秒数,并不是你所关心的),它真实的作用在于对动画的时间测量提供了一个相对值。注意当设备休眠的时候马赫时间会暂停,也就是所有的CAAnimations(基于马赫时间)同样也会暂停。
因此马赫时间对长时间测量并不有用。比如用CACurrentMediaTime去更新一个实时闹钟并不明智。(可以用[NSDate date]代替,就像第三章例子所示)。
每个CALayer和CAAnimation实例都有自己本地时间的概念,是根据父图层/动画层级关系中的beginTime,timeOffset和speed属性计算。就和转换不同图层之间坐标关系一样,CALayer同样也提供了方法来转换不同图层之间的本地时间。如下:
- (CFTimeInterval)convertTime:(CFTimeInterval)t fromLayer:(CALayer *)l;
- (CFTimeInterval)convertTime:(CFTimeInterval)t toLayer:(CALayer *)l;
当用来同步不同图层之间有不同的speed,timeOffset和beginTime的动画,这些方法会很有用。
暂停,倒回和快进
设置动画的speed属性为0可以暂停动画,但在动画被添加到图层之后不太可能再修改它了,所以不能对正在进行的动画使用这个属性。给图层添加一个CAAnimation实际上是给动画对象做了一个不可改变的拷贝,所以对原始动画对象属性的改变对真实的动画并没有作用。相反,直接用-animationForKey:来检索图层正在进行的动画可以返回正确的动画对象,但是修改它的属性将会抛出异常。
如果移除图层正在进行的动画,图层将会急速返回动画之前的状态。但如果在动画移除之前拷贝呈现图层到模型图层,动画将会看起来暂停在那里。但是不好的地方在于之后就不能再恢复动画了。
一个简单的方法是可以利用CAMediaTiming来暂停图层本身。如果把图层的speed设置成0,它会暂停任何添加到图层上的动画。类似的,设置speed大于1.0将会快进,设置成一个负值将会倒回动画。
通过增加主窗口图层的speed,可以暂停整个应用程序的动画。这对UI自动化提供了好处,我们可以加速所有的视图动画来进行自动化测试(注意对于在主窗口之外的视图并不会被影响,比如UIAlertview)。可以在app delegate设置如下进行验证:
self.window.layer.speed = 100;
你也可以通过这种方式来减速,但其实也可以在模拟器通过切换慢速动画来实现。
手动动画
timeOffset一个很有用的功能在于你可以它可以让你手动控制动画进程,通过设置speed为0,可以禁用动画的自动播放,然后来使用timeOffset来来回显示动画序列。这可以使得运用手势来手动控制动画变得很简单。
举个简单的例子:还是之前关门的动画,修改代码来用手势控制动画。我们给视图添加一个UIPanGestureRecognizer,然后用timeOffset左右摇晃。
因为在动画添加到图层之后不能再做修改了,我们来通过调整layer的timeOffset达到同样的效果(清单9.4)。
清单9.4 通过触摸手势手动控制动画
@interface ViewController () @property (nonatomic, weak) UIView *containerView; @property (nonatomic, strong) CALayer *doorLayer; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //add the door self.doorLayer = [CALayer layer]; self.doorLayer.frame = CGRectMake(0, 0, 128, 256); self.doorLayer.position = CGPointMake(150 - 64, 150); self.doorLayer.anchorPoint = CGPointMake(0, 0.5); self.doorLayer.contents = (__bridge id)[UIImage imageNamed:@"Door.png"].CGImage; [self.containerView.layer addSublayer:self.doorLayer]; //apply perspective transform CATransform3D perspective = CATransform3DIdentity; perspective.m34 = -1.0 / 500.0; self.containerView.layer.sublayerTransform = perspective; //add pan gesture recognizer to handle swipes UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] init]; [pan addTarget:self action:@selector(pan:)]; [self.view addGestureRecognizer:pan]; //pause all layer animations self.doorLayer.speed = 0.0; //apply swinging animation (which won't play because layer is paused) CABasicAnimation *animation = [CABasicAnimation animation]; animation.keyPath = @"transform.rotation.y"; animation.toValue = @(-M_PI_2); animation.duration = 1.0; [self.doorLayer addAnimation:animation forKey:nil]; } - (void)pan:(UIPanGestureRecognizer *)pan { //get horizontal component of pan gesture CGFloat x = [pan translationInView:self.view].x; //convert from points to animation duration //using a reasonable scale factor x /= 200.0f; //update timeOffset and clamp result CFTimeInterval timeOffset = self.doorLayer.timeOffset; timeOffset = MIN(0.999, MAX(0.0, timeOffset - x)); self.doorLayer.timeOffset = timeOffset; //reset pan gesture [pan setTranslation:CGPointZero inView:self.view]; } @end
这其实是个小诡计,也许相对于设置个动画然后每次显示一帧而言,用移动手势来直接设置门的transform会更简单。
在这个例子中的确是这样,但是对于比如说关键这这样更加复杂的情况,或者有多个图层的动画组,相对于实时计算每个图层的属性而言,这就显得方便的多了。
缓冲
现在我们来看一下另一个和时间相关的机制--所谓的缓冲。Core Animation使用缓冲来使动画移动更平滑更自然,而不是看起来的那种机械和人工,在这一章我们将要研究如何对你的动画控制和自定义缓冲曲线。
动画速度
动画实际上就是一段时间内的变化,这就暗示了变化一定是随着某个特定的速率进行。速率由以下公式计算而来:
velocity = change / time
这里的变化可以指的是一个物体移动的距离,时间指动画持续的时长,用这样的一个移动可以更加形象的描述(比如position和bounds属性的动画),但实际上它应用于任意可以做动画的属性(比如color和opacity)。
上面的等式假设了速度在整个动画过程中都是恒定不变的(就如同第八章“显式动画”的情况),对于这种恒定速度的动画我们称之为“线性步调”,而且从技术的角度而言这也是实现动画最简单的方式,但也是完全不真实的一种效果。
考虑一个场景,一辆车行驶在一定距离内,它并不会一开始就以60mph的速度行驶,然后到达终点后突然变成0mph。一是因为需要无限大的加速度(即使是最好的车也不会在0秒内从0跑到60),另外不然的话会干死所有乘客。在现实中,它会慢慢地加速到全速,然后当它接近终点的时候,它会慢慢地减速,直到最后停下来。
那么对于一个掉落到地上的物体又会怎样呢?它会首先停在空中,然后一直加速到落到地面,然后突然停止(然后由于积累的动能转换伴随着一声巨响,砰!)。
现实生活中的任何一个物体都会在运动中加速或者减速。那么我们如何在动画中实现这种加速度呢?一种方法是使用物理引擎来对运动物体的摩擦和动量来建模,然而这会使得计算过于复杂。我们称这种类型的方程为缓冲函数,幸运的是,Core Animation内嵌了一系列标准函数提供给我们使用。
CAMediaTimingFunction
那么该如何使用缓冲方程式呢?首先需要设置CAAnimation的timingFunction属性,是CAMediaTimingFunction类的一个对象。如果想改变隐式动画的计时函数,同样也可以使用CATransaction的+setAnimationTimingFunction:方法。
这里有一些方式来创建CAMediaTimingFunction,最简单的方式是调用+timingFunctionWithName:的构造方法。这里传入如下几个常量之一:
kCAMediaTimingFunctionLinear
kCAMediaTimingFunctionEaseIn
kCAMediaTimingFunctionEaseOut
kCAMediaTimingFunctionEaseInEaseOut
kCAMediaTimingFunctionDefault
CAMediaTimingFunctionLinear选项创建了一个线性的计时函数,同样也是CAAnimation的timingFunction属性为空时候的默认函数。线性步调对于那些立即加速并且保持匀速到达终点的场景会有意义(例如射出枪膛的子弹),但是默认来说它看起来很奇怪,因为对大多数的动画来说确实很少用到。
kCAMediaTimingFunctionEaseIn常量创建了一个慢慢加速然后突然停止的方法。对于之前提到的自由落体的例子来说很适合,或者比如对准一个目标的导弹的发射。
kCAMediaTimingFunctionEaseOut则恰恰相反,它以一个全速开始,然后慢慢减速停止。它有一个削弱的效果,应用的场景比如一扇门慢慢地关上,而不是砰地一声。
kCAMediaTimingFunctionEaseInEaseOut创建了一个慢慢加速然后再慢慢减速的过程。这是现实世界大多数物体移动的方式,也是大多数动画来说最好的选择。如果只可以用一种缓冲函数的话,那就必须是它了。那么你会疑惑为什么这不是默认的选择,实际上当使用UIView的动画方法时,他的确是默认的,但当创建CAAnimation的时候,就需要手动设置它了。
最后还有一个kCAMediaTimingFunctionDefault,它和kCAMediaTimingFunctionEaseInEaseOut很类似,但是加速和减速的过程都稍微有些慢。它和kCAMediaTimingFunctionEaseInEaseOut的区别很难察觉,可能是苹果觉得它对于隐式动画来说更适合(然后对UIKit就改变了想法,而是使用kCAMediaTimingFunctionEaseInEaseOut作为默认效果),虽然它的名字说是默认的,但还是要记住当创建显式的CAAnimation它并不是默认选项(换句话说,默认的图层行为动画用kCAMediaTimingFunctionDefault作为它们的计时方法)。
你可以使用一个简单的测试工程来实验一下(清单10.1),在运行之前改变缓冲函数的代码,然后点击任何地方来观察图层是如何通过指定的缓冲移动的。
清单10.1 缓冲函数的简单测试
@interface ViewController () @property (nonatomic, strong) CALayer *colorLayer; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //create a red layer self.colorLayer = [CALayer layer]; self.colorLayer.frame = CGRectMake(0, 0, 100, 100); self.colorLayer.position = CGPointMake(self.view.bounds.size.width/2.0, self.view.bounds.size.height/2.0); self.colorLayer.backgroundColor = [UIColor redColor].CGColor; [self.view.layer addSublayer:self.colorLayer]; } - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { //configure the transaction [CATransaction begin]; [CATransaction setAnimationDuration:1.0]; [CATransaction setAnimationTimingFunction:[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseOut]]; //set the position self.colorLayer.position = [[touches anyObject] locationInView:self.view]; //commit transaction [CATransaction commit]; } @end
UIView的动画缓冲
UIKit的动画也同样支持这些缓冲方法的使用,尽管语法和常量有些不同,为了改变UIView动画的缓冲选项,给options参数添加如下常量之一:
UIViewAnimationOptionCurveEaseInOut
UIViewAnimationOptionCurveEaseIn
UIViewAnimationOptionCurveEaseOut
UIViewAnimationOptionCurveLinear
它们和CAMediaTimingFunction紧密关联,UIViewAnimationOptionCurveEaseInOut是默认值(这里没有kCAMediaTimingFunctionDefault相对应的值了)。
具体使用方法见清单10.2(注意到这里不再使用UIView额外添加的图层,因为UIKit的动画并不支持这类图层)。
清单10.2 使用UIKit动画的缓冲测试工程
@interface ViewController () @property (nonatomic, strong) UIView *colorView; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //create a red layer self.colorView = [[UIView alloc] init]; self.colorView.bounds = CGRectMake(0, 0, 100, 100); self.colorView.center = CGPointMake(self.view.bounds.size.width / 2, self.view.bounds.size.height / 2); self.colorView.backgroundColor = [UIColor redColor]; [self.view addSubview:self.colorView]; } - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { //perform the animation [UIView animateWithDuration:1.0 delay:0.0 options:UIViewAnimationOptionCurveEaseOut animations:^{ //set the position self.colorView.center = [[touches anyObject] locationInView:self.view]; } completion:NULL]; } @end
缓冲和关键帧动画
或许你会回想起第八章里面颜色切换的关键帧动画由于线性变换的原因(见清单8.5)看起来有些奇怪,使得颜色变换非常不自然。为了纠正这点,我们来用更加合适的缓冲方法,例如kCAMediaTimingFunctionEaseIn,给图层的颜色变化添加一点脉冲效果,让它更像现实中的一个彩色灯泡。
我们不想给整个动画过程应用这个效果,我们希望对每个动画的过程重复这样的缓冲,于是每次颜色的变换都会有脉冲效果。
CAKeyframeAnimation有一个NSArray类型的timingFunctions属性,我们可以用它来对每次动画的步骤指定不同的计时函数。但是指定函数的个数一定要等于keyframes数组的元素个数减一,因为它是描述每一帧之间动画速度的函数。
在这个例子中,我们自始至终想使用同一个缓冲函数,但我们同样需要一个函数的数组来告诉动画不停地重复每个步骤,而不是在整个动画序列只做一次缓冲,我们简单地使用包含多个相同函数拷贝的数组就可以了(见清单10.3)。
运行更新后的代码,你会发现动画看起来更加自然了。
清单10.3 对CAKeyframeAnimation使用CAMediaTimingFunction
@interface ViewController () @property (nonatomic, weak) IBOutlet UIView *layerView; @property (nonatomic, weak) IBOutlet CALayer *colorLayer; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //create sublayer self.colorLayer = [CALayer layer]; self.colorLayer.frame = CGRectMake(50.0f, 50.0f, 100.0f, 100.0f); self.colorLayer.backgroundColor = [UIColor blueColor].CGColor; //add it to our view [self.layerView.layer addSublayer:self.colorLayer]; } - (IBAction)changeColor { //create a keyframe animation CAKeyframeAnimation *animation = [CAKeyframeAnimation animation]; animation.keyPath = @"backgroundColor"; animation.duration = 2.0; animation.values = @[ (__bridge id)[UIColor blueColor].CGColor, (__bridge id)[UIColor redColor].CGColor, (__bridge id)[UIColor greenColor].CGColor, (__bridge id)[UIColor blueColor].CGColor ]; //add timing function CAMediaTimingFunction *fn = [CAMediaTimingFunction functionWithName: kCAMediaTimingFunctionEaseIn]; animation.timingFunctions = @[fn, fn, fn]; //apply animation to layer [self.colorLayer addAnimation:animation forKey:nil]; } @end
自定义缓冲函数
在第八章中,我们给时钟项目添加了动画。看起来很赞,但是如果有合适的缓冲函数就更好了。在显示世界中,钟表指针转动的时候,通常起步很慢,然后迅速啪地一声,最后缓冲到终点。但是标准的缓冲函数在这里每一个适合它,那该如何创建一个新的呢?
除了+functionWithName:之外,CAMediaTimingFunction同样有另一个构造函数,一个有四个浮点参数的+functionWithControlPoints::::(注意这里奇怪的语法,并没有包含具体每个参数的名称,这在objective-C中是合法的,但是却违反了苹果对方法命名的指导方针,而且看起来是一个奇怪的设计)。
使用这个方法,我们可以创建一个自定义的缓冲函数,来匹配我们的时钟动画,为了理解如何使用这个方法,我们要了解一些CAMediaTimingFunction是如何工作的。
三次贝塞尔曲线
CAMediaTimingFunction函数的主要原则在于它把输入的时间转换成起点和终点之间成比例的改变。我们可以用一个简单的图标来解释,横轴代表时间,纵轴代表改变的量,于是线性的缓冲就是一条从起点开始的简单的斜线(图10.1)。
图10.1 线性缓冲函数的图像
这条曲线的斜率代表了速度,斜率的改变代表了加速度,原则上来说,任何加速的曲线都可以用这种图像来表示,但是CAMediaTimingFunction使用了一个叫做三次贝塞尔曲线的函数,它只可以产出指定缓冲函数的子集(我们之前在第八章中创建CAKeyframeAnimation路径的时候提到过三次贝塞尔曲线)。
你或许会回想起,一个三次贝塞尔曲线通过四个点来定义,第一个和最后一个点代表了曲线的起点和终点,剩下中间两个点叫做控制点,因为它们控制了曲线的形状,贝塞尔曲线的控制点其实是位于曲线之外的点,也就是说曲线并不一定要穿过它们。你可以把它们想象成吸引经过它们曲线的磁铁。
图10.2展示了一个三次贝塞尔缓冲函数的例子
图10.2 三次贝塞尔缓冲函数
实际上它是一个很奇怪的函数,先加速,然后减速,最后快到达终点的时候又加速,那么标准的缓冲函数又该如何用图像来表示呢?
CAMediaTimingFunction有一个叫做-getControlPointAtIndex:values:的方法,可以用来检索曲线的点,这个方法的设计的确有点奇怪(或许也就只有苹果能回答为什么不简单返回一个CGPoint),但是使用它我们可以找到标准缓冲函数的点,然后用UIBezierPath和CAShapeLayer来把它画出来。
曲线的起始和终点始终是{0, 0}和{1, 1},于是我们只需要检索曲线的第二个和第三个点(控制点)。具体代码见清单10.4。所有的标准缓冲函数的图像见图10.3。
清单10.4 使用UIBezierPath绘制CAMediaTimingFunction
@interface ViewController () @property (nonatomic, weak) IBOutlet UIView *layerView; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //create timing function CAMediaTimingFunction *function = CAMediaTimingFunction functionWithName: kCAMediaTimingFunctionEaseOut]; //get control points CGPoint controlPoint1, controlPoint2; [function getControlPointAtIndex:1 values:(float *)&controlPoint1]; [function getControlPointAtIndex:2 values:(float *)&controlPoint2]; //create curve UIBezierPath *path = [[UIBezierPath alloc] init]; [path moveToPoint:CGPointZero]; [path addCurveToPoint:CGPointMake(1, 1) controlPoint1:controlPoint1 controlPoint2:controlPoint2]; //scale the path up to a reasonable size for display [path applyTransform:CGAffineTransformMakeScale(200, 200)]; //create shape layer CAShapeLayer *shapeLayer = [CAShapeLayer layer]; shapeLayer.strokeColor = [UIColor redColor].CGColor; shapeLayer.fillColor = [UIColor clearColor].CGColor; shapeLayer.lineWidth = 4.0f; shapeLayer.path = path.CGPath; [self.layerView.layer addSublayer:shapeLayer]; //flip geometry so that 0,0 is in the bottom-left self.layerView.layer.geometryFlipped = YES; } @end
图10.3 标准CAMediaTimingFunction缓冲曲线
那么对于我们自定义时钟指针的缓冲函数来说,我们需要初始微弱,然后迅速上升,最后缓冲到终点的曲线,通过一些实验之后,最终结果如下:
[CAMediaTimingFunction functionWithControlPoints:1 :0 :0.75 :1];
如果把它转换成缓冲函数的图像,最后如图10.4所示,如果把它添加到时钟的程序,就形成了之前一直期待的非常赞的效果(见代清单10.5)。
图10.4 自定义适合时钟的缓冲函数
清单10.5 添加了自定义缓冲函数的时钟程序
- (void)setAngle:(CGFloat)angle forHand:(UIView *)handView ?animated:(BOOL)animated { //generate transform CATransform3D transform = CATransform3DMakeRotation(angle, 0, 0, 1); if (animated) { //create transform animation CABasicAnimation *animation = [CABasicAnimation animation]; animation.keyPath = @"transform"; animation.fromValue = [handView.layer.presentationLayer valueForKey:@"transform"]; animation.toValue = [NSValue valueWithCATransform3D:transform]; animation.duration = 0.5; animation.delegate = self; animation.timingFunction = [CAMediaTimingFunction functionWithControlPoints:1 :0 :0.75 :1]; //apply animation handView.layer.transform = transform; [handView.layer addAnimation:animation forKey:nil]; } else { //set transform directly handView.layer.transform = transform; } }
更加复杂的动画曲线
考虑一个橡胶球掉落到坚硬的地面的场景,当开始下落的时候,它会持续加速知道落到地面,然后经过几次反弹,最后停下来。如果用一张图来说明,它会如图10.5所示。
图10.5 一个没法用三次贝塞尔曲线描述的反弹的动画
这种效果没法用一个简单的三次贝塞尔曲线表示,于是不能用CAMediaTimingFunction来完成。但如果想要实现这样的效果,可以用如下几种方法:
用CAKeyframeAnimation创建一个动画,然后分割成几个步骤,每个小步骤使用自己的计时函数(具体下节介绍)。
使用定时器逐帧更新实现动画(见第11章,“基于定时器的动画”)。
基于关键帧的缓冲
为了使用关键帧实现反弹动画,我们需要在缓冲曲线中对每一个显著的点创建一个关键帧(在这个情况下,关键点也就是每次反弹的峰值),然后应用缓冲函数把每段曲线连接起来。同时,我们也需要通过keyTimes来指定每个关键帧的时间偏移,由于每次反弹的时间都会减少,于是关键帧并不会均匀分布。
清单10.6展示了实现反弹球动画的代码(见图10.6)
清单10.6 使用关键帧实现反弹球的动画
-
@interface ViewController () @property (nonatomic, weak) IBOutlet UIView *containerView; @property (nonatomic, strong) UIImageView *ballView; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //add ball image view UIImage *ballImage = [UIImage imageNamed:@"Ball.png"]; self.ballView = [[UIImageView alloc] initWithImage:ballImage]; [self.containerView addSubview:self.ballView]; //animate [self animate]; } - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { //replay animation on tap [self animate]; } - (void)animate { //reset ball to top of screen self.ballView.center = CGPointMake(150, 32); //create keyframe animation CAKeyframeAnimation *animation = [CAKeyframeAnimation animation]; animation.keyPath = @"position"; animation.duration = 1.0; animation.delegate = self; animation.values = @[ [NSValue valueWithCGPoint:CGPointMake(150, 32)], [NSValue valueWithCGPoint:CGPointMake(150, 268)], [NSValue valueWithCGPoint:CGPointMake(150, 140)], [NSValue valueWithCGPoint:CGPointMake(150, 268)], [NSValue valueWithCGPoint:CGPointMake(150, 220)], [NSValue valueWithCGPoint:CGPointMake(150, 268)], [NSValue valueWithCGPoint:CGPointMake(150, 250)], [NSValue valueWithCGPoint:CGPointMake(150, 268)] ]; animation.timingFunctions = @[ [CAMediaTimingFunction functionWithName: kCAMediaTimingFunctionEaseIn], [CAMediaTimingFunction functionWithName: kCAMediaTimingFunctionEaseOut], [CAMediaTimingFunction functionWithName: kCAMediaTimingFunctionEaseIn], [CAMediaTimingFunction functionWithName: kCAMediaTimingFunctionEaseOut], [CAMediaTimingFunction functionWithName: kCAMediaTimingFunctionEaseIn], [CAMediaTimingFunction functionWithName: kCAMediaTimingFunctionEaseOut], [CAMediaTimingFunction functionWithName: kCAMediaTimingFunctionEaseIn] ]; animation.keyTimes = @[@0.0, @0.3, @0.5, @0.7, @0.8, @0.9, @0.95, @1.0]; //apply animation self.ballView.layer.position = CGPointMake(150, 268); [self.ballView.layer addAnimation:animation forKey:nil]; } @end
图10.6 使用关键帧实现的反弹球动画这种方式还算不错,但是实现起来略显笨重(因为要不停地尝试计算各种关键帧和时间偏移)并且和动画强绑定了(因为如果要改变动画的一个属性,那就意味着要重新计算所有的关键帧)。那该如何写一个方法,用缓冲函数来把任何简单的属性动画转换成关键帧动画呢,下面我们来实现它。
- 流程自动化
-
在清单10.6中,我们把动画分割成相当大的几块,然后用Core Animation的缓冲进入和缓冲退出函数来大约形成我们想要的曲线。但如果我们把动画分割成更小的几部分,那么我们就可以用直线来拼接这些曲线(也就是线性缓冲)。为了实现自动化,我们需要知道如何做如下两件事情:
自动把任意属性动画分割成多个关键帧
用一个数学函数表示弹性动画,使得可以对帧做便宜
为了解决第一个问题,我们需要复制Core Animation的插值机制。这是一个传入起点和终点,然后在这两个点之间指定时间点产出一个新点的机制。对于简单的浮点起始值,公式如下(假设时间从0到1):
value = (endValue – startValue) × time + startValue;
那么如果要插入一个类似于CGPoint,CGColorRef或者CATransform3D这种更加复杂类型的值,我们可以简单地对每个独立的元素应用这个方法(也就CGPoint中的x和y值,CGColorRef中的红,蓝,绿,透明值,或者是CATransform3D中独立矩阵的坐标)。我们同样需要一些逻辑在插值之前对对象拆解值,然后在插值之后在重新封装成对象,也就是说需要实时地检查类型。
一旦我们可以用代码获取属性动画的起始值之间的任意插值,我们就可以把动画分割成许多独立的关键帧,然后产出一个线性的关键帧动画。清单10.7展示了相关代码。
注意到我们用了60 x 动画时间(秒做单位)作为关键帧的个数,这时因为Core Animation按照每秒60帧去渲染屏幕更新,所以如果我们每秒生成60个关键帧,就可以保证动画足够的平滑(尽管实际上很可能用更少的帧率就可以达到很好的效果)。
我们在示例中仅仅引入了对CGPoint类型的插值代码。但是,从代码中很清楚能看出如何扩展成支持别的类型。作为不能识别类型的备选方案,我们仅仅在前一半返回了fromValue,在后一半返回了toValue。
清单10.7 使用插入的值创建一个关键帧动画
-
float interpolate(float from, float to, float time) { return (to - from) * time + from; } - (id)interpolateFromValue:(id)fromValue toValue:(id)toValue time:(float)time { if ([fromValue isKindOfClass:[NSValue class]]) { //get type const char *type = [fromValue objCType]; if (strcmp(type, @encode(CGPoint)) == 0) { CGPoint from = [fromValue CGPointValue]; CGPoint to = [toValue CGPointValue]; CGPoint result = CGPointMake(interpolate(from.x, to.x, time), interpolate(from.y, to.y, time)); return [NSValue valueWithCGPoint:result]; } } //provide safe default implementation return (time < 0.5)? fromValue: toValue; } - (void)animate { //reset ball to top of screen self.ballView.center = CGPointMake(150, 32); //set up animation parameters NSValue *fromValue = [NSValue valueWithCGPoint:CGPointMake(150, 32)]; NSValue *toValue = [NSValue valueWithCGPoint:CGPointMake(150, 268)]; CFTimeInterval duration = 1.0; //generate keyframes NSInteger numFrames = duration * 60; NSMutableArray *frames = [NSMutableArray array]; for (int i = 0; i < numFrames; i++) { float time = 1 / (float)numFrames * i; [frames addObject:[self interpolateFromValue:fromValue toValue:toValue time:time]]; } //create keyframe animation CAKeyframeAnimation *animation = [CAKeyframeAnimation animation]; animation.keyPath = @"position"; animation.duration = 1.0; animation.delegate = self; animation.values = frames; //apply animation [self.ballView.layer addAnimation:animation forKey:nil]; }
这可以起到作用,但效果并不是很好,到目前为止我们所完成的只是一个非常复杂的方式来使用线性缓冲复制CABasicAnimation的行为。这种方式的好处在于我们可以更加精确地控制缓冲,这也意味着我们可以应用一个完全定制的缓冲函数。那么该如何做呢?
缓冲背后的数学并不很简单,但是幸运的是我们不需要一一实现它。罗伯特·彭纳有一个网页关于缓冲函数(http://www.robertpenner.com/easing),包含了大多数普遍的缓冲函数的多种编程语言的实现的链接,包括C。
对我们的弹性球来说,我们可以使用bounceEaseOut函数:
float bounceEaseOut(float t) { if (t < 4/11.0) { return (121 * t * t)/16.0; } else if (t < 8/11.0) { return (363/40.0 * t * t) - (99/10.0 * t) + 17/5.0; } else if (t < 9/10.0) { return (4356/361.0 * t * t) - (35442/1805.0 * t) + 16061/1805.0; } return (54/5.0 * t * t) - (513/25.0 * t) + 268/25.0; }
如果修改清单10.7的代码来引入bounceEaseOut方法,我们的任务就是仅仅交换缓冲函数,现在就可以选择任意的缓冲类型创建动画了(见清单10.8)。
清单10.8 用关键帧实现自定义的缓冲函数
- (void)animate { //reset ball to top of screen self.ballView.center = CGPointMake(150, 32); //set up animation parameters NSValue *fromValue = [NSValue valueWithCGPoint:CGPointMake(150, 32)]; NSValue *toValue = [NSValue valueWithCGPoint:CGPointMake(150, 268)]; CFTimeInterval duration = 1.0; //generate keyframes NSInteger numFrames = duration * 60; NSMutableArray *frames = [NSMutableArray array]; for (int i = 0; i < numFrames; i++) { float time = 1/(float)numFrames * i; //apply easing time = bounceEaseOut(time); //add keyframe [frames addObject:[self interpolateFromValue:fromValue toValue:toValue time:time]]; } //create keyframe animation CAKeyframeAnimation *animation = [CAKeyframeAnimation animation]; animation.keyPath = @"position"; animation.duration = 1.0; animation.delegate = self; animation.values = frames; //apply animation [self.ballView.layer addAnimation:animation forKey:nil]; }