iOS 动画

本文最后更新于 2021年4月4日 晚上

UI 开发过程中, 时常会遇到实现一些复杂动画的需求, 借着这次机会, 来系统重温 iOS 动画的实现和一些相关概念, 最后实现一个复杂的动画效果. 在梳理过程中, 主要参考的是 iOS Animation by Tutorial 一书.

看完了书, 说句老实话, 动画实现的话是需要去写代码和练习的, 同时添加自己的一些创意在其中, 是非常有意思的一块内容.

整体梳理

在 iOS 平台上, 动画在实现上可以分为如下几种:

  • View 动画: 使用 UIKit 中的相关 API 实现的动画.

  • 约束改变动画

  • Layer 动画

  • 视图控制器的转场动画

View 动画

View 动画主要使用的是一系列 animate 开头的 UIView 类方法, 比如 UIView.animate(withDuration: animations:), 在动画块 animations 中针对某个视图的属性进行改变, 从而驱动动画执行.

可动画属性

只有可动画属性的改变才能触发动画, 如下是一些可动画属性:

  • 位置和尺寸: bounds, frame, center 以及它们内部的一些属性, 比如 x, width 等.

  • 外观: backgroundColor, alpha

  • 变形: transform 及其相关属性.

动画选项

动画选项主要是用来控制动画的执行, 有如下的选项:

  • Repeat: 动画的重复, 比如 repeat, autoreverse 等, 通过 [.repeat, .autoreverse] 这样的方式可以将多个选项组合起来使用.

  • easing(缓和): 用于控制动画随时间变化的速率, 比如 curveEaseIn, curveEaseOut 等.

弹性(Spring)

使用 UIView.animate(withDuration:delay:usingSpringWithDamping:initialSpringVelocity:options:animations:completion:) 这个 API 在动画的末端可以附加一些弹性效果.

  • usingSpringWithDamping: 用于控制弹性动画的阻尼.

  • initialSpringVelocity: 弹性动画初始速率.

转换(Transition)

这些转换动画都是预定义的, 通过 UIView.transition 接口使用.

一共有如下的预定义转换动画的选项/类型:

1
2
3
4
5
6
7
public static var transitionFlipFromLeft: UIView.AnimationOptions { get }
public static var transitionFlipFromRight: UIView.AnimationOptions { get }
public static var transitionCurlUp: UIView.AnimationOptions { get }
public static var transitionCurlDown: UIView.AnimationOptions { get }
public static var transitionCrossDissolve: UIView.AnimationOptions { get }
public static var transitionFlipFromTop: UIView.AnimationOptions { get }
public static var transitionFlipFromBottom: UIView.AnimationOptions { get }

关键帧动画(Key-Frame)

有时候需要把多个动画连续执行, 如果使用 UIView.animate 一类的接口, 只有在 completion 块中串联下一个动画, 这样并非好的实现.

iOS 中提供了一类实现方式, 即把一个复杂动画的若干组成进行分割, 形成多个不同的过程, 这些过程就是 KeyFrame, 然后将这些单个的 KeyFrame 重新组合形成一个 KeyFrame 动画.

通过 UIView.animateKeyframes 接口来组合 KeyFrame 动画, 在它的 animations 块中调用 UIView.addKeyframe 添加关键帧. 添加时指定相对开始时刻, 指定时长, 即可让多个关键帧动画自由组合, 实现复杂动画效果. 比如要让一个视图变上升边变大, 则可以将这两个关键帧的时刻和时长重叠, 这样就可以达到组合的效果.

约束动画

约束动画指使用新约束替换现有的约束或改变约束的某些可变属性, 从而让约束状态更新触发动画.

过程是这样的: 替换或更新约束后, 在 UIView.animate 动画块中调用 view.layoutIfNeeded() 触发视图布局计算, 通过视图属性的改变, 从而触发动画.

图层动画

图层动画主要使用 Core Animation 库中的 API, 这些 API 更加底层, 同时也更加强大.

首先要明白 View 和 Layer 的区别.

Layer 实际是一个存放视图数据的容器, 每个 View 背后都有一个 Layer 作为支撑. Layer 只是一种数据容器, 它里面不保存复杂的自动布局依赖关系, 也不处理用户输入. 在其中包含许多可视属性, 这些属性最终作为渲染图层对应图像的依据.

Core Animation 拥有缓存 layer 内容的能力, 并且可以直接使用 GPU 进行快速绘制.

View 有如下特点:

  • 视图上包含有复杂的布局信息, 视图关系(父子)信息.

  • 视图负责处理用户输入

  • 拥有自定义的逻辑或动画, 这些代码在主线程通过 CPU 进行.

  • 有许许多多不同的视图类.

Layer 有如下特点:

  • 只有简单的层级结构, 可以快速计算布局, 快速进行渲染.

  • 不处理用户输入, 也就没有响应链的开销.

  • 默认没有自定义的逻辑代码, 并且直接通过 GPU 绘制

  • 至少少数几种 Layer 类.

图层动画在使用上和视图动画类似, 也是改变可动画属性, 指定时长, 然后让 CA 框架去计算和呈现动画. 不同之处在于, layer 有比视图多得多的可动画属性.

图层动画初步

列举一些图层的可动画属性:

  • 位置和尺寸

  • 边框

  • 阴影

  • 内容(Contents): 一些控制 layer 内容应该如何渲染的属性, 比如 content, mask, opacity 等.

CALayer 的子类还有可能有更多的可动画属性.

实现图层动画时, 使用 CABasicAnimation 等动画数据容器来指定动画的一些属性, 这些 Animation 对象只是动画的数据容器, 描述动画的执行. 因为这些动画对象不会被绑定到任何特定的 layer 上, 故可以非常方便地重用.

图层动画的简单使用

使用如下方式创建动画对象:

1
2
3
4
5
6
7
private func constructAnimation(size: CGSize) -> CAAnimation {
let anim = CABasicAnimation(keyPath: "position.x")
anim.fromValue = -size.width / 2.0
anim.toValue = size.width / 2.0
anim.duration = 0.5
return anim
}

要把这个动画添加到某个图层上, 只需要调用如下代码:

1
2
let anim = constructAnimation(size: view.bounds.size)
view.layer.add(anim, forKey: "用于识别动画, 并进行后续操作")

add 方法会添加该动画的拷贝到图层上, 所以图层上的动画对象和创建出来的动画对象是不同的.

控制开始时间

可以通过如下方式控制动画开始时间:

1
2
3
4
let anim = constructAnimation(size: view.bounds.size)
// 使用 CACurrentMediaTime 获取当前时刻, 然后添加延时
anim.beginTime = CACurrentMediaTime() + 2.0
_contentView.layer.add(anim, forKey: "用于识别动画, 并进行后续操作")

使用 fillMode

通过 fillMode 可以控制动画的开始和结束时候的显示效果, fillMode 的默认值是 removed. 有如下 fillMode 可用:

  • removed: 当动画结束后, 将动画改变的视图效果移除, 即”动画结束后恢复原状”.

  • backwards: 在动画开始时显示动画的第一帧

  • forwards: 在动画结束时保持显示动画最后一帧

  • both: 组合 backwardsforwards.

图层动画的实质

图层动画时, 并没有把实际的视图进行改变, 进行动画的只是一个 presentation layer, 当动画结束后, 这个 layer 就被移除掉了, 然后原始的图层会显示出来, 所以才会看到动画结束之后又回到了原始状态的效果.

比如一个视图本来在屏幕的左边不可见位置, 要动画移动到屏幕中央, 此时需要设置动画对象的 isRemovedOnCompletion 为 false.

不过这个设置只是一个 “障眼法”, 因为视图的位置仍然是没有变化的, 看到的仅仅是动画结束时候最后一帧的”显示效果”, 如果视图有相关交互, 此时点击动画结束时候看到的那个”视图”, 实际上点不到任何东西.

正是这个原因, 所以在日常动画实现中, 如果不是特殊需要, 就不要去碰 fillMode. 原则是: 动画结束后就移除, 尽量不要使用其他 fillMode.

结束时, 如果想要视图处于结束位置, 则将视图或视图图层的位置按照动画的位置来改变即可.

总结: 如果视图原本在某个位置, 设置把视图从某个其他位置动画到原位置, 则可以不需要进行任何的后续操作. 但如果是视图原来不在最终位置, 此时就需要把视图的属性调整到和动画结束时候一致.

如果视图状态原本就是最终状态, 添加图层动画需要它从开始到结束, 则可以让 fillMode 设置为 backward.

弹性动画

弹性动画的典型实现如下所示, CASpringAnimationCABasicAnimation 的子类:

1
2
3
4
5
6
7
8
let pulse = CASpringAnimation(keyPath: "transform.scale")
// 值越大弹性阻尼越大, 意味着从弹性到结束的时间越短
pulse.damping = 7.5
pulse.fromValue = 1.25
pulse.toValue = 1.0
// 弹性时间需要设置, 否则弹性会直接跳变
pulse.duration = pulse.settlingDuration
layer?.add(pulse, forKey: nil)

弹性动画还可以设置如下属性:

  • damping

  • mass

  • stiffness

  • initialVelocity

如下代码是设置某个 TextField 当输入结束且文本长度超过5个的时候就添加动画:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func textFieldDidEndEditing(_ textField: UITextField) {
guard let text = textField.text else { return }
if text.count > 5 {
addAnimationTo(textField: textField)
}
}

private func addAnimationTo(textField: UITextField) {
let jump = CASpringAnimation(keyPath: "position.y")
jump.fromValue = textField.layer.position.y + 1.0
jump.toValue = textField.layer.position.y
jump.duration = jump.settlingDuration
// 越大跳地越远
jump.initialVelocity = 100.0
// 越大弹性动画越慢, 默认 1.0
jump.mass = 10.0
// 阻尼
jump.damping = 50.0
// 越大震动越多, 默认 100.0
jump.stiffness = 1500.0
textField.layer.add(jump, forKey: nil)
}

设置图层的其他属性进行弹性动画也是可以的, 比如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 添加到上面的 textFieldDidEndEditing 方法里面

textField.layer.borderWidth = 3.0
textField.layer.borderColor = UIColor.clear.cgColor
// 有的 iOS 版本会把圆角移除掉, 需要手动设置回去, 否则动画的时候 textField 会没有圆角
textField.layer.cornerRadius = 5

let flash = CASpringAnimation(keyPath: "borderColor")
flash.damping = 7.0
flash.stiffness = 1500.0
flash.fromValue = UIColor(red: 1.0, green: 0.27, blue: 0.0, alpha: 1.0).cgColor
flash.toValue = UIColor.white.cgColor
flash.duration = flash.settlingDuration
textField.layer.add(flash, forKey: nil)

这样就实现了边框的弹性变化效果.

动画组和 KeyFrame 图层动画

动画组的作用是允许将多个动画合并然后针对一个 layer 进行添加动画.

KeyFrame 动画则是针对单个图层的单个属性指定关键帧的值和差值点数组, 这样可以把动画的关键点指定好, 从而实现单个复杂的动画.

通过如下的方式就可以创建关键帧动画, 同样, 关键帧动画也可以被添加到动画组中实现复杂动画.

1
2
3
4
5
6
let wobble = CAKeyframeAnimation(keyPath: "transform.rotation")
wobble.duration = 0.25
wobble.repeatCount = 4
wobble.values = [0.0, -.pi/4.0, 0.0, .pi/4.0, 0.0]
wobble.keyTimes = [0.0, 0.25, 0.5, 0.75, 1.0]
heading.layer.add(wobble, forKey: nil)

在指定值的时候一定要注意, 在 OC 中不支持 Swift 的 Struct, 故如果是一系列的点数据, 比如 CGPoint, 则需要把它们包裹到 NSValue 中使用.

如下代码创建一个气球, 气球在动画末端的位置已经指定好了, 开始的时候气球在屏幕外部:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 创建气球图层
let balloon = CALayer()
balloon.contents = UIImage(named: "balloon")?.cgImage
balloon.frame = CGRect(x: -50, y: 0, width: 50, height: 65)
view.layer.insertSublayer(balloon, below: username.layer)

// 创建气球运动动画
let flight = CAKeyframeAnimation(keyPath: "position")
flight.duration = 12.0
flight.values = [CGPoint(x: -50.0, y: 0.0),
CGPoint(x: view.frame.width + 50.0, y: 160.0),
CGPoint(x: -50.0, y: loginButton.center.y)]
.map { NSValue(cgPoint: $0) }
flight.keyTimes = [0.0, 0.5, 1.0]
balloon.add(flight, forKey: nil)

// 最终位置赋值
balloon.position = CGPoint(x: -50.0, y: loginButton.center.y)

Shape 和 Mask

主要是实现多图层共存效果, 并对动画进行合成处理.

图形(Shape)主要通过 CAShapeLayer 来处理, 在图形绘制过程中, 并非对图层直接使用绘制命令, 而是交给图层一个 CGPath 来绘制. 可以通过 CG 的 API 或者 UIBezierPath 绘制. 把需要的 shape 创建出来之后, 就可以在它上面动画改变诸如如下的属性:

  • path:

  • fillColor:

  • lineDashPhase:

  • lineWidth:

关于图层创建和添加时机的问题, 在例子中也是使用 layoutSubviews 里面处理的, 如果怕重复, 添加标志位即可. 而 layer 间的关系在 didMoveToWindow 方法中配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
override func didMoveToWindow() {
// 添加照片图层
layer.addSublayer(photoLayer)
// 照片图层使用 mask 图层
photoLayer.mask = maskLayer
// 添加顶部圆圈图层
layer.addSublayer(circleLayer)
}

override func layoutSubviews() {
super.layoutSubviews()
guard let image = image else { return }

// 创建处于最下层的照片 layer
photoLayer.frame = CGRect(
x: (bounds.size.width - image.size.width + lineWidth)/2,
y: (bounds.size.height - image.size.height - lineWidth)/2,
width: image.size.width,
height: image.size.height
)

// 创建最上层的圆圈 layer
circleLayer.path = UIBezierPath(ovalIn: bounds).cgPath
circleLayer.strokeColor = UIColor.white.cgColor
circleLayer.lineWidth = lineWidth
circleLayer.fillColor = UIColor.clear.cgColor

// 创建照片层的 mask, mask 的作用是控制使用它的图层的某些部分的显示或隐藏.
maskLayer.path = circleLayer.path
maskLayer.position = CGPoint(x: 0.0, y: 10.0)
}

在创建动画的时候, 针对可动画属性进行相应变化即可. 比如将图层的 path 进行改变, 同时改变 mask 图层的 path, 使得可以实现一些复杂的动画效果.

路径和绘制动画

这一节和 “Shape 和 Mask” 一节联系比较紧密.

例子中首先在刷新视图上的图层中绘制一个路径:

1
2
3
4
5
6
7
8
9
10
11
12
// 在 layer 上添加路径
ovalShapeLayer.strokeColor = UIColor.white.cgColor
ovalShapeLayer.fillColor = UIColor.clear.cgColor
ovalShapeLayer.lineWidth = 4.0
ovalShapeLayer.lineDashPattern = [2, 3]
let refreshRadius = frame.size.height / 2 * 0.8
ovalShapeLayer.path = UIBezierPath(ovalIn: CGRect(
x: frame.size.width/2 - refreshRadius,
y: frame.size.height/2 - refreshRadius,
width: 2 * refreshRadius,
height: 2 * refreshRadius)).cgPath
layer.addSublayer(ovalShapeLayer)

当进度改变时, 调用如下代码改变 strokeEnd 的值:

1
ovalShapeLayer.strokeEnd = progress

在刷新开始的时候添加动画:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let strokeStartAnimation = CABasicAnimation(keyPath: "strokeStart")
strokeStartAnimation.fromValue = -0.5
strokeStartAnimation.toValue = 1.0

let strokeEndAnimation = CABasicAnimation(keyPath: "strokeEnd")
strokeEndAnimation.fromValue = 0.0
strokeEndAnimation.toValue = 1.0

let strokeAnimationGroup = CAAnimationGroup()
strokeAnimationGroup.duration = 1.5
strokeAnimationGroup.repeatDuration = 5.0
strokeAnimationGroup.animations = [strokeStartAnimation, strokeEndAnimation]

ovalShapeLayer.add(strokeAnimationGroup, forKey: nil)

通过上述代码, 即可实现该图层路径的 start 和 end 相互 catch 的效果. 因为 start 是从 -0.5 开始的, 故一直只是部分路径被绘制, 且由于 start 的范围在相同时间内更大, 故它的变化速率更快, 从而追赶 end 的速度会更快, 而 end 只需要 0 到 1 即可.

Replicating 动画

CAReplicatorLayer 用于复制一个图层上的动画, 它使用非常简单: 在目标图层上创建一些内容, 比如绘制的图形/图片或其他想绘制的内容, 而后 CAReplicatorLayer 就可以将它进行复制. 并且复制的孩子还可以根据需要来进行小修改. 而且它还有一个更强大的功能是允许设置每个复制品的动画延时.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/// 复制图层
let replicator = CAReplicatorLayer()
/// 这个图层用来表示其中的元素
let dot = CALayer()

let dotLength: CGFloat = 6.0
let dotOffset: CGFloat = 8.0

override func viewDidLoad() {
super.viewDidLoad()
replicator.frame = view.bounds
view.layer.addSublayer(replicator)

// 创建用于加入到 CAReplicatorLayer 中的孩子图层
dot.frame = CGRect(
x: replicator.frame.size.width - dotLength,
y: replicator.position.y,
width: dotLength,
height: dotLength
)
dot.backgroundColor = UIColor.lightGray.cgColor
dot.borderColor = UIColor(white: 1.0, alpha: 1.0).cgColor
dot.borderWidth = 0.5
dot.cornerRadius = 1.5

// 添加到图层
replicator.addSublayer(dot)
}

CAReplicatorLayer 中主要使用如下属性:

  • instanceCount: 复制品的总数

  • instanceTransform: 复制品间的 transform

  • instanceDelay: 复制品之间的动画间隔延迟

通过设置这些属性, 并在原始图层(这里是 dot)上设置动画, 即可实现所有的东西一起动画的效果:

1
2
3
4
5
6
7
8
9
10
11
12
replicator.instanceCount = Int(view.frame.size.width / dotOffset)
replicator.instanceTransform = CATransform3DMakeTranslation(-dotOffset, 0, 0)
replicator.instanceDelay = 0.02

// This is a test animation, you're going to delete it
let move = CABasicAnimation(keyPath: "position.y")
move.fromValue = dot.position.y
move.toValue = dot.position.y - 50.0
move.duration = 1.0
move.repeatCount = 10
dot.add(move, forKey: nil)
// This is the end of the code you're going to delete

上面的测试动画中只是把位置移动, 下面来做一个更漂亮的动画:

1
2
3
4
5
6
7
8
let scale = CABasicAnimation(keyPath: "transform")
scale.fromValue = NSValue(caTransform3D: CATransform3DIdentity)
scale.toValue = NSValue(caTransform3D: CATransform3DMakeScale(1.4, 15, 1.0))
scale.duration = 0.33
scale.repeatCount = .infinity
scale.autoreverses = true
scale.timingFunction = CAMediaTimingFunction(name: .easeOut)
dot.add(scale, forKey: "dotScale")

关键就是上面的 replicator.instanceDelay = 0.02 这句话, 它让多个复制品间的动画有了一个间隔, 这样可以实现非常复杂的动画效果.

后续的动画也是添加到原始图层上就可以了.

另外 CAReplicatorLayer 图层的一些属性也可以进行动画, 比如上述的那几个属性, 通过对这几个属性进行动画, 可以实现非常炫酷的效果.

示例

需要实现如下图的效果:

并且具体要求如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/*
每条放射线端点移动动画从0.14s开始,1s结束

放射线整体旋转动画从0s开始,1s结束

勾勾动画从0s开始,1s结束

其中放射线长度为30;勾勾总长102,其中长边长度为62

注:
放射线靠近圆心的一端为终点。
速度曲线表示物体移动速度随时间的变化关系。
修剪路径:表示对于一段矢量路径的部分显示,其中的开始表示矢量路径的起点,结束表示矢量路径的终点。其中0%表示路径未变形前的起始位置,100%表示路径未变形前的终止位置。

不使用任何第三方框架的情况下, 请根据以上动画示例及提供的相关部分动画曲线,还原示例中动画效果。
需要提供定义动画过程的接口,支持传入任意运动函数以实现对于动画运动过程的控制。
*/

这个动画的实现主要思路就是: 使用一个 CAShapeLayer 图层实现中间的对勾动画, 另外一个 CAReplicatorLayer 图层实现周边射线的其他动画. 将两个图层添加到视图上即可. 只是注意一些细节参数.

展开来说就是: 旋转动画添加到 CAReplicatorLayer 上, 射线动画放到原始的 CAShapeLayer 图层上, 通过 CAReplicatorLayer 将原始图层复制, 即可实现效果, 对勾的动画独立在一个中间的图层上.


iOS 动画
https://blog.rayy.top/2019/11/30/2019-2019-11-30-iOS-Animation/
作者
貘鸣
发布于
2019年11月30日
许可协议