iOS核心动画高级技巧-4
8. 显式动画
显式动画
如果想让事情变得顺利,只有靠自己 -- 夏尔·纪尧姆
上一章介绍了隐式动画的概念。隐式动画是在ios平台创建动态用户界面的一种直接方式,也是uikit动画机制的基础,不过它并不能涵盖所有的动画类型。在这一章中,我们将要研究一下显式动画,它能够对一些属性做指定的自定义动画,或者创建非线性动画,比如沿着任意一条曲线移动。
8.1 属性动画
属性动画
caanimationdelegate
在任何头文件中都找不到,但是可以在caanimation
头文件或者苹果开发者文档中找到相关函数。在这个例子中,我们用-animationdidstop:finished:
方法在动画结束之后来更新图层的backgroundcolor
。
当更新属性的时候,我们需要设置一个新的事务,并且禁用图层行为。否则动画会发生两次,一个是因为显式的cabasicanimation
,另一次是因为隐式动画,具体实现见订单8.3。
一个开发者,有一个学习的氛围跟一个交流圈子特别重要,这是一个我的ios交流群:1012951431, 分享bat,阿里面试题、面试经验,讨论技术, 大家一起交流学习成长!希望帮助开发者少走弯路。
清单8.3 动画完成之后修改图层的背景色
@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
对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对动画打标签
@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]; }
我们成功的识别出每个图层停止动画的时间,然后更新它的变换到一个新值,很好。
不幸的是,即使做了这些,还是有个问题,清单8.4在模拟器上运行的很好,但当真正跑在ios设备上时,我们发现在-animationdidstop:finished:
委托方法调用之前,指针会迅速返回到原始值,这个清单8.3图层颜色发生的情况一样。
问题在于回调方法在动画完成之前已经被调用了,但不能保证这发生在属性动画返回初始状态之前。这同时也很好地说明了为什么要在真实的设备上测试动画代码,而不仅仅是模拟器。
我们可以用一个fillmode
属性来解决这个问题,下一章会详细说明,这里知道在动画之前设置它比在动画结束之后更新属性更加方便。
关键帧动画
cabasicanimation
揭示了大多数隐式动画背后依赖的机制,这的确很有趣,但是显式地给图层添加cabasicanimation
相较于隐式动画而言,只能说费力不讨好。
cakeyframeanimation
是另一种uikit没有暴露出来但功能强大的类。和cabasicanimation
类似,cakeyframeanimation
同样是capropertyanimation
的一个子类,它依然作用于单一的一个属性,但是和cabasicanimation
不一样的是,它不限制于设置一个起始和结束的值,而是可以根据一连串随意的值来做动画。
关键帧起源于传动动画,意思是指主导的动画在显著改变发生时重绘当前帧(也就是关键帧),每帧之间剩下的绘制(可以通过关键帧推算出)将由熟练的艺术家来完成。cakeyframeanimation
也是同样的道理:你提供了显著的帧,然后core animation在每帧之间进行插入。
我们可以用之前使用颜色图层的例子来演示,设置一个颜色的数组,然后通过关键帧动画播放出来(清单8.5)
清单8.5 使用cakeyframeanimation
应用一系列颜色的变化
- (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 ]; //apply animation to layer [self.colorlayer addanimation:animation forkey:nil]; }
注意到序列中开始和结束的颜色都是蓝色,这是因为cakeyframeanimation
并不能自动把当前值作为第一帧(就像cabasicanimation
那样把fromvalue
设为nil
)。动画会在开始的时候突然跳转到第一帧的值,然后在动画结束的时候突然恢复到原始的值。所以为了动画的平滑特性,我们需要开始和结束的关键帧来匹配当前属性的值。
当然可以创建一个结束和开始值不同的动画,那样的话就需要在动画启动之前手动更新属性和最后一帧的值保持一致,就和之前讨论的一样。
我们用duration
属性把动画时间从默认的0.25秒增加到2秒,以便于动画做的不那么快。运行它,你会发现动画通过颜色不断循环,但效果看起来有些奇怪。原因在于动画以一个恒定的步调在运行。当在每个动画之间过渡的时候并没有减速,这就产生了一个略微奇怪的效果,为了让动画看起来更自然,我们需要调整一下缓冲,第十章将会详细说明。
提供一个数组的值就可以按照颜色变化做动画,但一般来说用数组来描述动画运动并不直观。cakeyframeanimation
有另一种方式去指定动画,就是使用cgpath
。path
属性可以用一种直观的方式,使用core graphics函数定义运动序列来绘制动画。
我们来用一个宇宙飞船沿着一个简单曲线的实例演示一下。为了创建路径,我们需要使用一个三次贝塞尔曲线,它是一种使用开始点,结束点和另外两个控制点来定义形状的曲线,可以通过使用一个基于c的core graphics绘图指令来创建,不过用uikit提供的uibezierpath
类会更简单。
我们这次用cashapelayer
来在屏幕上绘制曲线,尽管对动画来说并不是必须的,但这会让我们的动画更加形象。绘制完cgpath
之后,我们用它来创建一个cakeyframeanimation
,然后用它来应用到我们的宇宙飞船。代码见清单8.6,结果见图8.1。
清单8.6 沿着一个贝塞尔曲线对图层做动画
@interface viewcontroller () @property (nonatomic, weak) iboutlet uiview *containerview; @end @implementation viewcontroller - (void)viewdidload { [super viewdidload]; //create a path uibezierpath *bezierpath = [[uibezierpath alloc] init]; [bezierpath movetopoint:cgpointmake(0, 150)]; [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 = bezierpath.cgpath; pathlayer.fillcolor = [uicolor clearcolor].cgcolor; pathlayer.strokecolor = [uicolor redcolor].cgcolor; pathlayer.linewidth = 3.0f; [self.containerview.layer addsublayer:pathlayer]; //add the ship calayer *shiplayer = [calayer layer]; shiplayer.frame = cgrectmake(0, 0, 64, 64); shiplayer.position = cgpointmake(0, 150); shiplayer.contents = (__bridge id)[uiimage imagenamed: @"ship.png"].cgimage; [self.containerview.layer addsublayer:shiplayer]; //create the keyframe animation cakeyframeanimation *animation = [cakeyframeanimation animation]; animation.keypath = @"position"; animation.duration = 4.0; animation.path = bezierpath.cgpath; [shiplayer addanimation:animation forkey:nil]; } @end
这么做是可行的,但看起来更因为是运气而不是设计的原因,如果我们把旋转的值从m_pi
(180度)调整到2 * m_pi
(360度),然后运行程序,会发现这时候飞船完全不动了。这是因为这里的矩阵做了一次360度的旋转,和做了0度是一样的,所以最后的值根本没变。
现在继续使用m_pi
,但这次用byvalue
而不是tovalue
。也许你会认为这和设置tovalue
结果一样,因为0 + 90度 == 90度,但实际上飞船的图片变大了,并没有做任何旋转,这是因为变换矩阵不能像角度值那样叠加。
那么如果需要独立于角度之外单独对平移或者缩放做动画呢?由于都需要我们来修改transform
属性,实时地重新计算每个时间点的每个变换效果,然后根据这些创建一个复杂的关键帧动画,这一切都是为了对图层的一个独立做一个简单的动画。
幸运的是,有一个更好的解决方案:为了旋转图层,我们可以对transform.rotation
关键路径应用动画,而不是transform
本身(清单8.9)。
清单8.9 对虚拟的transform.rotation
属性做动画
@interface viewcontroller () @property (nonatomic, weak) iboutlet uiview *containerview; @end @implementation viewcontroller - (void)viewdidload { [super viewdidload]; //add the ship calayer *shiplayer = [calayer layer]; shiplayer.frame = cgrectmake(0, 0, 128, 128); shiplayer.position = cgpointmake(150, 150); shiplayer.contents = (__bridge id)[uiimage imagenamed: @"ship.png"].cgimage; [self.containerview.layer addsublayer:shiplayer]; //animate the ship rotation cabasicanimation *animation = [cabasicanimation animation]; animation.keypath = @"transform.rotation"; animation.duration = 2.0; animation.byvalue = @(m_pi * 2); [shiplayer addanimation:animation forkey:nil]; } @end
结果运行的特别好,用transform.rotation
而不是transform
做动画的好处如下:
-
我们可以不通过关键帧一步旋转多于180度的动画。
-
可以用相对值而不是绝对值旋转(设置
byvalue
而不是tovalue
)。 -
可以不用创建
catransform3d
,而是使用一个简单的数值来指定角度。 -
不会和
transform.position
或者transform.scale
冲突(同样是使用关键路径来做独立的动画属性)。
transform.rotation
属性有一个奇怪的问题是它其实并不存在。这是因为catransform3d
并不是一个对象,它实际上是一个结构体,也没有符合kvc
相关属性,transform.rotation实际上是一个calayer
用于处理动画变换的虚拟属性。
你不可以直接设置transform.rotation
或者transform.scale
,他们不能被直接使用。当你对他们做动画时,core animation自动地根据通过cavaluefunction
来计算的值来更新transform
属性。
cavaluefunction
用于把我们赋给虚拟的transform.rotation
简单浮点值转换成真正的用于摆放图层的catransform3d
矩阵值。你可以通过设置capropertyanimation
的valuefunction
属性来改变,于是你设置的函数将会覆盖默认的函数。
cavaluefunction
看起来似乎是对那些不能简单相加的属性(例如变换矩阵)做动画的非常有用的机制,但由于cavaluefunction
的实现细节是私有的,所以目前不能通过继承它来自定义。你可以通过使用苹果目前已近提供的常量(目前都是和变换矩阵的虚拟属性相关,所以没太多使用场景了,因为这些属性都有了默认的实现方式)。
8.2 动画组
动画组
cabasicanimation
和cakeyframeanimation
仅仅作用于单独的属性,而caanimationgroup
可以把这些动画组合在一起。caanimationgroup
是另一个继承于caanimation
的子类,它添加了一个animations数组的属性,用来组合别的动画。我们把清单8.6那种关键帧动画和调整图层背景色的基础动画组合起来(清单8.10),结果如图8.3所示。
清单8.10 组合关键帧动画和基础动画
- (void)viewdidload { [super viewdidload]; //create a path uibezierpath *bezierpath = [[uibezierpath alloc] init]; [bezierpath movetopoint:cgpointmake(0, 150)]; [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 = bezierpath.cgpath; pathlayer.fillcolor = [uicolor clearcolor].cgcolor; pathlayer.strokecolor = [uicolor redcolor].cgcolor; pathlayer.linewidth = 3.0f; [self.containerview.layer addsublayer:pathlayer]; //add a colored layer calayer *colorlayer = [calayer layer]; colorlayer.frame = cgrectmake(0, 0, 64, 64); colorlayer.position = cgpointmake(0, 150); colorlayer.backgroundcolor = [uicolor greencolor].cgcolor; [self.containerview.layer addsublayer:colorlayer]; //create the position animation cakeyframeanimation *animation1 = [cakeyframeanimation animation]; animation1.keypath = @"position"; animation1.path = bezierpath.cgpath; animation1.rotationmode = kcaanimationrotateauto; //create the color animation cabasicanimation *animation2 = [cabasicanimation animation]; animation2.keypath = @"backgroundcolor"; animation2.tovalue = (__bridge id)[uicolor redcolor].cgcolor; //create group animation caanimationgroup *groupanimation = [caanimationgroup animation]; groupanimation.animations = @[animation1, animation2]; groupanimation.duration = 4.0; //add the animation to the color layer [colorlayer addanimation:groupanimation forkey:nil]; }
8.3 过渡
过渡
有时候对于ios应用程序来说,希望能通过属性动画来对比较难做动画的布局进行一些改变。比如交换一段文本和图片,或者用一段网格视图来替换,等等。属性动画只对图层的可动画属性起作用,所以如果要改变一个不能动画的属性(比如图片),或者从层级关系中添加或者移除图层,属性动画将不起作用。
于是就有了过渡的概念。过渡并不像属性动画那样平滑地在两个值之间做动画,而是影响到整个图层的变化。过渡动画首先展示之前的图层外观,然后通过一个交换过渡到新的外观。
为了创建一个过渡动画,我们将使用catransition
,同样是另一个caanimation
的子类,和别的子类不同,catransition
有一个type和subtype来标识变换效果。type属性是一个nsstring
类型,可以被设置成如下类型:
kcatransitionfade kcatransitionmovein kcatransitionpush kcatransitionreveal
到目前为止你只能使用上述四种类型,但你可以通过一些别的方法来自定义过渡效果,后续会详细介绍。
默认的过渡类型是kcatransitionfade
,当你在改变图层属性之后,就创建了一个平滑的淡入淡出效果。
我们在第七章的例子中就已经用到过kcatransitionpush
,它创建了一个新的图层,从边缘的一侧滑动进来,把旧图层从另一侧推出去的效果。
kcatransitionmovein
和kcatransitionreveal
与kcatransitionpush
类似,都实现了一个定向滑动的动画,但是有一些细微的不同,kcatransitionmovein
从顶部滑动进入,但不像推送动画那样把老土层推走,然而kcatransitionreveal
把原始的图层滑动出去来显示新的外观,而不是把新的图层滑动进入。
后面三种过渡类型都有一个默认的动画方向,它们都从左侧滑入,但是你可以通过subtype来控制它们的方向,提供了如下四种类型:
kcatransitionfromright kcatransitionfromleft kcatransitionfromtop kcatransitionfrombottom
一个简单的用catransition
来对非动画属性做动画的例子如清单8.11所示,这里我们对uiimage的image
属性做修改,但是隐式动画或者capropertyanimation
都不能对它做动画,因为core animation不知道如何在插图图片。通过对图层应用一个淡入淡出的过渡,我们可以忽略它的内容来做平滑动画(图8.4),我们来尝试修改过渡的type
常量来观察其它效果。
清单8.11 使用catransition
来对uiimageview
做动画
@interface viewcontroller () @property (nonatomic, weak) iboutlet uiimageview *imageview; @property (nonatomic, copy) nsarray *images; @end @implementation viewcontroller - (void)viewdidload { [super viewdidload]; //set up images self.images = @[[uiimage imagenamed:@"anchor.png"], [uiimage imagenamed:@"cone.png"], [uiimage imagenamed:@"igloo.png"], [uiimage imagenamed:@"spaceship.png"]]; } - (ibaction)switchimage { //set up crossfade transition catransition *transition = [catransition animation]; transition.type = kcatransitionfade; //apply transition to imageview backing layer [self.imageview.layer addanimation:transition forkey:nil]; //cycle to next image uiimage *currentimage = self.imageview.image; nsuinteger index = [self.images indexofobject:currentimage]; index = (index + 1) % [self.images count]; self.imageview.image = self.images[index]; } @end
你可以从代码中看出,过渡动画和之前的属性动画或者动画组添加到图层上的方式一致,都是通过-addanimation:forkey:
方法。但是和属性动画不同的是,对指定的图层一次只能使用一次catransition
,因此,无论你对动画的键设置什么值,过渡动画都会对它的键设置成“transition”
,也就是常量kcatransition
。
8.4 在动画过程中取消动画
在动画过程中取消动画
之前提到过,你可以用-addanimation:forkey:
方法中的key参数来在添加动画之后检索一个动画,使用如下方法:
- (caanimation *)animationforkey:(nsstring *)key;
但并不支持在动画运行过程中修改动画,所以这个方法主要用来检测动画的属性,或者判断它是否被添加到当前图层中。
为了终止一个指定的动画,你可以用如下方法把它从图层移除掉:
- (void)removeanimationforkey:(nsstring *)key;
或者移除所有动画:
- (void)removeallanimations;
动画一旦被移除,图层的外观就立刻更新到当前的模型图层的值。一般说来,动画在结束之后被自动移除,除非设置removedoncompletion
为no
,如果你设置动画在结束之后不被自动移除,那么当它不需要的时候你要手动移除它;否则它会一直存在于内存中,直到图层被销毁。
我们来扩展之前旋转飞船的示例,这里添加一个按钮来停止或者启动动画。这一次我们用一个非nil的值作为动画的键,以便之后可以移除它。-animationdidstop:finished:
方法中的flag
参数表明了动画是自然结束还是被打断,我们可以在控制台打印出来。如果你用停止按钮来终止动画,它会打印no
,如果允许它完成,它会打印yes
。
清单8.15是更新后的示例代码,图8.6显示了结果。
清单8.15 开始和停止一个动画
@interface viewcontroller () @property (nonatomic, weak) iboutlet uiview *containerview; @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]; } - (ibaction)start { //animate the ship rotation cabasicanimation *animation = [cabasicanimation animation]; animation.keypath = @"transform.rotation"; animation.duration = 2.0; animation.byvalue = @(m_pi * 2); animation.delegate = self; [self.shiplayer addanimation:animation forkey:@"rotateanimation"]; } - (ibaction)stop { [self.shiplayer removeanimationforkey:@"rotateanimation"]; } - (void)animationdidstop:(caanimation *)anim finished:(bool)flag { //log that the animation stopped nslog(@"the animation stopped (finished: %@)", flag? @"yes": @"no"); } @end
8.5 总结
总结
这一章中,我们涉及了属性动画(你可以对单独的图层属性动画有更加具体的控制),动画组(把多个属性动画组合成一个独立单元)以及过度(影响整个图层,可以用来对图层的任何内容做任何类型的动画,包括子图层的添加和移除)。
在第九章中,我们继续学习camediatiming
协议,来看一看core animation是怎样处理逝去的时间。