转场:指的是定义视图如何从视图层中被【插入】或【移除】的。一个视图元素的出现和消失,就叫转场。
transition 修饰器要理解转场动画何时会生效,首先要明确怎么才算视图元素的出现(或消失)。例如当一个上级视图调起一个子视图时,子视图这时是否算“出现” ? 并且子视图里面的那些元素,是否这时也算“出现” ? 还有如果我们用 dismiss 关闭子视图,它是否算“消失”?这些情况能否自动触发 transition 动画?
这个问题是几乎所有初学者都会困惑的地方。直觉上,我们认为应该算“子视图出现了”,所以应该自动触发转场动画 transition。但现实是该场景下无法触发转场动画。这背后是对“出现”(Appearance)这个词的两种不同理解。
这里我们首先要区别两个概念:“结构性”出现 & “可见性”出现。
transition 转场关心的是“结构性”出现
什么叫“结构性”改变?就是 if 语句从 false 变为 true,或者 ForEach 的数据源增加了一个元素。transition 只在一个视图被真正插入到或移除出 SwiftUI 的视图树(View Hierarchy)时才会被触发。这与视图在屏幕上“是否可见”无关。
onAppear 事件关心的是“可见性”出现
onAppear 在视图的渲染结果即将显示在屏幕上时触发。这通常发生在父视图被 NavigationStack 推入、或者 Sheet 弹出时。此时,这个视图的结构已经存在了,它只是从“屏幕外”变成了“屏幕上”。
例如,当上级视图(比如一个 NavigationStack)推入一个子视图 SubView 时:
SubView 及其所有子元素(VStack, ForEach)的结构在这一瞬间被立即创建了NavigationStack 会播放它自己的转场动画(从右侧滑入)SubView 即将可见时,它的 .onAppear 被触发那它的子元素(VStack, ForEach)有默认的 ‘插入/出现’ 的状态吗?没有,这就是问题的关键。
SubView 的 body 角度来看,那些 (VStack, ForEach)是和 SubView 同时被创建的if false 到 if true 的结构性插入过程.transition() 永远不会被触发如果用 dismiss 关掉视图,它能否自动应用 transition 消失动画?答案也是不能。当您调用 dismiss 时,NavigationStack 会播放它自己的“滑出”动画。SubView 及其子视图会立即(或者说,跟随 NavigationStack 的动画)从屏幕上消失,因此我们附加在 SubView 子视图上的转场动画,不会自动播放。如何解决?一般都需要手动实现“退出动画”。例如:
dismiss 函数showItems = false(并确保 .animation 也能响应这个变化)async/await Task.sleep(for: .seconds(0.5)))dismiss()当我们给视图中的某个子视图添加 transition 修饰符,会发现每次页面展示时,其元素的转场动画是不生效的。因为视图及其所有的子元素的结构,都在这一瞬间被立即创建了。所以它对我们来说只是视觉上的 “可见性” 出现了,而不是 “结构性” 的出现。
struct ContentView: View {
var body: some View {
NavigationStack {
VStack(spacing: 20) {
Image(systemName: "globe")
.imageScale(.large)
.foregroundColor(.accentColor)
.transition(.scale)
}
}
}
}
那针对当前元素属于 “可见性” 出现,而又想应用出场动画的情况。我们一般可以改用通过 .animation(_, value:) 来实现
.animation 修饰符动画.onAppear 触发,我们会修改 @State 属性的值@State 属性值变了,会根据 .animation(_, value:) 修饰符,把所有依赖这个值的属性生成动画.fill(showItem ? .red : .orange) ,因为属性动画有状态值控制,而状态也关联了动画修饰符,所以当值改变,动画就会产生.transition(.scale) ,依然不会生效。因为状态值和它没有关联关系struct SubView: View {
@State var showItem: Bool = false
var body: some View {
VStack(spacing: 20) {
Rectangle()
// 动画1:这是属性动画
.fill(showItem ? .red : .orange)
.frame(width: 100, height: 100, alignment: .center)
// 动画2:这是转场动画
.transition(.scale)
}
.padding(40)
.background(.cyan)
// 改为在 “可见性” 出现的时候,去更改某个值
.onAppear { showItem = true }
// 并且也为这个状态值应用了动画
.animation(.easeIn(duration: 2.0), value: showItem)
}
}
<aside> 💡
animation 方法在 iOS 15.0 往后已经被弃用了,改为使用 withAnimation 或 animation(_:value:) 代替。而我们往往希望转场动画是自动开始的,所以只能人为设置一个开关监视值 value 。
</aside>
根据上面的分析,能够触发 transition 转场的“开关”不是一个随意的值,而是那个真正控制视图出现或消失的状态。它必须是视图树的结构性变化(if),而不仅仅是屏幕上的可见性变化(onAppear)。那我们可以人为地构造一个结构性出现,来触发转场动画。类似这样:
struct SubView: View {
@State var showItem: Bool = false
var body: some View {
VStack(spacing: 20) {
// 由于加了 if,这个状态值真正影响了视图的出现和消失,所以转场动画就生效了
if showItem {
Rectangle()
.fill(showItem ? .red : .orange)
.frame(width: 100, height: 100, alignment: .center)
.transition(.scale)
}
}
.padding(40)
.background(.cyan)
// 这两行代码还要留着,因为是它们促使状态值的改变; 状态值的改变,再影响了 Rectangle 元素的出现和消失
.animation(.easeIn(duration: 2.0), value: showItem)
.onAppear { showItem = true }
}
}
针对以上代码,我们还可以使用 withAnimation 闭包来包裹这个状态的改变,从而代替 .animation 修饰符。这是 transition 的标准用法。
struct SubView: View {
@State var showItem: Bool = false
var body: some View {
VStack(spacing: 20) {
// 由于加了 if,这个状态值真正影响了视图的出现和消失,所以转场动画就生效了
if showItem {
Rectangle()
.fill(showItem ? .red : .orange)
.frame(width: 100, height: 100, alignment: .center)
.transition(.scale)
}
}
.padding(40)
.background(.cyan)
// 关键:使用 withAnimation 来包裹状态变更,这时不再需要 .animation(..., value:) 修饰符
.onAppear {
withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) {
showItem = true
}
}
}
}
<aside> 💡
注意:如果该视图是用 if 等判断控制是否显示的,注意 animation 修饰符要加到父元素上才有效。参见: 对处于判断条件下的视图,其视图的展示和隐藏是通过条件判断实现的,那它的动画修饰符最好加到父元素上,否则可能动画修饰符不会起效果。比如给元素A设置了 animation(.default),但是实际上A如果消失了,是看不到动画效果的,因为动画想发生时,A已经不在了。所以要把 animation(.default) 加到A的上一级父元素上。
</aside>
// 例如:设置以下代码,发现 CustomTabbarView 的转场效果没有生效
if showTabbar {
CustomTabbarView(selectedTab: $selectedTab, showAddProjectView: $showAddProjectView)
.transition(.move(edge: .bottom))
.animation(.easeInOut(duration: 3), value: showTabBar)
}
// 改用【把视图当作属性】的写法就可以了
// 1.把条件判断部分一起包裹成属性
@ViewBuilder
private var tabBarView: some View {
if showTabBar {
CustomTabbarView(selectedTab: $selectedTab, showAddProjectView: $showAddProjectView)
.transition(.move(edge: .bottom))
}
}
// 2.在视图中使用该属性,给它添加动画修饰符
tabBarView
.animation(.easeInOut(duration: 0.2), value: showTabBar)
通过以上例子发现,似乎用 transition 实现的转场效果,都能被属性动画所替代(例如 .scaleEffect( showItem ? 1 : 2) )。这是一个绝妙的观察!已经触及到了 SwiftUI 动画系统设计的核心哲学。在“出现” (Insertion)这个单一场景下,几乎所有 .transition 提供的预设效果(scale, opacity, offset...),都能被等效的属性动画(scaleEffect, opacity, offset...)所替代。那么,.transition 的必要性在哪?
struct SubView: View {
@State var showItem: Bool = false
var body: some View {
VStack(spacing: 20) {
// 以下两种方式都能实现类似的转场效果
Rectangle()
.fill(.blue)
.frame(width: 100, height: 100, alignment: .center)
// 方式1:属性动画
.scaleEffect( showItem ? 1 : 0)
// 方式2:转场动画
if showItem {
Rectangle()
.fill(showItem ? .red : .orange)
.frame(width: 100, height: 100, alignment: .center)
.transition(.scale)
}
}
.padding(40)
.background(.cyan)
.animation(.easeIn(duration: 2.0), value: showItem)
.onAppear { showItem = true }
}
}
| 特性 | 通过 “可见性” 出现模拟转场 (属性动画) | 构造 “结构性” 出现触发转场 (Transition + if) |
|---|---|---|
| 触发器 | .onAppear 中改变 @State |
.onAppear 中用 withAnimation 改变 @State |
| 动画定义 | .animation(..., value:) |
.transition(...) |
| 视图控制 | View.opacity(show ? 1:0) |
if show { View } |
| 工作原理 | 改变视图的属性 | 改变视图的结构(插入/移除) |
| 优缺点 | 视图始终在树中,布局稳定。 | 更符合 transition 的语义。视图真的被插入/移除了 |
答案是:.transition 解决了一个属性动画无法(或极难)优雅解决的核心问题:视图的“移除” (Removal) 和 “非对称” (Asymmetric) 动画。
这是 .transition 独一无二的“杀手锏”。
scaleEffect(showItem ? 1.0 : 0.0) 这行代码,当 showItem 变为 true 时,它从 0.0 动画到 1.0。当 showItem 变为 false 时,它必须原路返回,从 1.0 动画到 0.0。你无法让它“出现”时缩放,“消失”时却淡出。transition 可以是“非对称”的:transition 允许你为“插入”和“移除”指定完全不同的动画。想象一个视图,我们希望它:出现时:从左侧滑入 (.slide);消失时:淡出并缩小 (.opacity + .scale)。
如果使用属性动画,这将是一场噩梦。您需要多个 @State 变量,甚至可能需要 AnimatableData。而使用 transition,这轻而易举:当 showItem 变为 true 时,它会滑入。当 showItem 变为 false 时,它会淡出并缩小。这是属性动画无法简单取代的。
// 1. 定义一个非对称转场
let slideInFadeOut = AnyTransition.asymmetric(
// 插入(出现)
insertion: .move(edge: .leading),
// 移除(消失)
removal: .opacity.combined(with: .scale(scale: 0.1))
)
// 2. 在视图上使用,轻松实现
if showItem {
Rectangle()
.fill(.green)
.frame(width: 100, height: 100)
.transition(slideInFadeOut)
}
// 3. 触发
Button("Toggle") {
withAnimation(.easeInOut(duration: 1.0)) {
showItem.toggle()
}
}
另外一个区别是两者的语义和布局。.transition 是在视图的“生”与“死”的时刻起作用,而属性动画是在视图“活着”的期间改变它的状态。
属性动画:
转场动画:
属性动画:就像一个舞台上的演员改变姿势(从站立变为蹲下,或挥手)。演员(View)始终在舞台上(View Hierarchy)。
转场动画:就像一个演员从舞台侧翼登场,或表演结束后退场。演员(View)是真正地“进入”或“离开”了舞台(View Hierarchy)。
在视图后面添加 .transition 修饰符,即可创建普通转场。常见的内置转场类型包括: scale、opaque、offset、move、slide 等
// 可以理解成实际上是定义该视图的一个初始位置,swiftUI 自动生成动画
// 例如:从自身底部缩放
.transition(.scale(scale: 0, anchor: .bottom))
// 例如:从左侧-600的位置移入
.transition(.offset(x: -600, y: 0))
我们可以调用 combined(with:) 方法,将多个转场效果结合起来,打造混合式转场效果
// 例如:结合移动和缩放
.transition(
AnyTransition.offset(x: -600, y: 0)
.combined(with: .scale)
)
// 例如:结合移动和缩放和透明度 3 种
.transition(
AnyTransition.offset(x: -600, y: 0)
.combined(with: .scale)
.combined(with: .opacity)
)
前面提到的转场都是对称的,也就是插入和移除使用的是同样的转场效果。举例就是如果视图出现时是放大,那移除时就是缩小。如果希望插入和移除使用不一样的效果,就要使用 assymetric 方法,来指定 insertion,和 removal 的转场。
.transition(
.asymmetric(
//视图出现时的效果
insertion: .scale(scale: 0, anchor: .bottom),
//视图移除时的效果
removal: .offset(x: -600, y: 0)
)
)