SwiftUI 带来了大量的新功能,并且为在所有 Apple 平台上编写 UI 代码提供了更简便的方法。作为额外的福利,它还具有一种新的状态转换动画方法。
如果您习惯使用 UIView 动画,则可能会发现它们更易于编写和理解。如果您要使用 SwiftUI,那么恭喜您!你会发现这更容易。
在本教程中,您将学习 SwiftUI 动画的基础知识,包括:
• animation 修改器。
• withAnimation,该函数可让您对状态更改进行动画处理。
• 自定义动画。
最好使用 Xcode 11.2.1 或更高版本,该版本包含 SwiftUI 代码中已知动画错误的修复程序。如果您运行的是 macOS Catalina,则时间会更轻松,因为 Xcode 会在 Canvas 窗格中并排显示代码和实时预览。
但是,如果您仍然使用 Mojave,那也可以;您可以构建并运行以查看代码更改如何影响应用程序。实际上,由于预览窗口可能有问题,因此该技术在 Catalina 上仍然很方便。
本教程假定您具有 SwiftUI 的使用知识。您应该熟悉诸如 Text,Image,HStack 和 VStack 之类的组件。如果您刚刚开始使用 SwiftUI,请先阅读参考 11 中的教程。
开始
使用本教程顶部或底部的 Download Materials 按钮下载本教程的项目。
该教程的 App(MySolarSystem) 是简单列出太阳系中的行星。完成后,点按一下即可显示每个行星,并列出该行星最大的卫星。从那里轻敲卫星将为用户提供有关行星的其他信息。
ContentView 代表应用程序的主屏幕。它是一个由 Planets 数组构成表格的列表。列表中的每个元素都是一个带有多个控件的 HStack:一个用于行星图片的图像,一个用于行星名称的文本,一个空格键和一个按钮(如果该行星上有卫星)。此按钮的目的是显示有关卫星的信息。
在本教程的后面,您将把可选的 MoonList 放入 VStack。点击月亮按钮将显示或隐藏它。这将是您要添加并设置动画的第一个地方。
预览窗口
如果您在 macOS Catalina 上运行,则可以在编辑器窗格中访问Canvas。如果看不到它,请选择“编辑器”▸“画布”以启用它。更改代码后,将显示视图和实时更新。
如果单击“Live Preview”按钮(又称播放图标),它将使预览具有交互性。添加动画和交互性后,您就可以单击按钮并观察状态变化。
基本动画
要添加动画,您需要做的第一件事就是制作动画。因此,首先,您将创建一个状态更改,以触发对 UI 的更新。
打开 ContentView.swift 并将以下内容添加到makePlanetRow(planet:) 中的 VStack 的末尾:
if self.toggleMoons(planet.name) {
MoonList(planet: planet)
}
这会检查 toggleMoons(_:),以确定是否应该切换该星球的行。如果打开了卫星,则 MoonStack 视图将出现在 VStack 中。此方法与 state 属性 showMoon 绑定。
接下来,通过设置 showMoon 属性完成操作。在按钮的操作回调中,添加以下代码:
self.showMoon = self.toggleMoons(planet.name) ? nil : planet.name
如果用户已经按下按钮,此代码将清除 showMoon;如果用户选择其他行,则将其设置为新的行星。
生成并运行该应用程序。轻触带有“moon disclosure”图标的行,将显示卡通地图以及该行星最大的一些卫星的列表。该按钮的大小也加倍,以引起人们的注意。这样,用户便知道在何处点击以关闭该行。
这使月亮列表视图内外闪烁,这几乎不是很棒的用户体验。幸运的是,添加一些动画很容易。
首先为按钮尺寸创建平滑的动画。在卫星图像上的 scaleEffect 修改器之后,添加以下修改器:
.animation(.default)
这会将默认动画添加到缩放效果。现在,如果您展开或折叠一行,则按钮将平滑地增大或缩小。如果您发现月亮视图的瞬时外观和消失有一些问题,请不要担心。您将在下面的动画状态更改部分中对此进行修复。
动画时间
animation(_:) 修饰符带有一个 Animation 参数。
您可以将多个选项用于动画。最基本的是简单的时序曲线,它们描述了动画速度在持续时间内如何变化。这些都修改了变化的属性,在起始值和结束值之间平滑地插值。
您有以下选择:
• .linear:随着时间的推移,它将属性从开始值到结束值均匀过渡。这是重复动画的良好时序曲线,但看起来不像缓动功能那样自然。
• .easeIn:缓动动画从慢速开始,并随着时间的推移加快速度。这对于从静止点开始并在屏幕外结束的动画很有用。
• .easeOut:缓动的动画开始快而结束慢。这对于使物体达到稳态或最终位置具有动画效果非常有用。
• .easeInOut:缓入和缓出曲线结合了 easyIn 和 easeOut。这对于从一个稳定点开始并在另一个平衡点处结束的动画很有用。这是大多数应用程序的最佳选择。这就是为什么这是 .default 使用的时序曲线的原因。
• .timingCurve:这使您可以指定自定义时序曲线。很少需要这么做,并且不在本教程的讨论范围之内。
大多数情况下,.default 足以满足您的需求。如果您还需要其他一些功能,则基本的计时功能之一将为您提供一些额外的改进。通过将 .default 替换为其中的一种来进行尝试,以找出最适合您的选择。
上图显示了每个时序曲线随时间的速度。但是动画将如何实际显示?将每个曲线应用到月亮按钮的 scaleEffect 时,这是随着时间变化的外观。
Linear
EaseIn
EaseOut
EaseInOut
使用时间修补
如果您看不到细微的差异,可以通过拉长动画时间来放慢动画的播放速度。将 animation 修改器替换为:
.animation(.easeIn(duration: 5))
指定更长的持续时间将使时序曲线更明显。
在模拟器中而不是 SwiftUI 预览窗口中构建和运行的一个优点是,您可以启用“调试▸慢速动画”标志。这将大大降低任何动画的速度,因此您可以更清楚地看到细微的差异。这样,您无需添加额外的 duration 参数。
除了时序曲线和持续时间外,您还可以使用其他一些方法来控制动画的时序。首先,有 speed。
在 ContentView 的顶部创建一个新属性:
let moonAnimation = Animation.easeInOut.speed(0.1)
该常量仅存储动画,因此您以后可以在代码中更轻松地使用它。它还为您提供了一个改变周围事物的场所。
接下来,替换 Image 动画修改器:
.animation(moonAnimation)
现在,月亮的大小将非常缓慢地调整。尝试将速度更改为 2,动画将变得非常活泼。
除了速度之外,您还可以添加 delay。将 moonAnimation 更改为:
let moonAnimation = Animation.easeInOut.delay(1)
现在,动画具有一秒钟的延迟。在月亮列表中点击该行闪烁,然后稍后,该按钮将更改大小。一次为多个属性或对象设置动画时,延迟最为有用,这将在后面介绍。
最后,您可以使用修改器来重复动画。将动画更改为:
let moonAnimation = Animation.easeInOut.repeatForever(autoreverses: true)
现在,该按钮将不停地跳动。您还可以使用 repeatCount(autoreverses:) 重复动画有限的次数。
完成实验后,将动画设置回:
let moonAnimation = Animation.default
同步动画
.animation 是一个修改器,可像其他修改器一样叠加到 SwiftUI 视图上。如果视图具有多个变化的属性,则单个“动画”可以应用于所有这些属性。
通过将其放置在 Image 和 scaleEffect 线之间,添加以下旋转效果:
.rotationEffect(.degrees(self.toggleMoons(planet.name) ? -50 : 0))
这会使“月亮”按钮略微旋转,因此当出现“月亮”视图时,新月形会横向排列。由于将动画添加到最后,这些动画将一起运动。
当然,您可以为每个属性指定单独的动画。例如,在 rotationEffect 修改器之后添加以下修改器:
.animation(.easeOut(duration: 1))
这为旋转提供了一秒钟的动画,因此与缩放效果相比,您会注意到旋转结束的时间稍晚一些。接下来,将 moonAnimation 更改为:
let moonAnimation = Animation.default.delay(1)
这会将大小动画延迟一秒,因此缩放在旋转完成后开始。试试看。
最后,可以通过为动画指定 nil 来选择不为特定属性更改设置动画。将 rotationEffect 动画更改为:
.animation(nil)
并将 moonAnimation 更改为:
let moonAnimation = Animation.default
现在,仅有大小更改动画。
动画状态变化
您已经花了一些时间完善“月亮”按钮的动画,但是那落在所有圆圈上的大视图又如何呢?
好了,您可以使用简单的 withAnimation 块为任何状态转换设置动画。将按钮操作块的内容替换为:
withAnimation {
self.showMoon = self.toggleMoons(planet.name) ? nil : planet.name
}
withAnimation 明确告诉 SwiftUI 要设置动画。在这种情况下,它将对 showMoon 和具有依赖于该属性的任何视图的切换进行动画处理。
如果您现在观看动画,将会看到月亮视图使用默认的出现动画逐渐消失,并且列表行滑出以腾出空间。
您还可以向显式动画块提供特定的动画。用以下内容替换 withAnimation:
withAnimation(.easeIn(duration: 2))
现在,它使用持续时间为两秒的缓动动画,而不是默认值。
如果您尝试一下,放慢的动画可能看起来不正确。有更好的方法可以对进出层次结构的视图进行动画处理。
在继续之前,将 withAnimation 更改为:
withAnimation(.easeInOut)
转场
转场介绍了如何插入或删除视图。要查看其工作原理,请将此修改器添加到 if self.toggleMoons(planet.name) 块中的 MoonList 中:
.transition(.slide)
现在,视图将滑入而不是淡入。从前缘滑入动画,然后从尾部滑出。
转换属于 AnyTransition 类型。SwiftUI 带有一些预制的过渡:
• .slide:您已经在使用中看到了它 -- 将视图从侧面滑入。
• .opacity:此过渡使视图淡入和淡出。
• .scale:通过放大或缩小视图进行动画处理。
• .move:类似于幻灯片,但是可以指定边缘。
• .offset:沿任意方向移动视图。
继续尝试这些过渡,以了解它们的工作方式。
组合过渡
您还可以组合过渡来组成自己的自定义效果。在 ContentView.swift 的顶部,添加以下扩展名:
extension AnyTransition {
static var customTransition: AnyTransition {
let transition = AnyTransition.move(edge: .top)
.combined(with: .scale(scale: 0.2, anchor: .topTrailing))
.combined(with: .opacity)
return transition
}
}
它组合了三个过渡:从顶部边缘移动,从 20% 锚定到顶部后角的比例以及不透明的淡入淡出效果。
要使用它,请将 MoonList 实例的 transition 一行更改为:
.transition(.customTransition)
生成并运行您的项目,然后尝试打开卫星表。组合的效果就像视图从“月亮”按钮突然进出一样。
异步转场
如果需要,还可以使进入过渡与退出过渡不同。
在 ContentView.swift 中,将 customTransition 的定义替换为:
static var customTransition: AnyTransition {
let insertion = AnyTransition.move(edge: .top)
.combined(with: .scale(scale: 0.2, anchor: .topTrailing))
.combined(with: .opacity)
let removal = AnyTransition.move(edge: .top)
return .asymmetric(insertion: insertion, removal: removal)
}
这样可以保持插入状态,但是现在,月亮视图通过移动到顶部退出屏幕。
Springs
现在,卫星列表仅显示卫星以同心圆彼此堆叠。如果它们间隔开,甚至可以以动画方式进入,看起来会更好。
在 MoonView.swift 中,将以下修改器添加到主体链的末尾:
.onAppear {
withAnimation {
self.angle = self.targetAngle
}
}
这将在代表月亮的橙色球上设置一个随机角度。然后,月亮将从直线上的零度动画到新位置。
该动画效果还不错,因此您需要再添加一点点爵士乐。Spring 动画使您可以添加一些弹跳和跳动,以使视图生动活泼。
在 SolarSystem.swift 中,将此方法添加到 SolarSystem 中:
func animation(index: Double) -> Animation {
return Animation.spring(dampingFraction: 0.5)
}
此辅助方法创建一个弹簧动画。
然后,在 makeSystem(_:) 的最后一个 ForEach 中,将以下修改器附加到self.moon行:
.animation(self.animation(index: Double(index)))
这会将动画添加到每个卫星圆圈。现在不用担心索引;您将在下一部分中使用它。
下次加载视图时,随着卫星到达其最终位置,将会有一些反弹。
使用 SwiftUI 的预构建弹簧选项
像 SwiftUI 中的其他所有内容一样,spring 有一些内置选项:
• spring() :默认的弹簧行为。这是一个很好的起点。
• spring(response:dampingFraction:blendDuration):带有更多选项以微调其行为的弹簧动画。
• interpolatingSpring(mass:stiffness:damping:initialVelocity:):一种可定制的基于物理建模的弹簧。
弹簧动画使用弹簧的真实世界作为基础。想象一下,如果可以的话,请在弹簧线圈的末端安装一个重块。如果该块的质量较大,则释放弹簧会导致其下落时产生较大的位移,从而使其弹跳得越来越远。
弹簧越硬,其作用就相反 -- 弹簧越硬,它将移动得越远。增大阻尼就像增大摩擦:它将减少行程并缩短弹跳时间。初始速度设置动画的速度:更大的速度将使视图移动更多,并使其弹跳时间更长。
很难将物理原理映射到动画的实际工作方式。这就是为什么每次都使用一个参数以获得漂亮的动画是非常有用。
这也是为什么有 spring(response:dampingFraction:blendDuration) 的原因,它是编写 spring 动画的一种更容易理解的方式。在幕后,它仍然使用物理模型,但杠杆较少。
阻尼控制视图反弹的时间。零阻尼是一个无阻尼的系统,这意味着它将永远反弹。大于 1 的阻尼值根本不会弹起。如果您使用弹簧,通常会选择一个介于 0 到 1 之间的值。较大的值会降低速度。
响应是完成一次振荡的时间量。这将控制动画的持续时间。这两个值协同工作以调整视图的距离和速度,并且经常会反弹视图。如果更改响应或组合多个弹簧,blendDuration 将影响动画。这是一种高级机动。
完善动画
现在您对弹簧的工作原理有了更好的了解,请花点时间整理动画。在为一堆相对独立的视图制作动画时,一件好事是给它们稍稍不同的时间。这有助于使动画更生动。
在 SolarSystem.swift 中,将 animation(index:) 更改为以下内容:
func animation(index: Double) -> Animation {
return Animation
.spring(response: 0.55, dampingFraction: 0.45, blendDuration: 0)
.speed(2)
.delay(0.075 * index)
}
这给每个“卫星”增加了滚动延迟,因此它们彼此之间在 75 毫秒内开始下降,从而产生了一些奇特的效果。
Animatable
这看起来不错,但如果卫星沿轨道运行而不是从顶部弹起,那将更加现实。您可以使用自定义修改器 GeometryEffect 来实现。几何效果描述了一种动画,该动画可以修改视图的位置,形状或大小。
将以下结构添加到 SolarSystem.swift:
struct OrbitEffect: GeometryEffect {
let initialAngle = CGFloat.random(in: 0 ..< 2 * .pi)
var percent: CGFloat = 0
let radius: CGFloat
var animatableData: CGFloat {
get { return percent }
set { percent = newValue }
}
func effectValue(size: CGSize) -> ProjectionTransform {
let angle = 2 * .pi * percent + initialAngle
let pt = CGPoint(
x: cos(angle) * radius,
y: sin(angle) * radius)
return ProjectionTransform(CGAffineTransform(translationX: pt.x, y: pt.y))
}
}
此 OrbitEffect 将其视图绕一圈移动。它通过 GeometryEffect 的 effectValue(size:) 方法实现。此方法返回一个 ProjectionTransform,它是一个坐标变换。在这种情况下,将使用平移 CGAffineTransform 创建平移,该平移对应于圆上的一个点。
GeometryEffect 的神奇之处在于它符合 Animatable 协议。这意味着它需要一个名为 animatableData 的属性。在这种情况下,它包装了一个名为 percent 的 CGFloat 属性。可设置动画的数据可以是符合 VectorArithmetic 协议的任何值,这意味着可以在开始值和结束值之间进行插值。为此,您将选择 0% 到 100% 之间的动画。
通过计算 0 到 2π 之间的相对值,可以将百分比应用于 effectValue。还有一个随机的 initialAngle。这样,每个“卫星”将从不同的位置开始;否则,将它们全部紧紧地锁定似乎很奇怪。
要应用此效果,您需要稍微更改 SolarSystem 视图。首先,向其添加一个新的 state 属性:
@State private var animationFlag = false
动画是根据状态变化而发生的。由于显示视图时轨道动画将保持不变,因此此简单的布尔值将作为动画的触发器。
接下来,添加此辅助方法:
func makeOrbitEffect(diameter: CGFloat) -> some GeometryEffect {
return OrbitEffect(
percent: self.animationFlag ? 1.0 : 0.0,
radius: diameter / 2.0)
}
这将创建一个 OrbitEffect,将其百分比从 0 开始,并在切换标志后将其更改为 1。
要调用此方法,请在单个月亮 ForEach 中,将 .animation 修改器替换为以下内容:
.modifier(self.makeOrbitEffect(
diameter: planetSize + radiusIncrement * CGFloat(index)
))
.animation(Animation
.linear(duration: Double.random(in: 10 ... 100))
.repeatForever(autoreverses: false)
)
通过使用辅助方法,这会将 OrbitEffect 应用于每个月圆。它还应用了随机重复的线性动画。这样,每个卫星将以各自独立的速度无限期绕轨道运行。这不是行星的逼真的视图,但看起来很漂亮。
接下来,替换 moon(planetSize:moonSize:radiusIncrement:index:)
的实现,如下所示:
func moon(planetSize: CGFloat,
moonSize: CGFloat,
radiusIncrement: CGFloat,
index: CGFloat) -> some View {
return Circle()
.fill(Color.orange)
.frame(width: moonSize, height: moonSize)
}
以前的辅助方法使用 MoonView,该方法用于将自身放置在正确的半径处。现在,OrbitEffect 处理此放置,因此更改辅助函数可以消除冲突。
最后,在 ZStack的makeSystem(_:) 末尾,应用以下修改器:
.onAppear {
self.animationFlag.toggle()
}
这将切换该标志,并在显示视图时开始播放动画。
再次构建并运行。现在,卫星将以不同的速度绕行星旋转。
替代动画数据
在上一步中,您通过将百分比从 0 更改为 1 来使效果动起来。这是对任何事物进行动画处理的良好通用策略。例如,您可以对进度条执行相同操作,并使用 GeometryEffect 在整个屏幕上扩展该条的宽度。
或者,您可以直接为角度设置动画并保存一个步骤。
在 OrbitEffect 中,将百分比重命名为 angle,现在将其从 0 设置为 2π。确保您也用 animatableData 定义的角度 n 替换了百分比。
接下来,将 effectValue(size:) 中的计算更改为:
func effectValue(size: CGSize) -> ProjectionTransform {
let pt = CGPoint(
x: cos(angle + initialAngle) * radius,
y: sin(angle + initialAngle) * radius)
let translation = CGAffineTransform(translationX: pt.x, y: pt.y)
return ProjectionTransform(translation)
}
这将直接使用 angle 属性,而不是基于百分比来计算角度。
然后,将 makeOrbitEffect(diameter:) 更改为:
func makeOrbitEffect(diameter: CGFloat) -> some GeometryEffect {
return OrbitEffect(
angle: self.animationFlag ? 2 * .pi : 0,
radius: diameter / 2.0)
}
在此,当切换 animationFlag 时,更改将从 0 更改为 2π,而不是 0。
现在就构建并运行,但是您不会注意到动画上的差异。
当起点和终点是任意的时,例如将用户设置的两个位置之间的视图或其他视图设置为动画时,使用值与百分比的动画效果最佳。
其他动画方式
到目前为止,当视图由于状态变化而变化时,您所看到的动画类型将隐式或显式地发生。您还可以通过显式控制视图的属性来为视图设置动画。例如,您可以循环更改视图的偏移量,以将其从一个位置移动到另一个位置。
在卫星列表中点击一个卫星名称,将显示一个带有可滚动的卫星缩略图列表的星球详细屏幕。现在,这个列表很无聊。
您可以通过使用 rotation3DEffect 为其提供类似于 CoverFlow 的界面来为列表增添趣味。
在 MoonFlow.swift 中,将以下内容添加到主体中间的 VStack 的末尾:
.rotation3DEffect(
.degrees(Double(moonGeometry
.frame(in: .global).midX - geometry.size.width / 2) / 3),
axis: (x: 0, y: 1, z: 0)
)
这将沿 y 轴应用旋转效果,具体取决于单个 VStack 距整个视图中心的距离。当用户滚动时,该堆栈的几何形状将不断变化,从而导致旋转效果更新。这样可以平滑地改变旋转效果,从而制作出动画。
通过结合使用 GeometryReader 和诸如 projectionEffect,transformEffect,scaleEffect 或 rotationEffect 之类的效果,您可以在视图更改屏幕上位置时更改其位置和形状。
下一步
您刚刚了解了 SwiftUI 动画可以做的所有事情。幸运的是,添加 .animation(.default) 或 .animation(.spring()) 通常可以使您受益匪浅。
参考
作者:Michael Katz