transition 转场

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

如何让转场动画生效

transition 转场必须要与 animation 配对使用,才会产生动画。例如:

VStack { ... }
	.offset(y: geometry.size.height/2)
	.transition(.offset(x: -600, y: 0))
	.animation(.interpolatingSpring(stiffness: 200.0, damping: 25.0, initialVelocity: 10.0))

但 'animation' 方法在 iOS 15.0 往后已经被弃用了,需要使用 withAnimation 或 animation(_:value:) 代替。但我们往往希望转场动画是自动开始的,这里的监视值 value 填什么呢?我们可能需要人为设置一个开关。例如:

struct AnimationTest: View {

    @State private var animationAmount = 1.0
    
    var body: some View {
        Button("Tap Me") {
            // important button function here
        }
        .overlay(
            Circle()
                .stroke(.indigo)
                .scaleEffect(animationAmount)
                .opacity(1.5 - animationAmount)
                .transition(.offset(x: -600, y: 0))
                .animation(.easeInOut(duration: 1).repeatForever(autoreverses: false),
                    value: animationAmount
                )
        )
        // 在视图出现时改变前面设定的值,即可激活
        .onAppear {
            animationAmount = 1.5
        }
    }
}

带判断视图的转场

<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)

在 SwiftUI 中,@ViewBuilder 和条件语句对视图的创建和转场效果有非常重要的影响。通过这个例子可以看到:

原本写法:

使用 @ViewBuilder 的写法:

关于 @ViewBuilder 的其他特性:


普通转场 transition

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

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

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

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

混合式转场 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)
)

非对称转场 asymmetric

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

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

扩展 AnyTransition 自定义转场

简单的自定义转场

//【定义时】首先扩展 AnyTransition 类,如下:
extension AnyTransition {
		// 然后声明一个自定义转场
    static var offsetScaleOpacity: AnyTransition {
				// 这个自定义转场混合了3种转场方式
        AnyTransition.offset(x: -600, y: 0).combined(with: .scale).combined(with: .opacity)
    }
}

//【使用时】给相应的视图添加 transition 修飾器,然后使用刚定义的转场属性:
Rectangle()
	.transition(.offsetScaleOpacity)
// AnyTransition 的拓展,可以同时包含多个:
extension AnyTransition {
    
		//第一个:对称的
    static var scaleDownAndUp: AnyTransition {
        AnyTransition.offset(y: 600).combined(with: .scale(scale: 0, anchor: .bottom)).combined(with: .opacity)
    }
    
		//第二个:非对称的
    static var slideInAndOut: AnyTransition {
        AnyTransition.asymmetric(
						//标注颜色的这一行和对称转场的写法是一样的
            insertion: AnyTransition.offset(x: 800).combined(with: .opacity).combined(with: .scale(scale: 0, anchor: .trailing)),
            removal: AnyTransition.offset(x: -800).combined(with: .opacity).combined(with: .scale(scale: 0, anchor: .leading))
        )
    }

}

结合修饰符自定义转场

先构建一个自定义的视图修改器。然后对转场类进行拓展:

struct CornerRotateModifier: ViewModifier {
    let amount: Double
    let anchor: UnitPoint
    func body(content: Content) -> some View {
        content
            .rotationEffect(.degrees(amount), anchor: anchor)
						//这个clip可以让视图出到画面外时,不会被渲染出来
            .clipped()
    }
}

extension AnyTransition {

		// 这是一个静态的计算型属性。静态表示它属于类型而不是实例,可以通过类型直接访问。
		// **AnyTransition**:这是用于管理视图过渡的类型。它允许你定义视图切换时的过渡效果。
    static var pivot: AnyTransition {

				// 这是 AnyTransition 的一个构造器,表示我们要使用一个自定义的过渡效果。
        .modifier(
						// active 和 identity:这两个参数定义了在【激活状态】和【非激活状态】下的过渡效果
						// 在这里,它们都使用了前面定义的 CornerRotateModifier 视图修改器进行定义初始和结束状态
            active: CornerRotateModifier(amount: -90, anchor: .topLeading),
            identity: CornerRotateModifier(amount: 0, anchor: .topLeading)
        )
    }
}

完整例子:我们编写一个形状和视图修改器组合,模仿 Keynote 中的虹膜动画 —— 它会让新的幻灯片出现在扩大的圆圈中。

  1. 定义一个 ScaledCircle 形状,该形状在矩形内创建一个圆形,该矩形根据一些可设置动画的数据进行缩放
  2. 创建自定义 ViewModifier 结构以应用任何形状(在示例中为缩放后的圆形)作为另一个视图的剪辑形状
  3. 将其包装在 AnyTransition 扩展中,以将该修饰符包装在转换中以便于访问
  4. 创建一个 SwiftUI 视图来演示我们的转换操作
struct ScaledCircle: Shape {
    // This controls the size of the circle inside the
    // drawing rectangle. When it's 0 the circle is
    // invisible, and when it’s 1 the circle fills
    // the rectangle.
    var animatableData: Double

    func path(in rect: CGRect) -> Path {
        let maximumCircleRadius = sqrt(rect.width * rect.width + rect.height * rect.height)
        let circleRadius = maximumCircleRadius * animatableData

        let x = rect.midX - circleRadius / 2
        let y = rect.midY - circleRadius / 2

        let circleRect = CGRect(x: x, y: y, width: circleRadius, height: circleRadius)

        return Circle().path(in: circleRect)
    }
}

// A general modifier that can clip any view using a any shape.
struct ClipShapeModifier<T: Shape>: ViewModifier {
    let shape: T
    func body(content: Content) -> some View {
        content.clipShape(shape)
    }
}

// A custom transition combining ScaledCircle and ClipShapeModifier.
extension AnyTransition {
    static var iris: AnyTransition {
        .modifier(
            active: ClipShapeModifier(shape: ScaledCircle(animatableData: 0)),
            identity: ClipShapeModifier(shape: ScaledCircle(animatableData: 1))
        )
    }
}

// An example view move showing and hiding a red
// rectangle using our transition.
struct ContentView: View {
    @State private var isShowingRed = false

    var body: some View {
        ZStack {
            Color.blue
                .frame(width: 200, height: 200)

            if isShowingRed {
                Color.red
                    .frame(width: 200, height: 200)
                    .transition(.iris)
                    .zIndex(1)
            }
        }
        .padding(50)
        .onTapGesture {
            withAnimation(.easeInOut) {
                isShowingRed.toggle()
            }
        }
    }
}