以下内容已整理到小册子中,本文会随着系统更新和我更多的实践而新增和更新,你可以下载“戴铭的开发小册子”应用,来跟踪查看本文内容新增和更新。小册子应用的代码可以在 Github 上查看。

本文属于小册子系列中的一篇,已发布系列文章有:

SwiftUI动画

SwiftUI 里实现动画的方式包括有 .animation 隐式动画、withAnimation 和 withTransaction 显示动画、matchedGeometryEffect Hero 动画和 TimelineView 等。

示例代码如下:

struct PlayAnimation: View {
    @State private var isChange = false
    private var anis:[String: Animation] = [
        "p1": .default,
        "p2": .linear(duration: 1),
        "p3": .interpolatingSpring(stiffness: 5, damping: 3),
        "p4": .easeInOut(duration: 1),
        "p5": .easeIn(duration: 1),
        "p6": .easeOut(duration: 1),
        "p7": .interactiveSpring(response: 3, dampingFraction: 2, blendDuration: 1),
        "p8": .spring(),
        "p9": .default.repeatCount(3)
    ]
    @State private var selection = 1
    
    var body: some View {
        // animation 隐式动画和 withAnimation 显示动画
        Text(isChange ? "另一种状态" : "一种状态")
            .font(.headline)
            .padding()
            .animation(.easeInOut, value: isChange) // 受限的隐式动画,只绑定某个值。
            .onTapGesture {
                // 使用 withAnimation 就是显式动画,效果等同 withTransaction(Transaction(animation: .default))
                withAnimation {
                    isChange.toggle()
                }

                // 设置 Transaction。和隐式动画共存时,优先执行 withAnimation 或 Transaction。
                var t = Transaction(animation: .linear(duration: 2))
                t.disablesAnimations = true // 用来禁用隐式动画
                withTransaction(t) {
                    isChange.toggle()
                }
            } // end onHover
        
        LazyVGrid(columns: [GridItem(.adaptive(minimum: isChange ? 60 : 30), spacing: 60)]) {
            ForEach(Array(anis.keys), id: \.self) { s in
                Image(s)
                    .resizable()
                    .scaledToFit()
                    .animation(anis[s], value: isChange)
                    .scaleEffect()
            }
        }
        .padding()
        Button {
            isChange.toggle()
        } label: {
            Image(systemName: isChange ? "pause.fill" : "play.fill")
                .renderingMode(.original)
        }
        
        // matchedGeometryEffect 的使用
        VStack {
            Text("后台")
                .font(.headline)
            placeStayView
            Text("前台")
                .font(.headline)
            placeShowView
        }
        .padding(50)
        
        // 通过使用相同 matchedGeometryEffect 的 id,绑定两个元素变化。
        HStack {
            if isChange {
                Rectangle()
                    .fill(.pink)
                    .matchedGeometryEffect(id: "g1", in: mgeStore)
                    .frame(width: 100, height: 100)
            }
            Spacer()
            Button("转换") {
                withAnimation(.linear(duration: 2.0)) {
                    isChange.toggle()
                }
            }
            Spacer()
            if !isChange {
                Circle()
                    .fill(.orange)
                    .matchedGeometryEffect(id: "g1", in: mgeStore)
                    .frame(width: 70, height: 70)
            }
            HStack {
                Image("p1")
                    .resizable()
                    .scaledToFit()
                    .frame(width: 50, height: 50)
                if !isChange {
                    Image("p19")
                        .resizable()
                        .scaledToFit()
                        .frame(width: 50, height: 50)
                        .matchedGeometryEffect(id: "g1", in: mgeStore)
                }
                Image("p1")
                    .resizable()
                    .scaledToFit()
                    .frame(width: 50, height: 50)
            }
        }
        .padding()
        
        // 使用 isSource,作为移动到相同 matchedGeometryEffect id 的方法。
        HStack {
            Image("p19")
                .resizable()
                .scaledToFit()
                .frame(width: isChange ? 100 : 50, height: isChange ? 100 : 50)
                .matchedGeometryEffect(id: isChange ? "g2" : "", in: mgeStore, isSource: false)
            
            Image("p19")
                .resizable()
                .scaledToFit()
                .frame(width: 100, height: 100)
                .matchedGeometryEffect(id: "g2", in: mgeStore)
                .opacity(0)
        }
        
        
        
        // 点击跟随的效果
        HStack {
            ForEach(Array(1...4), id: \.self) { i in
                Image("p\(i)")
                    .resizable()
                    .scaledToFit()
                    .frame(width: i == selection ? 200 : 50)
                    .matchedGeometryEffect(id: "h\(i)", in: mgeStore)
                    .onTapGesture {
                        withAnimation {
                            selection = i
                        }
                    }
                    .shadow(color: .black, radius: 3, x: 2, y: 3)
            }
        }
        .background(
            RoundedRectangle(cornerRadius: 8).fill(.pink)
                .matchedGeometryEffect(id: "h\(selection)", in: mgeStore, isSource: false)
        )
        
        // matchedGeometryEffect 还可以应用到 List 中,通过 Array enumerated 获得 index 作为 matchedGeometryEffect 的 id。右侧固定按钮可以直接让对应 id 的视图滚动到固定按钮的位置
        
        
        // TimelineView
        TimelineView(.periodic(from: .now, by: 1)) { t in
            Text("\(t.date)")
            HStack(spacing: 20) {
                let e = "p\(Int.random(in: 1...30))"
                Image(e)
                    .resizable()
                    .scaledToFit()
                    .frame(height: 40)
                    .animation(.default.repeatCount(3), value: e)
                
                TimelineSubView(date: t.date) // 需要传入 timeline 的时间给子视图才能够起作用。
                    
            }
            .padding()
        }
        
        // matchedGeometryEffect

        /// TimelineScheduler 的使用,TimelineScheduler 有以下类型
        /// .animation:制定更新的频率,可以控制暂停
        /// .everyMinute:每分钟更新一次
        /// .explicit:所有要更新的放到一个数组里
        /// .periodic:设置开始时间和更新频率
        /// 也可以自定义 TimelineScheduler
        TimelineView(.everySecond) { t in
            let e = "p\(Int.random(in: 1...30))"
            Image(e)
                .resizable()
                .scaledToFit()
                .frame(height: 40)
        }
        
        // 自定义的 TimelineScheduler
        TimelineView(.everyLoop(timeOffsets: [0.2, 0.7, 1, 0.5, 2])) { t in
            TimelineSubView(date: t.date)
        }
    }
    
    // MARK: - TimelineSubView
    struct TimelineSubView: View {
        let date : Date
        @State private var s = "let's go"
        // 顺序从数组中取值,取完再重头开始
        @State private var idx: Int = 1
        func advanceIndex(count: Int) {
            idx = (idx + 1) % count
            if idx == 0 { idx = 1 }
        }
        
        var body: some View {
            HStack(spacing: 20) {
                Image("p\(idx)")
                    .resizable()
                    .scaledToFit()
                    .frame(height: 40)
                    .animation(.easeIn(duration: 1), value: date)
                    .onChange(of: date) { newValue in
                        advanceIndex(count: 30)
                        s = "\(date.hour):\(date.minute):\(date.second)"
                    }
                    .onAppear {
                        advanceIndex(count: 30)
                    }
                    
                Text(s)
            }
        }
    }
    
    // MARK: - 用 matchedGeometryEffect 做动画
    /// matchedGeometryEffect 可以无缝的将一个图像变成另外一个图像。
    @State private var placeStayItems = ["p1", "p2", "p3", "p4"]
    @State private var placeShowItems: [String] = []
    
    @Namespace private var mgeStore
    
    private var placeStayView: some View {
        LazyVGrid(columns: [GridItem(.adaptive(minimum: 30), spacing: 10)]) {
            ForEach(placeStayItems, id: \.self) { s in
                Image(s)
                    .resizable()
                    .scaledToFit()
                    .matchedGeometryEffect(id: s, in: mgeStore)
                    .onTapGesture {
                        withAnimation {
                            placeStayItems.removeAll { $0 == s }
                            placeShowItems.append(s)
                        }
                    }
                    .shadow(color: .black, radius: 2, x: 2, y: 4)
            } // end ForEach
        } // end LazyVGrid
    } // private var placeStayView
    
    private var placeShowView: some View {
        LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 10)]) {
            ForEach(placeShowItems, id: \.self) { s in
                Image(s)
                    .resizable()
                    .scaledToFit()
                    .matchedGeometryEffect(id: s, in: mgeStore)
                    .onTapGesture {
                        withAnimation {
                            placeShowItems.removeAll { $0 == s }
                            placeStayItems.append(s)
                        }
                    }
                    .shadow(color: .black, radius: 2, x: 0, y: 2)
                    .shadow(color: .white, radius: 5, x: 0, y: 2)
            } // end ForEach
        } // end LazyVGrid
    } // end private var placeShowView
    
} // end struct PlayAnimation

// MARK: - 扩展 TimelineSchedule
extension TimelineSchedule where Self == PeriodicTimelineSchedule {
    static var everySecond: PeriodicTimelineSchedule {
        get {
            .init(from: .now, by: 1)
        }
    }
}

// MARK: - 自定义一个 TimelineSchedule
// timeOffsets 用完,就会再重头重新再来一遍
struct PCLoopTimelineSchedule: TimelineSchedule {
    let timeOffsets: [TimeInterval]
    
    func entries(from startDate: Date, mode: TimelineScheduleMode) -> Entries {
        Entries(last: startDate, offsets: timeOffsets)
    }
    
    struct Entries: Sequence, IteratorProtocol {
        var last: Date
        let offsets: [TimeInterval]
        var idx: Int = -1
        mutating func next() -> Date? {
            idx = (idx + 1) % offsets.count
            last = last.addingTimeInterval(offsets[idx])
            return last
        }
    } // end Struct Entries
}

// 为自定义的 PCLoopTimelineSchedule 做一个 TimelineSchedule 的扩展函数,方便使用
extension TimelineSchedule where Self == PCLoopTimelineSchedule {
    static func everyLoop(timeOffsets: [TimeInterval]) -> PCLoopTimelineSchedule {
        .init(timeOffsets: timeOffsets)
    }
}

contentTransition

.contentTransition(.numericText()) 修饰符用于在视图内容发生变化时,以数字动画的方式进行过渡。

struct ContentView: View {
    @State private var filmNumber: Int = 0
    
    var body: some View {
        VStack(spacing: 20) {
            Text("\(filmNumber)")
                .contentTransition(.numericText())
                .animation(.easeIn, value: filmNumber)
            Stepper("电影数量", value: $filmNumber, in: 0...100)
        }
        .font(.largeTitle)
        .foregroundColor(.indigo)
    }
}

animation修饰符

基本用法

在 SwiftUI 中,创建一个动画需要以下三个组成部分:

  • 一个时间曲线函数
  • 一个声明将状态(或特定的依赖项)与该时间曲线函数关联起来
  • 一个依赖于该状态(或特定的依赖项)的可动画组件

动画的接口定义为 Animation(timingFunction:property:duration:delay)

  • timingFunction 是时间曲线函数,可以是线性、缓动、弹簧等
  • property 是动画属性,可以是颜色、大小、位置等
  • duration 是动画持续时间
  • delay 是动画延迟时间

三种写法

  • withAnimation(_:_:) 全局应用
  • animation(_:value:) 应用于 View
  • animation(_:) 应用于绑定的变量

第一种

withAnimation(.easeInOut(duration: 1.5).delay(1.0)) {
    myProperty = newValue
}

第二种

View().animation(.easeInOut(duration: 1.5).delay(1.0), value: myProperty)

第三种

struct ContentView: View {
    @State private var scale: CGFloat = 1.0
    var body: some View {
        PosterView(scale: $scale.animation(.linear(duration: 1)))
    }
}

struct PosterView: View {
    @Binding var scale: CGFloat
    var body: some View {
        Image("evermore")
            .resizable()
            .scaledToFit()
            .scaleEffect(scale)
            .onAppear {
                scale = 1.5
            }
    }
}

在这个示例中,我们创建了一个 MovieView,它有一个状态变量 scale。当 scale 的值改变时,PosterView 中的海报图片会以线性动画的方式进行缩放。当 PosterView 出现时,scale 的值会改变为 1.5,因此海报图片会以线性动画的方式放大到 1.5 倍。

在 SwiftUI 中,我们也可以创建一个自定义的 AnimatableModifier 来实现对图文卡片大小的动画处理。

struct ContentView: View {
    @State private var isSmall = false
    var body: some View {
        VStack {
            Image("evermore")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .clipShape(.rect(cornerSize: CGSize(width: 16, height: 16)))
            Text("电影标题")
                .font(.title)
                .fontWeight(.bold)
        }
        .animatableCard(size: isSmall ? CGSize(width: 200, height: 300) : CGSize(width: 400, height: 600))
        .onTapGesture {
            withAnimation(.easeInOut(duration: 1)){
                isSmall.toggle()
            }
        }
    }
}

struct AnimatableCardModifier: AnimatableModifier {
    var size: CGSize
    var color: Color = .white
    
    var animatableData: CGSize.AnimatableData {
        get { CGSize.AnimatableData(size.width, size.height) }
        set { size = CGSize(width: newValue.first, height: newValue.second) }
    }
    
    func body(content: Content) -> some View {
        content
            .frame(width: size.width, height: size.height)
            .background(color)
            .cornerRadius(10)
    }
}

extension View {
    func animatableCard(size: CGSize,
                        color: Color = .white) -> some View {
        self.modifier(AnimatableCardModifier(size: size,
                                             color: color))
    }
}

SwiftUI 内置了许多动画过渡函数,主要分为四类:

  • 时间曲线动画函数
  • 弹簧动画函数
  • 高阶动画函数
  • 自定义动画函数

时间曲线动画函数

时间曲线函数决定了动画的速度如何随时间变化,这对于动画的自然感觉非常重要。

SwiftUI 提供了以下几种预设的时间曲线函数:

  • linear:线性动画,动画速度始终保持不变。
  • easeIn:动画开始时速度较慢,然后逐渐加速。
  • easeOut:动画开始时速度较快,然后逐渐减速。
  • easeInOut:动画开始和结束时速度较慢,中间阶段速度较快。

除此之外,SwiftUI 还提供了 timingCurve 函数,可以通过二次曲线或 Bézier 曲线来自定义插值函数,实现更复杂的动画效果。

以下是代码示例:

struct ContentView: View {
    @State private var scale: CGFloat = 1.0

    var body: some View {
        VStack {
            Text("电影标题")
                .font(.title)
                .padding()
            Image("evermore")
                .resizable()
                .scaledToFit()
                .scaleEffect(scale)
        }
        .onAppear {
            withAnimation(.easeInOut(duration: 1.0)) {
                scale = 1.5
            }
        }
    }
}

在这个示例中,我们创建了一个 MovieView,它包含一个电影标题和一个电影海报。当视图出现时,海报的大小会以 easeInOut 的方式在 1 秒内放大到 1.5 倍。

弹簧动画函数

弹簧动画函数可以模拟物理世界中的弹簧运动,使动画看起来更加自然和生动。

SwiftUI 提供了以下几种预设的弹簧动画函数:

  • smooth:平滑的弹簧动画,动画速度逐渐减慢,直到停止。
  • snappy:快速的弹簧动画,动画速度快速减慢,然后停止。
  • bouncy:弹跳的弹簧动画,动画在结束时会有一些弹跳效果。

除此之外,SwiftUI 还提供了 spring 函数,可以自定义弹簧动画的持续时间、弹跳度和混合持续时间,实现更复杂的弹簧动画效果。

以下是代码示例:

struct ContentView: View {
    @State private var scale: CGFloat = 1.0

    var body: some View {
        VStack {
            Text("电影标题")
                .font(.title)
                .padding()
            Image("evermore")
                .resizable()
                .scaledToFit()
                .scaleEffect(scale)
        }
        .onAppear {
            withAnimation(.spring(response: 0.5, dampingFraction: 0.5, blendDuration: 1)) {
                scale = 1.5
            }
        }
    }
}

在这个示例中,我们创建了一个 MovieView,它包含一个电影标题和一个电影海报。当视图出现时,海报的大小会以自定义的弹簧动画方式在 0.5 秒内放大到 1.5 倍。

高阶动画函数

高级动画函数可以在基础动画函数的基础上,添加延迟、重复、翻转和速度等功能,使动画效果更加丰富和复杂。

以下是这些函数的简单介绍:

  • func delay(TimeInterval) -> Animation:此函数可以使动画在指定的时间间隔后开始。
  • func repeatCount(Int, autoreverses: Bool) -> Animation:此函数可以使动画重复指定的次数。如果 autoreverses 参数为 true,则每次重复时动画都会翻转。
  • func repeatForever(autoreverses: Bool) -> Animation:此函数可以使动画无限次重复。如果 autoreverses 参数为 true,则每次重复时动画都会翻转。
  • func speed(Double) -> Animation:此函数可以调整动画的速度,使其比默认速度快或慢。

以下是代码示例:

struct MovieView: View {
    @State private var scale: CGFloat = 1.0

    var body: some View {
        VStack {
            Text("电影标题")
                .font(.title)
                .padding()
            Image("movie_poster")
                .resizable()
                .scaledToFit()
                .scaleEffect(scale)
        }
        .onAppear {
            withAnimation(Animation.easeInOut(duration: 1.0).delay(0.5).repeatCount(3, autoreverses: true).speed(2)) {
                scale = 1.5
            }
        }
    }
}

在这个示例中,我们创建了一个 MovieView,它包含一个电影标题和一个电影海报。当视图出现时,海报的大小会以 easeInOut 的方式在 1 秒内放大到 1.5 倍,然后在 0.5 秒后开始,重复 3 次,每次重复都会翻转,速度是默认速度的 2 倍。

自定义动画函数

SwiftUI 可以通过实现 CustomAnimation 协议来完全自定义插值算法。

以下是一个简单的 Linear 动画函数的实现:

struct ContentView: View {
    @State private var scale: CGFloat = 1.0

    var body: some View {
        VStack {
            Text("电影标题")
                .font(.title)
                .padding()
            Image("evermore")
                .resizable()
                .scaledToFit()
                .scaleEffect(scale)
                .animation(.myLinear(duration: 1), value: scale) // use myLinear animation
        }
        .onAppear {
            scale = 1.5
        }
    }
}


struct MyLinearAnimation: CustomAnimation {
  var duration: TimeInterval

  func animate<V>(value: V, time: TimeInterval, context: inout AnimationContext<V>) -> V? where V : VectorArithmetic {
    if time <= duration {
      value.scaled(by: time / duration)
    } else {
      nil
    }
  }

  func velocity<V: VectorArithmetic>(
    value: V, time: TimeInterval, context: AnimationContext<V>
  ) -> V? {
    value.scaled(by: 1.0 / duration)
  }

  func shouldMerge<V>(previous: Animation, value: V, time: TimeInterval, context: inout AnimationContext<V>) -> Bool where V : VectorArithmetic {
    true
  }
}

extension Animation {
  public static func myLinear(duration: TimeInterval) -> Animation { // define function like linear
    return Animation(MyLinearAnimation(duration: duration))
  }
}

Transaction

Transaction 使用指南

这段内容主要介绍了 SwiftUI 中的 TransactionwithTransactionTransaction 是 SwiftUI 中用于控制动画的一种方式,它可以用来定义动画的详细参数,如动画类型、持续时间等。withTransaction 是一个函数,它接受一个 Transaction 实例和一个闭包作为参数,闭包中的代码将在这个 Transaction 的上下文中执行。

以下是一个使用 TransactionwithTransaction 的代码示例:

struct ContentView: View {
    @State private var scale: CGFloat = 1.0

    var body: some View {
        VStack {
            Text("电影标题")
                .font(.title)
                .padding()
            Image("evermore")
                .resizable()
                .scaledToFit()
                .scaleEffect(scale)
        }
        .onAppear {
            let transaction = Transaction(animation: .easeInOut(duration: 1))
            withTransaction(transaction) {
                scale = 1.5
            }
        }
    }
}

在这个示例中,我们创建了一个 MovieView,它包含一个电影标题和一个电影海报。当视图出现时,我们创建了一个 Transaction,并设置了动画类型为 easeInOut,持续时间为 1 秒。然后我们在 withTransaction 的闭包中改变 scale 的值,这样海报的大小就会以 easeInOut 的方式在 1 秒内放大到 1.5 倍。

使用 TransactionwithTransaction

SwiftUI 中 TransactiondisablesAnimationsisContinuous 属性,以及 transaction(_:) 方法怎么使用?

disablesAnimations 属性可以用来禁止动画,isContinuous 属性可以用来标识一个连续的交互(例如拖动)。transaction(_:) 方法可以用来创建一个新的 Transaction 并在其闭包中设置动画参数。

以下是一个使用这些特性的代码示例:

struct ContentView: View {
    @State var size: CGFloat = 100
    @GestureState var dragSize: CGSize = .zero

    var body: some View {
        VStack {
            Image("fearless")
                .resizable()
                .scaledToFit()
                .frame(width: size, height: size) // 使用 size 控制尺寸,而非位置
                .animation(.spring(), value: size) // 使用弹簧动画
                .transaction {
                    if $0.isContinuous {
                        $0.animation = nil // 拖动时,不设置动画
                    } else {
                        $0.animation = .spring() // 使用弹簧动画
                    }
                }
                .gesture(
                    DragGesture()
                        .updating($dragSize, body: { current, state, transaction in
                            state = .init(width: current.translation.width, height: current.translation.height)
                            transaction.isContinuous = true // 拖动时,设置标识
                        })
                )

            Stepper("尺寸: \(size)", value: $size, in: 50...200) // 使用 Stepper 替代 Slider
            Button("开始动画") {
                var transaction = Transaction()
                if size < 150 { transaction.disablesAnimations = true }
                withTransaction(transaction) {
                    size = 50
                }
            }
        }
    }
}

在这个示例中,当 size 小于 150 时,我们禁用动画。通过 .isContinuous 属性,我们可以标识一个连续的交互(例如拖动)。在这个示例中,当拖动时,我们禁用动画。通过 transaction(_:) 方法,我们可以创建一个新的 Transaction 并在其中设置动画参数。

用于视图组件

大部分 SwiftUI 视图组件都有 transaction(_:) 方法,可以用来设置动画参数。比如 NavigationStack, Sheet, Alert 等。

Transaction 也可以用于 BindingFetchRequest

看下面的例子:

struct ContentView: View {
    @State var size: CGFloat = 100
    @State var isBold: Bool = false
    let animation: Animation? = .spring

    var sizeBinding: Binding<CGFloat> {
        let transaction = Transaction(animation: animation)
        return $size.transaction(transaction)
    }

    var isBoldBinding: Binding<Bool> {
        let transaction = Transaction(animation: animation)
        return $isBold.transaction(transaction)
    }

    var body: some View {
        VStack {
            Image(systemName: "film")
                .resizable()
                .scaledToFit()
                .frame(width: size, height: size) // 使用 size 控制尺寸,而非位置
                .font(.system(size: size, weight: isBold ? .bold : .regular)) // 使用 isBold 控制粗细
            Stepper("尺寸: \(size)", value: sizeBinding, in: 50...200)
            Toggle("粗细", isOn: isBoldBinding)
        }
        .padding()
    }
}

传播行为

Transaction 可以用于控制动画的传播行为。在 SwiftUI 中,动画可以在视图层次结构中传播,这意味着一个视图的动画效果可能会影响到其子视图。Transaction 可以用来控制动画的传播行为,例如禁用动画、设置动画类型等。

以下是一个使用 Transaction 控制动画传播行为的代码示例:

enum BookStatus {
    case small, medium, large, extraLarge
}

extension View {
    @ViewBuilder func debugAnimation() -> some View {
        transaction {
            debugPrint($0.animation ?? "")
        }
    }
}

struct ContentView: View {
    @State var status: BookStatus = .small

    var animation: Animation? {
        switch status {
        case .small:
            return .linear
        case .medium:
            return .easeIn
        case .large:
            return .easeOut
        case .extraLarge:
            return .spring()
        }
    }

    var size: CGFloat {
        switch status {
        case .small:
            return 100
        case .medium:
            return 200
        case .large:
            return 300
        case .extraLarge:
            return 400
        }
    }

    var body: some View {
        VStack {
            Image(systemName: "book")
                .resizable()
                .scaledToFit()
                .frame(width: size, height: size)
                .debugAnimation() // 查看动画变化信息
            Button("改变状态") {
                var transaction = Transaction(animation: animation)
                withTransaction(transaction) {
                    switch self.status {
                    case .small:
                        self.status = .medium
                    case .medium:
                        self.status = .large
                    case .large:
                        self.status = .extraLarge
                    case .extraLarge:
                        self.status = .small
                    }
                }
            }
        }
    }
}

这个示例中,我们创建了一个 BookView,它包含一个书籍图标。我们通过 BookStatus 枚举来控制书籍的大小,通过 animation 计算属性来根据状态返回不同的动画类型。在 withTransaction 中,我们根据状态创建一个新的 Transaction,并在其中设置动画类型。通过 debugAnimation 修饰符,我们可以查看动画的变化信息。

TransactionKey

TransactionKey 是一种在 SwiftUI 的视图更新过程中传递额外信息的机制,它可以让你在不同的视图和视图更新之间共享数据。

struct ContentView: View {
    @State private var store = MovieStore()
    var body: some View {
        VStack {
            Image("evermore")
                .resizable()
                .scaledToFit()
                .frame(width: 300, height: 300)
                .saturation(store.isPlaying ? 1 : 0) // 滤镜变化
                .transaction {
                    $0.animation = $0[StatusKey.self].animation
                }

            PlayView(store: store)
            PauseView(store: store)
        }
    }
}

struct PlayView: View {
    let store: MovieStore
    var body: some View {
        Button("播放") {
            withTransaction(\.status, .playing) {
                store.isPlaying.toggle()
            }
        }
    }
}

struct PauseView: View {
    let store: MovieStore
    var body: some View {
        Button("暂停") {
            withTransaction(\.status, .paused) {
                store.isPlaying.toggle()
            }
        }
    }
}

@Observable
class MovieStore {
    var isPlaying = false
}

enum MovieStatus {
    case playing
    case paused
    case stopped

    var animation: Animation? {
        switch self {
        case .playing:
            Animation.linear(duration: 2)
        case .paused:
            nil
        case .stopped:
            Animation.easeInOut(duration: 1)
        }
    }
}

struct StatusKey: TransactionKey {
    static var defaultValue: MovieStatus = .stopped
}

extension Transaction {
    var status: MovieStatus {
        get { self[StatusKey.self] }
        set { self[StatusKey.self] = newValue }
    }
}

以上代码中,我们创建了一个 MovieStore 类,用于存储电影播放状态。我们通过 PlayViewPauseView 分别创建了播放和暂停按钮,点击按钮时,我们通过 withTransaction 函数改变了 MovieStoreisPlaying 属性,并根据状态设置了动画类型。在 ContentView 中,我们通过 transaction 修饰符设置了动画类型为 MovieStatus 中的动画类型。

AnyTransition

AnyTransition 是一个用于创建自定义过渡效果的类型,它可以让你定义视图之间的过渡动画。你可以使用 AnyTransitionmodifier 方法将自定义过渡效果应用到视图上。

struct ContentView: View {
    
    @StateObject var musicViewModel = MusicViewModel()
    
    var body: some View {
        VStack {
            ForEach(musicViewModel.musicNames, id: \.description) { musicName in
                if musicName == musicViewModel.currentMusic {
                    Image(musicName)
                        .resizable()
                        .frame(width: 250, height: 250)
                        .ignoresSafeArea()
                        .transition(.glitch.combined(with: .opacity))
                }
            }
            
            Button("Next Music") {
                musicViewModel.selectNextMusic()
            }
            .buttonStyle(.borderedProminent)
            .padding()
            .background(Color.blue)
            .foregroundColor(.white)
            .cornerRadius(10)
        }
    }
}

struct MyTransition: ViewModifier {
    let active: Bool

    func body(content: Content) -> some View {
        content
            .rotationEffect(active ? .degrees(Double.random(in: -10...10)) : .zero)
            .offset(x: active ? CGFloat.random(in: -10...10) : 0, y: active ? CGFloat.random(in: -10...10) : 0)
    }
}

extension AnyTransition {
    static var glitch: AnyTransition {
        AnyTransition.modifier(
            active: MyTransition(active: true),
            identity: MyTransition(active: false)
        )
    }
}

class MusicViewModel: ObservableObject {
    @Published var currentMusic = ""
    
    let musicNames = ["fearless", "evermore", "red", "speaknow", "lover"]
    
    init() {
        currentMusic = musicNames.first ?? "fearless"
    }
    
    func selectNextMusic() {
        guard let currentIndex = musicNames.firstIndex(of: currentMusic) else {
            return
        }
        
        let nextIndex = currentIndex + 1 < musicNames.count ? currentIndex + 1 : 0
        
        withAnimation(.easeInOut(duration: 2)) {
            currentMusic = musicNames[nextIndex]
        }
    }
}

以上代码中,我们创建了一个 MusicViewModel 类,用于存储音乐播放状态。我们通过 MyTransition 自定义了一个过渡效果,通过 AnyTransitionmodifier 方法将自定义过渡效果应用到视图上。在 ContentView 中,我们通过 transition 修饰符设置了过渡效果为 glitch,并在点击按钮时切换音乐。

Matched Geometry Effect

位置变化

Matched Geometry Effect 是一种特殊的动画效果。当你有两个视图,并且你想在一个视图消失,另一个视图出现时,创建一个平滑的过渡动画,你就可以使用这个效果。你只需要给这两个视图添加同样的标识符和命名空间,然后当你删除一个视图并添加另一个视图时,就会自动创建一个动画,让一个视图看起来像是滑动到另一个视图的位置。

示例代码如下:

struct ContentView: View {
    @StateObject var viewModel = ViewModel()
    @Namespace var musicSelectionNamespace
    var body: some View {
        VStack {
            HStack {
                ForEach(viewModel.topMusic) { item in
                    Button(action: { viewModel.selectTopMusic(item) }) {
                        ZStack {
                            Image(item.name)
                                .resizable()
                                .frame(width: 60, height: 60)
                            Text(item.name)
                                .fontDesign(.rounded)
                                .foregroundColor(.white)
                                .shadow(radius: 10)
                        }
                    }
                    .matchedGeometryEffect(id: item.id, in: musicSelectionNamespace)
                }
            }
            .frame(minHeight: 150)
            Spacer()
                .frame(height: 250)
            HStack {
                ForEach(viewModel.bottomMusic) { item in
                    Button(action: { viewModel.selectBottomMusic(item) }) {
                        ZStack {
                            Image(item.name)
                                .resizable()
                                .frame(width: 90, height: 90)
                            Text(item.name)
                                .font(.title3)
                                .fontWeight(.bold)
                                .foregroundColor(.white)
                                .shadow(radius: 10)
                        }
                    }
                    .matchedGeometryEffect(id: item.id, in: musicSelectionNamespace)
                }
            }
            .frame(minHeight: 150)
        }
    }
}

以上代码中,我们创建了一个 ContentView 视图,其中包含两个 HStack 视图,分别展示了 viewModel 中的 topMusicbottomMusic 数组。我们为每个 topMusicbottomMusic 元素创建了一个 Button 视图,当用户点击按钮时,会调用 viewModel 中的 selectTopMusicselectBottomMusic 方法。我们使用 matchedGeometryEffect 修饰符为每个 Button 视图添加了一个标识符,这样当用户点击按钮时,就会自动创建一个动画,让一个视图看起来像是滑动到另一个视图的位置。

大小变化

Matched Geometry Effect 在大小和位置上都可以进行动画过渡,这样可以让你创建更加复杂的动画效果。

以下是一个视图大小切换的示例:

struct ContentView: View {
    @State var isExpanded: Bool = false
    
    private var albumId = "Album"
    
    @Namespace var expansionAnimation
    
    var body: some View {
        VStack {
            albumView(isExpanded: isExpanded)
        }
        .padding()
        .onTapGesture {
            withAnimation {
                isExpanded.toggle()
            }
        }
    }
    
    @ViewBuilder
    func albumView(isExpanded: Bool) -> some View {
        let imageSize = isExpanded ? CGSize(width: 300, height: 450) : CGSize(width: 100, height: 150)
        Image(isExpanded ? "evermore" : "fearless")
            .resizable()
            .aspectRatio(contentMode: .fill)
            .frame(width: imageSize.width, height: imageSize.height)
            .clipped()
            .matchedGeometryEffect(id: albumId, in: expansionAnimation)
            .overlay {
                Text("Taylor Swift")
                    .font(isExpanded ? .largeTitle : .headline)
                    .fontDesign(.monospaced)
                    .fontDesign(.rounded)
                    .foregroundStyle(.white)
            }
    }
}

内容位置变化

内容位置变化的动画效果。以下是一个内容位置变化的示例:

struct ContentView: View {
    @State var show = false
    @Namespace var placeHolder
    @State var albumCoverSize: CGSize = .zero
    @State var songListSize: CGSize = .zero
    var body: some View {
        ZStack {
            VStack {
                Text("Taylor Swift,1989年12月13日出生于美国宾夕法尼亚州,美国乡村音乐、流行音乐女歌手、词曲创作人、演员、慈善家。")
                    .font(.title)
                    .fontDesign(.monospaced)
                    .fontDesign(.rounded)
                    .padding(20)
                Spacer()
            }
            Color.clear
                // AlbumCover placeholder
                .overlay(alignment: .bottom) {
                    Color.clear // AlbumCoverView().opacity(0.01)
                        .frame(height: albumCoverSize.height)
                        .matchedGeometryEffect(id: "bottom", in: placeHolder, anchor: .bottom, isSource: true)
                        .matchedGeometryEffect(id: "top", in: placeHolder, anchor: .top, isSource: true)
                }
                .overlay(
                    AlbumCoverView()
                        .sizeInfo($albumCoverSize)
                        .matchedGeometryEffect(id: "bottom", in: placeHolder, anchor: show ? .bottom : .top, isSource: false)
                )
                .overlay(
                    SongListView()
                        .matchedGeometryEffect(id: "top", in: placeHolder, anchor: show ? .bottom : .top, isSource: false)
                )
                .animation(.default, value: show)
                .ignoresSafeArea()
                .overlayButton(show: $show)
        }
    }
}

struct AlbumCoverView: View {
    var body: some View {
        Image("evermore")
            .resizable()
            .aspectRatio(contentMode: .fill)
    }
}

struct SongListView: View {
    var body: some View {
        List {
            Text("Fearless")
            Text("Speak Now")
            Text("Red")
            // ...
        }
    }
}

extension View {
    func overlayButton(show: Binding<Bool>) -> some View {
        self.overlay(
            Button(action: {
                withAnimation {
                    show.wrappedValue.toggle()
                }
            }) {
                Image(systemName: "arrow.up.arrow.down.square")
                    .font(.largeTitle)
                    .padding()
                    .background(Color.white.opacity(0.75))
                    .clipShape(Circle())
            }
            .padding()
            , alignment: .topTrailing
        )
    }
    
    func sizeInfo(_ size: Binding<CGSize>) -> some View {
        self.background(
            GeometryReader { geometry in
                Color.clear.preference(key: SizePreferenceKey.self, value: geometry.size)
            }
        )
        .onPreferenceChange(SizePreferenceKey.self) { size.wrappedValue = $0 }
    }
}

struct SizePreferenceKey: PreferenceKey {
    static var defaultValue: CGSize = .zero
    static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
        value = nextValue()
    }
}

我们使用 matchedGeometryEffect 修饰符为 AlbumCoverViewSongListView 添加了一个标识符,这样当用户点击按钮时,就会自动创建一个动画,让 AlbumCoverViewSongListView 看起来像是从一个位置切换到另一个位置。

点击显示详细信息

点击显示详细信息的动画效果。

struct ContentView: View {
    @Namespace var animation
    @State var showDetail = false
        
    var body: some View {
        ZStack {
            if (!showDetail) {
                VStack {
                    Text("Taylor Swift")
                            .matchedGeometryEffect(id: "artist", in: animation)
                            .font(.largeTitle.bold())
                            .foregroundColor(Color.white)
                    
                    Text("美国歌手")
                        .matchedGeometryEffect(id: "description", in: animation)
                        .font(.title3.bold())
                        .foregroundColor(Color.white)


                }
                .padding(30)
                .background(
                    Rectangle().fill(.black.gradient)
                        .matchedGeometryEffect(id: "background", in: animation)

                )
            } else {
                SingerView(animation: animation)

            }
        }
        .onTapGesture {
            withAnimation {
                showDetail.toggle()
            }
        }
    }
}

struct SingerView: View {
    var animation: Namespace.ID

    var body: some View {
        VStack{
            Text("Taylor Swift")
                    .matchedGeometryEffect(id: "artist", in: animation)
                    .font(.largeTitle.bold())
                    .foregroundColor(Color.white)
            
            Text("美国歌手")
                .matchedGeometryEffect(id: "description", in: animation)
                .font(.title3.bold())
                .foregroundColor(Color.white)

            Spacer()
                .frame(height: 30)
            Text("泰勒·阿利森·斯威夫特(Taylor Swift),1989年12月13日出生于美国宾夕法尼亚州,美国乡村音乐、流行音乐女歌手、词曲创作人、演员、慈善家。")
                .font(.subheadline.bold())
                .foregroundColor(Color.white)
            
            Spacer()
                .frame(height: 30)
            Image("evermore")
                .resizable()
                .scaledToFit()
                .clipShape(.rect(cornerSize: CGSize(width: 16, height: 16)))
            
            Text("Evermore 是 Taylor Swift 的最新专辑,这是她在 2020 年的第二张专辑,也是她的第九张录音室专辑。")
                .font(.subheadline.bold())
                .foregroundColor(Color.white)
            
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
        .padding(.all, 20)
        .background(
            Rectangle().fill(.black.gradient)
                .matchedGeometryEffect(id: "background", in: animation)
                .ignoresSafeArea(.all)
        )
    }
}

导航动画

以下是一个导航动画的示例:

struct ContentView: View {
    @Namespace var animation
    @State var selectedManga: String? = nil
        
    var body: some View {
        ZStack {
            if (selectedManga == nil) {
                MangaListView(animation: animation, selectedManga: $selectedManga)

            } else {
                MangaDetailView(selectedManga: $selectedManga, animation: animation)
            }
        }

    }
}

struct MangaDetailView: View {
    @Binding var selectedManga: String?
    var animation: Namespace.ID
    
    var body: some View {
        VStack {
            Text( "\(selectedManga ?? "")")
                    .matchedGeometryEffect(id: "mangaTitle", in: animation)
                    .font(.title3.bold())
                    .foregroundColor(Color.black)
            
            Spacer()
                .frame(height: 50)

            Button(action: {
                withAnimation {
                    selectedManga = nil
                }
            }, label: {
                Text( "返回")
                    .font(.title3.bold())
                    .foregroundColor(Color.black)
            })
            .foregroundColor(Color.red)
            .padding(.all, 8)
            .background(
                RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))
                    .fill(Color.white.gradient)
            )
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
        .padding(.all, 20)
        .background(
            Color(UIColor.systemTeal)
                .matchedGeometryEffect(id: "background", in: animation)
                .ignoresSafeArea(.all)
        )
    }
}


struct MangaListView: View {
    var animation: Namespace.ID
    @Binding var selectedManga: String?

    var body: some View {
        VStack {
            Button(action: {
                withAnimation {
                    selectedManga = "海贼王"
                }
            }, label: {
                Text( "海贼王")
                    .matchedGeometryEffect(id: "mangaTitle", in: animation)
                    .font(.title3.bold())
                    .foregroundColor(Color.black)
            })
            .foregroundColor(Color.black)
            .padding(.all, 8)
            .background(
                RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))
                    .fill(Color.teal)
            )
            
            Button(action: {
                withAnimation {
                    selectedManga = "火影忍者"
                }
            }, label: {
                Text( "火影忍者")
                    .font(.title3.bold())
                    .foregroundColor(Color.black)
            })
            .foregroundColor(Color.black)
            .padding(.all, 8)
            .background(
                RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))
                    .fill(Color.mint)
                    .matchedGeometryEffect(id: "background", in: animation)
            )

            Button(action: {
                withAnimation {
                    selectedManga = "进击的巨人"
                }
            }, label: {
                Text( "进击的巨人")
                    .font(.title3.bold())
                    .foregroundColor(Color.black)
            })
            .foregroundColor(Color.black)
            .padding(.all, 8)
            .background(
                RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))
                    .fill(Color.orange)
            )

            Button(action: {
                withAnimation {
                    selectedManga = "鬼灭之刃"
                }
            }, label: {
                Text( "鬼灭之刃")
                    .font(.title3.bold())
                    .foregroundColor(Color.black)
            })
            .foregroundColor(Color.black)
            .padding(.all, 8)
            .background(
                RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))
                    .fill(Color.purple)
            )

            Button(action: {
                withAnimation {
                    selectedManga = "我的英雄学院"
                }
            }, label: {
                Text( "我的英雄学院")
                    .font(.title3.bold())
                    .foregroundColor(Color.black)
            })
            .foregroundColor(Color.black)
            .padding(.all, 8)
            .background(
                RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))
                    .fill(Color.green)
            )
        }
    }
}

geometryGroup

.geometryGroup() 主要用于处理一组视图动画变化时不协调的问题。如果你有一组视图,它们的位置和大小会随着动画变化,你可以使用 .geometryGroup() 修饰符来确保它们的位置和大小保持一致。

PhaseAnimator

PhaseAnimator

以下代码示例演示了如何使用 PhaseAnimator 视图修饰符创建一个动画,该动画通过循环遍历所有动画步骤来连续运行。在这个例子中,我们使用 PhaseAnimator 来创建一个简单的动画,该动画通过循环遍历所有动画步骤来连续运行。当观测值发生变化时,动画会触发一次。

enum AlbumAnimationPhase: String, CaseIterable, Comparable {
    case evermore, fearless, folklore, lover, midnights, red, speaknow

    static func < (lhs: AlbumAnimationPhase, rhs: AlbumAnimationPhase) -> Bool {
        lhs.rawValue < rhs.rawValue
    }
}

struct ContentView: View {
    @State var animate: Bool = false

    var body: some View {
        ScrollView {
            PhaseAnimator(
                AlbumAnimationPhase.allCases,
                trigger: animate,
                content: { phase in
                    VStack {
                        ForEach(AlbumAnimationPhase.allCases, id: \.self) { album in
                            if phase >= album {
                                VStack {
                                    Image(album.rawValue)
                                        .resizable()
                                        .frame(width: 100, height: 100)
                                    Text(album.rawValue.capitalized)
                                        .font(.title)
                                }
                                .transition(.asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .trailing)))
                            }
                        }
                    }
                    .padding()
                }, animation: { phase in
                    .spring(duration: 0.5)
                }
            )
        } // end ScrollView
        Button(action: {
            animate.toggle()
        }, label: {
            Text("开始")
                .font(.largeTitle)
                .bold()
        })
    }
}

在上面的代码中,我们首先定义了一个枚举类型 AlbumAnimationPhase,用于表示专辑的不同阶段。然后,我们在 ContentView 视图中创建了一个 PhaseAnimator 视图修饰符,该修饰符接受一个观测值 trigger,用于触发动画。在 content 闭包中,我们遍历所有专辑,并根据当前阶段 phase 来决定是否显示专辑。在 animation 闭包中,我们使用 .spring(duration: 0.5) 创建了一个弹簧动画效果。

KeyframeAnimator

KeyframeAnimator是一个在SwiftUI中创建关键帧动画的工具。关键帧动画是一种动画类型,其中定义了动画开始和结束的关键帧,以及可能的一些中间关键帧,然后动画系统会在这些关键帧之间进行插值以创建平滑的动画。

KeyframeAnimator接受一个初始值,一个内容闭包,以及一个关键帧闭包。初始值是一个包含了动画所需的所有属性的结构(在这个例子中是scalerotationoffset)。内容闭包接受一个这样的结构实例,并返回一个视图。这个视图将使用结构中的值进行配置,以便它可以根据这些值进行动画。关键帧闭包接受一个这样的结构实例,并定义了一系列的关键帧轨道。每个轨道都对应于结构中的一个属性,并定义了一系列的关键帧。每个关键帧都定义了一个值和一个时间点,动画系统将在这些关键帧之间进行插值。

此外,SwiftUI提供了四种不同类型的关键帧:LinearKeyframeSpringKeyframeCubicKeyframeMoveKeyframe。前三种关键帧使用不同的动画过渡函数进行插值,而MoveKeyframe则立即跳转到指定值,无需插值。

KeyframeAnimator可以用于创建各种复杂的动画效果,例如根据滚动位置调整关键帧驱动的效果,或者根据时间进行更新。

struct ContentView: View {
    @State var animationTrigger: Bool = false

    var body: some View {
        VStack {
            KeyframeAnimator(
                initialValue: AnimatedMovie(),
                content: { movie in
                    Image("evermore")
                        .resizable()
                        .frame(width: 100, height: 150)
                        .scaleEffect(movie.scaleRatio)
                        .rotationEffect(movie.rotationAngle)
                        .offset(y: movie.verticalOffset)
                }, keyframes: { movie in
                    KeyframeTrack(\.scaleRatio) {
                        LinearKeyframe(1.0, duration: 0.36)
                        SpringKeyframe(1.5, duration: 0.8, spring: .bouncy)
                        SpringKeyframe(1.0, spring: .bouncy)
                    }

                    KeyframeTrack(\.rotationAngle) {
                        CubicKeyframe(.degrees(-30), duration: 1.0)
                        CubicKeyframe(.zero, duration: 1.0)
                    }

                    KeyframeTrack(\.verticalOffset) {
                        LinearKeyframe(0.0, duration: 0.1)
                        SpringKeyframe(20.0, duration: 0.15, spring: .bouncy)
                        CubicKeyframe(-60.0, duration: 0.2)
                        MoveKeyframe(0.0)
                    }
                }
            )
        }
    }
}

struct AnimatedMovie {
    var scaleRatio: Double = 1
    var rotationAngle = Angle.zero
    var verticalOffset: Double = 0
}

以上代码中,我们首先定义了一个AnimatedMovie结构,它包含了动画所需的所有属性。然后,我们在ContentView视图中创建了一个KeyframeAnimator,该修饰符接受一个观测值animationTrigger,用于触发动画。在content闭包中,我们使用Image视图创建了一个电影海报,并根据AnimatedMovie结构中的值对其进行配置。在keyframes闭包中,我们为每个属性定义了一系列的关键帧轨道。例如,我们为scaleRatio属性定义了三个关键帧,分别使用LinearKeyframeSpringKeyframe进行插值。我们还为rotationAngleverticalOffset属性定义了两个关键帧轨道,分别使用CubicKeyframeMoveKeyframe进行插值。

也可以使用 .keyframeAnimator 修饰符来创建关键帧动画。以下是一个示例,演示了如何使用 .keyframeAnimator 修饰符创建一个关键帧动画,该动画在用户点击时触发。

struct ContentView: View {
    @State var animationTrigger: Bool = false
    
    var body: some View {
        Image("evermore")
            .resizable()
            .frame(width: 100, height: 150)
            .scaleEffect(animationTrigger ? 1.5 : 1.0)
            .rotationEffect(animationTrigger ? .degrees(-30) : .zero)
            .offset(y: animationTrigger ? -60.0 : 0.0)
            .keyframeAnimator(initialValue: AnimatedMovie(),
                              trigger: animationTrigger,
                              content: { view, value in
                view
                    .scaleEffect(value.scaleRatio)
                    .rotationEffect(value.rotationAngle)
            },
                              keyframes: { value in
                KeyframeTrack(\.scaleRatio) {
                    LinearKeyframe(1.5, duration: 0.36)
                    SpringKeyframe(1.0, duration: 0.8, spring: .bouncy)
                    SpringKeyframe(1.5, spring: .bouncy)
                }
                
                KeyframeTrack(\.rotationAngle) {
                    CubicKeyframe(.degrees(-30), duration: 1.0)
                    CubicKeyframe(.zero, duration: 1.0)
                }
                
                KeyframeTrack(\.verticalOffset) {
                    LinearKeyframe(-60.0, duration: 0.1)
                    SpringKeyframe(0.0, duration: 0.15, spring: .bouncy)
                    CubicKeyframe(-60.0, duration: 0.2)
                    MoveKeyframe(0.0)
                }
            })
        
            .onTapGesture {
                withAnimation {
                    animationTrigger.toggle()
                }
            }
    }
}

struct AnimatedMovie {
    var scaleRatio: Double = 1
    var rotationAngle = Angle.zero
    var verticalOffset: Double = 0
}

在这个例子中,我们创建了一个 AnimatedMovie 结构,它包含了动画所需的所有属性。然后,我们在 ContentView 视图中创建了一个 KeyframeAnimator,该修饰符接受一个观测值 animationTrigger,用于触发动画。在 content 闭包中,我们使用 Image 视图创建了一个电影海报,并根据 AnimatedMovie 结构中的值对其进行配置。在 keyframes 闭包中,我们为每个属性定义了一系列的关键帧轨道。例如,我们为 scaleRatio 属性定义了三个关键帧,分别使用 LinearKeyframe 和 SpringKeyframe 进行插值。我们还为 rotationAngle 和 verticalOffset 属性定义了两个关键帧轨道,分别使用 CubicKeyframe 和 MoveKeyframe 进行插值。

布局动画

import SwiftUI

struct AnimateLayout: View {
 @State var changeLayout: Bool = true
 @Namespace var namespace

 var body: some View {
  VStack(spacing: 30) {
   if changeLayout {
    HStack { items }
   } else {
    VStack { items }
   }
   Button("切换布局") {
    withAnimation { changeLayout.toggle() }
   }
  }
  .padding()
 }

 @ViewBuilder var items: some View {
  Text("one")
   .matchedGeometryEffect(id: "one", in: namespace)
  Text("Two")
   .matchedGeometryEffect(id: "Two", in: namespace)
  Text("Three")
   .matchedGeometryEffect(id: "Three", in: namespace)
 }
}

动画-例子

动画的例子有很多。准备中… 请期待。