matchedGeometryEffect 魔法变换

如果你有一个视图,分别出现在界面中的两个不同的地方,并且想要在它们之间设置动画(例如从列表视图转到缩放的详细视图) ,那么您应该使用 matchedGeometryEffect() 修饰符,这有点像 Keynote 中的 Magic Move 效果。

// 例如有以下代码:在一个视图状态下有一个红色圆圈,然后是一些文本;但在另一种视图状态下,圆圈出现在文本后面并改变了颜色
struct ContentView: View {

    @State private var isFlipped = false

    var body: some View {
        VStack {
            if isFlipped {
                Circle()
                    .fill(.red)
                    .frame(width: 44, height: 44)
                Text("Taylor Swift – 1989")
                    .font(.headline)
            } else {
                Text("Taylor Swift – 1989")
                    .font(.headline)
                Circle()
                    .fill(.blue)
                    .frame(width: 44, height: 44)
            }
        }
        .onTapGesture {
            withAnimation {
                isFlipped.toggle()
            }
        }
    }
}

以上布局发生了变化,相比于使用传统方式去设置动画,更快的方式是使用 matchedGeometryEffect 修饰符,具体使用方法如下:

1. 创建 @Namespace 命名空间

首先,您需要使用 @Namespace 属性包装器,为您的视图创建全局命名空间。@Namespace 实际上只是某种视图上的属性,在背后,它让我们可以将视图附加在一起。您可以像下面这样添加属性:

@Namespace private var animation 

2. 添加 matchedGeometryEffect 修饰符

接下来,您需要将 .matchedGeometryEffect(id: YourIdentifierHere, in: animation) 添加到 “您想要使用同步效果进行动画处理” 的所有视图。 YourIdentifierHere 部分应替换为双方各部分共享的某个唯一编号。

// 在示例中,可以将其用于 Circle:
.matchedGeometryEffect(id: "Shape",in: animation)

// 并将其用于 Text 文本:
.matchedGeometryEffect(id: "AlbumTitle",in: animation)

// 当您再次运行该示例时,您将看到两个视图能够平滑地移动。

最终代码如下所示:

struct ContentView: View {

		// 创建命名空间
    @Namespace private var animation
    @Stateprivatevar isFlipped = false

		var body:some View {
        VStack {
						if isFlipped {
                Circle()
                    .fill(.red)
                    .frame(width: 44, height: 44)
                    // 在创建的命名空间里,id 要和另外一个状态下的一样
                    .matchedGeometryEffect(id: "Shape",in: animation)
                Text("Taylor Swift – 1989")
                    .matchedGeometryEffect(id: "AlbumTitle",in: animation)
                    .font(.headline)
            }else {
                Text("Taylor Swift – 1989")
                    .matchedGeometryEffect(id: "AlbumTitle",in: animation)
                    .font(.headline)
                Circle()
                    .fill(.blue)
                    .frame(width: 44, height: 44)
                    .matchedGeometryEffect(id: "Shape",in: animation)
            }
        }
        .onTapGesture {
            withAnimation {
                isFlipped.toggle()
            }
        }
    }
}

以下是更高级的示例,它借用了 Apple Music 的专辑显示样式,点击时将小视图扩展为更大的视图。

在本例中,只有文本是动画的,因为它的位置改变了:

struct ContentView: View {

    @Namespaceprivatevar animation
    @Stateprivatevar isZoomed = false

		var frame: Double {
        isZoomed ? 300 : 44
    }

		var body:some View {
        VStack {
            Spacer()

            VStack {
                HStack {
                    RoundedRectangle(cornerRadius: 10)
                        .fill(.blue)
                        .frame(width: frame, height: frame)
                        .padding(.top, isZoomed ? 20 : 0)

								if isZoomed == false {
                        Text("Taylor Swift – 1989")
                            .matchedGeometryEffect(id: "AlbumTitle",in: animation)
                            .font(.headline)
                        Spacer()
                    }
                }

								if isZoomed == true {
                    Text("Taylor Swift – 1989")
                        .matchedGeometryEffect(id: "AlbumTitle",in: animation)
                        .font(.headline)
                        .padding(.bottom, 60)
                    Spacer()
                }
            }
            .onTapGesture {
                withAnimation(.spring()) {
                    isZoomed.toggle()
                }
            }
            .padding()
            .frame(maxWidth: .infinity)
            .frame(height: 400)
            .background(Color(white: 0.9))
            .foregroundStyle(.black)
        }
    }
}

phase animators 多阶段动画

iOS 17 中的新功能。

SwiftUI 的 PhaseAnimator 视图和 phaseAnimator 修饰符允许我们通过持续或触发时循环选择动画阶段来执行多步动画。

创建这些多阶段动画需要三个步骤:

  1. 定义您要经历的阶段 phases。这可以是任何类型的【序列】,但您会发现使用 CaseIterable 枚举最简单
  2. 读取 phase animator 中的一个 phase ,并调整您的视图以匹配该 phase 想要的外观
  3. 添加一个触发器(可选),使 phase animator 可以从头开始重复其序列。没有这个它就会不断循环

例如,该示例创建一个简单的动画,使某些文本开始很小且不可见,放大到自然大小并完全不透明,然后放大到非常大且不可见。 它使用数字 0、1 和 3 的数组来表示我们将使用的各种缩放大小(0%、100% 和 300%),并且当大小为 1 时,它使文本不透明:

Text("Hello, world!")
    .font(.largeTitle)
    .phaseAnimator([0, 1, 3]) { view, phase in
        view
            .scaleEffect(phase)
            .opacity(phase == 1 ? 1 : 0)
    }
    
// 因为我们没有为动画提供触发器,所以它会一直永远运行。

如果您愿意,可以使用 PhaseAnimator 视图来编写,其优点是多个视图可以同时在各阶段 phases 之间切换:

VStack(spacing: 50) {
    PhaseAnimator([0, 1, 3]) { value in 
		    Text("Hello, world!")
            .font(.largeTitle)
            .scaleEffect(value)
            .opacity(value == 1 ? 1 : 0)

        Text("Goodbye, world!")
            .font(.largeTitle)
            .scaleEffect(3 - value)
            .opacity(value == 1 ? 1 : 0)
    }
}

就像我说的,您可能更喜欢使用枚举定义各个 phases。这可能让原始值更加有意义,但这不是必须的。以下是使用枚举重写的相同内容:

enum AnimationPhase: Double, CaseIterable {
		case fadingIn = 0
		case middle = 1
		case zoomingOut = 3
}

struct ContentView: View {
		var body:some View {
        Text("Hello, world!")
            .font(.largeTitle)
            .phaseAnimator(AnimationPhase.allCases) { view, phase in
                view
                    .scaleEffect(phase.rawValue)
                    .opacity(phase.rawValue == 1 ? 1 : 0)
            }
    }
}

您可以让它根据命令触发动画序列,而不是让阶段动画无休止地重复。为此,需要 SwiftUI 监视一个触发器值,例如随机 UUID 或递增数字。每当该值发生变化时,SwiftUI 都会重置您的动画器并完整播放。

以下示例中,点击按钮会触发使用枚举情况的三步动画:

// 首先定义所需的各种动画阶段
enum AnimationPhase: CaseIterable {
		case start, middle, end
}

struct ContentView: View {
    @State private var animationStep = 0
		var body:some View {
        Button("Tap Me!") {
            animationStep += 1
        }
        .font(.largeTitle)
        // 然后只有当属性发生变化时,才会遍历定义的动画的各个阶段(这样动画不会自己不断循环)
        .phaseAnimator(AnimationPhase.allCases, trigger: animationStep) { content, phase in
            content
                .blur(radius: phase == .start ? 0 : 10)
                .scaleEffect(phase == .middle ? 3 : 1)
        }
    }
}

为了获得更多控制,您可以准确指定每个阶段使用哪个动画。以下例子会在快速的 .bouncy 和缓慢的 .easeInOut 动画之间切换:

enum AnimationPhase: CaseIterable {
		case start, middle, end
}

struct ContentView: View {
    @State private var animationStep = 0
		var body:some View {
        Button("Tap Me!") {
            animationStep += 1
        }
        .font(.largeTitle)
        .phaseAnimator(AnimationPhase.allCases, trigger: animationStep) { content, phase in
            content
                .blur(radius: phase == .start ? 0 : 10)
                .scaleEffect(phase == .middle ? 3 : 1)
        } animation: { phase in
		        switch phase {
								case .start, .end: .bouncy
								case .middle: .easeInOut(duration: 2)
            }
        }
        // 通过一个 animation 闭包实现分阶段动画的控制
    }
}

最后,一种有用方法是向动画阶段添加额外的计算属性,以使其余代码更易于阅读,如下所示:

// 这种方法有点像关键帧动画,先把每一帧的状态定义出来
enum AnimationPhase: CaseIterable {

		case fadingIn, middle, zoomingOut

		// 计算属性:定义每个阶段的 scale 值
		var scale: Double {
				switchself {
						case .fadingIn: 0
						case .middle: 1
						case .zoomingOut: 3
		    }
		}

		// 计算属性:定义每个阶段的 opacity 值
		var opacity: Double {
				switchself {
						case .fadingIn: 0
						case .middle: 1
						case .zoomingOut: 0
        }
    }
    
}

struct ContentView: View {
		var body:some View {
        Text("Hello, world!")
            .font(.largeTitle)
            .phaseAnimator(AnimationPhase.allCases) { content, phase in
                content
                    .scaleEffect(phase.scale)
                    .opacity(phase.opacity)
            }
    }
}

Transaction 替换动画

SwiftUI 提供了一个 withTransaction() 函数,允许我们在运行时覆盖动画,例如删除隐式动画并将其替换为自定义动画。

使用 withTransaction 覆盖隐式动画

// 例如,此代码在小尺寸和大尺寸之间切换文本,并一直进行动画处理,因为它附加了隐式动画:
struct ContentView: View {
    @State private var isZoomed = false
    var body: some View {
        VStack {
            Button("Toggle Zoom") {
                isZoomed.toggle()
            }
            Text("Zoom Text")
                .font(.title)
                .scaleEffect(isZoomed ? 3 : 1)
                .animation(.easeInOut(duration: 2), value: isZoomed)
        }
    }
}

Transactions 允许我们根据具体情况覆盖现有动画。具体做法如下:

例如您希望在特定情况下,文本动画以 linear 的方式发生,而不是之前定义的动画。于是使用 Transaction 插入自定义动画:

struct ContentView: View {

    @State private var isZoomed = false

    var body: some View {
        VStack {
            Button("Toggle Zoom") {
                var transaction = Transaction(animation: .linear)
                transaction.disablesAnimations = true
                withTransaction(transaction) {
                    isZoomed.toggle()
                }
            }

            Spacer()
                .frame(height: 100)

            Text("Zoom Text")
                .font(.title)
                .scaleEffect(isZoomed ? 3 : 1)
                .animation(.easeInOut(duration: 2), value: isZoomed)
        }
    }
}

使用 .transaction 修饰符覆盖 Transaction 动画

为了获得更多控制,您可以将 transaction() 修饰符,附加到您想要的任何视图,从而允许您覆盖应用于该视图的任何 Transaction

例如,我们可以在示例中添加第二个缩放文本,仍然使用 transaction 来触发缩放动画,但这次我们在第二个文本视图上使用 transaction() 修饰符,并且禁用该视图上的任何 transactions。该设定将覆盖之前的 Transaction

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

		var body:some View {
        VStack {
            Button("Toggle Zoom") {
								var transaction = Transaction(animation: .linear)
                transaction.disablesAnimations = true
                withTransaction(transaction) {
                    isZoomed.toggle()
                }
            }

            Spacer()
                .frame(height: 100)

            Text("Zoom Text 1")
                .font(.title)
                .scaleEffect(isZoomed ? 3 : 1)

            Spacer()
                .frame(height: 100)

            Text("Zoom Text 2")
                .font(.title)
                .scaleEffect(isZoomed ? 3 : 1)
                .transaction { t in
                    t.animation = .none
                }
        }
    }
}