在本文中,我们将深入研究一些高级技术来创建SwiftUI动画。我将广泛讨论Animatable协议、其值得信赖的配套anitableData、功能强大且经常被忽视的GeometryEffect以及完全被忽视但全能的AnimatableModifier协议。
这些都是官方文档完全忽视的主题,在SwiftUI帖子和文章中几乎从未提及。尽管如此,他们还是为我们提供了创建一些相当不错的动画的工具。
1. Explicit vs. Implicit Animations
SwiftUI中有两种类型的动画。显式和隐式。隐式动画是您使用.animation()修饰符指定的动画。每当视图上更改animatable参数时,SwiftUI都会从旧值动画化到新值。一些animatable参数是尺寸、偏移量、颜色、缩放比例等。
显式动画是那些用withAnimation { ... }闭包指定的动画。只有那些依赖于withAnimation闭包中更改的值的参数才会被动画化。让我们尝试一些例子来说明:
以下示例使用隐式动画来更改图像的大小和不透明度:

struct Example1: View {
@State private var half = false
@State private var dim = false
var body: some View {
Image("tower")
.scaleEffect(half ? 0.5 : 1.0)
.opacity(dim ? 0.2 : 1.0)
.animation(.easeInOut(duration: 1.0))
.onTapGesture {
self.dim.toggle()
self.half.toggle()
}
}
}
以下示例使用显式动画。在这里,缩放和不透明度都发生了变化,但只有不透明度才会被动画化,因为它是withAnimation闭包中唯一更改的参数:

struct Example2: View {
@State private var half = false
@State private var dim = false
var body: some View {
Image("tower")
.scaleEffect(half ? 0.5 : 1.0)
.opacity(dim ? 0.5 : 1.0)
.onTapGesture {
self.half.toggle()
withAnimation(.easeInOut(duration: 1.0)) {
self.dim.toggle()
}
}
}
}
请注意,您可以通过更改修饰符的放置顺序,使用隐式动画创建相同的结果:
struct Example2: View {
@State private var half = false
@State private var dim = false
var body: some View {
Image("tower")
.opacity(dim ? 0.2 : 1.0)
.animation(.easeInOut(duration: 1.0))
.scaleEffect(half ? 0.5 : 1.0)
.onTapGesture {
self.dim.toggle()
self.half.toggle()
}
}
}
如果您需要禁用动画,您可以使用.animation(nil)。
2. How Do Animations Work
在所有 SwiftUI 动画的背后,都有一个名为 Animatable 的协议。我们稍后将讨论细节,但主要是,它涉及具有符合VectorArithmetic的类型的计算属性。这使得框架可以随意插值。
在为视图制作动画时,SwiftUI实际上会多次重新生成视图,每次都修改动画参数。通过这种方式,它逐渐从起点到终点。
假设我们为视图的不透明度创建一个线性动画。我们打算从0.3升到0.8。该框架将多次重新生成视图,以微小的增量改变不透明度。由于不透明度表示为Double,并且Double符合VectorArithmetic,SwiftUI可以插值所需的不透明度值。在框架代码的某个地方,可能有一种这样的算法:
let from:Double = 0.3
let to:Double = 0.8
for i in 0..<6 {
let pct = Double(i) / 5
var difference = to - from
difference.scale(by: pct)
let currentOpacity = from + difference
print("currentOpacity = \(currentOpacity)")
}
该代码将创建从起点到目的地的渐进更改:
currentOpacity = 0.3
currentOpacity = 0.4
currentOpacity = 0.5
currentOpacity = 0.6
currentOpacity = 0.7
currentOpacity = 0.8
3. Why Do I Care About Animatable?
你可能会想,为什么我需要关心所有这些小细节。SwiftUI已经为不透明度制作了动画,而我不必担心这一切。是的,这是真的,但只要SwiftUI知道如何将值从原点插值到目的地。对于不透明度,这是一个直截了当的过程,SwiftUI知道该怎么做。然而,正如我们接下来将看到的,情况并非总是如此。
想到了一些很大的例外:路径、转换矩阵和任意视图更改(例如,文本视图中的文本、渐变颜色或渐变中的停止等)。在这种情况下,框架不知道该怎么做。关于如何从A到B,没有预定义的行为。我们即将在本文第二和第三部分中讨论转换矩阵并查看更改。现在,让我们专注于shapes。
4. Animating Shape Paths
想象一下,你有一个使用路径绘制正多边形的shape。当然,我们的实现将让您指出多边形将有多少方面:
PolygonShape(sides: 3).stroke(Color.blue, lineWidth: 3)
PolygonShape(sides: 4).stroke(Color.purple, lineWidth: 4)

这是我们的PolygonShape实现。
struct PolygonShape: Shape {
var sides: Int
func path(in rect: CGRect) -> Path {
// hypotenuse
let h = Double(min(rect.size.width, rect.size.height)) / 2.0
// center
let c = CGPoint(x: rect.size.width / 2.0, y: rect.size.height / 2.0)
var path = Path()
for i in 0..<sides {
let angle = (Double(i) * (360.0 / Double(sides))) * Double.pi / 180
// Calculate vertex position
let pt = CGPoint(x: c.x + CGFloat(cos(angle) * h), y: c.y + CGFloat(sin(angle) * h))
if i == 0 {
path.move(to: pt) // move to first vertex
} else {
path.addLine(to: pt) // draw line to next vertex
}
}
path.closeSubpath()
return path
}
}
我们可以更进一步,并尝试使用与不透明度相同的方法为side参数添加动画效果:
PolygonShape(sides: isSquare ? 4 : 3)
.stroke(Color.blue, lineWidth: 3)
.animation(.easeInOut(duration: duration))
你认为SwiftUI会如何将三角形变成正方形?你可能猜到了。它不会。当然,该框架不知道如何为它制作动画。您可以随心所欲地使用.animation(),但形状将从三角形跳转到没有动画的正方形。原因很简单:您只教SwiftUI如何绘制三面多边形或四面多边形,但您的代码不知道如何绘制3.379面多边形!
因此,要进行动画,我们需要两件事:
- 我们需要更改形状代码,以便它知道如何绘制具有非整数边数的多边形。
- 使框架多次生成形状,在动画参数中几乎没有增量。也就是说,我们希望形状被多次要求绘制,每次都对side参数有不同的值:3、3.1、3.15、3.2、3.25,一直到4。
一旦我们把它放到位,我们将能够在任意数量的方面之间制作动画:

为了使shape具有动画效果,我们需要SwiftUI使用原点到目标点之间的所有side值多次渲染视图。幸运的是,Shape已经符合Animatable协议。这意味着,有一个计算属性(animatableData),我们可以用它来处理此任务。然而,它的默认实现设置为EmptyAnimatableData。所以它什么也没做。
为了解决我们的问题,我们将首先将sides属性的类型从Int更改为Double。这样我们就可以有小数了。
我们稍后将讨论如何将属性保持为Int,并仍然执行动画。但就目前而言,为了保持简单,让我们使用Double。
struct PolygonShape: Shape {
var sides: Double
...
}
然后,我们需要创建计算属性anitableData。在这种情况下,它非常简单:
struct PolygonShape: Shape {
var sides: Double
var animatableData: Double {
get { return sides }
set { sides = newValue }
}
...
}
用小数绘sides
最后,我们需要教SwiftUI如何绘制具有非整数边数的多边形。我们将稍微更改我们的代码。随着小数点数的增长,这一新side将从零到全长。其他顶点将相应地平稳地重新定位。这听起来很复杂,但这是一个最小的变化:
func path(in rect: CGRect) -> Path {
// hypotenuse
let h = Double(min(rect.size.width, rect.size.height)) / 2.0
// center
let c = CGPoint(x: rect.size.width / 2.0, y: rect.size.height / 2.0)
var path = Path()
let extra: Int = Double(sides) != Double(Int(sides)) ? 1 : 0
for i in 0..<Int(sides) + extra {
let angle = (Double(i) * (360.0 / Double(sides))) * Double.pi / 180
// Calculate vertex
let pt = CGPoint(x: c.x + CGFloat(cos(angle) * h), y: c.y + CGFloat(sin(angle) * h))
if i == 0 {
path.move(to: pt) // move to first vertex
} else {
path.addLine(to: pt) // draw line to next vertex
}
}
path.closeSubpath()
return path
}
如前所述,对于我们shape的用户来说,sides参数是Double似乎很奇怪。应该期望sides是Int参数。幸运的是,我们可以再次更改代码,并将这一事实隐藏在我们的shape实现中:
struct PolygonShape: Shape {
var sides: Int
private var sidesAsDouble: Double
var animatableData: Double {
get { return sidesAsDouble }
set { sidesAsDouble = newValue }
}
init(sides: Int) {
self.sides = sides
self.sidesAsDouble = Double(sides)
}
...
}
通过这些更改,我们在内部使用Double,但在外部使用Int。它现在看起来更优雅了。不要忘记修改绘图代码,因此它使用 sidesAsDouble而不是 sides。
5. Animating More Than One Parameter
我们经常会发现自己需要为多个参数制作动画效果。一个Double是不会满足它。在这些时刻,我们可以使用AnimatablePair<First, Second>。在这里,第一和第二都是符合VectorArithmetic的类型。例如,AnimatablePair<CGFloat, Double>。

为了演示AnimatablePair的使用,我们将修改我们的示例。现在,我们的多边形形状将有两个参数:sides和scale。两者都将以double代表。
struct PolygonShape: Shape {
var sides: Double
var scale: Double
var animatableData: AnimatablePair<Double, Double> {
get { AnimatablePair(sides, scale) }
set {
sides = newValue.first
scale = newValue.second
}
}
...
}
6. Going Beyond Two Animatable Parameters
如果您浏览SwiftUI声明文件,您将看到该框架非常广泛地使用AnimatablePair。例如:CGSize、CGPoint、CGrect。虽然这些类型不符合VectorArithmetic,但它们可以动画,因为它们确实符合Animatable。
他们都以这样或那样的方式使用AnimatablePair:
extension CGPoint : Animatable {
public typealias AnimatableData = AnimatablePair<CGFloat, CGFloat>
public var animatableData: CGPoint.AnimatableData
}
extension CGSize : Animatable {
public typealias AnimatableData = AnimatablePair<CGFloat, CGFloat>
public var animatableData: CGSize.AnimatableData
}
extension CGRect : Animatable {
public typealias AnimatableData = AnimatablePair<CGPoint.AnimatableData, CGSize.AnimatableData>
public var animatableData: CGRect.AnimatableData
}
如果您更密切地关注CGRect,您会发现它实际上正在使用:
AnimatablePair<AnimatablePair<CGFloat, CGFloat>, AnimatablePair<CGFloat, CGFloat>>
这意味着矩形x、y、宽度和高度值可以通过first.first、first.second、second.first和second.second访问。
7. Making Your Own Type Animatable (with VectorArithmetic)
以下类型符合Animatable:Angle、CGPoint、CGrect、CGSize、EdgeInsets、StrokeStyle和UnitPoint。以下类型符合VectorArithmetic:AnimatablePair、CGFloat、Double、EmptyAnimatableData和Float。你可以用它们中的任何一个来为你的形状制作动画。
现有类型提供了足够的灵活性来制作任何动画。但是,如果您发现自己拥有想要动画的复杂类型,没有什么能阻止您添加自己的VectorArithmetic一致性实现。事实上,我们将在下一个例子中这样做。
为了说明,我们将创建一个模拟时钟形状。它将根据类型为ClockTime的自定义动画参数移动针头。
我们将以这种方式使用它:
ClockShape(clockTime: show ? ClockTime(9, 51, 15) : ClockTime(9, 55, 00))
.stroke(Color.blue, lineWidth: 3)
.animation(.easeInOut(duration: duration))
首先,我们首先创建自定义类型ClockTime。它包含三个属性(小时、分钟和秒),几个有用的初始化器,以及一些助手计算属性和方法:
struct ClockTime {
var hours: Int // Hour needle should jump by integer numbers
var minutes: Int // Minute needle should jump by integer numbers
var seconds: Double // Second needle should move smoothly
// Initializer with hour, minute and seconds
init(_ h: Int, _ m: Int, _ s: Double) {
self.hours = h
self.minutes = m
self.seconds = s
}
// Initializer with total of seconds
init(_ seconds: Double) {
let h = Int(seconds) / 3600
let m = (Int(seconds) - (h * 3600)) / 60
let s = seconds - Double((h * 3600) + (m * 60))
self.hours = h
self.minutes = m
self.seconds = s
}
// compute number of seconds
var asSeconds: Double {
return Double(self.hours * 3600 + self.minutes * 60) + self.seconds
}
// show as string
func asString() -> String {
return String(format: "%2i", self.hours) + ":" + String(format: "%02i", self.minutes) + ":" + String(format: "%02f", self.seconds)
}
}
现在,为了符合VectorArithmetic,我们需要编写以下方法和计算属性:
extension ClockTime: VectorArithmetic {
static var zero: ClockTime {
return ClockTime(0, 0, 0)
}
var magnitudeSquared: Double { return asSeconds * asSeconds }
static func -= (lhs: inout ClockTime, rhs: ClockTime) {
lhs = lhs - rhs
}
static func - (lhs: ClockTime, rhs: ClockTime) -> ClockTime {
return ClockTime(lhs.asSeconds - rhs.asSeconds)
}
static func += (lhs: inout ClockTime, rhs: ClockTime) {
lhs = lhs + rhs
}
static func + (lhs: ClockTime, rhs: ClockTime) -> ClockTime {
return ClockTime(lhs.asSeconds + rhs.asSeconds)
}
mutating func scale(by rhs: Double) {
var s = Double(self.asSeconds)
s.scale(by: rhs)
let ct = ClockTime(s)
self.hours = ct.hours
self.minutes = ct.minutes
self.seconds = ct.seconds
}
}
8. SwiftUI + Metal
如果您发现自己在编码复杂的动画,您当同时试图跟上所有绘图时,可能会看到您的设备受到影响。如果是这样,您肯定会从启用Metal中受益。以下是启用Metal如何让一切变得与众不同的示例:
在模拟器上运行时,您可能不会看到太大的区别。然而,在真实设备上,你会的。
幸运的是,启用Metal非常容易。您只需要添加.drawingGroup()修饰符:
FlowerView().drawingGroup()
根据WWDC 2019,Session237(使用SwiftUI构建自定义视图):绘图组是一种特殊的渲染方式,但仅适用于graphics等。它基本上会将SwiftUI视图扁平化为单个NSView/UIView,并用metal渲染。
如果您想尝试一下,但您的形状不够复杂,无法使设备挣扎,请添加一些渐变和阴影,您将立即看到区别。
9. What’s Next
在本文的第二部分,我们将学习如何使用GeometryEffect协议。它将为改变我们的view和动画的新方法打开大门。与path一样,SwiftUI没有关于如何在两个不同的转换矩阵之间转换的内置知识。GeometryEffect将在此过程中有所帮助。
目前,SwiftUI没有关键帧功能。我们将看看如何用basic animation来模拟一个。
在文章的第三部分中,我们将介绍AnimatableModifier,这是一个非常强大的工具,可以让我们为任何可以在视图中更改的东西制作动画,甚至文本!