Fork me on GitHub

系统学习iOS动画之三:图层动画

本文是我学习《iOS Animations by Tutorials》 笔记中的一篇。
文中详细代码都放在我的Github上 andyRon/LearniOSAnimations

系统学习iOS动画之一:视图动画 学习了创建视图动画(View Animations),这一部分学习功能更强大、更偏底层的Core Animation(核心动画) APIs。核心动画的这个名字可能令人有点误解,暂时可以理解为本文的标题图层动画(Layer Animations)

在本书的这一部分中,将学习动画层而不是视图以及如何使用特殊图层。

图层是一个简单的模型类,它公开了许多属性来表示一些基于图像的内容。 每个UIView都有一个图层支持(都有一个layer属性)。

视图 vs 图层

由于以下原因,图层(Layers)与视图(Views)(对于动画)不同:

  • 图层是一个模型对象 —— 它公开数据属性并且不实现任何逻辑。 它没有复杂的自动布局依赖关系,也不用处理用户交互。
  • 图层具有预定义的可见特征 —— 这些特征是许多影响内容在屏幕上呈现的数据属性,例如边框线,边框颜色,位置和阴影。
  • 最后,Core Animation优化了图层内容的缓存并直接在GPU上快速绘图。

单个来说,两者的优点。

视图:

  • 复杂视图层次结构布局,自动布局等。
  • 用户交互。
  • 通常具有在CPU上的主线程上执行的自定义逻辑或自定义绘图代码。
  • 非常灵活,功能强大,子类很多类。

图层:

  • 更简单的层次结构,更快地解决布局,绘制速度更快。
  • 没有响应者链开销。
  • 默认情况下没有自定义逻辑 并直接在GPU上绘制。
  • 不那么灵活,子类的类更少。

视图和图层的选择技巧: 任何时候都可以选择视图动画; 当需要更高的性能时,就需要使用图层动画。

两者在架构中的位置:

预览:

本文比较长,图片比较多,预警⚠️😀。

8-图层动画入门 —— 从最简单的图层动画开始,了解调试动画错误的方法。
9-动画的Keys和代理 —— 怎么更好地控制当前运行的动画,并使用代理方法对动画事件做出响应。
10-动画组和时间控制 —— 组合许多简单的动画,并将它们作为一个组一起运行。
11-图层弹簧动画 —— 学习如何使用CASpringAnimation创建强大而灵活的弹簧图层动画。
12-图层关键帧动画和结构属性 —— 学习图层关键帧动画, 动画结构属性的一些特殊处理。

接下来,学习几个专门的图层:

13-形状和蒙版 —— 通过CAShapeLayer在屏幕上绘制形状,并为其特殊路径属性设置动画。
14-渐变动画 —— 了解如何使用CAGradientLayer来绘制渐变和动画渐变。
15-Stroke和路径动画 —— 以交互方式绘制形状,并使用关键帧动画的一些强大功能。
16-复制动画 —— 学习如何创建图层内容的多个副本,然后利用副本制作动画。

8-图层动画入门

图层动画的工作方式与视图动画非常相似; 只需在定义的时间段内为起始值和结束值之间的属性设置动画,然后让Core Animation处理两者之间的渲染。

但是,图层动画具有比视图动画更多的可动画属性; 在设计效果时,这会提供了很多选择和灵活性; 图层动画还有许多专门的CALayer子类(如CATextLayerCAShapeLayerCATransformLayerCAGradientLayerCAReplicatorLayerCAScrollLayerCAEmitterLayerAVPlayerLayer等),这些子类有提供了许多其他属性。

本章介绍CALayer和Core Animation的基础知识。

可动画属性

可与视图动画的可动画属性对照着看。

位置 和 大小

boundspositiontransform

borderColorborderWidthcornerRadius

image-20181015154228090

阴影

image-20181015154548338

shadowOffset: 使阴影看起来更接近或更远离图层。
shadowOpacity:使阴影淡入或淡出。
shadowPath: 更改图层阴影的形状。 可以创建不同的3D效果,使图层看起来像浮动在不同的阴影形状和位置上。
shadowRadius: 控制阴影的模糊; 当模拟视图朝向或远离投射阴影的表面移动时,这尤其有用。

内容

contents :修改此项以将原始TIFF或PNG数据指定为图层内容。

mask :修改它将用于掩盖图层可见内容的形状或图像。 这个属性在13-形状和蒙版将详细介绍和使用。

opacity

第一个图层动画

开始项目使用 3-过渡动画完成的项目。

把原本head的视图动画替换为图层动画。

分别删除ViewControllerviewWillAppear()中:

1
heading.center.x    -=  view.bounds.width

viewDidAppear()中:

1
2
3
UIView.animate(withDuration: 0.5) {
self.heading.center.x += self.view.bounds.width
}

viewWillAppear()的开始(super调用后)添加:

1
2
3
4
let flyRight = CABasicAnimation(keyPath: "position.x")
flyRight.fromValue = -view.bounds.size.width/2
flyRight.toValue = view.bounds.size.width/2
flyRight.duration = 0.5

核心动画中的动画对象只是简单的数据模型; 上面的代码创建了CABasicAnimation的实例,并设置了一些数据属性。
这个实例描述了一个潜在的图层动画:可以选择立即运行,稍后运行,或者根本不运行

由于动画未绑定到特定图层,因此可以在其他图层上重复使用动画,每个图层将独立运行动画的副本。

在动画模型中,您可以将要设置为动画的属性指定为keypath参数(比如上面设置是"position.x"); 这很方便,因为动画总是在图层中设置。

接下来,为在keypath上指定的属性设置fromValuetoValue。需要动画对象(此处我要处理的是heading)从屏幕左侧到屏幕中央。动画持续时间的概念没有改变; duration设置为0.5秒。

动图已经设置完成,现在需要把它添加需要运行此动画的图层上。 在刚添加的代码下方添加,将动画添加到heading的图层:

1
heading.layer.add(flyRight, forKey: nil)

add(_:forKey:)会把动画做个一个拷贝给将要添加的图层。 如果之后需要更改或停止动画,可以添加forKey参数用于识别动画。

此时的动画看上去和之前视图动画没有什么区别。

更多图层动画知识

同一样的方法应用在Username Filed上,删除viewWillAppear()viewDidAppear()中对应代码。再把之前的动画添加的Username Filed的layer上:

1
username.layer.add(flyRight, forKey: nil)

此时运行项目,看上去会有点别扭,因为heading LabelUsername Filed的动画是相同的,Username Filed没有之前的延迟效果。

在添加动画到Username Filed的layer上之前,添加:

1
flyRight.beginTime = CACurrentMediaTime() + 0.3

动画的beginTime属性设置动画应该开始的绝对时间; 在这种情况下,可以使用CACurrentMediaTime()获取当前时间(系统的一个绝对时间,机器开启时间,取自机器时间 mach_absolute_time()),并以秒为单位添加所需的延迟。

此时,如果仔细观察会发现有个问题,Username Filed在开始动画之前已经出现了,这就涉及到另外一个图层动画属性 fillMode 了。

关于 fillMode

Username Field的移动动画来看看fillMode不同值的区别,为了方便观察,我把beginTime时间变大,代码类似于:

1
2
3
4
5
6
7
8
9
let flyRight = CABasicAnimation(keyPath: "position.x")
flyRight.fromValue = -view.bounds.size.width/2
flyRight.toValue = view.bounds.size.width/2
flyRight.duration = 0.5
heading.layer.add(flyRight, forKey: nil)

flyRight.beginTime = CACurrentMediaTime() + 2.3
flyRight.fillMode = kCAFillModeRemoved
username.layer.add(flyRight, forKey: nil)
  • kCAFillModeRemovedfillMode的默认值

    在定义的beginTime处启动动画(如果未设置beginTime,也就是beginTime等于CACurrentMediaTime(),则立即启动动画), 并在动画完成时删除动画期间所做的更改:

    实际效果:

    nowbegin 这段时间动画没有开始,但Username Field直接显示了,然后到 begin时动画才开始,这就是之前遇到的情况。

  • kCAFillModeBackwards

    无论动画的实际开始时间如何,kCAFillModeBackwards都会立即在屏幕上显示动画的第一帧,并在以后启动动画:

    实际效果:

    第一帧在fromValue处,也就是"position.x"是负的在屏幕外,因此开始时没有看见Username Field,等待2.3s后动画开始。

  • kCAFillModeForwards

    kCAFillModeForwards像往常一样播放动画,但在屏幕上保留动画的最后一帧,直到您删除动画:

    实际效果:

    除了设置kCAFillModeForwards之外,还需要对图层进行一些其他更改以使最后一帧“粘贴”。 你将在本章后面稍后了解这一点。 和第一个有点类似,但还是有区别的。

  • kCAFillModeBoth

    kCAFillModeBothkCAFillModeForwardskCAFillModeBackwards的组合; 这会使动画的第一帧立即出现在屏幕上,并在动画结束时在屏幕上保留最终帧:

    实际效果:

要解决之前发现的问题,将使用kCAFillModeBoth

同样对于Password Field,也删除其视图动画的代码,改换成类似Username Field的图层动画,不过beginTime要晚一点,具体代码:

1
2
3
4
5
6
flyRight.beginTime = CACurrentMediaTime() + 0.3
flyRight.fillMode = kCAFillModeBoth
username.layer.add(flyRight, forKey: nil)

flyRight.beginTime = CACurrentMediaTime() + 0.4
password.layer.add(flyRight, forKey: nil)

到目前为止,您的动画恰好在表单元素最初位于Interface Builder中的确切位置结束。 但是,很多时候情况并非如此。

调试动画

在上面的动画后继续添加:

1
2
username.layer.position.x -= view.bounds.width
password.layer.position.x -= view.bounds.width

这就是把两个文本框的图层移动到屏幕外,类似于flyRight.fromValue = -view.bounds.size.width/2(此时这段代码可以暂时注释掉),运行后发现问题,动画结束后两个文本框消失了,这是怎么回事呢?

继续在上面的代码后添加一个延迟函数:

1
2
3
delay(seconds: 5.0)
print("where are the fields?")
}

并打断点后运行:

进入UI hierarchy 窗口:

UI hierarchy 模式下可以查看当前运行时的UI层次结构,包括已经隐藏或透明视图以及在屏幕外的视图。还可以3D查看。

当然还可以在右侧检测器中查看实时属性:

image-20181125124028215

动画完成后,代码更改会导致字段跳回其初始位置。 但为什么?

动画 vs 真实内容

当你为Text Field设置动画时,你实际上并没有看到Text Field本身是动画的; 相反,你会看到它的缓存版本,称为presentation layer(显示层)。动画完成后原始图层再次到原本位置,则从屏幕上移除presentation layer
首先,请记住在viewWillAppear(_:)中将Text Field设置在屏幕外:

image-20181125145909389

动画开始时,Text Field暂时隐藏,预渲染的动画对象将替代它:

image-20181125145923978

现在无法点击动画对象,输入任何文本或使用任何其他特定文本字段功能,因为它不是真正的文本字段,只是可见的“幻像”。
动画一旦完成,它就会从屏幕上消失,原始Text Field将被取消隐藏。但它此时的位置还在屏幕左侧!

image-20181125150009137

要解决这个难题,您需要使用另一个CABasicAnimation属性:isRemovedOnCompletion

fillMode设置为kCAFillModeBoth可让动画在完成后保留在屏幕上,并在动画开始之前显示动画的第一帧。要完成效果,您需要相应地设置removedOnCompletion,两者的组合将使动画在屏幕上可见。
在设置fillMode之后,将以下行添加到viewWillAppear()

1
flyRight.isRemovedOnCompletion = false

isRemovedOnCompletion默认为true,因此动画一完成就会消失。将其设置为false并将其与正确的fillMode组合可将动画保留在屏幕上 。

现在运行项目,应该能看到所有元素都按预期保留在屏幕上。

更新图层模型

从屏幕上删除图层动画后,图层将回退到其当前位置和其他属性值。 这意味着您通常需要更新图层的属性以反映动画的最终值。

虽然前面已经说明过把isRemovedOnCompletion设置成false是如何工作的,但尽可能避免使用它。 在屏幕上保留动画会影响性能,因此需要自动删除它们并更新原始图层的位置。

需要把原始图层设置到屏幕中间,在viewWillAppear中天假:

1
2
username.layer.position.x = view.bounds.size.width/2
password.layer.position.x = view.bounds.size.width/2

当然此时要注意把之前注释掉的flyRight.fromValue = -view.bounds.size.width/2,去掉注释,也要把调试动画时的代码去掉。

使用图层动画实现☁️的淡入

删除viewWillAppear()中把四个☁️透明度设为0.0的代码,和viewDidAppear()的☁️的视图动画。

然后在viewDidAppear()加入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let cloudFade = CABasicAnimation(keyPath: "alpha")
cloudFade.duration = 0.5
cloudFade.fromValue = 0.0
cloudFade.toValue = 1.0
cloudFade.fillMode = kCAFillModeBackwards

cloudFade.beginTime = CACurrentMediaTime() + 0.5
cloud1.layer.add(cloudFade, forKey: nil)

cloudFade.beginTime = CACurrentMediaTime() + 0.7
cloud2.layer.add(cloudFade, forKey: nil)

cloudFade.beginTime = CACurrentMediaTime() + 0.9
cloud3.layer.add(cloudFade, forKey: nil)

cloudFade.beginTime = CACurrentMediaTime() + 1.1
cloud4.layer.add(cloudFade, forKey: nil)

登录按钮背景颜色变化的动画

把原登录按钮背景颜色变化的动画修改成图层动画。

删除logIn()中的:

1
self.loginButton.backgroundColor = UIColor(red: 0.85, green: 0.83, blue: 0.45, alpha: 1.0)

删除resetForm()中的:

1
self.loginButton.backgroundColor = UIColor(red: 0.63, green: 0.84, blue: 0.35, alpha: 1.0)

ViewController.swift文件中创建一个全局的背景颜色变化动画函数:

1
2
3
4
5
6
7
8
func tintBackgroundColor(layer: CALayer, toColor: UIColor) {
let tint = CABasicAnimation(keyPath: "backgroundColor")
tint.fromValue = layer.backgroundColor
tint.toValue = toColor.cgColor
tint.duration = 0.5
layer.add(tint, forKey: nil)
layer.backgroundColor = toColor.cgColor
}

logIn()中添加:

1
2
let tintColor = UIColor(red: 0.85, green: 0.83, blue: 0.45, alpha: 1.0)
tintBackgroundColor(layer: loginButton.layer, toColor: tintColor)

resetForm()中登录按钮动画方法的completion闭包中添加:

1
2
3
4
completion: { _ in
let tintColor = UIColor(red: 0.63, green: 0.84, blue: 0.35, alpha: 1.0)
tintBackgroundColor(layer: self.loginButton.layer, toColor: tintColor)
})

登录按钮的圆角动画

ViewController.swift文件中创建一个全局的圆角变化动画函数:

1
2
3
4
5
6
7
8
func roundCorners(layer: CALayer, toRadius: CGFloat) {
let round = CABasicAnimation(keyPath: "cornerRadius")
round.fromValue = layer.cornerRadius
round.toValue = toRadius
round.duration = 0.33
layer.add(round, forKey: nil)
layer.cornerRadius = toRadius
}

logIn()中添加:

1
roundCorners(layer: loginButton.layer, toRadius: 25.0)

resetForm()中登录按钮动画方法的completion闭包中添加:

1
roundCorners(layer: self.loginButton.layer, toRadius: 10.0)

两种状态的变化:

image-20181125155719946

两个动画函数tintBackgroundColorroundCorners最后都需要把动画最变化最终值赋值给动画的属性,这对应于前面的 动画 vs 真实内容 章节

本章节的最终效果:

9-动画的Keys和代理

关于视图动画和相应的闭包语法的一个棘手问题是,一旦您创建并运行视图动画,您就无法暂停,停止或以任何方式访问它。

但是,使用核心动画,您可以轻松检查在图层上运行的动画,并在需要时停止它们。 此外,您甚至可以在动画上设置委托对象并对动画事件做出反应。

本章的开始项目使用上一章完成的项目

动画代理介绍

CAAnimationDelegate的两个代理方法:

1
2
func animationDidStart(_ anim: CAAnimation)
func animationDidStop(_ anim: CAAnimation, finished flag: Bool)

做个小测试,在flyRight初始化时,添加:

1
flyRight.delegate = self

ViewController添加扩展,并实现一个代理方法:

1
2
3
4
5
6
extension ViewController: CAAnimationDelegate {

func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
print(anim.description, "动画完成")
}
}

运行,打印结果:

1
2
3
<CABasicAnimation: 0x6000032376e0> 动画完成
<CABasicAnimation: 0x600003237460> 动画完成
<CABasicAnimation: 0x600003237480> 动画完成

会发现animationDidStop(_:finished:)方法被调用三次,并且每次调用的动画都不同,这因为当每一次调用layer.add(_:forKey:)把动画添加给图层时,都会拷贝一份,这在前面的图层动画基础知识中说明过。

KVO

CAAnimation类及其子类是用Objective-C编写的,并且符合键值编码(KVO),这意味着您可以将它们视为字典,并在运行时向它们添加新属性。(关于KVO,可查看我的小结文章 OC中的键/值编码(KVC))

使用此机制为flyRight动画指定名称,以便之后可以从其他活动动画中识别它。

viewWillAppear()中的flyRight.delegate = self后添加:

1
2
flyRight.setValue("form", forKey: "name")
flyRight.setValue(heading.layer, forKey: "layer")

在上面的代码中,在flyRight动画上创建键为"name",值为"form"的键值对,可以从委托回调方法调用识别;

也创建了一个键为"layer",值为heading.layer的键值对,以方便之后引用动画所属的图层。

同样的可以添加(之前已经说过每次动画都会拷贝一份,所以不会覆盖):

1
2
3
4
5
flyRight.setValue(username.layer, forKey: "layer")

// ...

flyRight.setValue(password.layer, forKey: "layer")

在代理回调方法中验证上面的代码,上面的移动动画结束后再添加一个简单的脉动动画:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
// print(anim.description, "动画完成")
guard let name = anim.value(forKey: "name") as? String else {
return
}

if name == "form" {
// `value(forKey:)`的结果总是`Any`,因此需要转换为所需类型
let layer = anim.value(forKey: "layer") as? CALayer
anim.setValue(nil, forKey: "layer")
// 简单的脉动动画
let pulse = CABasicAnimation(keyPath: "transform.scale")
pulse.fromValue = 1.25
pulse.toValue = 1.0
pulse.duration = 0.25
layer?.add(pulse, forKey: nil)
}
}

注意: layer?.add()意味着如果动画中没有存储图层,则会跳过add(_:forKey:)的调用。 这是Swift中的可选链式调用,可参考以撸代码的形式学习Swift-17:可选链式调用(Optional Chaining)

移动动画结束后有一个简单变大的脉动动画效果:

动画Keys

add(_:forKey:)中的参数forKey(注意不要和setValue(_:forKey:)中的forKey混淆),之前一直没使用。

在这部分中,将创建另一个图层动画,学习如何一次运行多个动画,并了解如何使用动画Keys控制正在运行的动画。

添加一个新标签,新标签将从右到左缓慢动画,用来提示用户输入。 一旦用户开始输入他们的用户名或密码(Text Field获得焦点),该标签将停止移动并直接跳到其最终位置(居中位置)。 一旦用户知道该怎么做就没有必要继续动画。

ViewController中添加属性 let info = UILabel(),并在viewDidLoad()中配置:

1
2
3
4
5
6
7
info.frame = CGRect(x: 0.0, y: loginButton.center.y + 60.0,  width: view.frame.size.width, height: 30)
info.backgroundColor = UIColor.clear
info.font = UIFont(name: "HelveticaNeue", size: 12.0)
info.textAlignment = .center
info.textColor = UIColor.white
info.text = "Tap on a field and enter username and password"
view.insertSubview(info, belowSubview: loginButton)

info添加两个动画:

1
2
3
4
5
6
7
8
9
10
11
12
// 提示信息Label的两个动画
let flyLeft = CABasicAnimation(keyPath: "position.x")
flyLeft.fromValue = info.layer.position.x + view.frame.size.width
flyLeft.toValue = info.layer.position.x
flyLeft.duration = 5.0
info.layer.add(flyLeft, forKey: "infoappear")

let fadeLabelIn = CABasicAnimation(keyPath: "opacity")
fadeLabelIn.fromValue = 0.2
fadeLabelIn.toValue = 1.0
fadeLabelIn.duration = 4.5
info.layer.add(fadeLabelIn, forKey: "fadein")

flyLeft是从左到右移动的动画,fadeLabelIn是透明度渐渐变大的动画。

此时的动画效果如下:

Text Field添加代理。通过扩展,让ViewController遵循UITextFieldDelegate协议:

1
2
3
4
5
6
7
8
extension ViewController: UITextFieldDelegate {
func textFieldDidBeginEditing(_ textField: UITextField) {
guard let runningAnimations = info.layer.animationKeys() else {
return
}
print(runningAnimations)
}
}

viewDidAppear()中添加:

1
2
username.delegate = self
password.delegate = self

此时运行,info动画还在进行时点击文本框,会打印动画key值:

1
["infoappear", "fadein"]

textFieldDidBeginEditing(:)里添加:

1
info.layer.removeAnimation(forKey: "infoappear")

点击文本框后,删除从左向右移动的动画,info立即到达终点,也就是屏幕中央:

当然也可以通过removeAllAnimations()方法删除layer上的所有动画。

注意:动画进行完了,会默认被从layer上删除,也就是animationKeys()方法将获得不到动画keys了。

修改☁️的动画

通过本章所学的动画代理和动画KVO修改☁️的动画

先在ViewController中添加动画方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
/// 云的图层动画
func animateCloud(layer: CALayer) {
let cloudSpeed = 60.0 / Double(view.layer.frame.size.width)
let duration: TimeInterval = Double(view.layer.frame.size.width - layer.frame.origin.x) * cloudSpeed

let cloudMove = CABasicAnimation(keyPath: "position.x")
cloudMove.duration = duration
cloudMove.toValue = self.view.bounds.width + layer.bounds.width/2
cloudMove.delegate = self
cloudMove.setValue("cloud", forKey: "name")
cloudMove.setValue(layer, forKey: "layer")
layer.add(cloudMove, forKey: nil)
}

viewDidAppear()中的四个animateCloud方法调用替代为:

1
2
3
4
animateCloud(layer: cloud1.layer)
animateCloud(layer: cloud2.layer)
animateCloud(layer: cloud3.layer)
animateCloud(layer: cloud4.layer)

让☁️不停的移动,在动画代理方法animationDidStop中添加:

1
2
3
4
5
6
7
8
9
10
if name == "cloud" {
if let layer = anim.value(forKey: "layer") as? CALayer {
anim.setValue(nil, forKey: "layer")

layer.position.x = -layer.bounds.width/2
delay(0.5) {
self.animateCloud(layer: layer)
}
}
}

本章的效果:

10-动画组和时间控制

在上一章中,学习了如何向单个图层添加多个独立动画。 但是,如果您希望您的动画同步工作并保持彼此一致,该怎么办? 这就用到动画组(animation groups)

本章介绍如何使用CAAnimationGroup对动画进行分组,可以向组中添加多个动画并同时调整持续时间,委托和timingFunction等属性。
对动画进行分组会产生简化的代码,并确保您的所有动画将作为一个实体单元同步。

本章的开始项目使用上一章完成的项目

CAAnimationGroup

删除viewWillAppear()中的:

1
2
loginButton.center.y += 30.0
loginButton.alpha = 0.0

删除viewDidAppear()中登录按钮的显示动画:

1
2
3
4
UIView.animate(withDuration: 0.5, delay: 0.5, usingSpringWithDamping: 0.5, initialSpringVelocity: 0.0, options: [], animations: {
self.loginButton.center.y -= 30.0
self.loginButton.alpha = 1.0
}, completion: nil)

viewDidAppear()中组动画添加:

1
2
3
4
let groupAnimation = CAAnimationGroup()
groupAnimation.beginTime = CACurrentMediaTime() + 0.5
groupAnimation.duration = 0.5
groupAnimation.fillMode = kCAFillModeBackwards

CAAnimationGroup继承于CAAnimation,也有beginTime, duration, fillMode, delegate等属性。

继续三个动画,并把它们加入到上面的组动画中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let scaleDown = CABasicAnimation(keyPath: "transform.scale")
scaleDown.fromValue = 3.5
scaleDown.toValue = 1.0

let rotate = CABasicAnimation(keyPath: "transform.rotation")
rotate.fromValue = .pi / 4.0
rotate.toValue = 0.0

let fade = CABasicAnimation(keyPath: "opacity")
fade.fromValue = 0.0
fade.toValue = 1.0

groupAnimation.animations = [scaleDown, rotate, fade]
loginButton.layer.add(groupAnimation, forKey: nil)

登录按钮的效果为:

动画缓动

图层动画中的动画缓动与1-视图动画入门中介绍的视图动画的动画选项的,在概念上是相同的, 只是语法有所不同。

图层动画中的动画缓动通过类CAMediaTimingFunction来表示 。用法如下:

1
groupAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseIn)

name参数有如下几种,和视图动画中的差不多:

kCAMediaTimingFunctionLinear 速度不变化

kCAMediaTimingFunctionEaseIn 开始时慢,结束时快

kCAMediaTimingFunctionEaseOut 开始时快,结束时慢

kCAMediaTimingFunctionEaseInEaseOut 开始结束都慢,中间快

image-20181126112903447

可以试一下不同的效果。

另外CAMediaTimingFunction有个初始化方法init(controlPoints c1x: Float, _ c1y: Float, _ c2x: Float, _ c2y: Float),可以自定义缓动模式,具体可参考官方文档

更多动画时间控制的选项

重复动画

repeatCount 可设置重复动画指定的次数。
为提示信息Label的动画添加重复次数,在viewDidAppear()中为flyLeft动画设置属性:

1
flyLeft.repeatCount = 4

另外一个repeatDuration可用来设置总重复时间。

和视图动画一样,也要设置autoreverses,要不然不连贯:

1
flyLeft.autoreverses = true

现在效果看着不错了,但是还有点问题,就是4次重复结束后,会直接跳到屏幕中心,如下(由于太长,gif已经省略了前几次滚动):

这也很好理解,最后一个循环以标签离开屏幕结束。解决办法就是半个动画周期:

1
flyLeft.repeatCount = 2.5

改变动画的速度

可以通过设置速度属性来独立于持续时间来控制动画的速度。

1
flyLeft.speed = 2.0

把三个form的动画修改为动画组

下面代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let flyRight = CABasicAnimation(keyPath: "position.x")
flyRight.fromValue = -view.bounds.size.width/2
flyRight.toValue = view.bounds.size.width/2
flyRight.duration = 0.5
flyRight.fillMode = kCAFillModeBoth
flyRight.delegate = self
flyRight.setValue("form", forKey: "name")
flyRight.setValue(heading.layer, forKey: "layer")

heading.layer.add(flyRight, forKey: nil)

flyRight.setValue(username.layer, forKey: "layer")

flyRight.beginTime = CACurrentMediaTime() + 0.3
username.layer.add(flyRight, forKey: nil)

flyRight.setValue(password.layer, forKey: "layer")

flyRight.beginTime = CACurrentMediaTime() + 0.4
password.layer.add(flyRight, forKey: nil)

修改为:

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
let formGroup = CAAnimationGroup()
formGroup.duration = 0.5
formGroup.fillMode = kCAFillModeBackwards

let flyRight = CABasicAnimation(keyPath: "position.x")
flyRight.fromValue = -view.bounds.size.width/2
flyRight.toValue = view.bounds.size.width/2

let fadeFieldIn = CABasicAnimation(keyPath: "opacity")
fadeFieldIn.fromValue = 0.25
fadeFieldIn.toValue = 1.0

formGroup.animations = [flyRight, fadeFieldIn]
heading.layer.add(formGroup, forKey: nil)

formGroup.delegate = self
formGroup.setValue("form", forKey: "name")
formGroup.setValue(username.layer, forKey: "layer")

formGroup.beginTime = CACurrentMediaTime() + 0.3
username.layer.add(formGroup, forKey: nil)

formGroup.setValue(password.layer, forKey: "layer")
formGroup.beginTime = CACurrentMediaTime() + 0.4
password.layer.add(formGroup, forKey: nil)

本章节的最终效果:

11-图层弹簧动画

前面视图动画中的2-弹簧动画可以用于创建一些相对简单的弹簧式动画,而本章节学习的图层弹簧动画(Layer Springs)可以呈现一个看起来更自然的物理模拟。

本章的开始项目使用上一章完成的项目,添加一些新的图层弹簧动画,并说明两种弹簧动画之间的差异。

先说一些理论知识:

阻尼谐振子

阻尼谐振子,Damped harmonic oscillators(直译就是,逐渐衰弱的振荡器),可以理解为逐渐衰减的振动。

UIKit API简化了弹簧动画的制作,不需要了解它们的原理就可以很方便的使用。 但是,由于您现在是核心动画专家,因此您需要深入研究细节。

钟摆,理想状况下钟摆是不停的摆动,像下面的一样:

对应的运动轨迹图就像:

但现实中由于能量的损耗,钟摆的摇摆的幅度会逐渐减小:

image-20181112222946546

对应的运动轨迹:

image-20181112223028487

这就是一个阻尼谐振子 。

钟摆停下来所需的时间长度,以及最终振荡器图形的方式取决于振荡系统的以下参数:

  • 阻尼(damping):由于空气摩擦、机械摩擦和其他作用在系统上的外部减速力。
  • 质量(mass):摆锤越重,摆动的时间越长。
  • 刚度(stiffness):振荡器的“弹簧”越硬(钟摆的“弹簧”是指地球的引力),钟摆摆动越困难,系统停下来也越快。想象一下,如果在月球或木星上使用这个钟摆;在低重力和高重力情况下的运动将是完全不同的。

  • 初始速度(initial velocity):推一下钟摆。

“这一切都非常有趣,但与弹簧动画有什么关系呢?”

阻尼谐振子系统是推动iOS中弹簧动画的动力。 下一节将更详细地讨论这个问题。

视图弹簧动画 vs 图层弹簧动画

UIKit以动态方式调整所有其他变量,使系统在给定的持续时间内稳定下来。 这就是为什么UIKit弹簧动画有时有点被迫 停下来的感觉。 如果仔细观察会发现UIKit动画有点不太自然。

幸运的是,核心允许通过CASpringAnimation类为图层属性创建合适的弹簧动画。 CASpringAnimation在幕后为UIKit创建弹簧动画,但是当我们直接调用它时,可以设置系统的各种变量,让动画自己稳定下来。 这种方法的缺点是不能设置固定的持续时间(duration);持续时间取决于提供的其它变量,然后系统计算所得。

CASpringAnimation的一些属性(对应之前振荡系统的参数):

damping 阻尼系数,阻止弹簧伸缩的系数,阻尼系数越大,停止越快

mass 质量,影响图层运动时的弹簧惯性,质量越大,弹簧拉伸和压缩的幅度越大

stiffness 刚度系数(劲度系数/弹性系数),刚度系数越大,形变产生的力就越大,运动越快

initialVelocity 初始速率,动画视图的初始速度大小。速率为正数时,速度方向与运动方向一致,速率为负数时,速度方向与运动方向相反

第一个图层弹簧动画

BahamaAirLoginScreen项目中两个文本框移动动画结束后有个脉动动画,让用户知道该字段处于活动状态并可以使用。 然而,动画结束时有些突然。 通过用CASpringAnimation来让脉动动画更加自然一点。

animationDidStop(_:finished:)动画代码:

1
2
3
4
5
6
// 简单的脉动动画
let pulse = CABasicAnimation(keyPath: "transform.scale")
pulse.fromValue = 1.25
pulse.toValue = 1.0
pulse.duration = 0.25
layer?.add(pulse, forKey: nil)

转变为:

1
2
3
4
5
6
let pulse = CASpringAnimation(keyPath: "transform.scale")
pulse.damping = 2.0
pulse.fromValue = 1.25
pulse.toValue = 1.0
pulse.duration = pulse.settlingDuration
layer?.add(pulse, forKey: nil)

效果图前后对比:

CABasicAnimation

CASpringAnimation

这边要注意duration。要使用系统根据当前参数估算的弹簧动画从开始到结束的时间pulse.settlingDuration

弹簧系统不能在0.25秒内稳定下来; 提供的变量意味着动画应该在它停止前再运行一段时间。
关于如何切断弹簧动画的视觉演示:

如果抖动时间太长,可以加大阻尼系数damping,比如:pulse.damping = 7.5

弹簧动画属性

CASpringAnimation预定义的弹簧动画属性的默认值分别是:

1
2
3
4
damping: 10.0
mass: 1.0
stiffness: 100.0
initialVelocity: 0.0

实现文本框的一个代理方法:

1
2
3
4
5
6
7
8
9
10
11
12
func textFieldDidEndEditing(_ textField: UITextField) {
guard let text = textField.text else {
return
}
if text.count < 5 {
let jump = CASpringAnimation(keyPath: "position.y")
jump.fromValue = textField.layer.position.y + 1.0
jump.toValue = textField.layer.position.y
jump.duration = jump.settlingDuration
textField.layer.add(jump, forKey: nil)
}
}

上面代码,表示当用户在文本中输入结束后,如果输入字符数小于5,出现一个小幅度的抖动动画,提醒用户太短了。

initialVelocity

起始速度,默认值0。

在设置持续时间前添加,也就是在jump.duration = jump.settlingDuration前添加:

1
jump.initialVelocity = 100.0

效果:

由于开始时的额外推动,文本框弹的更高了。

mass

增加初始速度会使动画持续时间更长,如果增加质量会怎么样?

jump.initialVelocity = 100.0后添加:

1
jump.mass = 10.0

效果:

额外质量使文本框的跳跃的要高了,并且稳定下来的持续时间更久了。

stiffness

刚度,默认是100。越大弹簧更“硬”。

jump.mass = 10.0后添加:

1
jump.stiffness = 1500.0

效果:

现在跳跃的不是那么高了。

damping

动画看起来很棒,但似乎确实有点太长了。 增加系统阻尼以使动画更快地稳定下来。

jump.stiffness = 1500.0后添加:

1
jump.damping = 50.0

效果:

特殊图层属性

在文本框抖动时,添加有颜色的边框。

textFieldDidEndEditing(_:)中的textField.layer.add(jump, forKey: nil)后添加:

1
2
textField.layer.borderWidth = 3.0
textField.layer.borderColor = UIColor.clear.cgColor

此代码给文本框周围添加了透明边框。
在上面代码后添加:

1
2
3
4
5
6
7
let flash = CASpringAnimation(keyPath: "borderColor")
flash.damping = 7.0
flash.stiffness = 200.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)

运行,放慢效果:

注意:在某些iOS版本中,图层动画会删除文本字段的圆角。此情况可在最后一段代码之后添加此行:textField.layer.cornerRadius = 5.

把登录按钮的圆角和背景色变化动画转化为弹性动画

这个改变很方便,只要修改ViewController.swift中两个函数:

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
32
33
34
35
36
37
// 背景颜色变化的图层动画
func tintBackgroundColor(layer: CALayer, toColor: UIColor) {
// let tint = CABasicAnimation(keyPath: "backgroundColor")
// tint.fromValue = layer.backgroundColor
// tint.toValue = toColor.cgColor
// tint.duration = 0.5
// layer.add(tint, forKey: nil)
// layer.backgroundColor = toColor.cgColor

let tint = CASpringAnimation(keyPath: "backgroundColor")
tint.damping = 5.0
tint.initialVelocity = -10.0
tint.fromValue = layer.backgroundColor
tint.toValue = toColor.cgColor
tint.duration = tint.settlingDuration
layer.add(tint, forKey: nil)
layer.backgroundColor = toColor.cgColor


}
// 圆角动画
func roundCorners(layer: CALayer, toRadius: CGFloat) {
// let round = CABasicAnimation(keyPath: "cornerRadius")
// round.fromValue = layer.cornerRadius
// round.toValue = toRadius
// round.duration = 0.33
// layer.add(round, forKey: nil)
// layer.cornerRadius = toRadius

let round = CASpringAnimation(keyPath: "cornerRadius")
round.damping = 5.0
round.fromValue = layer.cornerRadius
round.toValue = toRadius
round.duration = round.settlingDuration
layer.add(round, forKey: nil)
layer.cornerRadius = toRadius
}

12-图层关键帧动画和结构属性

图层上的关键帧动画(Layer Keyframe Animations,CAKeyframeAnimation)与UIView上的关键帧动画略有不同。 视图关键帧动画是将独立简单动画组合在一起,可以为不同的视图和属性设置动画,动画两者之间可以重叠或存在间隙。

相比之下,CAKeyframeAnimation允许我们为给定图层上的单个属性设置动画。可以定义动画的不同关键点,但动画中不能有任何间隙或重叠。 尽管听起来有些限制,但可以使用CAKeyframeAnimation创建一些非常引人注目的效果。

在本章中,将创建许多图层关键帧动画,从非常基本模拟真实世界碰撞到更高级的动画。 在15-Stroke和路径动画中,您将学习如何进一步获取图层动画,并沿给定路径为图层设置动画。

现在,您将在跑步之前走路,并为您的第一层关键帧动画创建一个时髦的摇摆效果。

介绍图层关键帧动画

想一想基本动画是如何运作的? 使用fromValuetoValue,核心动画会在指定的持续时间内逐步修改这些值之间的特定图层属性。
例如,当在45°和-45°(或π/ 4和-π/ 4)之间旋转图层时,只需要指定这两个值,然后图层渲染所有中间值以完成动画:

image-20181127104828153

CAKeyframeAnimation使用一组值来完成动画,而不是fromValuetoValue。 另外,还需要提供动画应达到每个值的关键点的时间。

在上面的动画中,图层从45°旋转到-45°,但这次它有两个独立的阶段:

image-20181127104845088

首先,它在动画持续时间的前三分之二内从45°旋转到22°,然后它在剩余的时间内一直旋转到-45°。
实质上,使用关键帧设置动画,要求我们为设置动画的属性提供关键值,以及在0.0和1.0之间进行相应数量的相对关键时间。

本章的开始项目使用上一章完成的项目

创建图层关键帧动画

resetForm()中添加:

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)

keyTimes是从0.01.0的一系列值,并且与values一一对应。在登录按钮恢复原状后,heading有一个摇摆的效果:

眼睛敏锐的读者可能已经注意到我还没有介绍过结构属性的动画。 大多数情况下,你可以放弃动画结构的单个组件,例如CGPoint的x组件,或CATransformation3D的旋转组件,但是接下来你会发现动态结构值的动画比 你可能会先考虑一下。

Animating struct values

结构体是Swift中的一等公民。 实际上,在使用类和结构之间语法上几乎没有区别。(关于类和结构体可查以撸代码的形式学习Swift-9:类和结构体(Classes and Structures)
但是,核心动画是一个基于C构建的Objective-C框架,这意味着结构体的处理方式与Swift的结构体截然不同。 Objective-C API喜欢处理对象,因此结构体需要一些特殊的处理。
这就是为什么对图层属性(如颜色或数字)进行动画制作相对容易的原因,但是为CGPoint等结构体属性设置动画并不容易。
CALayer有许多可动画属性,它们包含struct值,包括CGPoint类型的位置,CATransform3D类型的转换和CGRect类型的边界。

为了解决这个问题,Cocoa使用NSValue类,它可将一个struct值“包装”为一个核心动画好处理的对象。

NSValue附带了许多便利初始化程序:

1
2
3
4
init(cgPoint: CGPoint)
init(cgSize: CGSize)
init(cgRect rect: CGRect)
init(caTransform3D: CATransform3D)

使用例子, 以下是使用CGPoint的示例位置动画:

1
2
3
4
let move = CABasicAnimation(keyPath: "position")
move.duration = 1.0
move.fromValue = NSValue(cgPoint: CGPoint(x: 100.0, y: 100.0))
move.toValue = NSValue(cgPoint: CGPoint(x: 200.0, y: 200.0))

在把CGPoint赋值给fromValuetoValue之前,需要把CGPoint转化为NSValue,否则动画无法工作。关键帧动画同样如此。

热气球的关键帧动画

logIn()中添加:

1
2
3
4
let balloon = CALayer()
balloon.contents = UIImage(named: "balloon")!.cgImage
balloon.frame = CGRect(x: -50.0, y: 0.0, width: 50.0, height: 65.0)
view.layer.insertSublayer(balloon, below: username.layer)

insertSublayer(_:below)方法创建了一个图片图层作为view.layer的子图层。

如果需要在屏幕上显示图像但不需要使用UIView的所有好处(例如自动布局约束,附加手势识别器等),可以简单地使用上面的代码示例中的CALayer

在上面的代码后添加动画代码:

1
2
3
4
5
6
7
8
9
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]

values的三个对应点如下:

最后把动画添加到气球图层上,并且设置气球图层最终位置:

1
2
balloon.add(flight, forKey: nil)
balloon.position = CGPoint(x: -50.0, y: loginButton.center.y)

运行,效果:

13-形状和蒙版

本章学习CALayer的一个子类CAShapeLayer,它可以在屏幕上绘制各种形状,从非常简单到非常复杂都可以。

本章的开始项目 MultiplayerSearch 模拟了正在搜索在线对手的战斗游戏的起始屏幕。其中一个视图控制器显示一个漂亮的背景图像,一些标签,一个”Search Again“按钮(默认是透明的),和两个头像图像,其中一个将是空的,直到应用程序”找到“一个对手。

image-20181214163442348

头像视图

两个头像都是AvatarView类的一个实例。 下面开始完成一些头像视图的效果。
打开AvatarView.swift,会发现有几个已定义的属性,它们分别表示:

photoLayer:头像的图片图层。
circleLayer:用于绘制圆的形状图层。
maskLayer:另一个用于绘制蒙版的形状图层。
label:显示玩家姓名的标签。

上面的组件已经存在于项目中,但尚未添加到视图中,第一个任务就是把它们添加动视图中。 将以下代码添加到didMoveToWindow()

1
photoLayer.mask = maskLayer

这简单地用maskLayer中的圆形掩盖方形图像。

还可以通过@IBDesignable(关于@IBDesignable,可查看iOS tutorial 8:使用IBInspectable 和 IBDesignable定制UI)在storyboard中看到设置属性。

运行效果:

image-20181214164234894

现在将圆形边框图层添加到头像视图图层,在didMoveToWindow()中添加代码:

1
layer.addSublayer(circleLayer)

这时的效果为:

image-20181214164408801

添加名字标签:

1
addSubview(label)

反弹动画

下面创建类似两个物体相撞,然后弹开的反弹(bounce-off)动画。

ViewController中创建searchForOpponent()函数,并在viewDidAppear中调用:

1
2
3
4
5
func searchForOpponent() {
let avatarSize = myAvatar.frame.size
let bounceXOffset: CGFloat = avatarSize.width/1.9
let morphSize = CGSize(width: avatarSize.width * 0.85, height: avatarSize.height * 1.1)
}

bounceXOffset是相互反弹时应移动的水平距离。

morphSize是头像碰撞后的形变大小(宽度变小,长度变大)。

searchForOpponent()里继续添加:

1
2
3
4
5
let rightBouncePoint = CGPoint(x: view.frame.size.width/2.0 + bounceXOffset, y: myAvatar.center.y)
let leftBouncePoint = CGPoint(x: view.frame.size.width/2.0 - bounceXOffset, y: myAvatar.center.y)

myAvatar.bounceOff(point: rightBouncePoint, morphSize: morphSize)
opponentAvatar.bounceOff(point: leftBouncePoint, morphSize: morphSize)

上面的bounceOff(point:morphSize:)方法,两个参数分别代表头像移动的位置和变形的大小。在AvatarView中添加:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func bounceOff(point: CGPoint, morphSize: CGSize) {
let originalCenter = center

UIView.animate(withDuration: animationDuration, delay: 0.0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.0, animations: {
self.center = point
}, completion: {_ in

})

UIView.animate(withDuration: animationDuration, delay: animationDuration, usingSpringWithDamping: 0.7, initialSpringVelocity: 1.0, animations: {
self.center = originalCenter
}) { (_) in
delay(seconds: 0.1) {
self.bounceOff(point: point, morphSize: morphSize)
}
}
}

上面的两个动画分别是,使用弹簧动画将头像移动到指定位置使用弹簧动画将头像移动到原来位置。此时效果如下:

图像变形

实际生活中,两个物体相撞时,有一个短时间暂停,并且物体变形(”压扁“的效果)。下面就实现这种效果。

bounceOff(point:morphSize:)添加:

1
2
3
let morphedFrame = (originalCenter.x > point.x) ?
CGRect(x: 0.0, y: bounds.height - morphSize.height, width: morphSize.width, height: morphSize.height) :
CGRect(x: bounds.width - bounds.width, y: bounds.height - morphSize.height, width: morphSize.width, height: morphSize.height)

通过originalCenter.x > point.x来判断是左边头像还是右边头像。

bounceOff(point:morphSize:)继续添加:

1
2
3
4
5
6
7
let morphAnimation = CABasicAnimation(keyPath: "path")
morphAnimation.duration = animationDuration
morphAnimation.toValue = UIBezierPath(ovalIn: morphedFrame).cgPath

morphAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)

circleLayer.add(morphAnimation, forKey: nil)

通过UIBezierPath创建椭圆。

运行后,效果有点问题:

image-20181127144558179

只有边框图层发生了变形,图片图层没有变化。

morphAnimation动画添加到蒙版图层:

1
maskLayer.add(morphAnimation, forKey: nil)

这样的效果就好很多:

搜索对手

searchForOppoent()里最后添加delay(seconds: 4.0, completion: foundOppoent),然后在ViewController中添加:

1
2
3
4
5
6
func foundOpponent() {
status.text = "Connecting..."

opponentAvatar.image = UIImage(named: "avatar-2")
opponentAvatar.name = "Andy"
}

利用延迟来模拟在寻找对手。

foundOpponent()里添加delay(seconds: 4.0, completion: connectedToOpponent),然后然后在ViewController中添加:

1
2
3
4
func connectedToOpponent() {
myAvatar.shouldTransitionToFinishedState = true
opponentAvatar.shouldTransitionToFinishedState = true
}

shouldTransitionToFinishedStateAvatarView中自定义的属性,用于判断连接是否完成,在下面使用。

connectedToOpponent()里添加delay(seconds: 1.0, completion: completed),然后然后在ViewController中添加:

1
2
3
4
5
6
7
func completed() {
status.text = "Ready to play"
UIView.animate(withDuration: 0.2) {
self.vs.alpha = 1.0
self.searchAgain.alpha = 1.0
}
}

对手找到后,修改状态语,并显示重新搜索按钮。

效果:

连接成功后头像变成正方形

AvatarView中添加一个属性var isSquare = false,用于判断头像是否需要转换为正方形。

bounceOff(point:morphSize:)的第一个动画(头像移动到指定位置)的 completion闭包中添加:

1
2
3
if self.shouldTransitionToFinishedState {
self.animateToSquare()
}

其中animateToSquare()为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 变换为正方形动画
func animateToSquare() {
isSquare = true

let squarePath = UIBezierPath(rect: bounds).cgPath
let morph = CABasicAnimation(keyPath: "path")
morph.duration = 0.25
morph.fromValue = circleLayer.path
morph.toValue = squarePath

circleLayer.add(morph, forKey: nil)
maskLayer.add(morph, forKey: nil)

circleLayer.path = squarePath
maskLayer.path = squarePath

}

bounceOff(point:morphSize:)的第二个动画(头像移动到原来位置)的 completion闭包添加判断:

1
2
3
if !self.isSquare {
self.bounceOff(point: point, morphSize: morphSize)
}

这样的最终效果就是:

14-渐变动画

本章通过以前iOS的屏幕“滑动解锁”效果来学习渐变动画(Gradient Animations)

image-20181214171411641

开始项目 SlideToReveal是一个简单的单页面项目,只有一个显示时间的UILabel,和一个之后用于渐变动画的自定义UIView子类AnimateMaskLabel

第一个渐变图层

CAGradientLayerCALayer的另一个子类,专门用于渐变的图层。

配置CAGradientLayer,在属性gradientLayer定义的函数块中添加:

1
2
gradientLayer.startPoint = CGPoint(x: 0.0, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1.0, y: 0.5)

这定义了渐变的方向及其起点和终点。

image-20181128090956756

1
2
3
4
5
6
7
>     let gradientLayer: CAGradientLayer = {
> let gradientLayer = CAGradientLayer()
> gradientLayer.startPoint = CGPoint(x: 0.0, y: 0.5)
> gradientLayer.endPoint = CGPoint(x: 1.0, y: 0.5)
> ...
> }()
>

这种写法表示定义函数后直接调用,返回值直接给属性。这中写法在其它语言中也比较常见,比如JS。

继续添加:

1
2
3
4
5
6
7
8
let colors = [
UIColor.black.cgColor,
UIColor.white.cgColor,
UIColor.black.cgColor
]
gradientLayer.colors = colors
let locations: [NSNumber] = [0.25, 0.5, 0.75]
gradientLayer.locations = locations

上面的定义方式和前面学习的图层关键帧动画 中的valueskeyTimes有点类似。

结果就是渐变以黑色开始,中间白色,最后为黑色。通过locations指定这些颜色应该出现在渐变过程中的确切位置。当然也是可以很多个颜色点,和对应位置点的。

上面的效果就类似:

layoutSubviews()中定义渐变图层的frame

1
2
gradientLayer.frame = bounds
layer.addSublayer(gradientLayer)

这就把渐变的图层定义在AnimateMaskLabel

给渐变图层添加动画

didMoveToWindow()中添加:

1
2
3
4
5
6
let gradientAnimation = CABasicAnimation(keyPath: "locations")
gradientAnimation.fromValue = [0.0, 0.0, 0.25]
gradientAnimation.toValue = [0.75, 1.0, 1.0]
gradientAnimation.duration = 3.0
gradientAnimation.repeatCount = .infinity
gradientLayer.add(gradientAnimation, forKey: nil)

repeatCount设置为无穷大,动画持续3秒并将永远重复。效果如下:

上面的效果可能一时不好理解,如果把渐变图层的locations分别设置成[0.0, 0.0, 0.25][0.75, 1.0, 1.0],也就是动画开始点和结束点,情况分别是:

image-20181128094342790

image-20181128094501134

动画的效果就是前者的状态到后者的状态,这样就方便理解了。

这看起来很漂亮,但渐变宽度有点小。 只需放大渐变边界,就会得到更温和的渐变。
layoutSubviews()中找到gradientLayer.frame = bounds行,替代为:

1
gradientLayer.frame = CGRect(x: -bounds.size.width, y: bounds.origin.y, width: 3 * bounds.size.width, height: bounds.size.height)

这会将渐变框设置为可见区域宽度的三倍。 动画进入视图,直接穿过它,并从右侧退出:

image-20181128100059850

效果:

创建文本蒙版

AnimateMaskLabel中创造一个文本属性:

1
2
3
4
5
6
7
8
let textAttributes: [NSAttributedString.Key: Any] = {
let style = NSMutableParagraphStyle()
style.alignment = .center
return [
NSAttributedString.Key.font: UIFont(name: "HelveticaNeue-Thin", size: 28.0)!,
NSAttributedString.Key.paragraphStyle: style
]
}()

接下来,需要将文本渲染为图像。 在text属性的属性观察者中的setNeedsDisplay()之后添加以下代码:

1
2
3
let image = UIGraphicsImageRenderer(size: bounds.size).image { (_) in
text.draw(in: bounds, withAttributes: textAttributes)
}

在这里,使用图像渲染器来设置上下文。

使用该图像在渐变图层上创建蒙版,在上面代码后继续添加:

1
2
3
4
5
let maskLayer = CALayer()
maskLayer.backgroundColor = UIColor.clear.cgColor
maskLayer.frame = bounds.offsetBy(dx: bounds.size.width, dy: 0)
maskLayer.contents = image.cgImage
gradientLayer.mask = maskLayer

现在效果:

滑动手势

viewDidLoad()中添加:

1
2
3
let swipe = UISwipeGestureRecognizer(target: self, action: #selector(ViewController.didSlide))
swipe.direction = .right
slideView.addGestureRecognizer(swipe)

效果:

彩色渐变

修改渐变的图层的colorslocations,然之前的黑白变成彩色:

1
2
3
4
5
6
7
8
let colors = [
UIColor.yellow.cgColor,
UIColor.green.cgColor,
UIColor.orange.cgColor,
UIColor.cyan.cgColor,
UIColor.red.cgColor,
UIColor.yellow.cgColor
]
1
let locations: [NSNumber] = [0.0, 0.0, 0.0, 0.0, 0.0, 0.25]

并修改动画的fromValuetoValue

1
2
gradientAnimation.fromValue = [0.0, 0.0, 0.0, 0.0, 0.0, 0.25]
gradientAnimation.toValue = [0.65, 0.8, 0.85, 0.9, 0.95, 1.0]

效果:

本章的最终效果:

15-Stroke和路径动画

注: stroke 可翻译成 笔画,但好像又不当恰当,就干脆不翻译😏。

开始项目 PullToRefresh

image-20181214182311997

有一个TableView,下拉新视图保持可见状态四秒钟,然后缩回。本章就是在这个下拉视图中做一个类似菊花转的动画。

创建交互stroke动画

构建动画的第一步是创建一个圆形。 打开RefreshView.swift并将以下代码添加到init(frame:scrollView:)中:

1
2
3
4
5
6
7
8
9
10
// 飞机移动路线图层
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)

ovalShapeLayer是一个类型为CAShapeLayerRefreshView的属性。CAShapeLayer之前已经学过了, 在这里,只需设置笔触和填充颜色,并将圆直径设置为视图高度的80%,这样可确保形成舒适的边距。

lineDashPattern属性是设置虚线模式,它是一个数组,其中包含短划线的长度和间隙的长度(以像素为单位),当然还可以设置很多种虚线,详细的可查看官方文档

redrawFromProgress()中添加:

1
ovalShapeLayer.strokeEnd = progress

把飞机图片添加到飞机图层中,在init(frame:scrollView:)中添加:

1
2
3
4
5
6
7
// 添加飞机
let airplaneImage = UIImage(named: "airplane.png")!
airplaneLayer.contents = airplaneImage.cgImage
airplaneLayer.bounds = CGRect(x: 0.0, y: 0.0, width: airplaneImage.size.width, height: airplaneImage.size.height)
airplaneLayer.position = CGPoint(x: frame.size.width/2 + frame.size.height/2 * 0.8, y: frame.size.height/2)
layer.addSublayer(airplaneLayer)
airplaneLayer.opacity = 0.0

下拉时逐步更改飞机图层的不透明度,在redrawFromProgress()添加:

1
airplaneLayer.opacity = Float(progress)

stroke的结尾

beginRefreshing()中添加:

1
2
3
4
5
6
7
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

beginRefreshing()的末尾添加以下代码以同时运行两个动画:

1
2
3
4
5
let strokeAnimationGroup = CAAnimationGroup()
strokeAnimationGroup.duration = 1.5
strokeAnimationGroup.repeatDuration = 5.0
strokeAnimationGroup.animations = [strokeEndAnimation, strokeEndAnimation]
ovalShapeLayer.add(strokeAnimationGroup, forKey: nil)

在上面的代码中,创建一个动画组并重复动画五次。 这应该足够长,以便在刷新视图可见时保持动画运行。 然后,将两个动画添加到组中,并将组添加到加载层。

运行效果:

创建path关键帧动画

12-图层关键帧动画和结构属性 学习了使用values属性来设置关键帧动画。下面学习另一种方式使用关键帧动画。

beginRefreshing()的末尾添加飞机动画:

1
2
3
4
5
6
7
8
9
10
// 飞机动画
let flightAnimation = CAKeyframeAnimation(keyPath: "position")
flightAnimation.path = ovalShapeLayer.path
flightAnimation.calculationMode = CAAnimationCalculationMode.paced

let flightAnimationGroup = CAAnimationGroup()
flightAnimationGroup.duration = 1.5
flightAnimationGroup.repeatDuration = 5.0
flightAnimationGroup.animations = [flightAnimation]
airplaneLayer.add(flightAnimationGroup, forKey: nil)

CAAnimationCalculationMode.paced是另一种控制动画时间的方法,这时核心动画会以恒定的速度设置动画,忽略设置的任何keyTimes,这对于在任意路径上生成平滑动画非常有用。

CAAnimationCalculationMode还有其他几种模式,详细可查看官方文档

运行效果:

这比较奇怪了,✈️移动时,角度也有相应的变化。

在创建flightAnimationGroup的行上方插入以下新动画代码,来调整飞机移动时角度

1
2
3
let airplaneOrientationAnimation = CABasicAnimation(keyPath: "transform.rotation")
airplaneOrientationAnimation.fromValue = 0
airplaneOrientationAnimation.toValue = 2.0 * .pi

最终效果

16-复制动画

本章节学习复制动画(Replicating Animations)

CAReplicatorLayerCALayer的另一个子类。它意思很简单,当创建了一些内容 —— 可以是一个形状,一个图像或任何可以用图层绘制的东西 —— 而CAReplicatorLayer可以在屏幕上复制它,如下所示:

为什么需要复制形状或图像?

CAReplicatorLayer的超级强大之处,在于可以让每个复制体与母体略有不同。
例如,可以逐步更改每个副本的颜色。 原始图层可能是洋红色,而在创建每个副本时,将颜色向青色方向改变:

此外,还可以在副本之间应用转换(transform)。 例如,可以在每个副本之间应用简单的旋转转换,将它们绘制成圆形,如下所示:

image-20181114152157889

但最好的功能是每个副本都能够设置动画延迟。 当原始内容的instanceDelay设置0.2秒时,第一个副本将延迟0.2秒执行动画,第二个副本将延迟0.4秒执行动画,第三个副本将延迟0.6秒执行动画,依此类推。

可以使用这种方式来创建引人入胜且复杂的动画。

在本章中,将创建一个模仿Siri,听到声音后,根据声音而产生波浪状的动画。这个开始项目 命名为Iris

这个项目将创建两个不同的复制。 首先,是在Iris会话时播放的视觉反馈动画,它看起来很像一个迷幻的正弦波:

image-20181214235839247

然后是一个交互式麦克风驱动的音频波,当用户说话时,它将提供视觉反馈:

image-20181214235912038

这两个动画覆盖了CAReplicatorLayer的大部分功能。

Replicating like rabbits

开始项目概述

打开Main.storyboard

image-20181215000456402

只有一个视图控制器,它具有一个按钮和一个标签。 用户在按下按钮时询问问题; 当他们释放按钮时,Iris会做出回应。 标签用来显示麦克风输入和Iris的答案。

ViewController.swift中,按钮事件已连接到操作。当用户触摸按钮时,actionStartMonitoring()会触发;当用户抬起手指时,actionEndMonitoring()会触发。

另外还有两个超出本章范围的类:

Assistant:人工智能助理。它预定义的有趣答案列表,并根据用户的问题说出来。
MicMonitor:监控iPhone麦克风上的输入,并反复调用您提供的闭包表达式。这是您有机会更新显示的地方。

下面开始!

设置复制器层

打开ViewController.swift并添加以下两个属性:

1
2
let replicator = CAReplicatorLayer()
let dot = CALayer()

dot使用CALayer,用来绘制基本的简单形状。replicator作为复制器,用来之后复制dot

下面添加一些常量 属性:

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

doLength用作点图层的宽度和高度,dotOffset是每个点复制体之间的偏移量。

将复制器层添加到视图控制器的视图中,在viewDidLoad()中添加:

1
2
replicator.frame = view.bounds
view.layer.addSublayer(replicator)

下一步是设置点图层。 在viewDidLoad()中添加:

1
2
3
4
5
6
7
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)

先将点图层定位到复制器的右边缘,然后设置图层的背景颜色并添加边框等,最后将点图层加入复制器图层。运行结果:

image-20181215113735985

在继续下面之前,先介绍CAReplicatorLayer的三个属性:
instanceCount: 副本数
instanceTransform: 副本之间的转换
instanceDelay: 副本之间的动画延迟

viewDidLoad()中添加:

1
2
replicator.instanceCount = Int(view.frame.size.width / dotOffset)
replicator.instanceTransform = CATransform3DMakeTranslation(-dotOffset, 0.0, 0.0)

屏幕宽度除以偏移量,根据不同屏幕宽度设置副本数。比如5.5英寸(宽度为414)的instanceCount是51,4.7英寸是46 。。。

每个副本向左(-dotOffset)移动8 。结果为:

测试复制动画

添加一个小测试动画,来了解instanceDelay的作用。 在viewDidLoad()的末尾添加:

1
2
3
4
5
6
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)

这个动画很简单,只是把点向上重复移动10次。

在上面代码的的末尾添加:

1
replicator.instanceDelay = 0.02

效果:

在继续之前,需要删除上面的测试动画,除了instanceDelay

复制多个动画

在本节中,您将学习在Iris讲话时播放的动画。 为此,您将结合使用具有不同延迟的多个简单动画来产生最终效果。

缩放动画

首先,在startSpeaking()中添加以下动画:

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: kCAMediaTimingFunctionEaseOut)
dot.add(scale, forKey: "dotScale")

这是一个简单的层动画,重点在CATransform3DMakeScale的几个参数选择。此处将点图层在垂直方向缩放15倍。

运行,并点击灰色按钮,分别先后调用actionStartMonitoringactionEndMonitoring(),最后调用startSpeaking(),效果:

可以尝试修改CATransform3DMakeScale的几个参数和duration来看看有什么不同效果。

透明动画

startSpeaking()添加淡出动画:

1
2
3
4
5
6
7
8
9
let fade = CABasicAnimation(keyPath: "opacity")
fade.fromValue = 1.0
fade.toValue = 0.2
fade.duration = 0.33
fade.beginTime = CACurrentMediaTime() + 0.33
fade.repeatCount = .infinity
fade.autoreverses = true
fade.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
dot.add(fade, forKey: "dotOpacity")

与缩放动画的持续时间相同,但延迟0.33秒,透明度从1.0到0.2,当“波浪”充分移动后,开始淡出效果。

当两个动画同时运行时,效果会更好一点:

色彩动画

设置点背景颜色变化动画,在startSpeaking()添加:

1
2
3
4
5
6
7
8
9
10
let tint = CABasicAnimation(keyPath: "backgroundColor")
tint.fromValue = UIColor.magenta.cgColor
tint.toValue = UIColor.cyan.cgColor
tint.duration = 0.66
tint.beginTime = CACurrentMediaTime() + 0.28
tint.fillMode = kCAFillModeBackwards
tint.repeatCount = .infinity
tint.autoreverses = true
tint.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
dot.add(tint, forKey: "dotColor")

三种动画的效果:

CAReplicatorLayer的属性

前面已经通过复制器层制作了很多令人眼花缭乱的效果。 由于CAReplicatorLayer本身就是一个图层,因此也可以为其自身的一些属性设置动画。

可以为CAReplicatorLayer的基本属性(如positionbackgroundColorcornerRadius)设置动画,也可以通过其特殊的属性设置非常酷的动画。

CAReplicatorLayer特有的可动画属性包括(前面已经介绍过三个):

instanceDelay: 副本之间的动画延迟
instanceTransform:副本之间的转换
instanceColor: 颜色
instanceRedOffsetinstanceGreenOffsetinstanceBlueOffset:应用增量以应用于每个实例颜色组件
instanceAlphaOffset: 透明度增量

startSpeaking()的末尾添加一个动画:

1
2
3
4
5
6
7
8
let initialRotation = CABasicAnimation(keyPath: "instanceTransform.rotation")
initialRotation.fromValue = 0.0
initialRotation.toValue = 0.01
initialRotation.duration = 0.33
initialRotation.isRemovedOnCompletion = false
initialRotation.fillMode = kCAFillModeForwards
initialRotation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
replicator.add(initialRotation, forKey: "initialRotation")

上面只是有一个微小的旋转,效果:

再需要一个上下扭动的效果,添加下面的动画以完成效果:

1
2
3
4
5
6
7
8
9
let rotation = CABasicAnimation(keyPath: "instanceTransform.rotation")
rotation.fromValue = 0.01
rotation.toValue = -0.01
rotation.duration = 0.99
rotation.beginTime = CACurrentMediaTime() + 0.33
rotation.repeatCount = .infinity
rotation.autoreverses = true
rotation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
replicator.add(rotation, forKey: "replicatorRotation")

这是在instanceTransform.rotation上运行第二个动画,它在之前第一个动画完成后启动。将旋转从0.01弧度(第一个动画的最终值)设置到-0.01弧度,这就有了扭到的效果(不同方向的旋转)。
效果:

下面模拟语音助手,假装回单。startSpeaking()的开始处添加:

1
2
3
meterLabel.text = assistant.randomAnswer()
assistant.speak(meterLabel.text!, completion: endSpeaking)
speakButton.isHidden = true

Assistant类中随机获得一个答案,然后在meterLabel上显示,并且读处答案,读完后调用endSpeaking方法。这是过程中按钮需要隐藏。

之后,需要删除所有正在运行的动画,在endSpeaking()中添加:

1
replicator.removeAllAnimations()

接下来,需要将点图层“优雅”地设置为原始比例的动画, 在endSpeaking()继续中添加:

1
2
3
4
5
6
let scale = CABasicAnimation(keyPath: "transform")
scale.toValue = NSValue(caTransform3D: CATransform3DIdentity)
scale.duration = 0.33
scale.isRemovedOnCompletion = false
scale.fillMode = kCAFillModeForwards
dot.add(scale, forKey: nil)

上面的动画,没有指定fromValue ,会从当前值开始动画,变换为CATransform3DIdentiy

最后,删除dot中当前正在运行的其余动画,并恢复说话按钮状态。 在endSpeaking()继续中添加:

1
2
3
4
dot.removeAnimation(forKey: "dotColor")
dot.removeAnimation(forKey: "dotOpacity")
dot.backgroundColor = UIColor.lightGray.cgColor
speakButton.isHidden = false

本节的效果:

交互式复制动画

前面这有Iris回答时,才会有对应波动动画。这一节要做的是,当用户按住按钮说话(问问题)时也就对应波动动画。

actionStartMonitoring()中添加:

1
2
3
4
dot.backgroundColor = UIColor.green.cgColor
monitor.startMonitoringWithHandler { (level) in
self.meterLabel.text = String(format: "%.2f db", level)
}

当用户按下说话按钮时,触发actionStartMonitoring。为了表示“正在收听”,将点图层颜色更改为绿色。

然后在监视器实例上调用startMonitoringWithHandler(),它的参数是一个闭包块,会被重复执行,获取麦克风分贝数(db)。

这边的分贝数和我们平常见到分贝数范围有点不同, 它的值在-160.0 db到0.0 db的范围内,-160.0 db是最安静的,0.0 db意味着非常大的声音。

向上面的闭包中添加一段代码,添加完如下:

1
2
3
4
monitor.startMonitoringWithHandler { (level) in
self.meterLabel.text = String(format: "%.2f db", level)
let scaleFactor = max(0.2, CGFloat(level) + 50) / 2
}

scaleFactor将存储介于0.1和25.0之间的值。

ViewController新加一个属性:

1
var lastTransformScale: CGFloat = 0.0

对于缩放动画,比例不断变化的,lastTransformScale保存最后一个缩放值。

在上面的麦克风处理闭包中添加用户声音动画:

1
2
3
4
5
6
7
let scale = CABasicAnimation(keyPath: "transform.scale.y")
scale.fromValue = self.lastTransformScale
scale.toValue = scaleFactor
scale.duration = 0.1
scale.isRemovedOnCompletion = false
scale.fillMode = kCAFillModeForwards
self.dot.add(scale, forKey: nil)

最后,保存lastTransformScale,接着上面的代码添加:

1
self.lastTransformScale = scaleFactor

当用户手指离开按钮时,需要重置动画并停止监听麦克风。 在actionEndMonitoring()开始处添加:

1
2
monitor.stopMonitoring()
dot.removeAllAnimations()

这个时候,效果:

平滑麦克风输入和Iris动画之间的过渡

仔细之前的效果,我发现用户麦克风输入动画和Iris动画之间是没有过渡,是直接跳过。这是actionEndMonitoring()中的dot.removeAllAnimations()造成的。

dot.removeAllAnimations()替代为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 麦克风输入和Iris动画之间的过渡
let scale = CABasicAnimation(keyPath: "transform.scale.y")
scale.fromValue = lastTransformScale
scale.toValue = 1.0
scale.duration = 0.2
scale.isRemovedOnCompletion = false
scale.fillMode = kCAFillModeForwards
dot.add(scale, forKey: nil)

dot.backgroundColor = UIColor.magenta.cgColor

let tint = CABasicAnimation(keyPath: "backgroundColor")
tint.fromValue = UIColor.green.cgColor
tint.toValue = UIColor.magenta.cgColor
tint.duration = 1.2
tint.fillMode = kCAFillModeBackwards
dot.add(tint, forKey: nil)

本章最后的效果:

坚持原创技术分享,您的支持将鼓励我继续创作!