更多关于 Animation

Button 点击 withAnimation

比如当一个Button点击时,触发显式动画

Button("Guess") {
    withAnimation(Animation.easeInOut(duration: 3)) {
        game.attemptGuess()
        selection = 0
    }
}

利用扩展 扩展Animation

Button("Guess") {
    withAnimation(Animation.guess) {
        game.attemptGuess()
        selection = 0
    }
}

extension Animation {
    static let guess = Animation.easeInOut(duration: 3)
}

.animation nil 抑制动画

为指定内容不使用动画

PegView()
    .padding(Selection.border)
    .background {
        if selection == index, code == .guess {
            Selection.shape
            .foregroundStyle(Selection.color)
        }
    }
    .overlay { // 遮罩
        Selection.shape
        .foregroundStyle(Code.isHidden ? Color.gray : .clear)
        .animation(nil, value: code.isHidden)
    }

这样隐藏动画,会使得双向生效,也就是 code.isHidden 从 false 到 true,从 true 到 false 的动画都没了。

.transaction 抑制动画

PegView()
    .padding(Selection.border)
    .background {
        if selection == index, code == .guess {
            Selection.shape
            .foregroundStyle(Selection.color)
        }
    }
    .overlay { // 遮罩
        Selection.shape
        .foregroundStyle(Code.isHidden ? Color.gray : .clear)
        .transaction { transaction in
            if code.isHidden {
                transaction.animation = nil
            }
        }
    }

条件渲染

Swift 这种MVVM,声明式组件,其实写法非常像 React.js

var body: some View {
    VStack {
        Button("Restart")
        ScrollView {
            // ...
        }
        if !game.isOver {
            PegChooser() // 根据 game.isOver 是否显示View
        }
    }
}

理解 transition 的正与逆

过渡动画,其实就是从有到无、从无到有

if !game.isOver {
    PegChooser()
    .transition(AnyTransition.offset(x: 0, y: 200))
}

当游戏isOver,PegChooser应该消失,向屏幕下方移动,PegChooser从消失到显示就会是从屏幕下方向上移,一正一逆。

坐标系屏幕左上是原点,向右为x正轴,向下为y正轴。

合理利用扩展

if !game.isOver {
    PegChooser()
    .transition(AnyTransition.pegChooser)
}

extension AnyTransition {
    static let pegChooser = AnyTransition.offset(x: 0, y: 200)
}

Button systemImage

在Button文字Title左边显示图标

Button("Restart", systemImage: "arrow.circlepath", action: restart)
// .labelStyle(iconOnly)
// .labelStyle(.titleOnly)

Binding .animation

@State private var big: Bool = false
MyView(big: $big.animation(...))

可以理解成

Binding(
    get: {
        big
    },
    set: { newValue in
        withAnimation(.easeInOut) {
            big = newValue
        }
    }
)
$big.animation(.easeInOut)
// 返回仍是 
Binding<Bool>
// 只是包装了一层动画行为。
struct ParentView: View {

    @State private var big = false

    var body: some View {
        MyView(big: $big.animation(.easeInOut))
    }
}
struct MyView: View {

    @Binding var big: Bool

    var body: some View {
        Rectangle()
            .frame(width: big ? 200 : 100,
                   height: big ? 200 : 100)
            .onTapGesture {
                big.toggle()
            }
    }
}

big.toggle() 实际变成了

Binding.set(...)

Transaction(animation: .easeInOut)

更新 State

100->200 会自动动画

子 View 根本不用写:

withAnimation {
    big.toggle()
}

Binding 如果没有 .animation()

MyView(big: $big)
big.toggle()

Binding.set()

State 更新

立即刷新

没有动画

什么时候适用?

Binding.animation() 最适合父子视图之间的状态传递。例如父视图希望”只要子视图通过这个绑定修改状态,就自动带动画”,而子视图保持纯粹,只负责业务逻辑:

ParentView
    └── $big.animation(.spring())


        ChildView


        big.toggle()   // 自动动画

动画方法对比

方法 动画作用对象 触发条件
withAnimation {} 一段状态修改 包裹的代码执行时
$binding.animation() Binding 的写操作 通过该 Binding 设置新值时
.animation(_, value:) View 指定的值变化导致视图更新时

Group

如果你有 React 的经验,可以把 SwiftUI 的 Group 当成 比 React Fragment(<>...</>) 功能更多一点的 Fragment。

它是真正的一个 View 类型。Group 不负责布局。

// 作用只有一个:避免增加额外的 DOM 节点。
<>
    <Text>Hello</Text>
    <Button>OK</Button>
</>
// 或
<React.Fragment>
    ...
</React.Fragment>

SwiftUI 的 Group

Group {
    Text("Hello")
    Button("OK") {}
}
// 它同样不会产生一个真正的 View 容器。

VStack
 ├── Group
 │     ├── Text
 │     └── Button

实际布局时更像,Group 自己不会占空间。

VStack
 ├── Text
 └── Button

例如,让多个 View 满足返回一个 View

var body: some View {
    if isLogin {
        Text("Login")
        Text("Success")      // ❌
    } else {
        Text("Guest")
    }
}
// 利用 Group
var body: some View {
    if isLogin {
        Group {
            Text("Login")
            Text("Success")
        }
    } else {
        Text("Guest")
    }
}

给多个 View 一起加 Modifier

Group {
    Text("A")
    Text("B")
    Text("C")
}
.font(.title)
.foregroundStyle(.red)

.matchedGeometryEffect

matchedGeometryEffect 是 SwiftUI 中最强大的动画 API 之一,它可以让两个不同 View 看起来像同一个 View 在移动、变形。

告诉 SwiftUI:这两个 View 是同一个东西,只是在不同地方出现。

然后 SwiftUI 自动帮你计算:

最终生成一个连续动画。

假设一个红方块点击以后跑到下面,没有 matchedGeometryEffect

if expand {
    Spacer()

    Rectangle()
        .fill(.red)
        .frame(width: 200, height: 200)
} else {
    Rectangle()
        .fill(.red)
        .frame(width: 80, height: 80)

    Spacer()
}

动画会变成 旧View消失 新View出现,即使加了

.animation(.easeInOut, value: expand)

也只是 Fade Out、Fade In,SwiftUI 认为 这是两个不同的 Rectangle

使用 matchedGeometryEffect

// 首先创建一个Namespace
@Namespace private var animation
// 然后两个View
if expand {
    Spacer()

    Rectangle()
        .matchedGeometryEffect(id: "box", in: animation)
        .frame(width: 200, height: 200)

} else {

    Rectangle()
        .matchedGeometryEffect(id: "box", in: animation)
        .frame(width: 80, height: 80)

    Spacer()
}
// 再加上动画
.onTapGesture {
    withAnimation(.spring()) {
        expand.toggle()
    }
}

不是两个Rectangle,而是 同一个 Rectangle,位置变化 大小变化 一起动画。

为什么需要 Namespace,Namespace就像 动画世界 或者 说动画组,SwiftUI会去这个Namespace里面找: 有没有另一个id一样的View

.matchedGeometryEffect 完整写法

.matchedGeometryEffect(
    id: "box",
    in: animation,
    properties: .frame
)
// properties 参数默认 .frame
// 也可以是 只动画位置 .position 大小不会变
// 或 .size 只改变大小 位置保持不动
// .frame 位置大小一起变