@zhangyuhangk
2015-08-12T15:01:44.000000Z
字数 14185
阅读 3545
CoreAnimation
教程:https://github.com/AttackOnDobby/iOS-Core-Animation-Advanced-Techniques
本来想写成一篇笔记就好,但是发现写太长了,机子会变得很卡……不知道是不是Cmd Markdown的bug。
分成两篇应该差不多了
默认情况下,Core Animation会开启隐式动画,动画时间为0.25秒。CALayer的一些特定属性(文档内会标记为Animatable),被修改了值之后,就会触发隐式动画。
CATransaction是动画的事务。
设置属性前一定要调用CATransaction.begin(),设置后一定要调用CATransaction.commit()。
begin
和commit
相当于事务的入栈和出栈。
系统在每一帧开始时都会调用begin,最后调用commit,这就实现了隐式动画。
可以显式地调用begin再压入一个栈,这样可以修改动画的一些参数(比如时间),而不影响其它动画。比如:
CATransaction.begin()
CATransaction.setAnimationDuration(3)
colorLayer.backgroundColor = UIColor.redColor.CGColor
CATransaction.commit()
setCompletionBlock
方法: 可以设置一个block作为动画全部结束时的通知。
注意: 必须在设置属性之前调用。如果在最后commit前才调用setCompletionBlock,则block会被马上执行!
The completion block object that is guaranteed to be called (on the main thread) as soon as all animations subsequently added by this transaction group have completed (or have been removed.)
根据文档的说明,如果在setCompletionBlock之后没有动画添加,则直接执行block。
setDisableActions
: 禁用隐式动画
CALayer的属性被修改时,会向delegate寻求对应的action(实现了CAAction接口的类型,如CABasicAnimation),寻找的路径依次为:
- 图层首先检测它是否有委托,并且是否实现CALayerDelegate协议指定的actionForLayer:forKey方法。如果有,直接调用并返回结果。
- 如果没有委托,或者委托没有实现actionForLayer:forKey方法,图层接着检查包含属性名称对应行为映射的actions字典。
- 如果actions字典没有包含对应的属性,那么图层接着在它的style字典接着搜索属性名。
- 最后,如果在style里面也找不到对应的行为,那么图层将会直接调用定义了每个属性的标准行为的defaultActionForKey:方法。
UIView实现了actionForLayer:forKey方法,并返回了nil,所以一般情况下,UIView的动画是禁用的。除非调用了animateWithDuration之类的动画函数。这些函数会在block之前执行beginAnimations:context:函数,最后再执行commitAnimations。在这两个调用之间,actionForLayer:forKey会返回非空值以产生动画。
对于单独的layer,一般可以用两种方式来修改隐式动画效果
let transition = CATransition()
transition.type = kCATransitionPush
transition.subtype = kCATransitionFromLeft
colorLayer.actions = ["backgroundColor": transition]
以上代码设置了backgroundColor的过渡效果,变成从左向右滑入的动画
presentationLayer
: 呈现图层。
设置属性时,layer上的属性会马上设置成新值,但界面上却没有马上显示当前值,而是在两个值之间逐帧过渡过来。这个中间状态就保存在呈现图层。presentationLayer属性可以返回layer对应的呈现图层,而呈现图层的modelLayer属性则返回layer。这是一个微型的MVC,layer就是model,presentation layer就是view。
下面的例子显示一个蓝色小方块。点击空白处,方块移动到该位置。在移动的过程中,如果点中方块,方块随机换一种颜色。
lazy var colorLayer: CALayer = {
let layer = CALayer()
layer.bounds = CGRect(x: 0, y: 0, width: 100, height: 100)
layer.position = self.view.center
layer.backgroundColor = UIColor.blueColor().CGColor
return layer
}()
func testColorAnimation() {
view.layer.addSublayer(colorLayer)
}
override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) {
let point = touches.first!.locationInView(view)
let presentationLayer = colorLayer.presentationLayer() as! CALayer
if presentationLayer.hitTest(point) != nil {
colorLayer.backgroundColor = randomColor().CGColor
} else {
CATransaction.begin()
CATransaction.setAnimationDuration(4)
colorLayer.position = point
CATransaction.commit()
}
}
func randomColor() -> UIColor {
return UIColor(red: randomPercent(), green: randomPercent(), blue: randomPercent(), alpha: 1)
}
func randomPercent() -> CGFloat {
return CGFloat(random() % 256) / 255
}
UIView中默认禁用了隐匿动画,所以需要动画的地方需要显示地指定。
基本动画。
CABasicAnimation的继承路径:
CABasicAnimation -> CAPropertyAnimation -> CAAnimation
removedOnCompletion
: CAAnimation的属性,默认为true。表示动画结束时自己移除动画对象。
主要属性:
fromValue
: 起始值,如不指定,则用当前值toValue
和byValue
: 最终值和累加值。只能指定其中一个。这几个都是AnyObject?
类型,其可能的类型有:
使用方法如下:
let animation = CABasicAnimation()
animation.duration = 1
animation.keyPath = "transform"
animation.toValue = NSValue(CATransform3D: CATransform3DMakeRotation(CGFloat(M_PI_4), 0, 0, 1))
view.layer.addAnimation(animation, forKey: nil)
以上代码可以实现旋转view的动画,但是动画执行完,view又回到原来的状态。这是因为动画类只影响了呈现层,而没有把最终值应用 到layer本身。
由于CAAnimation支持KVC,可以加以下代码,在动画完成时赋值。
需要实现CAAnimationDelegate协议,这又是个不存在声明的协议,只能在文档中找到说明。
animation.delegate = self
animation.setValue(view, forKey: "animatedView")
然后在动画结束时赋值
override func animationDidStop(anim: CAAnimation, finished flag: Bool) {
let view = anim.valueForKey("animatedView") as! UIView
let basicAnimation = anim as! CABasicAnimation
let value = basicAnimation.toValue as! NSValue
view.layer.transform = value.CATransform3DValue
}
关键帧动画。可以指定多个变化的值。
主要属性:
values
: 指定多个变化的值。path
: 指定一个变化路径。keyPath必须为CGPoint类型(如position)才能使用path。rotationMode
: 自动旋转模式。当layer在path上移动时,本身是不旋转的。如果指定该属性为kCAAnimationRotateAuto或kCAAnimationRotateAutoReverse,则layer会自动旋转以对准path的切线。 keyPath可以设置为一些『虚拟』的属性,比如:transform.rotation
具体可用的值如下:
虚拟属性的计算是通过CAPropertyAnimation的valueFunction属性(类型是CAValueFunction)来换算成CATransform3D的
CAAnimationGroup的主要属性是animations,可以添加多个动画。每个子动画可以指定各自的时间,group本身也可以设定duration。当group的duration时间到或者所有子动画结束时,整个group结束。
对于没有标记为Animatable的属性的变化,需要用到过渡来做动画。过渡相当于整个layer画面的切换(layer实例没变)
CATransition主要属性:
type
: 切换效果,支持4种效果: subtype
: 滑动方向 使用下面的代码来添加过渡效果:
let transition = CATransition()
transition.type = kCATransitionPush
transition.subtype = kCATransitionFromBottom
imageView.layer.addAnimation(transition, forKey: nil)
addAnimation:forKey:函数中,只要传入的是CATransition类型,后面的key会被置为kCATransition,无论传入什么值。所以对同一个layer,一次只能指定一个过渡效果。
UIView提供了两个函数,可以方便地进行过渡动画:
transitionWithView:duration:options:animations:
transitionFromView:toView:duration:options:completion:
这两个函数的options参数是UIViewAnimationsOptions类型,选项比较丰富(比CATransition的type提供的类型多,不知道为啥):
(layer中的renderInContext:函数可以把当前画面绘制到context上,可以再调用UIGraphicsGetImageFromCurrentImageContext()获得UIImage)
可以使用removeAnimationForKey:或removeAllAnimations取消动画。
被取消的动画仍然会触发animationDidStop:finished:函数,后面的finished标志会传入false
时间控制是通过CAMediaTiming协议实现。CALayer和CAAnimation都实现了该协议。
duration
: 控制一次循环的时间(默认为0,等同于0.25)repeatCount
: 控制循环次数(默认为0,等同于1)repeatDuration
: 在此时间内不断循环。(repeatCount和repeatDuration只能指定一个)autoreverses
: 播放完自动反向播放。(如果duration=1,repeatCount=3,autoreverses为true,则动画的总时间为6秒,应该每次循环都要反向播一次)beginTime
: 动画开始(调用addAnimation:forKey:的时间)前的延时时间speed
: 速度倍率。假如duration为1,而speed为2,则动画播放时间只要0.5秒。beginTime也受speed影响。timeOffset
: 从动画的某一秒开始播放(不受speed影响)。注意: 跳过的这部分动画仍然会在后面补回来。fillMode
: 填充模式。这个不好理解。 动画的时间是按动画的层级关系呈树状依赖(就像坐标系统一样)。
对CALayer或者CAGroupAnimation调整duration和repeatCount/repeatDuration属性并不会影响到子动画。但是beginTime,timeOffset和speed属性将会影响到子动画。然而在层级关系中,beginTime指定了父图层开始动画(或者组合关系中的父动画)和对象将要开始自己动画之间的偏移。类似的,调整CALayer和CAGroupAnimation的speed属性将会对动画以及子动画速度应用一个缩放的因子。
已经添加的动画无法再修改属性(会抛异常),所以可以通过修改其所属的layer来间接控制动画。
以下代码将layer的speed设置为0,使动画暂停。然后通过修改timeOffset到手动控制动画的进度。
lazy var curvePath: UIBezierPath = {
let path = UIBezierPath()
path.moveToPoint(self.pointFromCenter(0, -300))
path.addCurveToPoint(self.pointFromCenter(0, 300),
controlPoint1: self.pointFromCenter(-300, 0),
controlPoint2: self.pointFromCenter(300, 0))
return path
}()
lazy var colorLayer: CALayer = {
let layer = CALayer()
layer.bounds = CGRect(x: 0, y: 0, width: 100, height: 100)
layer.position = self.view.center
layer.backgroundColor = UIColor.blueColor().CGColor
return layer
}()
func testAnimationControl() {
view.layer.addSublayer(colorLayer)
let shape = CAShapeLayer()
shape.path = curvePath.CGPath
shape.fillColor = UIColor.clearColor().CGColor
shape.strokeColor = UIColor.redColor().CGColor
shape.lineWidth = 3
shape.lineCap = kCALineCapRound
view.layer.addSublayer(shape)
let animation = CAKeyframeAnimation(keyPath: "position")
animation.duration = 1
animation.path = curvePath.CGPath
colorLayer.addAnimation(animation, forKey: nil)
colorLayer.speed = 0
view.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: "controlAnimation:"))
}
func controlAnimation(gesture: UIPanGestureRecognizer) {
let sy = gesture.translationInView(view).y
let percent = sy / 600
colorLayer.timeOffset = max(0, min(0.999, colorLayer.timeOffset + CFTimeInterval(percent)))
gesture.setTranslation(CGPoint.zeroPoint, inView: view)
}
在屏幕上拖动就可以控制动画了。
(问题:这样会影响这个layer上的所有动画,如果我只想控制一个动画呢?)
对于隐式动画讲,动画就是一个属性在两个值之间的变化过程。比如说移动(position)。它可以是匀速运动,也可以是加速或减速运动。这就涉及计算每一帧位置的算法。对于CAAnimation,就是timingFunction。这是个CAMediaTimingFunction类型,它有一个init(name: String)构造函数,可以传入以下值:
(x轴是时间,y轴是速度)
单独的CALayer隐式动画使用的默认值是kCAMediaTimingFunctionDefault,而UIView里的layer的默认值是kCAMediaTimingFunctionEaseInEaseOut.
前面说过的UIView过渡函数的options参数也有相对应的几个值:
CAKeyframeAnimation有个timingFunctions属性,可以传多个CAMediaTimingFunction。其数量必须为values的数量减1,因为它是用来计算每两个值之间的过渡动画的。也可以直接指定timingFunction(没有s),则所有动画都用这个算法。
CAMediaTimingFunction的『速度/时间』曲线可以由『3次贝塞尔曲线』描述(如上面的图)
构造函数如下:
init(controlPoints c1x: Float,
_ c1y: Float,
_ c2x: Float,
_ c2y: Float)
『3次贝塞尔曲线』涉及4个点,起点和终点为(0, 0)和(1, 1),这里只需传入中间的两个控制点。
可以使用CAKeyframeAnimation把多个曲线动画连在一起,如下:
func testMultipleKeyframesAnimation() {
view.layer.addSublayer(colorLayer)
colorLayer.position.y -= 300
let p = colorLayer.position
let animation = CAKeyframeAnimation(keyPath: "position")
animation.duration = 6
animation.values = [
NSValue(CGPoint: p),
NSValue(CGPoint: CGPoint(x: p.x + 100, y: p.y + 200)),
NSValue(CGPoint: CGPoint(x: p.x + 100, y: p.y + 400)),
NSValue(CGPoint: CGPoint(x: p.x, y: p.y + 600)),
]
animation.timingFunctions = [
CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut),
CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear),
CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseIn),
]
animation.keyTimes = [0, 0.2, 0.8, 1]
colorLayer.addAnimation(animation, forKey: nil)
}
以上动画共有4个关键点(包含起点和终点),所以需要3个timing function,另外还可指定每帧的时间点。
keyTimes
属性一般来说是以0开始,以1结束,中间每个数字要大于或等于前一个数字。每个数字代表一个时间比例。数组的数量要与values一致。具体限制还受calculationMode影响,可以看文档。
上面的实现方法比较死板且不易维护,对于复杂的动画(比如球的多次弹跳)可以通过公式事先生成一系列的关键帧来实现。
这个网站有提供一些算法
http://robertpenner.com/easing/
借助上述网站的bounceEaseOut算法,实现小球弹跳3次至静止的动画:
lazy var ballLayer: CALayer = {
let ball = CALayer()
ball.bounds = CGRect(x: 0, y: 0, width: 100, height: 100)
ball.position = self.view.center
ball.cornerRadius = 50
ball.backgroundColor = UIColor.greenColor().CGColor
return ball
}()
func testBouncingBall() {
view.layer.addSublayer(ballLayer)
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: "dropBallToHere:"))
}
func dropBallToHere(gesture: UITapGestureRecognizer) {
let beginPosition = ballLayer.position
let destinationPosition = gesture.locationInView(view)
let duration: CFTimeInterval = 2
var positions = [CGPoint]()
let frames = Int(duration * 60)
for i in 0..<frames {
var time = CGFloat(i) / CGFloat(frames)
time = bounceEaseOut(time)
positions.append(interpolateFromPoint(beginPosition, toPoint: destinationPosition, time: time))
}
let animation = CAKeyframeAnimation(keyPath: "position")
animation.duration = duration
animation.values = positions.map{ NSValue(CGPoint: $0) }
ballLayer.addAnimation(animation, forKey: nil)
CATransaction.begin()
CATransaction.setDisableActions(true)
ballLayer.position = destinationPosition
CATransaction.commit()
}
func interpolateFromPoint(fromPoint: CGPoint, toPoint: CGPoint, time: CGFloat) -> CGPoint {
let x = interpolateFromValue(fromPoint.x, toValue: toPoint.x, time: time)
let y = interpolateFromValue(fromPoint.y, toValue: toPoint.y, time: time)
return CGPoint(x: x, y: y)
}
func interpolateFromValue(fromValue: CGFloat, toValue: CGFloat, time: CGFloat) -> CGFloat {
return fromValue + (toValue - fromValue) * time
}
注意:iOS的帧率是60/s,这里使用duration * 60来计算关键帧数。这属于比较土的办法。
可以通过NSTimer要手动绘制动画(不通过CAAnimation)。把时间设置为1/60秒,然后每帧调用timing function计算位置。但是NSTimer并不能保证帧率。更好的方案是用CADisplayLink。
CADisplayLink是CoreAnimation提供的另一个类似于NSTimer的类,它总是在屏幕完成一次更新之前启动,它的接口设计的和NSTimer很类似,所以它实际上就是一个内置实现的替代,但是和timeInterval以秒为单位不同,CADisplayLink有一个整型的frameInterval属性,指定了间隔多少帧之后才执行。默认值是1,意味着每次屏幕更新之前都会执行一次。但是如果动画的代码执行起来超过了六十分之一秒,你可以指定frameInterval为2,就是说动画每隔一帧执行一次(一秒钟30帧)或者3,也就是一秒钟20次,等等。
CACurrentMediaTime
函数返回当前“马赫时间”,即设备启动后的时间。
把上面的代码改成用CADisplayLink实现
var runningAnimation = false
var fromPosition: CGPoint!
var toPosition: CGPoint!
var duration: CFTimeInterval = 0
var startTime: CFTimeInterval = 0
func testCADisplayLink() {
view.layer.addSublayer(ballLayer)
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: "dropBall:"))
let timer = CADisplayLink(target: self, selector: "dropBallStep:")
timer.addToRunLoop(NSRunLoop.mainRunLoop(), forMode: NSDefaultRunLoopMode)
}
func dropBall(gesture: UITapGestureRecognizer) {
guard !runningAnimation else {
print("animation is not completed yet.")
return
}
fromPosition = ballLayer.position
toPosition = gesture.locationInView(view)
duration = 2
startTime = CACurrentMediaTime()
runningAnimation = true
}
func dropBallStep(timer: CADisplayLink) {
guard runningAnimation else { return }
let pastTime = CACurrentMediaTime() - startTime
let time = bounceEaseOut(CGFloat(pastTime / duration))
guard time <= 1 else {
runningAnimation = false
return
}
CATransaction.begin()
CATransaction.setDisableActions(true)
let position = interpolateFromPoint(fromPosition, toPoint: toPosition, time: time)
ballLayer.position = position
CATransaction.commit()
}
此处把CADisplayLink加入到main run loop里了,后面的run loop mode代表优先级,有3种可能值:
优先级如果设置得太高,有可能导致其它动画不能动。
教材中直接使用了物理引擎chipmunk,因为作者写这本书时用的还是iOS6,现在已经可以使用iOS7引入的UIDynamic框架了。这部分内容较多,另开一篇在这里。
后面的内容基本是讲优化了,放到第三篇里。