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

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