只有“变化”才能被动画化,是什么发生了变化?
动画是向用户展示已经发生过的变化
当一个视图到达或离开时,整个对象会作为一个单元进行动画处理。ViewBuilders 中的 ForEach 和 if-else 是让视图出现和消失的常见方式。
将视图出现或消失的动画称为过渡 transition,过渡的例子包括 不透明度(淡入 淡出)、移动或缩放(放大或缩小)。
过渡是在其包含视图 (containing View) 的上下文中执行的,因此,一个包含视图只有在整个过渡期间都显示在屏幕上时,才能支持动画化一个过渡。
也就是父容器要就绪,里面视图才能搞动画。
.animation(Animation, value:) 隐式启用动画withAnimation(Animation) { }
包裹代码来显式强制执行动画再次强调,以上所有内容仅在相关视图已经是用户界面一部分时才有效
隐式动画常用于 “独立” 的动画,也就是说 哪些希望独立于其他正在发生的事情而执行的动画。
也可以用来抑制动画(通过将动画声明为nil和.none),通过
.animation(Animation, value: some Equatable)
视图修饰符来指定。
.animation
视图修饰符的定义,它声明了一个动画,该动画应始终被使用,来对所有发生改变的内容(视图修饰符参数和过渡)进行动画处理。
这些改变是作为 value:
参数变化的结果,在它所修饰的View内容,只要在 value:
参数发生改变时,该视图显示在屏幕上 (on-screen) 即可。
如果没 .animation(.easeInOut, value: big), font 只有在
warning 变化时才能体现出动画 隐式动画 蹭的别人的。
import SwiftUI
struct AnimationTestView: View {
// 定义状态变量来控制视图的变化
@State private var warning: Bool = false
@State private var big: Bool = false
var body: some View {
VStack(spacing: 40) {
// 您的测试代码
Text("hello!")
.foregroundStyle(warning ? Color.red : Color.primary)
.font(big ? .largeTitle : .caption)
.animation(.easeInOut, value: warning)
.animation(.easeInOut, value: big)
// 控制面板:用于触发状态改变,方便您观察动画
VStack(spacing: 20) {
Button(action: {
warning.toggle()
}) {
Text("切换 Warning (有动画)")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.red)
Button(action: {
big.toggle()
}) {
Text("切换 Big (有动画)")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.blue)
}
.padding(.horizontal, 40)
}
.padding()
}
}
// Preview 供 Xcode 预览使用
#Preview {
AnimationTestView()
}在 SwiftUI
中,动画修饰符的顺序至关重要,因为修饰符是按从上到下的顺序应用到视图上的,且每个
.animation
修饰符只会对其上方(即在它之前应用)的视图属性变化产生影响。
Text("hello!")
.foregroundStyle(warning ? Color.red : Color.primary)
.font(big ? .largeTitle : .caption)
.animation(.easeInOut, value: warning)
.padding(warning ? 10 : 0)动画 (animation) 修饰符的顺序非常重要。上面的 animation 修饰符不会修改由 padding 返回的视图。 因此,即使是由于 warning 发生变化而导致的 padding 变化,也不会产生动画效果。
Text("hello!")
.foregroundColor(warning ? Color.red : Color.primary)
.font(big ? .largeTitle : .caption)
.animation(.easeInOut, value: warning)
.padding(warning ? 10 : 0)
.animation(.easeInOut, value: big) // 现在当 big 变化时,字体会有动画效果允许使用多个动画视图修饰符,padding在这里仍然不会产生动画(除非big和warning同时改变)
容器通常会把 animation 修饰符传播(propagate) 给它所包含的所有 View。View 内部发生的变化都会带有动画效果。
animation 的行为不像 padding,而更像 font。也就是说,它不是只作用于当前 View,而是会向下影响子 View。 这种行为有时容易让人困惑,因此我们一般不会依赖这种传播行为。
// 自动向所有子 View 传播
VStack {
Text("A")
Text("B")
}
.animation(.easeInOut, value: state)animation 视图修饰符(View Modifier)的第一个参数是一个 Animation 结构体。
它允许你控制动画的各种属性,例如:
动画类型决定了动画播放的速度变化,也就是所谓的动画曲线(curve)。
.linear 顾名思义,整个动画始终保持恒定速度。.easeInOut
动画开始较慢,随后逐渐加速,最后再慢慢减速结束。(典型的”缓入缓出”效果。).spring
动画结束时具有弹簧效果,会产生一种比较柔和的”落地感(soft
landing)“。.bouncy 同样具有弹性效果,但比 .spring
的缓冲更少,结束时的”软着陆”效果没有 .spring
那么明显,整体更加活泼、跳跃。隐式动画(Implicit Animation)与显式动画(Explicit Animation)。
“自动”的隐式动画通常不是动画行为的主要来源。它们大多用于叶子(leaf)视图,也就是非容器视图(non-container)。 更一般地说,它们用于那些通常独立于其他 View 工作的视图。更常见的动画产生原因,是用户执行某个操作后导致界面状态发生变化。
| 隐式动画(Implicit) | 显式动画(Explicit) |
|---|---|
| View 自己发生变化时自动动画 | 由某个事件统一触发动画 |
| 适合单个、小组件 | 适合整个界面联动 |
常用 .animation(...) |
常用 withAnimation { ... } |
| 更偏向局部 | 更偏向全局 |
显式动画会为某一次事务(transaction)(也就是一次 UI 状态变化)指定所使用的动画。
由于执行某个代码块所产生的所有符合条件的界面变化,都会作为同一次事务的一部分,因此它们会一起执行动画。
你需要提供:
withAnimation(.linear(duration: 2)) {
// 执行一些操作,
// 这些操作会导致某些 View Modifier
// 或 Transition 发生变化
} completion: {
// 动画结束后执行一些事情
// (例如启动下一个动画)
}显式动画通常包裹在 命令式代码(imperative code) 周围,例如: Button 的点击事件(Button actions) 对于处理用户手势(gesture)的代码,很少会不使用 withAnimation 包裹。
显式动画不会覆盖(override)隐式动画(Implicit Animation)。如果某个
View 已经通过 .animation(...)
设置了隐式动画,那么即使外层再使用
withAnimation { ... },隐式动画依然会按照自己的规则生效,而不是完全被
withAnimation 替代。
每一次 UI 状态变化(UI state change) 都会伴随着一个关联的 Transaction(事务)上下文结构体(Transaction context struct)。
Transaction 结构体包含了此次状态变化默认使用的动画(default animation)。 视图修饰符 transaction 允许你针对某一个 View(视图) 覆盖这个默认动画。
例如,假设我们永远不希望对我们的 master code 的变化执行动画
CodeView(code: code)
.transaction { transaction in
if code.kind == .master {
transaction.animation = .none // 也就是 nil
}
}Transition 用来指定 View 的出现(arrival)与消失(departure)时如何进行动画。
它们只对位于 CTAAOS 内部的 View 起作用。
CTAAOS = Containers That Are Already On-Screen (已经显示在屏幕上的容器)
从底层实现来看,一个 transition(过渡) 本质上不过是一对 ViewModifier(视图修饰器)。
因此,transition 其实只是:“修改 ViewModifier 参数变化所产生的动画”
它实际上等价于:给 View 指定一个 初始修饰状态(before),再指定一个 最终修饰状态(after),然后让 SwiftUI 对这两个状态之间的变化做动画插值。
所以:Transition = 两个 ViewModifier 状态之间的动画。
一个非对称转场包含两对 视图修饰符(即总共四个视图修饰符)。
一对用于视图出现时(插入)。另一对用于视图消失时(移除)。
示例:视图出现时淡入,但消失时飞出屏幕。
.transition(
.asymmetric(
insertion: .move(edge: .leading),
removal: .opacity
)
)我们大多数时候使用 预定义(pre-canned) 的转场效果,例如:
这些转场都是 AnyTransition 结构体提供的静态变量(static vars)或静态函数(static funcs)。
所有的 Transition API 都采用了 类型擦除(type erased) 设计。我们使用 AnyTransition 这个结构体,它会擦除底层 ViewModifier 的具体类型信息(type information)。
.transition(.opacity)
.transition(.scale)
.scale(scale: 0.1, anchor: .topLeading)
.transition(.identity)
.transition(
.offset(x: 100, y: 0)
)
.transition(
.offset(
CGSize(width: 200, height: 0)
)
)
// .leading .trailing .top .bottom
.transition(.move(edge: .leading)) | Transition | 效果 | 常见用途 |
|---|---|---|
AnyTransition.opacity |
渐隐渐现 | 最常用,淡入淡出 |
.scale |
缩放 | 弹出、卡片、按钮 |
.identity |
无动画 | 禁用过渡效果 |
.offset(CGSize) |
固定距离平移 | 自定义滑入/滑出距离 |
.move(edge:) |
从容器边缘滑入/滑出 | 抽屉、侧边栏、菜单 |
.modifier(active:identity:) |
任意 ViewModifier 动画 |
模糊、旋转、3D、透视、组合特效 |
.modifier 最强大的 Transition,可以把任意 ViewModifier
做成 Transition。
struct BlurModifier: ViewModifier {
let radius: CGFloat
func body(content: Content) -> some View {
content.blur(radius: radius)
}
}
.transition(
.modifier(
active: BlurModifier(radius: 20),
identity: BlurModifier(radius: 0)
)
)
active
↓
正在插入/删除时
identity
↓
最终状态
动画:
blur 20
↓
blur 0也可以:
struct RotationModifier: ViewModifier {
let angle: Double
func body(content: Content) -> some View {
content.rotationEffect(.degrees(angle))
}
}
.transition(
.modifier(
active: RotationModifier(angle: 90),
identity: RotationModifier(angle: 0)
)
)
90°
↓
0°AnyTransition 支持组合多个过渡效果:
.transition(
.opacity.combined(with: .scale)
)这样视图出现时会一边淡入一边放大。也可以使用 .asymmetric
为插入和移除指定不同的动画:
.transition(
.asymmetric(
insertion: .move(edge: .leading),
removal: .opacity
)
).transition() is just declaring what the view modifier
pairs are when animation occurs. It doesn’t cause any animation to occur
(SwiftUI is declarative, not imperative!). In other words, think of the
word transition as a noun here, not a verb.
.transition() 仅仅是声明当动画发生时,对应的视图修饰符
(view modifier) 对是什么。 它本身不会导致任何动画发生(SwiftUI
是声明式的,而非命令式的!)。 换句话说,在这里将 “transition”
这个词理解为名词,而不是动词。
有时你希望一个视图 (View) 从屏幕的一个位置移动到另一个位置。(并且可能在过程中调整大小。)
如果视图是在同一个容器 (container)
内移动到新位置,这就没有问题。像这样“移动”只是在对
.position 视图修饰符 (ViewModifier)
的参数进行动画处理。(.position 是 HStack、LazyVGrid
等用于在自身内部定位其视图的属性。)这种动画作为普通动画的一部分自动发生。
但如果视图是从一个容器“移动”到另一个不同的容器呢?在这种情况下,对
.position 进行动画处理是不可能的。
相反,你需要在“源”位置有一个视图,在“目标”位置有另一个不同的视图。然后你必须将它们的几何形状“匹配”起来,当一个离开
UI 而另一个到达时。所以这类似于. .transition,因为它是在对
UI 中视图的进出进行动画处理。只是它专门用于一对视图的到达
离开是同步的情况。
.onAppear { }。它会在其修改的视图出现在屏幕上时执行闭包(closure)。.onAppear { }
正在发生时,该视图就在屏幕上,因此它就是一个
CTAAOS,所以任何用于它或其子视图的动画都可以触发。.onAppear { } 内部使用
withAnimation。除非你已经妥善地放置了隐式动画(implicit
animations)。import SwiftUI
struct P7ContentView: View {
@State private var opacity = 0.0
@State private var scale = 0.5
var body: some View {
Circle()
.fill(Color.blue)
.frame(width: 100, height: 100)
.opacity(opacity)
.scaleEffect(scale)
// 当视图出现在屏幕上时触发
.onAppear {
// 使用 withAnimation 来包裹状态改变,从而产生动画效果
withAnimation(.easeIn(duration: 1.0)) {
opacity = 1.0
scale = 1.0
}
}
}
}
#Preview {
P7ContentView()
}.onChange(of:) { }
当你需要在某些状态发生改变时执行命令式操作,这通常很容易实现,可以使用
.onChange 视图修饰符。
import SwiftUI
struct P7ContentView2: View {
// 定义一个状态变量,必须遵循 Equatable 协议(Int 默认满足)
@State private var selection: Int = 0
var body: some View {
VStack(spacing: 20) {
Text("当前选择: \(selection)")
.font(.largeTitle)
Picker("选择一个数字", selection: $selection) {
Text("0").tag(0)
Text("1").tag(1)
Text("2").tag(2)
}
.pickerStyle(.segmented)
.padding()
}
// 使用 .onChange 监听 selection 的变化
// initial: true 会在视图初始化时立即触发一次
.onChange(of: selection) { oldValue, newValue in
print("选择已从 \(oldValue) 变更为 \(newValue)!")
if oldValue == newValue {
print("这是初始状态或值未发生实际改变")
}
}
}
}
#Preview {
P7ContentView2()
}随时间进行的视图动画,TimelineView 会按照预定义的计划绘制 ViewBuilder。
TimelineView(.everyMinute) { context in
Text("股票价格是 \(stock.price)")
}
// .everyMinute 也可以是 .periodic(from:by:)
// 或 .animation(minimumInterval:paused:)。应用场景: 这种方式非常适合制作时钟、倒计时器或者像图片示例中那样展示实时波动的股票价格等对时间敏感的界面组件。还有其他类似的动画视图。例如,你可以做关键帧动画 (Key Frame animation)。或者你可以对图像进行分阶段动画处理 (phased animation of an image)。
import SwiftUI
struct P7ContentView3: View {
var body: some View {
VStack(spacing: 20) {
Text("实时计时器")
.font(.headline)
// 使用 TimelineView 按设定的周期刷新内容
TimelineView(.periodic(from: .now, by: 1.0)) { context in
Text("当前时间: \(context.date.formatted(date: .omitted, time: .standard))")
.font(.system(.largeTitle, design: .monospaced))
.foregroundColor(.blue)
}
Text("TimelineView 会根据 context.date 自动刷新内部视图。")
.font(.caption)
.foregroundColor(.gray)
.multilineTextAlignment(.center)
.padding()
}
}
}
#Preview {
P7ContentView3()
}动画本质上就是在一段时间内,让属性值从 A 变成 B 的过程。SwiftUI 是这样处理的:
因为 SwiftUI
官方已经把大部分常用的动画(比如移动、缩放、透明度变化)都封装成了
ViewModifier。你直接用 .animation()
修饰符就行,不需要去从底层手动编写这些复杂的“分段逻辑”。