SwiftUI 动画进阶-part4 TimelineView

1,744 阅读15分钟

本文译自 swiftui-lab.com/swiftui-ani…

译者:距离作者上一次发表 《SwiftUI 动画进阶》系列的文章(上一篇是 SwiftUI 动画进阶-part3 )已经过去两年。由于今年 WWDC 推出了新的 TimelineViewCanvas,它为动画提供了一系列全新的可能性。作者将在这一篇和后续的文章带给我们他对于这些可能性的思考。

hello-there-25.gif

在这篇文章中,我们将深入探索 TimelineView。当然,我会以最常见的用法、较低的难度开始。不过,我认为这其中最大的可能性在于把 TimelineView 和现有动画结合起来应用。借助一些创意,这个组合能让我们实现类似“关键帧”的动画。

在第 5 部分中,我们还将探索 Canvas 视图,并见识它与我们新朋友 TimelineView 的结合有多么强大。

上面的动画正是用这篇文章中阐释的技术实现的,完整的代码可以从 gist.github.com/swiftui-lab… 下载。

TimelineView 的组件

TimelineView 是一个根据关联的调度程序确定的频率来自动重新计算其内容的容器视图:

TimelineView(.periodic(from: .now, by: 0.5)) { timeline in
    ViewToEvaluatePeriodically()
}

TimelineView 接收一个调度程序作为参数。稍后我们再深入细节。例子中的调度程序每半秒发射一次。

另一个参数则是内容闭包,它接收一个 TimelineView.Context 类型的参数,类型结构如下:

struct Context {
    let cadence: Cadence
    let date: Date

    enum Cadence: Comparable {
        case live
        case seconds
        case minutes
    }
}

我们可以基于 Cadence 的枚举值来决定视图显示的内容,可能的值包括:livesecondsminutes。把这个值看作一种提示,避免显示与对应节律无关的信息。典型的案例:如果调度程序的节律是 seconds 或者 minutes,那么时钟的毫秒就不应当被显示。

值得注意的是,节律并不是你可以改变的东西,而是设备的一种状态。官方文档只提供了一个例子。在 watchOS 上,当手腕放下时,节律将降低。

接下来我们开始构建我们的第一个 TimelineView 动画:

理解 TimelineView 的工作方式

请看下面的代码。我们有两个随机变化的 emoji 字符。两个字符文本的区别在于其中一个是放在 TimelineView 的内容闭包中,另一个则是放在单独的视图中。

struct ManyFaces: View {
    static let emoji = ["😀", "😬", "😄", "🙂", "😗", "🤓", "😏", "😕", "😟", "😎", "😜", "😍", "🤪"]
    
    var body: some View {
        TimelineView(.periodic(from: .now, by: 0.2)) { timeline in

            HStack(spacing: 120) {

                let randomEmoji = ManyFaces.emoji[Int.random(in: 0..<ManyFaces.emoji.count)]
            
                Text(randomEmoji)
                    .font(.largeTitle)
                    .scaleEffect(4.0)
                
                SubView()
                
            }
        }
    }
    
    struct SubView: View {
        var body: some View {
            let randomEmoji = ManyFaces.emoji[Int.random(in: 0..<ManyFaces.emoji.count)]

            Text(randomEmoji)
                .font(.largeTitle)
                .scaleEffect(4.0)
        }
    }
}

运行代码,看看效果:

emojis-changing-one.gif

为什么左边的 emoji 一直在变化而右边的一直保持不动呢?这是因为 SubView 并未接收任何会改变的参数,换句话说,它没有依赖项,因此 SwiftUI 没有为它重新调用 body。今年的 WWDC developer.apple.com/videos/play… 是一个很棒的 session,它解释了 View 的识别,生命周期和依赖。这些主题对于理解 TimelineView 的工作方式非常重要。

为了解决这个问题,我们只需要像下面这样为 SubView 视图添加一个会随着每次时间线更新而改变的参数。注意,我们并不需要实际使用这个参数。即便如此,这个没有使用的参数也大有用处。

struct SubView: View {
    let date: Date // just by declaring it, the view will now be recomputed apropriately.
    
    var body: some View {

        let randomEmoji = ManyFaces.emoji[Int.random(in: 0..<ManyFaces.emoji.count)]

        Text(randomEmoji)
            .font(.largeTitle)
            .scaleEffect(4.0)
    }
}

然后把 SubView 的创建改为:

SubView(date: timeline.date)

现在两个 emoji 都可以变化了。

emojis-changing-two.gif

跟随时间线动作

目前有关 TimelineView 的大部分例子都是绘制时钟。这当然是有道理的。毕竟 timeline 上下文提供的是一个 Date

一个最简单的 TimelineView 时钟可以是下面这样的:

TimelineView(.periodic(from: .now, by: 1.0)) { timeline in
            
    Text("\(timeline.date)")

}

实际的时钟会更复杂一些。例如,包含形状的模拟时钟,或者借助新的 Canvas 视图绘制的时钟。

不过,TimelineView 可不光是为时钟而生的。在很多场景中,我们都需要视图随着时间更新,相关代码放在 onChange(of:perform) 闭包中执行再合适不过了。

下面这个例子,我们将应用这些技术,实现每 3 秒更新一次模型。

anim-part4-example-3.gif

struct ExampleView: View {
    var body: some View {
        TimelineView(.periodic(from: .now, by: 3.0)) { timeline in
            QuipView(date: timeline.date)
        }
    }

    struct QuipView: View {
        @StateObject var quips = QuipDatabase()
        let date: Date
        
        var body: some View {
            Text("_\(quips.sentence)_")
                .onChange(of: date) { _ in
                    quips.advance()
                }
        }
    }
}

class QuipDatabase: ObservableObject {
    static var sentences = [
        "There are two types of people, those who can extrapolate from incomplete data",
        "After all is said and done, more is said than done.",
        "Haikus are easy. But sometimes they don't make sense. Refrigerator.",
        "Confidence is the feeling you have before you really understand the problem."
    ]
    
    @Published var sentence: String = QuipDatabase.sentences[0]
    
    var idx = 0
    
    func advance() {
        idx = (idx + 1) % QuipDatabase.sentences.count
        
        sentence = QuipDatabase.sentences[idx]
    }
}

值得注意的是,每当时间线更新,我们的 QuipView 会被刷新两次,这是因为一旦时间线更新,紧随其后的 quips.advance() 调用会导致 quips.sentence 属性更新,进而触发视图更新。这本身对上面的例子想要演示的效果不是问题,但知道这个细节对于接下来的例子很重要。

这个细节给我们带来一个重要的认知:尽管时间线能够产生一定数量的更新,视图内容的更新次数很有可能更多。

组合 TimelineView 和传统动画

新的 TimelineView 为我们带来了新的机会。可以将其与 Canvas 组合(作者会在之后的文章中介绍),但那要求我们自己编写每帧的代码。而我下面将展示的这项技术,用到了我们已经熟悉且喜爱的动画来跟随时间线给视图做动画。最后实现的效果是类似关键帧的纯 SwiftUI 动画。

让我们先看下面的视频,留意拍子的声音是如何与钟摆同步的。就像节拍器一样,每几拍发出一个铃声。

首先,看看我们的时间线长什么样:

struct Metronome: View {
    let bpm: Double = 60 // 每分钟的节拍数
    
    var body: some View {
        TimelineView(.periodic(from: .now, by: 60 / bpm)) { timeline in
            MetronomeBack()
                .overlay(MetronomePendulum(bpm: bpm, date: timeline.date))
                .overlay(MetronomeFront(), alignment: .bottom)
        }
    }
}

节拍器的速度通常是由 bpm 指定(每分钟的节拍数)。上面的例子使用了一个周期性调度程序,每 60/bpm 秒执行一次。我们的 bpm = 60,所以调度程序每 1 秒发射一次,也就是每分钟 60 次。

Metronome 视图有三层组成:MetronomeBackMetronomePendulumMetronomeFront,三者依次叠加,但只有 MetronomePendulum 会跟随时间线更新而刷新,刷新方式是摆动。其他两个视图由于没有依赖,所以不会刷新。

MetronomeBackMetronomeFront 的代码很简单,前者包含 RoundedTrapezoid(圆角梯形)。为了避免篇幅过长,这里不放出完整代码,读者如果需要可以到 gist.github.com/swiftui-lab… 下载。

struct MetronomeBack: View {
    let c1 = Color(red: 0, green: 0.3, blue: 0.5, opacity: 1)
    let c2 = Color(red: 0, green: 0.46, blue: 0.73, opacity: 1)
    
    var body: some View {
        let gradient = LinearGradient(colors: [c1, c2],
                                      startPoint: .topLeading,
                                      endPoint: .bottomTrailing)
        
        RoundedTrapezoid(pct: 0.5, cornerSizes: [CGSize(width: 15, height: 15)])
            .foregroundStyle(gradient)
            .frame(width: 200, height: 350)
    }
}

struct MetronomeFront: View {
    var body: some View {
        RoundedTrapezoid(pct: 0.85, cornerSizes: [.zero, CGSize(width: 10, height: 10)])
            .foregroundStyle(Color(red: 0, green: 0.46, blue: 0.73, opacity: 1))
            .frame(width: 180, height: 100).padding(10)
    }
}

MetronomePendulum 视图的代码比较有意思:

struct MetronomePendulum: View {
    @State var pendulumOnLeft: Bool = false
    @State var bellCounter = 0 // 每四拍响铃一次

    let bpm: Double
    let date: Date
    
    var body: some View {
        Pendulum(angle: pendulumOnLeft ? -30 : 30)
            .animation(.easeInOut(duration: 60 / bpm), value: pendulumOnLeft)
            .onChange(of: date) { _ in beat() }
            .onAppear { beat() }
    }
    
    func beat() {
        pendulumOnLeft.toggle() // 触发动画
        bellCounter = (bellCounter + 1) % 4 // 记录节拍数,每四下响铃一次
        
        // 播放铃声或者拍子音效
        if bellCounter == 0 {
            bellSound?.play()
        } else {
            beatSound?.play()
        }
    }
        
    struct Pendulum: View {
        let angle: Double
        
        var body: some View {
            return Capsule()
                .fill(.red)
                .frame(width: 10, height: 320)
                .overlay(weight)
                .rotationEffect(Angle.degrees(angle), anchor: .bottom)
        }
        
        var weight: some View {
            RoundedRectangle(cornerRadius: 10)
                .fill(.orange)
                .frame(width: 35, height: 35)
                .padding(.bottom, 200)
        }
    }
}

我们的视图需要跟踪所处动画的位置,我将其称为“动画的阶段”。 因为需要跟踪这些阶段,我们会用到 @State 变量:

  1. pendulumOnLeft: 用来跟踪钟摆的方向。
  2. bellCounter: 用来跟踪节拍数,从而确定要播放拍子音效还是响铃。

上面使用了新的 .animation(_:value:) modifier API,这个 API 取代了被废弃的隐式 .animation(_:) modifier。这个新版的 modifier,是在指定值改变时应用动画。注意,你当然也可以使用显式动画。你可以不用 .animation(),而是用 withAnimation 闭包来改变 pendulumOnLeft 变量。

为了让我们的视图动画能被驱动,我们需要监控 date 的变化,实现方式是使用 onChange(of:perform) modifier,这和之前的做法是一样的。

除了通过日期变化来驱动动画,我们在 onAppear 闭包也要执行一次 beat(),否则初始时会有一段暂停的时间。

最后一部分代码与 SwiftUI 无关,主要是创建 NSSound 实例。为了避免把案例复杂化,这里我简单使用全局变量来实现。

let bellSound: NSSound? = {
    guard let url = Bundle.main.url(forResource: "bell", withExtension: "mp3") else { return nil }
    return NSSound(contentsOf: url, byReference: true)
}()

let beatSound: NSSound? = {
    guard let url = Bundle.main.url(forResource: "beat", withExtension: "mp3") else { return nil }
    return NSSound(contentsOf: url, byReference: true)
}()

TimelineScheduler 调度程序

如我们所见,TimelineView 需要一个 TimelineScheduler 来决定更新内容的时机。SwiftUI 提供了一些预置的 schedulers,包括我们上面用到那个。另外,我们也可以创建自己的 scheduler。在下一节我会详细说明,这里我们先从预置的开始了解。

一个时间线调度程序基本上就是一个遵循了 TimelineScheduler 协议的结构。预置的类型有:

  • AnimationTimelineSchedule:尽可能快地更新,提供给我们逐帧绘制动画的机会。它有参数可以让我们限制更新频率,以及暂停更新。这个调度程序在组合应用 TimelineViewCanvas 时大有用处。
  • EveryMinuteTimelineSchedule:顾名思义,它在每分钟开始的那个瞬间更新。
  • ExplicitTimelineSchedule:通过提供时间数组,显式地指定时间线进行更新的时机。
  • PeriodicTimelineSchedule:指定开始时间和更新发生的频率。

尽管我们可以像下面这样编写一个 TimelineView

TimelineView(EveryMinuteTimelineSchedule()) { timeline in
    ...
}

Swift 5.5 开始可以支持枚举风格的语法,上面的代码现在可以简化如下:

TimelineView(.everyMinute) { timeline in
    ...
}

注意:你应该听说这个语法今年也已经被用到 style 上了。只要我们使用 Swift 5.5,那么 app 仍然可以向后发布到之前的版本。

对于所有这些预置的调度程序,SwiftUI 提供了很多枚举风格的选项。比如,下面这两行代码都创建了 AnimationTimelineSchedule 类型的调度程序:

TimelineView(.animation) { ... }
TimelineView(.animation(minimumInterval: 0.3, paused: false)) { ... }

你甚至可以创建你自己的类型(别忘了 static 关键字)

extension TimelineSchedule where Self == PeriodicTimelineSchedule {
    static var everyFiveSeconds: PeriodicTimelineSchedule {
        get { .init(from: .now, by: 5.0) }
    }
}

struct ContentView: View {
    var body: some View {
        TimelineView(.everyFiveSeconds) { timeline in
            ...
        }
    }
}

自定义 TimelineScheduler

如果预置的调度程序都无法满足你的需求,你可以创建你自己的调度程序。请看下面的动画:

beating-heart.gif

在这个动画中,有一个爱心 emoji,以不规则的间隔和倍率改变自身的缩放值:

它先从 1.0 的缩放值开始,0.2 秒内放大到 1.6,再 0.2 秒放大到 2.0,然后缩放 1.0 并且保持 0.4 秒,然后重新开始。

缩放值变化:1.0 → 1.6 → 2.0 → 重新开始 变化的时间:0.2 → 0.2 → 0.4 → 重新开始

我们可以创建一个 HeartTimelineSchedule,严格按照上面的方式更新。但是从可重用性的角度出发,我们可以实现地更通用一些,以便将来重用代码。

新的调度程序叫 CyclicTimelineSchedule,接收一组时间偏移量。每个偏移值都是相对于数组中的前一个偏移值。但调度程序消费完全部的偏移值,循环回到数组的开头,重新开始。

struct CyclicTimelineSchedule: 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
        }
    }
}

实现一个 TimelineSchedule 需要满足以下条件:

  • 提供 entries(from:mode:) 函数
  • Entries 类型必须遵循 Sequence,并且 Entries.Element == Date

遵循 Sequence 协议的方式有很多种,上面的例子实现了 IteratorProtocol,并且声明同时遵循 SequenceIteratorProtocol。关于如何遵循 Sequence 可以参考 developer.apple.com/documentati…

为了让 Entries 实现 IteratorProtocol,我们必须编写 next() 函数,用于生产时间线中的日期。我们的调度程序会记忆上一个日期,添加合适的偏移量。当没有新的偏移量可用时,回到数组的第一个元素。

最后是一个枚举风格的语法糖:

extension TimelineSchedule where Self == CyclicTimelineSchedule {
    static func cyclic(timeOffsets: [TimeInterval]) -> CyclicTimelineSchedule {
            .init(timeOffsets: timeOffsets)
    }
}

现在我们已经准备好了 TimelineSchedue 类型,可以让我们爱心动起来了:

struct BeatingHeart: View {
    var body: some View {
        TimelineView(.cyclic(timeOffsets: [0.2, 0.2, 0.4])) { timeline in
            Heart(date: timeline.date)
        }
    }
}

struct Heart: View {
    @State private var phase = 0
    let scales: [CGFloat] = [1.0, 1.6, 2.0]
    
    let date: Date
    
    var body: some View {
        HStack {
            Text("❤️")
                .font(.largeTitle)
                .scaleEffect(scales[phase])
                .animation(.spring(response: 0.10,
                                   dampingFraction: 0.24,
                                   blendDuration: 0.2),
                           value: phase)
                .onChange(of: date) { _ in
                    advanceAnimationPhase()
                }
                .onAppear {
                    advanceAnimationPhase()
                }

        }
    }
    
    func advanceAnimationPhase() {
        phase = (phase + 1) % scales.count
    }
}

关于上面的模式你应该很熟悉了,它和钟摆的模式是一样的。用 @State 变量来跟踪动画阶段,用时间线驱动时间,然后随着时间变化推进动画的阶段。在这里例子中,我们使用了 .spring 动画,以便呈现很好的抖动效果。

关键帧动画

爱心和钟摆的例子其实采用的是关键帧动画的方式。我们在整个动画中定义几个关键点,在这些关键点修改视图的参数,并让 SwiftUI 在这些变化之间做出过渡。下面的例子将前面的方式泛化,让关键帧的思想更加明显。来瞧一瞧我们的新朋友:

jumping-emoji-1.gif

如果你仔细观察动画,会发现在不同的时间点,emoji 有多个参数发生变化,包括:y轴偏移量,旋转角度和y方向的缩放值。更为重要的是,动画的每一段采用了不同动画类型(linear, easeIneaseOut)。因为同为要改变的参数,我们把这些参数放在一个数组中。

struct KeyFrame {
    let offset: TimeInterval    
    let rotation: Double
    let yScale: Double
    let y: CGFloat
    let animation: Animation?
}

let keyframes = [
    // 初始状态
    KeyFrame(offset: 0.0, rotation: 0, yScale: 1.0, y: 0, animation: nil),

    // 动画关键帧
    KeyFrame(offset: 0.2, rotation:   0, yScale: 0.5, y:  20, animation: .linear(duration: 0.2)),
    KeyFrame(offset: 0.4, rotation:   0, yScale: 1.0, y: -20, animation: .linear(duration: 0.4)),
    KeyFrame(offset: 0.5, rotation: 360, yScale: 1.0, y: -80, animation: .easeOut(duration: 0.5)),
    KeyFrame(offset: 0.4, rotation: 360, yScale: 1.0, y: -20, animation: .easeIn(duration: 0.4)),
    KeyFrame(offset: 0.2, rotation: 360, yScale: 0.5, y:  20, animation: .easeOut(duration: 0.2)),
    KeyFrame(offset: 0.4, rotation: 360, yScale: 1.0, y: -20, animation: .linear(duration: 0.4)),
    KeyFrame(offset: 0.5, rotation:   0, yScale: 1.0, y: -80, animation: .easeOut(duration: 0.5)),
    KeyFrame(offset: 0.4, rotation:   0, yScale: 1.0, y: -20, animation: .easeIn(duration: 0.4)),
]

我们需要了解的是,当 TimelineView 呈现时,即使没有调度程序触发的更新,它也会绘制视图。因此,当 TimelineView 出现时,我们取第一个关键帧来作为视图初始的状态,随着循环动画的启动,这一帧之后会被忽略掉。这是一个实现层面的策略,你自己也可以采取不同的方式。

现在来看看我们的时间线:

struct JumpingEmoji: View {
    // 获取提供给循环时间线的时间偏移量,抛掉第一个
    let offsets = Array(keyframes.map { $0.offset }.dropFirst())
    
    var body: some View {
        TimelineView(.cyclic(timeOffsets: offsets)) { timeline in
            HappyEmoji(date: timeline.date)
        }
    }
}

得益于我们之前实现的例子,我们可以重用 CyclicTimelineScheduler。根据前面提到的,我们不需要第一帧的偏移量,所以我们抛弃第一个偏移值。

接下来是有趣的部分:

struct HappyEmoji: View {
    // 当前关键字序号
    @State var idx: Int = 0

    // 由时间线驱动更新的时间
    let date: Date
    
    var body: some View {
        Text("😃")
            .font(.largeTitle)
            .scaleEffect(4.0)
            .modifier(Effects(keyframe: keyframes[idx]))
            .animation(keyframes[idx].animation, value: idx)
            .onChange(of: date) { _ in advanceKeyFrame() }
            .onAppear { advanceKeyFrame()}
    }
    
    func advanceKeyFrame() {
        // 推进到下一个关键字
        idx = (idx + 1) % keyframes.count
        
        // 跳过第一帧,它只用于初始状态
        if idx == 0 { idx = 1 }
    }
    
    struct Effects: ViewModifier {
        let keyframe: KeyFrame
        
        func body(content: Content) -> some View {
            content
                .scaleEffect(CGSize(width: 1.0, height: keyframe.yScale))
                .rotationEffect(Angle(degrees: keyframe.rotation))
                .offset(y: keyframe.y)
        }
    }
}

出于可读性的考虑,我把所有改变参数的逻辑放在一个 modifier 里,取名 Effects。如你所见,我们再一次使用了相同的模式:用 onChangeonAppear 来推进动画,每个关键帧之间添加动画。

避开陷阱

运行上面的代码,你会遭遇下面这个错误:

Action Tried to Update Multiple Times Per Frame

解释这个错误请先跟随我看一下下面这个例子,它也会导致该错误:

struct ExampleView: View {
    @State private var flag = false
    
    var body: some View {

        TimelineView(.periodic(from: .now, by: 2.0)) { timeline in

            Text("Hello")
                .foregroundStyle(flag ? .red : .blue)
                .onChange(of: timeline.date) { (date: Date) in
                    flag.toggle()
                }

        }
    }
}

代码看起来没什么问题,每两秒改变文本颜色,在红色和蓝色之间变化。那问题在哪里呢?稍作思考,看看你能否发现。

我们面对的实际上并不是一个 bug,而是一个可预见的行为。

记得吗?时间线的第一次更新是在它第一次显示的时候。随后时间线将跟随调度程序的规则触发后续的更新。所以基本调度程序不会产生更新,TimelineView 内容也至少会生成一次。

在上面这个例子中,随着 timeline.date 的值发生改变,我们反转 flag 变量,从而改变颜色。

TimelineView 首先会显示,然后两秒后时间线更新,触发 onChange 闭包,这将导致 flag 变量反转。由于我们的 TimelineView 依赖 flag,它将立即刷新,从而再一次触发 onChange,如此多次。我们就遇到了这个错误:一帧更新多次。

那么要如何解决这个问题呢?方案很多。在这个例子中,我们可以简单地把内容包装到一个新的视图,并且把 flag 变量移入这个封装的视图里。这样一来 TimelineView 就不再依赖 flag 了。

struct ExampleView: View {
    var body: some View {

        TimelineView(.periodic(from: .now, by: 1.0)) { timeline in
            SubView(date: timeline.date)
        }

    }
}

struct SubView: View {
    @State private var flag = false
    let date: Date

    var body: some View {
        Text("Hello")
            .foregroundStyle(flag ? .red : .blue)
            .onChange(of: date) { (date: Date) in
                flag.toggle()
            }
    }
}

探索新想法

每时间线更新:如前所述,这个模式会令我们的视图 body 在每次更新中计算两次:第一次是时间线更新触发的,第二次则是我们推进动画关联的状态触发的。在这种类型的动画中,如果关键点在时间上是有间隔的,那么这种模式可以完美运作。

但是如果动画的关键点在时间上非常密集,我们应该避免采用上述模式。诀窍是用 @StateObject 来代替 @State,同时确保这些值不被发布(不使用 @Published)。在需要刷新视图的时候,你需要调用 objectWillChange.send() 方法。

匹配动画时长和时间偏移量:在关键帧的例子中,我们为每个动画段配置了不同的动画,这是通过把动画的参数存在数组中实现的。如果你仔细观察,会发现动画的时长和时间偏移量是匹配的,对吧?这是很自然的,因此我们可以动画的代码抽离到通用的方法中,像下面这样:

enum KeyFrameAnimation {
    case none
    case linear
    case easeOut
    case easeIn
}

struct KeyFrame {
    let offset: TimeInterval    
    let rotation: Double
    let yScale: Double
    let y: CGFloat
    let animationKind: KeyFrameAnimation
    
    var animation: Animation? {
        switch animationKind {
        case .none: return nil
        case .linear: return .linear(duration: offset)
        case .easeIn: return .easeIn(duration: offset)
        case .easeOut: return .easeOut(duration: offset)
        }
    }
}

let keyframes = [
    // 初始状态
    KeyFrame(offset: 0.0, rotation: 0, yScale: 1.0, y: 0, animationKind: .none),

    // 动画关键帧
    KeyFrame(offset: 0.2, rotation:   0, yScale: 0.5, y:  20, animationKind: .linear),
    KeyFrame(offset: 0.4, rotation:   0, yScale: 1.0, y: -20, animationKind: .linear),
    KeyFrame(offset: 0.5, rotation: 360, yScale: 1.0, y: -80, animationKind: .easeOut),
    KeyFrame(offset: 0.4, rotation: 360, yScale: 1.0, y: -20, animationKind: .easeIn),
    KeyFrame(offset: 0.2, rotation: 360, yScale: 0.5, y:  20, animationKind: .easeOut),
    KeyFrame(offset: 0.4, rotation: 360, yScale: 1.0, y: -20, animationKind: .linear),
    KeyFrame(offset: 0.5, rotation:   0, yScale: 1.0, y: -80, animationKind: .easeOut),
    KeyFrame(offset: 0.4, rotation:   0, yScale: 1.0, y: -20, animationKind: .easeIn),
]

基于重构后的代码,我们可以很方便地调整动画参数。

我们还可以把多个 JumpingEmoji 放在一个 TimelineView 内容闭包里,让它们先后呈现。

wave-emoji-1.gif

完整代码可以从这里下载 gist.github.com/swiftui-lab…

总结

恭喜你看完了这篇长文。我们完成了最简单的 TimelineView 用例一直到富有创造力的复杂动画的挑战。在第五部分,我将带你探索新的 Canvas 视图,并像你展示如何把它和 TimelineView 结合起来应用。通过组合两者的能力,我们可以在 SwiftUI 动画的世界里扩展更多的可能性。

封面来自 Lance Anderson on Unsplash


更多文章,欢迎关注微信公众号:Swift花园