[iOS Animation]-CALayer 显示动画
显式动画
如果想让事情变得顺利,只有靠自己 -- 夏尔·纪尧姆
上一章介绍了隐式动画的概念。隐式动画是在iOS平台创建动态用户界面的一种直接方式,也是UIKit动画机制的基础,不过它并不能涵盖所有的动画类型。在这一章中,我们将要研究一下显式动画,它能够对一些属性做指定的自定义动画,或者创建非线性动画,比如沿着任意一条曲线移动。
属性动画
首先我们来探讨一下属性动画。属性动画作用于图层的某个单一属性,并指定了它的一个目标值,或者一连串将要做动画的值。属性动画分为两种:基础和关键帧。
基础动画
动画其实就是一段时间内发生的改变,最简单的形式就是从一个值改变到另一个值,这也是 CABasicAnimation 最主要的功能。 CABasicAnimation 是 CAPropertyAnimation 的一个子类,而 CAPropertyAnimation 的父类是 CAAnimation , CAAnimation 同时也是Core Animation所有动画类型的抽象基类。作为一个抽象类,CAAnimation本身并没有做多少工作,它提供了一个计时函数(见第十章“缓冲”),一个委托(用于反馈动画状态)以及一个 removedOnCompletion ,用于标识动画是否该在结束后自动释放(默认YES,为了防止内存泄露)。CAAnimation同时实现了一些协议,包括CAAction(允许CAAnimation的子类可以提供图层行为),以及 CAMediaTiming (第九章“图层时间”将会详细解释)。
CAPropertyAnimation通过指定动画的 keyPath 作用于一个单一属性,CAAnimation通常应用于一个指定的CALayer,于是这里指的也就是一个图层的keyPath了。实际上它是一个关键路径(一些用点表示法可以在层级关系中指向任意嵌套的对象),而不仅仅是一个属性的名称,因为这意味着动画不仅可以作用于图层本身的属性,而且还包含了它的子成员的属性,甚至是一些虚拟的属性(后面会详细解释)。
CABasicAnimation继承于CAPropertyAnimation,并添加了如下属性:
id fromValue; id toValue; id byValue;
从命名就可以得到很好的解释:fromValue代表了动画开始之前属性的值,toValue代表了动画结束之后的值,byValue代表了动画执行过程中改变的值。
通过组合这三个属性就可以有很多种方式来指定一个动画的过程。它们被定义成id类型而不是一些具体的类型是因为属性动画可以用作很多不同种的属性类型,包括数字类型,矢量,变换矩阵,甚至是颜色或者图片。
id类型可以包含任意由NSObject派生的对象,但有时候你会希望对一些不直接从NSObject继承的属性类型做动画,这意味着你需要把这些值用一个对象来封装,或者强转成一个对象,就像某些功能和Objective-C对象类似的Core Foundation类型。但是如何从一个具体的数据类型转换成id看起来并不明显,一些普通的例子见表8.1。
表8.1 用于CAPropertyAnimation的一些类型转换
Type | Object Type | Code Example |
---|---|---|
CGFloat | NSNumber | id obj = @(float); |
CGPoint | NSValue | id obj = [NSValue valueWithCGPoint:point); |
CGSize | NSValue | id obj = [NSValue valueWithCGSize:size); |
CGRect | NSValue | id obj = [NSValue valueWithCGRect:rect); |
CATransform3D | NSValue | id obj = [NSValue valueWithCATransform3D:transform); |
CGImageRef | id | id obj = (__bridge id)imageRef; |
CGColorRef | id | id obj = (__bridge id)colorRef; |
fromValue , toValue 和 byValue 属性可以用很多种方式来组合,但为了防止冲突,不能一次性同时指定这三个值。例如,如果指定了fromValue等于2,toValue等于4,byValue等于3,那么Core Animation就不知道结果到底是4(toValue)还是5(fromValue + byValue)了。他们的用法在CABasicAnimation头文件中已经描述的很清楚了,所以在这里就不重复了。总的说来,就是只需要指定toValue或者byValue,剩下的值都可以通过上下文自动计算出来。
举个例子:我们修改一下第七章中的颜色渐变的动画,用显式的CABasicAnimation来取代之前的隐式动画,代码见清单8.1。
清单8.1 通过CABasicAnimation来设置图层背景色
![复制代码](https://i-blog.csdnimg.cn/blog_migrate/fcccfe3cd60dfbaf525aa7390b5c5f91.gif)
@interface ViewController ()
@property (nonatomic, weak) IBOutlet UIView *layerView;
@property (nonatomic, strong) 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 new random color CGFloat red = arc4random() / (CGFloat)INT_MAX;
CGFloat green = arc4random() / (CGFloat)INT_MAX;
CGFloat blue = arc4random() / (CGFloat)INT_MAX;
UIColor *color = [UIColor colorWithRed:red green:green blue:blue alpha:1.0]; //create a basic animation CABasicAnimation *animation = [CABasicAnimation animation];
animation.keyPath = @"backgroundColor";
animation.toValue = (__bridge id)color.CGColor; //apply animation to layer [self.colorLayer addAnimation:animation forKey:nil];
} @end
![复制代码](https://i-blog.csdnimg.cn/blog_migrate/fcccfe3cd60dfbaf525aa7390b5c5f91.gif)
运行程序,结果有点差强人意,点击按钮,的确可以使图层动画过渡到一个新的颜色,然动画结束之后又立刻变回原始值。
这是因为动画并没有改变图层的模型,而只是呈现(第七章)。一旦动画结束并从图层上移除之后,图层就立刻恢复到之前定义的外观状态。我们从没改变过backgroundColor属性,所以图层就返回到原始的颜色。
当之前在使用隐式动画的时候,实际上它就是用例子中CABasicAnimation来实现的(回忆第七章,我们在-actionForLayer:forKey:委托方法打印出来的结果就是CABasicAnimation)。但是在那个例子中,我们通过设置属性来打开动画。在这里我们做了相同的动画,但是并没有设置任何属性的值(这就是为什么会立刻变回初始状态的原因)。
把动画设置成一个图层的行为(然后通过改变属性值来启动动画)是到目前为止同步属性值和动画状态最简单的方式了,假设由于某些原因我们不能这么做(通常因为UIView关联的图层不能这么做动画),那么有两种可以更新属性值的方式:在动画开始之前或者动画结束之后。
动画之前改变属性的值是最简单的办法,但这意味着我们不能使用fromValue这么好的特性了,而且要手动将fromValue设置成图层当前的值。
于是在动画创建之前插入如下代码,就可以解决问题了
animation.fromValue = (__bridge id)self.colorLayer.backgroundColor;
self.colorLayer.backgroundColor = color.CGColor;
这的确是可行的,但还是有些问题,如果这里已经正在进行一段动画,我们需要从呈现图层那里去获得fromValue,而不是模型图层。另外,由于这里的图层并不是UIView关联的图层,我们需要用CATransaction来禁用隐式动画行为,否则默认的图层行为会干扰我们的显式动画(实际上,显式动画通常会覆盖隐式动画,但在文章中并没有提到,所以为了安全最好这么做)。
更新之后的代码如下:
![复制代码](https://i-blog.csdnimg.cn/blog_migrate/fcccfe3cd60dfbaf525aa7390b5c5f91.gif)
CALayer *layer = self.colorLayer.presentationLayer ?:
self.colorLayer;
animation.fromValue = (__bridge id)layer.backgroundColor;
[CATransaction begin];
[CATransaction setDisableActions:YES];
self.colorLayer.backgroundColor = color.CGColor;
[CATransaction commit];
![复制代码](https://i-blog.csdnimg.cn/blog_migrate/fcccfe3cd60dfbaf525aa7390b5c5f91.gif)
如果给每个动画都添加这些,代码会显得特别臃肿。幸运的是,我们可以从CABasicAnimation去自动设置这些。于是可以创建一个可复用的代码。清单8.2修改了之前的示例,通过使用CABasicAnimation的一个函数来避免在每次动画时候都重复那些臃肿的代码。
清单8.2 修改动画立刻恢复到原始状态的一个可复用函数
![复制代码](https://i-blog.csdnimg.cn/blog_migrate/fcccfe3cd60dfbaf525aa7390b5c5f91.gif)
- (void)applyBasicAnimation:(CABasicAnimation *)animation toLayer:(CALayer *)layer
{ //set the from value (using presentation layer if available) animation.fromValue = [layer.presentationLayer ?: layer valueForKeyPath:animation.keyPath]; //update the property in advance //note: this approach will only work if toValue != nil [CATransaction begin];
[CATransaction setDisableActions:YES];
[layer setValue:animation.toValue forKeyPath:animation.keyPath];
[CATransaction commit]; //apply animation to layer [layer addAnimation:animation forKey:nil];
} - (IBAction)changeColor
{ //create a new random color CGFloat red = arc4random() / (CGFloat)INT_MAX;
CGFloat green = arc4random() / (CGFloat)INT_MAX;
CGFloat blue = arc4random() / (CGFloat)INT_MAX;
UIColor *color = [UIColor colorWithRed:red green:green blue:blue alpha:1.0]; //create a basic animation CABasicAnimation *animation = [CABasicAnimation animation];
animation.keyPath = @"backgroundColor";
animation.toValue = (__bridge id)color.CGColor; //apply animation without snap-back [self applyBasicAnimation:animation toLayer:self.colorLayer];
}
![复制代码](https://i-blog.csdnimg.cn/blog_migrate/fcccfe3cd60dfbaf525aa7390b5c5f91.gif)
这种简单的实现方式通过toValue而不是byValue来处理动画,不过这已经是朝更好的解决方案迈出一大步了。你可以把它添加给CALayer作为一个分类,以方便更好地使用。
解决看起来如此简单的一个问题都着实麻烦,但是别的方案会更加复杂。如果不在动画开始之前去更新目标属性,那么就只能在动画完全结束或者取消的时候更新它。这意味着我们需要精准地在动画结束之后,图层返回到原始值之前更新属性。那么该如何找到这个点呢?
CAAnimationDelegate
在第七章使用隐式动画的时候,我们可以在CATransaction完成块中检测到动画的完成。但是这种方式并不适用于显式动画,因为这里的动画和事务并没太多关联。
那么为了知道一个显式动画在何时结束,我们需要使用一个实现了CAAnimationDelegate协议的delegate。
CAAnimationDelegate在任何头文件中都找不到,但是可以在CAAnimation头文件或者苹果开发者文档中找到相关函数。在这个例子中,我们用-animationDidStop:finished:方法在动画结束之后来更新图层的backgroundColor。
当更新属性的时候,我们需要设置一个新的事务,并且禁用图层行为。否则动画会发生两次,一个是因为显式的CABasicAnimation,另一次是因为隐式动画,具体实现见订单8.3。
清单8.3 动画完成之后修改图层的背景色
![复制代码](https://i-blog.csdnimg.cn/blog_migrate/fcccfe3cd60dfbaf525aa7390b5c5f91.gif)
@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 new random color CGFloat red = arc4random() / (CGFloat)INT_MAX;
CGFloat green = arc4random() / (CGFloat)INT_MAX;
CGFloat blue = arc4random() / (CGFloat)INT_MAX;
UIColor *color = [UIColor colorWithRed:red green:green blue:blue alpha:1.0]; //create a basic animation CABasicAnimation *animation = [CABasicAnimation animation];
animation.keyPath = @"backgroundColor";
animation.toValue = (__bridge id)color.CGColor;
animation.delegate = self; //apply animation to layer [self.colorLayer addAnimation:animation forKey:nil];
} - (void)animationDidStop:(CABasicAnimation *)anim finished:(BOOL)flag
{ //set the backgroundColor property to match animation toValue [CATransaction begin];
[CATransaction setDisableActions:YES];
self.colorLayer.backgroundColor = (__bridge CGColorRef)anim.toValue;
[CATransaction commit];
} @end
![复制代码](https://i-blog.csdnimg.cn/blog_migrate/fcccfe3cd60dfbaf525aa7390b5c5f91.gif)
对CAAnimation而言,使用委托模式而不是一个完成块会带来一个问题,就是当你有多个动画的时候,无法在在回调方法中区分。在一个视图控制器中创建动画的时候,通常会用控制器本身作为一个委托(如清单8.3所示),但是所有的动画都会调用同一个回调方法,所以你就需要判断到底是那个图层的调用。
考虑一下第三章的闹钟,“图层几何学”,我们通过简单地每秒更新指针的角度来实现一个钟,但如果指针动态地转向新的位置会更加真实。
我们不能通过隐式动画来实现因为这些指针都是UIView的实例,所以图层的隐式动画都被禁用了。我们可以简单地通过UIView的动画方法来实现。但如果想更好地控制动画时间,使用显式动画会更好(更多内容见第十章)。使用CABasicAnimation来做动画可能会更加复杂,因为我们需要在 -animationDidStop:finished: 中检测指针状态(用于设置结束的位置)。
动画本身会作为一个参数传入委托的方法,也许你会认为可以控制器中把动画存储为一个属性,然后在回调用比较,但实际上并不起作用,因为委托传入的动画参数是原始值的一个深拷贝,从而不是同一个值。
当使用 -addAnimation:forKey: 把动画添加到图层,这里有一个到目前为止我们都设置为nil的key参数。这里的键是 -animationForKey: 方法找到对应动画的唯一标识符,而当前动画的所有键都可以用animationKeys获取。如果我们对每个动画都关联一个唯一的键,就可以对每个图层循环所有键,然后调用 -animationForKey: 来比对结果。尽管这不是一个优雅的实现。
幸运的是,还有一种更加简单的方法。像所有的NSObject子类一样,CAAnimation实现了KVC(键-值-编码)协议,于是你可以用 -setValue:forKey: 和 -valueForKey: 方法来存取属性。但是CAAnimation有一个不同的性能:它更像一个NSDictionary,可以让你随意设置键值对,即使和你使用的动画类所声明的属性并不匹配。
这意味着你可以对动画用任意类型打标签。在这里,我们给UIView类型的指针添加的动画,所以可以简单地判断动画到底属于哪个视图,然后在委托方法中用这个信息正确地更新钟的指针(清单8.4)。
清单8.4 使用KVC对动画打标签
![复制代码](https://i-blog.csdnimg.cn/blog_migrate/fcccfe3cd60dfbaf525aa7390b5c5f91.gif)
@interface ViewController ()
@property (nonatomic, weak) IBOutlet UIImageView *hourHand;
@property (nonatomic, weak) IBOutlet UIImageView *minuteHand;
@property (nonatomic, weak) IBOutlet UIImageView *secondHand;
@property (nonatomic, weak) NSTimer *timer; @end @implementation ViewController - (void)viewDidLoad
{
[super viewDidLoad]; //adjust anchor points self.secondHand.layer.anchorPoint = CGPointMake(0.5f, 0.9f);
self.minuteHand.layer.anchorPoint = CGPointMake(0.5f, 0.9f);
self.hourHand.layer.anchorPoint = CGPointMake(0.5f, 0.9f); //start timer self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(tick) userInfo:nil repeats:YES]; //set initial hand positions [self updateHandsAnimated:NO];
} - (void)tick
{
[self updateHandsAnimated:YES];
} - (void)updateHandsAnimated:(BOOL)animated
{ //convert time to hours, minutes and seconds NSCalendar *calendar = [[NSCalendar alloc] initWithCalendarIdentifier:NSGregorianCalendar];
NSUInteger units = NSHourCalendarUnit | NSMinuteCalendarUnit | NSSecondCalendarUnit;
NSDateComponents *components = [calendar components:units fromDate:[NSDate date]];
CGFloat hourAngle = (components.hour / 12.0) * M_PI * 2.0; //calculate hour hand angle //calculate minute hand angle CGFloat minuteAngle = (components.minute / 60.0) * M_PI * 2.0; //calculate second hand angle CGFloat secondAngle = (components.second / 60.0) * M_PI * 2.0; //rotate hands [self setAngle:hourAngle forHand:self.hourHand animated:animated];
[self setAngle:minuteAngle forHand:self.minuteHand animated:animated];
[self setAngle:secondAngle forHand:self.secondHand animated:animated];
} - (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];
[self updateHandsAnimated:NO];
animation.keyPath = @"transform";
animation.toValue = [NSValue valueWithCATransform3D:transform];
animation.duration = 0.5;
animation.delegate = self;
[animation setValue:handView forKey:@"handView"];
[handView.layer addAnimation:animation forKey:nil];
} else { //set transform directly handView.layer.transform = transform;
}
} - (void)animationDidStop:(CABasicAnimation *)anim finished:(BOOL)flag
{ //set final position for hand view UIView *handView = [anim valueForKey:@"handView"];
handView.layer.transform = [anim.toValue CATransform3DValue];
}
![复制代码](https://i-blog.csdnimg.cn/blog_migrate/fcccfe3cd60dfbaf525aa7390b5c5f91.gif)
我们成功的识别出每个图层停止动画的时间,然后更新它的变换到一个新值,很好。
不幸的是,即使做了这些,还是有个问题,清单8.4在模拟器上运行的很好,但当真正跑在iOS设备上时,我们发现在-animationDidStop:finished:委托方法调用之前,指针会迅速返回到原始值,这个清单8.3图层颜色发生的情况一样。
问题在于回调方法在动画完成之前已经被调用了,但不能保证这发生在属性动画返回初始状态之前。这同时也很好地说明了为什么要在真实的设备上测试动画代码,而不仅仅是模拟器。
我们可以用一个fillMode属性来解决这个问题,下一章会详细说明,这里知道在动画之前设置它比在动画结束之后更新属性更加方便。