Skip to main content

SwiftUI动画和过渡(Transitions)详解教程

鱼雪

上一节我们介绍了 SwiftUI 中的 PathShape 控件, 本节我们将介绍 SwiftUI 中的动画与过渡。

SwiftUI 的出现,让我们在进行动画开发时,可以像在 Keynote 中使用 “Magic Move” 一样简单。 Keynote 的 Magic Move 会自动分析幻灯片之间元素的变化并自动添加过渡效果; 而在 SwiftUI 中,你只需要定义视图的两个状态,SwiftUI 就能帮你完成从一个状态到另一个状态的动画过渡。

在这篇文章里,你将学习如何使用 SwiftUI 提供的 隐式动画显式动画 来为视图创建各种动画效果,以及如何在视图之间使用 Transitions(过渡)来实现插入与移除动画。

目录

  1. 隐式动画(Implicit Animations)
  2. 显式动画(Explicit Animations)
  3. 使用 rotationEffect 创建加载指示器
  4. 创建进度指示器(Progress Indicator)
  5. 延迟动画(delay)
  6. 矩形到圆形的变形动画
  7. 视图的插入与移除过渡(Transitions)
  8. 总结
  9. 完整示例代码

隐式动画

在 SwiftUI 中,隐式动画 是指通过使用 animation 修饰符(modifier) 来自动动画化视图的状态变化。你只需要将 animation 修饰符附加到需要被动画化的视图上, 并指定动画类型即可。

SwiftUI 会在监测到状态发生变化时,自动为这些变化创建动画。

基本示例

例如,在下面的示例中,我们创建了一个红色圆形和一个心形图标。当你点击它时:

  1. 圆形由红色变为浅灰色;
  2. 心形由白色变成红色;
  3. 心形图标放大一倍。

ContentView.swift 中的示例(简化版):

struct ContentView: View {
@State private var circleColorChanged = false
@State private var heartColorChanged = false
@State private var heartSizeChanged = false

var body: some View {
ZStack {
Circle()
.frame(width: 200, height: 200)
.foregroundStyle(circleColorChanged ? Color(.systemGray5) : .red)
Image(systemName: "heart.fill")
.foregroundStyle(heartColorChanged ? .red : .white)
.font(.system(size: 100))
.scaleEffect(heartSizeChanged ? 1.0 : 0.5)
}
.onTapGesture {
circleColorChanged.toggle()
heartColorChanged.toggle()
heartSizeChanged.toggle()
}
}
}

如果只是这样写,没有添加动画修饰符,那么点击圆形和心形图标时, 它们的颜色与大小会在 没有过渡效果 的情况下瞬间切换。

为实现隐式动画,我们可以给 Circle() 或者 Image() 分别添加 .animation(.default, value: circleColorChanged) 这样的修饰符。 当 SwiftUI 检测到 circleColorChanged 的变化时,就会自动计算并渲染动画:

Circle()
.frame(width: 200, height: 200)
.foregroundStyle(circleColorChanged ? Color(.systemGray5) : .red)
.animation(.default, value: circleColorChanged)

Image(systemName: "heart.fill")
.foregroundStyle(heartColorChanged ? .red : .white)
.font(.system(size: 100))
.scaleEffect(heartSizeChanged ? 1.0 : 0.5)
.animation(.default, value: heartSizeChanged)

这里的 .default 动画在 iOS 17 中默认是一个弹性动画(spring),你也可以使用其它如 .linear.easeInOut 等内置动画类型,或使用 .spring() 进行更多自定义。

多视图同时动画

我们也可以把 animation 修饰符放在父视图上(如 ZStack),实现多个视图同时动画, 但要注意指定哪些 state 的变化需要动画,以及动画如何组合。


显式动画

与隐式动画相比,显式动画 可以帮助我们更精细地控制动画的执行。 要实现显式动画,我们使用 withAnimation() 包裹需要动画化的状态改变。

例如,如果想让上面示例中的所有状态变化都使用相同的 spring 动画,我们可以这样写:

ZStack {
Circle()
.frame(width: 200, height: 200)
.foregroundColor(circleColorChanged ? Color(.systemGray5) : .red)
Image(systemName: "heart.fill")
.foregroundColor(heartColorChanged ? .red : .white)
.font(.system(size: 100))
.scaleEffect(heartSizeChanged ? 1.0 : 0.5)
}
.onTapGesture {
withAnimation(.spring(.bouncy, blendDuration: 1.0)) {
self.circleColorChanged.toggle()
self.heartColorChanged.toggle()
self.heartSizeChanged.toggle()
}
}

这样就 不需要 在视图上使用 animation 修饰符, 而是把所有的 state 切换都放进 withAnimation 的闭包中, SwiftUI 会自动为这些切换添加动画。

局部控制

如果你只想对某个状态变化使用动画,而对其他状态不使用动画, 可以将对应的那行代码从 withAnimation 闭包里剥离出来, 或对某个状态分别使用不同的动画修饰符。


使用 rotationEffect 创建加载指示器

SwiftUI 动画的另一个妙处在于:我们不需要详细编码动画的过程,只需要设置初始状态和结束状态, SwiftUI 就会为我们补间计算动画。

下面来看一个 旋转动画 的示例。

假设我们想要实现一个旋转的加载指示器,可以使用 .rotationEffect.animation 配合 repeatForever 来实现。

struct ContentView: View {
@State private var isLoading = false

var body: some View {
Circle()
.trim(from: 0, to: 0.7)
.stroke(Color.green, lineWidth: 5)
.frame(width: 100, height: 100)
.rotationEffect(Angle(degrees: isLoading ? 360 : 0))
.animation(.default.repeatForever(autoreverses: false), value: isLoading)
.onAppear {
isLoading = true
}
}
}
  • trim(from: 0, to: 0.7) 让圆只显示 70% 的弧度;
  • isLoadingfalse 切换为 true 时,rotationEffect 就会从 0 度变为 360 度;
  • .repeatForever(autoreverses: false) 让动画可以无限循环。

如果想调整转速,可以将 .default 替换为 .linear(duration: 5) 等等。


创建进度指示器(Progress Indicator)

有些场景下,我们不仅仅想告诉用户“正在加载”,还希望用户能看到具体的任务进度。 这时可以使用一个 浮点数类型的 state 来作为当前进度。

下面给出一个圆形的进度指示器示例,它包含:

  1. 一个用于显示进度数字的 Text
  2. 一个背景圆形;
  3. 一个显示进度的弧形(通过 trim(from: 0, to: progress))。
struct ContentView: View {
@State private var progress: CGFloat = 0.0

var body: some View {
ZStack {
Text("\(Int(progress * 100))%")
.font(.system(.title, design: .rounded))
.bold()
Circle()
.stroke(Color(.systemGray5), lineWidth: 10)
.frame(width: 150, height: 150)
Circle()
.trim(from: 0, to: progress)
.stroke(.green, lineWidth: 10)
.frame(width: 150, height: 150)
.rotationEffect(Angle(degrees: -90))
}
.onAppear {
// 利用定时器模拟进度递增
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { timer in
self.progress += 0.05
if self.progress >= 1.0 {
timer.invalidate()
}
}
}
}
}

真实项目中,你会根据实际进度(例如网络上传下载进度)更新 progress, 动画和界面展示就可同步更新。


延迟动画(delay)

SwiftUI 中你还可以让动画 延迟 一段时间才开始,常见的做法是使用 .delay(_:)。例如:

Animation.default.delay(1.0)

如果想打造类似 小点依次放大缩小 的“加载中”动画, 可以利用 .delay 在多个视图上设置不同的延迟时间,制造“先后次序感”。

struct ContentView: View {
@State private var isLoading = false

var body: some View {
HStack {
ForEach(0...4, id: \.self) { index in
Circle()
.frame(width: 10, height: 10)
.foregroundStyle(.green)
.scaleEffect(self.isLoading ? 0 : 1)
.animation(
.linear(duration: 0.6)
.repeatForever()
.delay(0.2 * Double(index)),
value: isLoading
)
}
}
.onAppear {
self.isLoading = true
}
}
}

矩形到圆形的变形动画

SwiftUI 的内置图形元素(如 RoundedRectangleCircle 等)也可以利用动画在属性发生改变时, 进行“形状变形”,如下所示:

struct ContentView: View {
@State private var recordBegin = false
@State private var recording = false

var body: some View {
ZStack {
RoundedRectangle(cornerRadius: recordBegin ? 30 : 5)
.frame(width: recordBegin ? 60 : 250, height: 60)
.foregroundStyle(recordBegin ? .red : .green)
.overlay(
Image(systemName: "mic.fill")
.font(.system(.title))
.foregroundStyle(.white)
.scaleEffect(recording ? 0.7 : 1)
)

RoundedRectangle(cornerRadius: recordBegin ? 35 : 10)
.trim(from: 0, to: recordBegin ? 0.0001 : 1)
.stroke(lineWidth: 5)
.frame(width: recordBegin ? 70 : 260, height: 70)
.foregroundStyle(.green)
}
.onTapGesture {
withAnimation(.default) {
self.recordBegin.toggle()
}
withAnimation(.default.repeatForever().delay(0.5)) {
self.recording.toggle()
}
}
}
}
  • recordBegin 控制 按钮的形变(从绿色矩形到红色圆形);
  • recording 控制 麦克风的缩放,并通过 repeatForever() 让这个缩放动画不断重复。

视图的插入与移除过渡(Transitions)

前面提到的所有示例都是基于 同一个视图 的属性变化。 SwiftUI 还可以让你控制 视图的插入或移除 动画,这在实践中更能打造“有进有退”的交互体验。

在 SwiftUI 中,过渡(Transition)就是指视图在 插入移除 时使用的动画方式。 例如,当我们要在点击按钮后展示一个新视图,就会用到过渡动画。

基本示例

下面是一段简化的代码示例:点击绿色方块后,蓝色方块才出现;再次点击时,蓝色方块移除。 这就需要我们在添加蓝色方块的 if show { ... } 里,给它添加 .transition(...) 修饰符, 并与一个 withAnimation 结合使用:

struct ContentView: View {
@State private var show = false

var body: some View {
VStack {
RoundedRectangle(cornerRadius: 10)
.frame(width: 300, height: 300)
.foregroundColor(.green)
.overlay(
Text("Show details")
.font(.system(.largeTitle, design: .rounded))
.bold()
.foregroundColor(.white)
)

if show {
RoundedRectangle(cornerRadius: 10)
.frame(width: 300, height: 300)
.foregroundColor(.blue)
.overlay(
Text("Well, here is the details")
.font(.system(.largeTitle, design: .rounded))
.bold()
.foregroundColor(.white)
)
.transition(.scale(scale: 0, anchor: .bottom))
}
}
.onTapGesture {
withAnimation(.default) {
self.show.toggle()
}
}
}
}

如果我们 不指定 transition,默认情况下 SwiftUI 会使用 淡入淡出 的方式插入和移除视图。

其它常见过渡

  • .slide:从一侧滑入/滑出
  • .move(edge: .leading):在插入时从某一个方向移动进来,在移除时向相同方向移出
  • .offset(x: -600, y: 0):可以自定义偏移距离,模拟从屏幕外飞入/飞出

还可以使用 combined(with:) 将多个过渡组合起来,比如缩放+偏移+透明度:

.transition(.offset(x: -600, y: 0).combined(with: .scale).combined(with: .opacity))

或定义一个扩展,把常用的过渡封装起来:

extension AnyTransition {
static var offsetScaleOpacity: AnyTransition {
AnyTransition.offset(x: -600, y: 0)
.combined(with: .scale)
.combined(with: .opacity)
}
}

非对称过渡(Asymmetric Transitions)

有时你想插入视图时用 scale 动画,移除视图时用 offset 动画, 这就需要 不对称(Asymmetric)过渡。例如:

.transition(.asymmetric(insertion: .scale(scale: 0, anchor: .bottom),
removal: .offset(x: -600, y: 0)))

这样,在视图出现时执行缩放效果,移除时执行偏移动画。


总结

动画在移动端 UI 设计中有着极其重要的作用。 它们不仅让应用更具观感与吸引力,也可以在用户交互时传递额外的信息。 大量的应用都在使用动画来构建流畅的用户体验,以在竞争激烈的应用市场中脱颖而出。

SwiftUI 通过 声明式 的思路大大简化了动画和过渡的实现过程:

  • 只需定义视图在各个状态下的外观;
  • 由 SwiftUI 负责插值(补间)计算与渲染;
  • 通过隐式动画与显式动画,你可以轻松细化对动画的控制;
  • 过渡动画让视图的插入和移除更加平滑自然。

希望通过以上内容,你已经对 SwiftUI 中的动画和过渡原理有了更清晰的理解, 并能灵活应用到自己的实际项目中。


完整示例代码

动画(Animations)示例

import SwiftUI

struct ContentView: View {
@State private var recordBegin = false
@State private var recording = false

var body: some View {
ZStack {
RoundedRectangle(cornerRadius: recordBegin ? 30: 5)
.frame(width: recordBegin ? 60 : 250, height: 60)
.foregroundColor(recordBegin ? .red : .green)
.overlay(
Image(systemName: "mic.fill")
.font(.system(.title))
.foregroundStyle(.white)
.scaleEffect(recording ? 0.7 : 1)
)

RoundedRectangle(cornerRadius: recordBegin ? 30: 10)
.trim(from: 0, to: recording ? 0.0001 : 1)
.stroke(lineWidth: 5)
.frame(width: recordBegin ? 70 : 260, height: 70)
.foregroundColor(.green)
}
.onTapGesture {
withAnimation(.default) {
self.recordBegin.toggle()
}

withAnimation(.default.repeatForever().delay(0.5)) {
self.recording.toggle()
}
}
}
}

// 下方都是示例演示,便于阅读。
struct Demo9: View {
@State private var isLoading = false
var body: some View {
HStack {
ForEach(0..<5, id: \.self) { index in
Circle()
.frame(width: 10, height: 10)
.foregroundStyle(.blue)
.scaleEffect(self.isLoading ? 1 : 0)
.animation(Animation.linear(duration: 0.6)
.repeatForever()
.delay(0.2 * Double(index)), value: isLoading)
}
.onAppear {
self.isLoading = true
}
}
}
}

struct Demo8: View {
@State private var progress: CGFloat = 0.0
var body: some View {
ZStack {
Text("\(Int(progress * 100))%")
.font(.system(.title, design: .rounded))
.bold()
Circle()
.stroke(Color(.systemGray5), lineWidth: 20)
.frame(width: 150, height: 150)
Circle()
.trim(from: 0, to: progress)
.stroke(Color.blue, lineWidth: 20)
.frame(width: 150, height: 150)
.rotationEffect(Angle(degrees: -90))
}
.onAppear {
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { timer in
self.progress += 0.05
if self.progress >= 1.0 {
timer.invalidate()
}
}
}
}
}

struct Demo7: View {
@State private var isLoading = false
var body: some View {
ZStack {
Text("Loading...")
.font(.system(.body, design: .rounded))
.bold()
.offset(y: -25)

RoundedRectangle(cornerRadius: 3)
.stroke(Color(.systemGray5), lineWidth: 3)
.frame(width: 250, height: 3)

RoundedRectangle(cornerRadius: 3)
.stroke(Color(.green), lineWidth: 3)
.frame(width: 30, height: 3)
.offset(x: isLoading ? 110 : -110)
.animation(Animation.linear(duration: 1)
.repeatForever(autoreverses: false),
value: isLoading)
}
.onAppear {
self.isLoading = true
}
}
}

struct Demo6: View {
@State private var isLoading = false
var body: some View {
ZStack {
Circle()
.stroke(Color(.systemGray5), lineWidth: 14)
.frame(width: 100, height: 100)

Circle()
.trim(from: 0, to: 0.2)
.stroke(Color.blue, lineWidth: 7)
.frame(width: 100, height: 100)
.rotationEffect(Angle(degrees: isLoading ? 360 : 0))
.animation(Animation.linear(duration: 1)
.repeatForever(autoreverses: false),
value: isLoading)
.onAppear {
self.isLoading = true
}
}
}
}

struct Demo5: View {
@State private var isLoading = false
var body: some View {
Circle()
.trim(from: 0, to: 0.7)
.stroke(Color.blue, lineWidth: 5)
.frame(width: 100, height: 100)
.rotationEffect(Angle(degrees: isLoading ? 360 : 0))
.animation(.linear(duration: 5)
.repeatForever(autoreverses: false),
value: isLoading)
.onAppear {
isLoading = true
}
}
}

struct Demo4: View {
@State private var circleColorChanged = false
@State private var heartColorChanged = false
@State private var heartSizeChanged = false

var body: some View {
ZStack {
Circle()
.frame(width: 200, height: 200)
.foregroundStyle(circleColorChanged ? Color(.systemGray5) : .red)
.animation(.spring(.bouncy, blendDuration: 1.0),
value: circleColorChanged)

Image(systemName: "heart.fill")
.foregroundStyle(heartColorChanged ? .red : .white)
.font(.system(size: 100))
.animation(.spring(.bouncy, blendDuration: 1.0),
value: heartColorChanged)
.scaleEffect(heartSizeChanged ? 1.0 : 0.5)
}
.onTapGesture {
self.circleColorChanged.toggle()
self.heartColorChanged.toggle()
self.heartSizeChanged.toggle()
}
}
}

struct Demo3: View {
@State private var circleColorChanged = false
@State private var heartColorChanged = false
@State private var heartSizeChanged = false

var body: some View {
ZStack {
Circle()
.frame(width: 200, height: 200)
.foregroundColor(circleColorChanged ? Color(.systemGray5) : .red)

Image(systemName: "heart.fill")
.foregroundColor(heartColorChanged ? .red : .white)
.font(.system(size: 100))
.scaleEffect(heartSizeChanged ? 1.0 : 0.5)
}
.onTapGesture {
withAnimation(.spring(.bouncy, blendDuration: 1.0)) {
self.circleColorChanged.toggle()
self.heartColorChanged.toggle()
self.heartSizeChanged.toggle()
}
}
}
}

struct Demo2: View {
@State private var circleColorChanged = false
@State private var heartColorChanged = false
@State private var heartSizeChanged = false

var body: some View {
ZStack {
Circle()
.frame(width: 200, height: 200)
.foregroundStyle(circleColorChanged ? Color(.systemGray5): .red)

Image(systemName: "heart.fill")
.foregroundStyle(heartColorChanged ? .red : .white)
.font(.system(size: 100))
.scaleEffect(heartSizeChanged ? 1.0 : 0.5)
}
.animation(.spring(.bouncy, blendDuration: 1.0), value: circleColorChanged)
.animation(.default, value: heartSizeChanged)
.onTapGesture {
self.circleColorChanged.toggle()
self.heartColorChanged.toggle()
self.heartSizeChanged.toggle()
}
}
}

struct Demo1: View {
@State private var circleColorChanged = false
@State private var heartColorChanged = false
@State private var heartSizeChanged = false

var body: some View {
ZStack {
Circle()
.frame(width: 200, height: 200)
.foregroundColor(circleColorChanged ? Color(.systemGray5) : .red)

Image(systemName: "heart.fill")
.foregroundColor(heartColorChanged ? .red : .white)
.font(.system(size: 140))
.scaleEffect(heartSizeChanged ? 1.0 : 0.5)
}
.onTapGesture {
circleColorChanged.toggle()
heartColorChanged.toggle()
heartSizeChanged.toggle()
}
}
}

#Preview {
ContentView()
}

过渡(Transitions)示例

import SwiftUI

struct ContentView: View {
@State private var show = false

var body: some View {
VStack {
RoundedRectangle(cornerRadius: 10)
.frame(width: 300, height: 300)
.foregroundColor(.green)
.overlay(
Text("Show details")
.font(.system(.largeTitle, design: .rounded))
.bold()
.foregroundColor(.white)
)
if show {
RoundedRectangle(cornerRadius: 10)
.frame(width: 300, height: 300)
.foregroundColor(.blue)
.overlay(
Text("Well, here is the details")
.font(.system(.largeTitle, design: .rounded))
.bold()
.foregroundColor(.white)
)
.transition(.scaleAndOffset)
}
}
.onTapGesture {
withAnimation(.default) {
self.show.toggle()
}
}
}
}

extension AnyTransition {
static var offsetScaleOpacity: AnyTransition {
AnyTransition.offset(x: -600, y: 0)
.combined(with: .scale)
.combined(with: .opacity)
}

static var scaleAndOffset: AnyTransition {
AnyTransition.asymmetric(
insertion: .scale(scale: 0, anchor: .bottom),
removal: .offset(x: -600, y: 0)
)
}
}

struct Demo4: View {
@State private var show = false

var body: some View {
VStack {
RoundedRectangle(cornerRadius: 10)
.frame(width: 300, height: 300)
.foregroundColor(.green)
.overlay(
Text("Show details")
.font(.system(.largeTitle, design: .rounded))
.bold()
.foregroundColor(.white)
)
if show {
RoundedRectangle(cornerRadius: 10)
.frame(width: 300, height: 300)
.foregroundColor(.blue)
.overlay(
Text("Well, here is the details")
.font(.system(.largeTitle, design: .rounded))
.bold()
.foregroundColor(.white)
)
.transition(.offsetScaleOpacity)
}
}
.onTapGesture {
withAnimation(.default) {
self.show.toggle()
}
}
}
}

struct Demo3: View {
@State private var show = false

var body: some View {
VStack {
RoundedRectangle(cornerRadius: 10)
.frame(width: 300, height: 300)
.foregroundColor(.green)
.overlay(
Text("Show details")
.font(.system(.largeTitle, design: .rounded))
.bold()
.foregroundColor(.white)
)
if show {
RoundedRectangle(cornerRadius: 10)
.frame(width: 300, height: 300)
.foregroundColor(.blue)
.overlay(
Text("Well, here is the details")
.font(.system(.largeTitle, design: .rounded))
.bold()
.foregroundColor(.white)
)
.transition(.offset(x: -600, y: 0)
.combined(with: .scale)
.combined(with: .opacity))
}
}
.onTapGesture {
withAnimation(.default) {
self.show.toggle()
}
}
}
}

struct Demo2: View {
@State private var show = false

var body: some View {
VStack {
RoundedRectangle(cornerRadius: 10)
.frame(width: 300, height: 300)
.foregroundColor(.green)
.overlay(
Text("Show details")
.font(.system(.largeTitle, design: .rounded))
.bold()
.foregroundColor(.white)
)
if show {
RoundedRectangle(cornerRadius: 10)
.frame(width: 300, height: 300)
.foregroundColor(.blue)
.overlay(
Text("Well, here is the details")
.font(.system(.largeTitle, design: .rounded))
.bold()
.foregroundColor(.white)
)
.transition(.offset(x:-600, y:0))
}
}
.onTapGesture {
withAnimation(.default) {
self.show.toggle()
}
}
}
}

struct Demo1: View {
@State private var show = false

var body: some View {
VStack {
RoundedRectangle(cornerRadius: 10)
.frame(width: 300, height: 300)
.foregroundColor(.green)
.overlay(
Text("Show details")
.font(.system(.largeTitle, design: .rounded))
.bold()
.foregroundColor(.white)
)
if show {
RoundedRectangle(cornerRadius: 10)
.frame(width: 300, height: 300)
.foregroundColor(.blue)
.overlay(
Text("Well, here is the details")
.font(.system(.largeTitle, design: .rounded))
.bold()
.foregroundColor(.white)
)
.transition(.scale(scale: 0, anchor: .bottom))
}
}
.onTapGesture {
withAnimation(.default) {
self.show.toggle()
}
}
}
}

#Preview {
ContentView()
}

通过上面这些示例,你应该能够熟悉 SwiftUI 动画与过渡的基本使用方式,并逐步在自己的项目中加以灵活运用。祝你编码愉快,打造属于自己独特风格的炫酷动画与过渡效果!