苹果中的动画采用的是 "按需求播放" 这样的形式, 即不需要自己计算许多参数, 只需要提供如何动画的要求, 系统自动去计算相关的参数.
需要将动画看作是用户交互的一种反馈或提示, 而不是简单的效果而已.
1 绘图, 动画和线程
绘图和动画是相辅相成的, 即当提供绘制指令后, 系统并不立即执行, 而是等到一个绘制时机统一执行, 这个时机称为 redraw moment.
动画和绘图的执行是一个道理. 动画拥有帧(frame)的概念, 即动画是由一张一张的帧组成的.
苹果将执行动画的系统组件称为 animation server.
动画是在用户和真实的屏幕显示中间插入了一段"电影"画面, 当动画结束后, 这个电影画面也就从屏幕上移除了, 然后恢复真实屏幕的显示. 但用户不会察觉到这点, 因为当动画结束后, 真实屏幕上的绘制也会变为和动画结束时的状态一样, 但结束状态需要程序员来保证其正确性.
一个简单 view 动画流程如下:
- 将 view 由位置 1 移动到位置 2, 由于没有到 redraw 时机, 故现在屏幕没有任何变化.
- 提出一个动画请求, 动画的内容是 view 从位置 1 移动到位置 2. 由于没有到 redraw 时机, 故屏幕也没有任何变化.
- 系统将所有代码执行完毕后, 出现空闲时机, 即 redraw 时机.
- 在 redraw 时机后, 系统将动画进行播放.
- 动画结束后, view 也和动画的最后一帧的状态一致.
需要对动画过程有正确认识: 动画只是在真实屏幕上的一层"电影"效果. 不过实际上并不是真的存在"电影"效果, 只是为了方便理解.
真实的情况是, 在进行动画时, 并非现有的 layer 在进行动画, 而是单独的一个 presentation layer
, 在这个图层上显示动画的每一帧效果.
layer 的 presentation layer 可以通 presentation
方法来访问, 而 presentation layer 对应的 layer 可以通过 presentation layer 的 model
属性来获取.
另外 Animation server 组件是自动在单独线程执行的, 所以不用担心线程管理问题. 但需要对自己的界面终态进行操作, 以符合动画终态.
在动画结束事件到达时, 可以在对应的方法中去开始下一个动画, 或者是去做一些清理工作.
2 ImageView 和 Image 动画
绘图也是首先讲的 Image 绘制, 这里就先来看 Image 的动画.
给 UIImageView 的 animationImages
或 highlightedAnimationImages
属性设置一个 UIImage 数组, 这个数组代表的就是动画的每一帧, 然后向 UIImageView 发送 startAnimating
消息, 它就开始动画. 动画时根据 animationDuration
的设置来决定如何计算帧的出现时间. 默认情况下动画是无限循环的, 可以通过 animationRepeatCount
来设置循环次数. 还可通过 stopAnimating
消息来结束动画. 在动画开始前和结束后, 它都是在显示 image
属性或 highlightedImage
属性对应的图片.
有一个技巧就是通过图片上下文绘制若干张图片保存到数组中, 然后将这个数组赋值给 UIImageView 的动画图片数组, 让 UIImageView 来进行动画.
3 View 动画
所有的动画本质上都是图层动画. 只是在系统中为 UIView 提供了一些属性, 可以方便直接通过 UIView 进行动画.
对 View 进行动画的方式目前有三种:
- 开始--提交方式: 很少使用
- 动画块方式: 当前最常用, 除了需要重复进行固定次数循环动画的情况.
- 属性动画器: iOS 10 之后新增. 它不是来替代动画块的, 而是对动画块的扩展和补充. 这个应该会在未来慢慢推广使用.
下面就来看一些动画基础.
对于 View 而言, 在动画块包裹中的所有内容, 只要是对可动画属性的修改, 就会生成动画. 并且由于是直接在操作诸如位置等属性, 故视图的终态就和动画终态是一致的. 但这里有一个问题, 如果使用约束的话, 约束没有改变, 则在未来的任何时候, 如果重新布局, 则界面中的状态会回到初态, 所以约束布局的情况下需要单独在动画时对约束进行处理, 即重新定义约束后, 再在动画块中调用 view 的 layoutIfNeeded
方法.
如果想要动画块中的某些可动画属性改变不会计入动画, 则可以调用 UIView 的类方法 performWithoutAnimation
, 将这些代码写到其中即可.
当进行重复动画时, 指定重复次数需要一些技巧. 默认情况下 UIView 的动画如果指定重复选项, 动画是永远重复的.
不过可以通过在动画块中通过 UIView 的类方法 setAnimationRepeatCount
设置次数. 即不指定 repeat 选项的情况下使用这个方法来指定重复次数.
弹性动画的两个参数:
- Damping ratio: 取值0到1, 描述的是最终的震荡效果, 值越小, 最终晃动越大, 0.8 是比较合适的数值.
- Initial velocity: 初速度, 值越大的话, 则动画将要结束时, 到达终点后偏离终点的距离越大. 一般设置为0, 看需要什么效果来定.
4 取消 View 动画
动画在进行过程中如何停止动画?
在 iOS 10 引入属性动画器之前, 往往都是将动画从图层中移除的方式来停止动画.
但这个方法的缺点很明显: 移除动画后, 正在进行的动画就生硬地没了, 视图也直接到达终态.
故取消动画的一个正确做法(或者说是普遍做法)是让动画加速行进至终态. 这正是动画叠加的绝佳应用场合.
但之前的动画是在行进过程中, 只有先将所有动画取消, 但这样会导致视图直接处于终态. 如果可以获取到在取消时刻的动画状态, 然后取消之前动画, 再插入一个新的加速动画, 则可以将这个问题解决.
而上述方法的核心就是利用 presentation layer, 将视图在取消时刻的状态和当前 presentation layer 的状态设置为一致, 然后再开始新加速后的动画.
流程即:
- 获取视图根图层的 presentation layer 的当前某个需要的状态, 然后将其赋值给视图的根图层.
- 将视图动画(在根图层上的动画)取消掉.
- 新建一个动画, 该动画的终态是原来的终态, 但时间缩短很多.
- 如果动画取消的上下文意味着动画回到原来位置, 则将这个动画的终态设置为视图初始状态即可.
- 另外如果取消在当前的意思是停在当前位置, 则就不加新建的这个动画.)
这样的效果就可以很平滑了.
private func cancelSprintAnimation() {
let presentationLayer = self.aView.layer.presentation()!
// 将进行动画的视图状态赋值为其 presentationLayer 的当前状态, 这里是 position
self.aView.layer.position = presentationLayer.position
// 移除之前的动画
self.aView.layer.removeAllAnimations()
print(aView.center)
// 开始新的加速动画, 这里是加速到最终位置.
UIView.animate(withDuration: 0.1) {
self.aView.center.y = self.view.bounds.height / 2.0
}
}
private func performSpringAnimation() {
print("进行弹性动画")
UIView.animate(withDuration: 2.0, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0, options: [], animations: {
self.aView.center.y = 450
})
}
上面设置 layer 的属性为 presentation layer 的当前属性, 因为动画实际是 presentation layer 在进行的.
取消 repeat 动画
重复动画取消的话, 也是采用加速到终态或加速回到初态. 或保持在当前状态.
因为重复动画不会被其他动画叠加, 所以在重复动画上添加动画实际就自动把重复动画取消了. 然后利用 beginFromCurrentState
选项, 即可做到之前的平滑取消动画.
private func performRepeatAnimation() {
UIView.animate(withDuration: 2.0, delay: 0, options: [.repeat, .autoreverse], animations: {
self.aView.center.y = 600
}, completion: nil)
}
private func cancelRepeatAnimation() {// 取消重复动画
UIView.animate(withDuration: 0.1, delay: 0, options: [.beginFromCurrentState], animations: {
self.aView.center.y = self.view.frame.height / 2.0
}, completion: nil)
}
取消重复动画时, 如果下一个加速动画的终态是和重复终态相同的话, 则有 bug... 取消动画没有被执行, 故需要加速动画的终态不和重复终态一样, 比如下面的本来需要 600, 这里设置 600.000001:
private func cancelRepeatAnimation() {
UIView.animate(withDuration: 0.1, delay: 0, options: [.beginFromCurrentState], animations: {
self.aView.center.y = 600.000001
}, completion: nil)
}
如果想要的效果是在当前位置停下, 则也是获取 presentation layer 的当前参数, 然后在动画块中赋值给 layer 即可.
可以使用一个视图属性将若干可动画属性进行改变, 然后进行动画, 这个先了解一下.
5 帧动画
原理都一样, 不过可以通过帧动画来组装一些复杂的动画效果.
6 转变动画
transition 动画表示的是视图内容的改变, 有两种:
- transition(with:duration:options:animations:completion:): 对一个视图的内容改变进行动画, 提供的选项详见文档.
- transition(from:to:duration:options:completion:): 将 fromView 替换为 toView.
7 隐式 Layer 动画
只有当 Layer 布局并显示到界面上后, 改变它的可动画属性才会出现动画. 另外根 layer 只能进行显式动画.
layer 的隐式动画都是在 CATransaction 的上下文中执行的. 另外总是有一个不可见的 CATransaction "包围着"代码, 故可以不用调用 CATransaction 的 begin 和 commit 也能改变动画的一些属性, 比如:
CATransaction.setAnimationDuration(0.8)
arrow.transform = CATransform3DRotate(arrow.transform, .pi/4.0, 0, 0, 1)
8 核心动画
下面正式进入 Core Animation 的内容.
CA 动画就是指的显式图层动画, 主要使用 CAAnimation 和它的子类来实现动画.
在使用 CA 进行动画时, 视图的终态不会自动设置, 需要手动设. 不然视图在动画完成后会回到原位置, 因为动画是在 layer 上进行的.
8.1 CABasicAnimation
CA 的使用方式是: 创建动画对象(CAAnimation子类型), 然后将它添加到 layer 上即可. 添加时需要一个 key, 这个key用于标志唯一的动画.
添加了动画对象后, layer 就开始动画(redraw 时机), 但动画结束后被移除掉, 此时 layer 的状态又会恢复到动画前, 故需要手动修改终态.
使用 CA 动画的基本模式是:
- 获取 layer 的某个当前动画对应的属性的初始值和终值.
- 将 layer 的属性改变为终值, 如果不想隐式动画起作用, 需要调用
setDisableActions(true)
. - 创建 CA 动画, 在其中指定需要动画的属性
- 将动画添加到 layer 上.
显式动画被添加到 layer 时, 实际添加的是它的不可变副本.
8.2 CAKeyFrameAnimation
这个是图层帧动画类
8.3 CASpringAnimation
这个是图层弹性动画类.
9 动画组
可以把一组动画通过 CAAnimationGroup
组合在一起, 每个单独的动画都添加到它的 animation
属性中, 通过动画的延迟和时长来决定动画的执行顺序, 从而可以完成许多复杂的效果.
CAAnimationGroup
本身就是 CAAnimation 的子类, 可以把它当成是一个父动画, 可以在其中添加子动画. 其中的子动画会继承它的一些默认属性值(如果子动画没有设置的话).
下面来实现一个动船的动画:
第一个动画: 图层沿指定曲线路径运动.
func createAnim1() -> CAAnimation {
let areaHeight: CGFloat = 200
let verticalSpace: CGFloat = 7
let path = CGMutablePath()
var leftright: CGFloat = 1 // 表示现在是朝左还是朝右, 左为 -右为 1.
// 以下代码生成需要的路径
var next: CGPoint = self.view.layer.position // 路径起点
var pos: CGPoint
path.move(to: CGPoint(next.x, next.y))
for _ in 0 ..< 4 {
pos = next
leftright *= -1
next = CGPoint(pos.x + areaHeight * leftright, pos.y verticalSpace)
path.addCurve(to: CGPoint(next.x, next.y),
control1: CGPoint(pos.x, pos.y + 30),
control2: CGPoint(next.x, next.y - 30))
}
endPoint = next // 记录最终点的位置.
// 动画1: 将图层的 position 沿着曲线运动. 这个动画添加到任何图层上都适用.
let anim1 = CAKeyframeAnimation(keyPath: #keyPa(CALayer.position))
anim1.path = path
anim1.calculationMode = kCAAnimationPaced
return anim1
}
第二个动画: 船需要在转弯的时候同时翻转, 否则看起来就不正常了. 翻转时直接沿着 Y 轴旋转即可. 第二个动画需要和第一个动画配合, 当第一个动画中小船每次处于曲线的顶点时, 都需要对其进行翻转, 如果遇到比较复杂的情况, 则需要定义 keyTimes
数组, 让两个动画可以协作.
func createAnim2() -> CAAnimation {
let revs = [0.0, .pi, 0.0, .pi]
let anim2 = CAKeyframeAnimation(keyPath:#keyPath(CALayer.transform))
anim2.values = revs
anim2.valueFunction = CAValueFunction(name:kCAValueFunctionRotateY)
anim2.calculationMode = kCAAnimationDiscrete
return anim2
}
第三个动画: 小船的重复震动效果, 模拟的是风雨飘摇.
func createAnim3() -> CAAnimation {
let pitches = [0.0, .pi/60.0, 0.0, -.pi/60.0, 0.0]
let anim3 = CAKeyframeAnimation(keyPath:#keyPath(CALayer.transform))
anim3.values = pitches
anim3.repeatCount = .infinity
anim3.duration = 0.5
anim3.isAdditive = true
anim3.valueFunction = CAValueFunction(name:kCAValueFunctionRotateZ)
return anim3
}
最后通过动画组将三个动画组合, 然后将动画组应用到小船图层上.
private func addAnimToBoatImageViewLayer() {
let animGroup = createAnimGroup()
boatImageView.layer.add(animGroup, forKey: nil)
CATransaction.setDisableActions(true)
boatImageView.layer.position = endPoint ?? .zero
}
利用 CAAnimationGroup 可以实现许多复杂的动画效果, 这里看到的小船动画就是一个.
10 关于动画冻结
可以不把动画取消掉, 而是在某个位置将动画冻结, 这样在未来的某个时候, 可以手动继续开始动画.
由于 CALayer 有一个 speed 属性, 如果将它改为 0, 就可以把动画冻结. 另外还有一个 timeOffset 属性, 可以控制显示动画的任意一帧. 这两个属性结合后, 就可以实现动画按 timeOffset 的值来动态控制显示了.
func increase() {
guard shape.timeOffset + 0.1 <= 1 else { return }
shape.timeOffset += 0.1
}
func decrease() {
guard shape.timeOffset - 0.1 >= 0 else { return }
shape.timeOffset -= 0.1
}
func setupAnims() {
shape.frame = bounds
layer.addSublayer(shape)
shape.fillColor = UIColor.clear.cgColor
shape.strokeColor = UIColor.red.cgColor
let path = CGPath(ellipseIn: CGRect(10, 10, 50, 50), transform: nil)
shape.path = path
let path2 = CGPath(rect: CGRect(10, 10, 50, 50), transform: nil)
let basicAnim = CABasicAnimation(keyPath: #keyPath(CAShapeLayer.path))
basicAnim.duration = 1.0
basicAnim.fromValue = path
basicAnim.toValue = path2
shape.speed = 0
shape.timeOffset = 0
shape.add(basicAnim, forKey: nil)
}
11 关于图层的转变
图层的转变(transition) 指的是被转变的图层拥有两个"拷贝", 通过第二个替换第一个, 从而实现一些转变的效果.
主要是设置转变的类型和子类型来达到效果, 这里由于历史原因, 转变中的 Bottom 和 Top 正好和手机方向是相反的.
private func rootLayerTransition() {
let transition = CATransition()
transition.type = kCATransitionPush
transition.subtype = kCATransitionFromBottom
transition.duration = 2.0
CATransaction.setDisableActions(true)
layer.contents = UIImage(named: "img_highlighted")?.cgImage
layer.add(transition, forKey: nil)
}
这样的动画应用场景主要是父图层的 maskToBounds 属性是 true, 然后子图层从范围外移动到范围内的情况, 这样就可以达到动画效果, 且在父图层外不会看大子图层的移动效果.
12 动画列表
为了了解动画的内部原理, 需要首先看看什么是 Animation List.
显式动画是通过 CALayer 的 add
方法添加到图层上的. 动画对象(CAAnimation)改变的是图层的绘制方式. 当动画添加到图层上后, 剩下的工作都是由图层的绘制机制来完成的.
在图层中维护了一张当前正在或需要进行的动画的列表, 动画通过 add
方法添加到列表中, 当动画时机到达时, 图层就根据动画列表中的所有动画来决定如何将自己绘制出来, 且按照一定的顺序进行, 而绘制时候要进行的任务在文档中称为 rendering tree.
动画的添加顺序就是它们在绘制时候的执行顺序.
动画列表中如果存在某个 key, 当另外一个拥有相同 key 的动画添加进来时, 之前的相同 key 的动画会被移除掉.
如果添加动画时指定 key 为 nil, 则不受 key 值的影响, 即可以多次添加 nil key 的动画.
但是苹果对 keyPath 和 key 搞了一个小动作, 即如果在创建动画时将 keyPath 设置为 nil, 则动画默认指向的属性就是 add
方法中 key 对应的属性, 这个不得不说是个蛋疼的地方. 有时这样的结合规则会被误用.
所以一定要清楚地认识这两个方法参数的作用:
- 创建动画时一定要指定动画的 keyPath
- 添加动画时候的 key 一定只是作为不同动画的标记
这样的话, 在某些情况下就可以通过相同 key 的动画将之前的那个替换掉.
另外 CATransition 添加的动画的 key 一直是 "transition"(也就是 kCATransition
代表的字符串), 故同一个 layer 同时只能添加一个 CATransition 动画.
如果没有特殊处理的话, 当动画结束后, 动画就会从动画列表中移除掉. 当然可以设置动画的 isRemovedOnCompletion
来将它保留在动画列表中, 下次动画时机时就会再次被执行.
这里有一个经典的错误实现:
很多例子都是使用
isRemovedOnCompletion
结合 fillMode 设置来把图层动画的最终状态保留在动画的最后状态上, 但这样的错误的, 因为这个时候的图层只是 "看起来" 在最终位置.而正确的解决办法是: 将图层对应的属性修改为动画的终态一致, 然后进行动画即可, 而非设置 fillMode.
而 kCAFillMode 的用途是在动画组(Animation Group)中, 和子动画相关的.
在代码中无法直接访问动画列表的所有动画, 只能利用 animation(forKey:)
方法来获取某个 key 对应的动画. 且动画完成回调调用时, 该动画已经完成, 故就已经从动画列表中移除了, 所以在完成回调中, 是获取不到该动画的.
可以使用 removeAnimation(forKey:)
或者 removeAllAnimations
移除动画, 当移除 nil key 的动画时, 只有通过 removeAllAnimations
才可以办到.
如果 APP 被暂停(suspended)的时候, 系统会自动在所有图层上调用 removeAllAnimations
方法.
当手动将进行中的动画被移除时, 它会直接停止. 但停止的时机是在下一个重绘时机到达时. 如果想直接停止动画, 需要在 transaction 块中写.
待续...