转场的概念

转场:指的是定义视图如何从视图层中被【插入】或【移除】的。一个视图元素的出现和消失,就叫转场。

1. 结构性出现 & 可见性出现

要理解转场动画何时会生效,首先要明确怎么才算视图元素的出现(或消失)。例如当一个上级视图调起一个子视图时,子视图这时是否算“出现” ? 并且子视图里面的那些元素,是否这时也算“出现” ? 还有如果我们用 dismiss 关闭子视图,它是否算“消失”?这些情况能否自动触发 transition 动画?

这个问题是几乎所有初学者都会困惑的地方。直觉上,我们认为应该算“子视图出现了”,所以应该自动触发转场动画 transition。但现实是该场景下无法触发转场动画。这背后是对“出现”(Appearance)这个词的两种不同理解。

这里我们首先要区别两个概念:“结构性”出现 & “可见性”出现。

例如,当上级视图(比如一个 NavigationStack)推入一个子视图 SubView 时:

那它的子元素(VStack, ForEach)有默认的 ‘插入/出现’ 的状态吗?没有,这就是问题的关键。

如果用 dismiss 关掉视图,它能否自动应用 transition 消失动画?答案也是不能。当您调用 dismiss 时,NavigationStack 会播放它自己的“滑出”动画。SubView 及其子视图会立即(或者说,跟随 NavigationStack 的动画)从屏幕上消失,因此我们附加在 SubView 子视图上的转场动画,不会自动播放。如何解决?一般都需要手动实现“退出动画”。例如:

当我们给视图中的某个子视图添加 transition 修饰符,会发现每次页面展示时,其元素的转场动画是不生效的。因为视图及其所有的子元素的结构,都在这一瞬间被立即创建了。所以它对我们来说只是视觉上的 “可见性” 出现了,而不是 “结构性” 的出现。

struct ContentView: View {
    var body: some View {
        NavigationStack {
            VStack(spacing: 20) {
                    Image(systemName: "globe")
                        .imageScale(.large)
                        .foregroundColor(.accentColor)
                        .transition(.scale)
            }
        }
    }
}

2. 改用 animation 实现转场

那针对当前元素属于 “可见性” 出现,而又想应用出场动画的情况。我们一般可以改用通过 .animation(_, value:) 来实现

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>

3. 构造 “结构性” 出现

根据上面的分析,能够触发 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)

4. 两种方式的区别

通过以上例子发现,似乎用 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 独一无二的“杀手锏”。

想象一个视图,我们希望它:出现时:从左侧滑入 (.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 是在视图的“生”与“死”的时刻起作用,而属性动画是在视图“活着”的期间改变它的状态。


transition 基本语法

1. 普通转场 transition

在视图后面添加 .transition 修饰符,即可创建普通转场。常见的内置转场类型包括: scaleopaqueoffsetmoveslide

// 可以理解成实际上是定义该视图的一个初始位置,swiftUI 自动生成动画

// 例如:从自身底部缩放
.transition(.scale(scale: 0, anchor: .bottom))

// 例如:从左侧-600的位置移入
.transition(.offset(x: -600, y: 0))

2. 混合式转场 combined

我们可以调用 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)
)

3. 非对称转场 asymmetric

前面提到的转场都是对称的,也就是插入和移除使用的是同样的转场效果。举例就是如果视图出现时是放大,那移除时就是缩小。如果希望插入和移除使用不一样的效果,就要使用 assymetric 方法,来指定 insertion,和 removal 的转场。

.transition(
	.asymmetric(
		//视图出现时的效果
		insertion: .scale(scale: 0, anchor: .bottom), 
		//视图移除时的效果
		removal: .offset(x: -600, y: 0)
	)
)