前言
筆者從發(fā)布高級(jí) SwiftUI 動(dòng)畫(huà)系列的高級(jí) SwiftUI 動(dòng)畫(huà) — Part 3:AnimatableModifier 至今已經(jīng)兩年了,對(duì) 2021 年 WWDC 介紹的 TimelineView 和 Canvas 感到激動(dòng)。這開(kāi)啟了一個(gè)全新的可能性,筆者將試圖在這一部分和下一部分的系列中闡釋這些可能性。

在這篇文章中,我們將詳細(xì)地探索 TimelineView 。我們將從最常見(jiàn)的用法開(kāi)始,循序漸進(jìn)。然而筆者認(rèn)為,最大的可能性來(lái)自于 TimelineView 和我們已知現(xiàn)有的動(dòng)畫(huà)相結(jié)合。在其他事物中,通過(guò)一點(diǎn)創(chuàng)意,這樣的組合將讓我們最終實(shí)現(xiàn)“關(guān)鍵幀類似”的動(dòng)畫(huà)。
在第 5 部分,我們將探索 Canvas 視圖,以及它和我們的新朋友 TimelineView 相結(jié)合是如此的優(yōu)秀。
上文中展示的動(dòng)畫(huà),是使用本文中介紹的技術(shù)創(chuàng)建的。該動(dòng)畫(huà)的完整代碼可在此 gist 中找到。
TimelineView 的組件
TimelineView 是一個(gè)容器視圖,它以相關(guān)調(diào)度程序確定的頻率重新評(píng)估其內(nèi)容:
TimelineView(.periodic(from: .now, by: 0.5)) { timeline in
ViewToEvaluatePeriodically()
}
TimelineView 接收調(diào)度程序作為參數(shù)。 稍后我們將詳細(xì)認(rèn)識(shí)它們,現(xiàn)在,上述示例使用每半秒觸發(fā)一次的調(diào)度程序。
另一個(gè)參數(shù)是一個(gè)內(nèi)容閉包,它接收一個(gè)看起來(lái)像這樣的 TimelineView.Context 參數(shù):
struct Context {
let cadence: Cadence
let date: Date
enum Cadence: Comparable {
case live
case seconds
case minutes
}
}
Cadence 是一個(gè)枚舉類型,我們可以使用它來(lái)決定在我們的視圖中顯示什么。 可能的值是:live、seconds 和 minutes。 以此為提示,避免顯示與 Cadence 無(wú)關(guān)的信息。 典型的例子,是避免在具有秒或分鐘節(jié)奏的調(diào)度程序的時(shí)鐘上顯示毫秒。
請(qǐng)注意,Cadence 不是你可以更改的東西,而是反映設(shè)備狀態(tài)的東西。文檔僅提供了一個(gè)例子。 在 watchOS 上,降低手腕時(shí) Cadence 會(huì)減慢。 如果你發(fā)現(xiàn)了 Cadence 發(fā)生變化的其他情況,筆者非常想知道。 請(qǐng)?jiān)谙路桨l(fā)表評(píng)論。
好吧,這一切看起來(lái)都很棒,但是我們應(yīng)該注意許多微妙之處。 讓我們開(kāi)始構(gòu)建我們的第一個(gè) TimelineView 動(dòng)畫(huà),看看它們是什么。
理解 TimelineView 如何工作
觀察下面的代碼。 我們有兩個(gè)隨機(jī)變化的表情符號(hào)。 兩者之間的唯一區(qū)別是,一個(gè)寫(xiě)在內(nèi)容閉包中,而另一個(gè)被放在單獨(dú)的視圖中以提高可讀性。
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.randomElement() ?? ""
Text(randomEmoji)
.font(.largeTitle)
.scaleEffect(4.0)
SubView()
}
}
}
struct SubView: View {
var body: some View {
let randomEmoji = ManyFaces.emoji.randomElement() ?? ""
Text(randomEmoji)
.font(.largeTitle)
.scaleEffect(4.0)
}
}
}
現(xiàn)在,讓我們看下運(yùn)行代碼會(huì)發(fā)生什么:

驚了? 為什么左邊的 emoji 會(huì)變,而另一個(gè)總是悲傷? 事實(shí)證明, SubView 沒(méi)有接收到任何變化的參數(shù),這意味著它沒(méi)有依賴關(guān)系。 SwiftUI 沒(méi)有理由重新計(jì)算視圖的主體。 2021 年 WWDC 的一個(gè)精彩演講是 Demystify SwiftUI。 它解釋了視圖標(biāo)識(shí)、生命周期和依賴關(guān)系。 所有這些主題對(duì)于理解時(shí)間線為何如此運(yùn)行都非常重要。
為了解決這個(gè)問(wèn)題,我們更改了 SubView 視圖以添加一個(gè)參數(shù),該參數(shù)將隨著時(shí)間軸的每次更新而改變。 請(qǐng)注意,我們不需要使用參數(shù),它只需要在那里。 盡管如此,我們將看到這個(gè)未使用的值稍后會(huì)非常有用。
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.randomElement() ?? ""
Text(randomEmoji)
.font(.largeTitle)
.scaleEffect(4.0)
}
}
現(xiàn)在 SubView 是這樣創(chuàng)建的:
SubView(date: timeline.date)
最后,我們的兩個(gè)表情都可以體驗(yàn)到情緒的狂飆:

按照時(shí)間線執(zhí)行
大多數(shù)關(guān)于 TimelineView 的示例(截至編寫(xiě)本文)通常是關(guān)于繪制時(shí)鐘的。 這就說(shuō)得通了。 時(shí)間線提供的數(shù)據(jù)畢竟是一個(gè)Date類型實(shí)例。
有史以來(lái)最簡(jiǎn)單的 TimelineView 時(shí)鐘:
TimelineView(.periodic(from: .now, by: 1.0)) { timeline in
Text("\(timeline.date)")
}
時(shí)鐘可能會(huì)變得更加精致。 例如,使用帶有形狀的模擬時(shí)鐘,或使用新的 Canvas 視圖繪制時(shí)鐘。
但是,TimelineView 不僅僅用于時(shí)鐘。 在許多情況下,我們希望每次時(shí)間線更新我們的視圖時(shí),視圖處理一些事情。 放置此代碼的最佳位置是 onChange(of:perform) 閉包。
在以下示例中,我們使用此技術(shù)每 3 秒更新一次模型。

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]
}
}
需要注意的是,每次時(shí)間線更新,我們的 QuipView 都會(huì)刷新兩次。 也就是說(shuō),在時(shí)間線更新時(shí)一次,然后在之后立即再次,因?yàn)橥ㄟ^(guò)調(diào)用 quips.advance() 導(dǎo)致 quips.sentence 的 @Published 值發(fā)生變化并觸發(fā)視圖更新。 這很好,但需要注意,因?yàn)樯院笏鼤?huì)變得更加重要。
我們從中得出的一個(gè)重要概念是,盡管時(shí)間線可能會(huì)產(chǎn)生一定數(shù)量的更新,但視圖的內(nèi)容很可能會(huì)更新更多次。
TimelineView 與傳統(tǒng)動(dòng)畫(huà)相結(jié)合
新的 TimelineView 帶來(lái)了很多新的機(jī)會(huì)。 正如我們將在以后的文章中看到的那樣,將它與 Canvas 結(jié)合起來(lái)是一個(gè)很好的補(bǔ)充。 但為動(dòng)畫(huà)的每一幀編寫(xiě)所有代碼給了我們帶來(lái)了很多負(fù)擔(dān)。 筆者將在本節(jié)中介紹的技術(shù),使用我們已熟知的動(dòng)畫(huà)并且熱衷于視圖動(dòng)畫(huà)從一個(gè)時(shí)間線更新到下一個(gè)時(shí)間線。 這最終將讓我們?cè)诩?SwiftUI 中創(chuàng)建我們自己的類似關(guān)鍵幀的動(dòng)畫(huà)。
但是讓我們慢慢開(kāi)始,從我們的小項(xiàng)目開(kāi)始:如下所示的節(jié)拍器。 調(diào)高音量播放視頻,欣賞節(jié)拍聲如何與鐘擺同步。 此外,就像節(jié)拍器一樣,每隔幾拍就會(huì)響起一次鈴聲:

具體見(jiàn)視頻:https://swiftui-lab.com/wp-content/uploads/2021/06/metronome.mp4
首先,讓我們看看我們的時(shí)間線是什么樣的:
struct Metronome: View {
let bpm: Double = 60 // beats per minute
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)
}
}
}
節(jié)拍器速度通常以 bpm(每分鐘節(jié)拍數(shù))指定。 該示例使用周期性調(diào)度程序,每 60/bpm 秒重復(fù)一次。 對(duì)于我們的例子,bpm = 60,所以調(diào)度程序每 1 秒觸發(fā)一次。 即每分鐘 60 次。
Metronome 視圖由三層組成:MetronomeBack、MetronomePendulum 和 MetronomeFront。 它們按此順序疊加。 每次時(shí)間線更新都必須刷新的唯一視圖是 MetronomePendulum,它可以左右擺動(dòng)。 其他視圖不會(huì)刷新,因?yàn)樗鼈儧](méi)有依賴關(guān)系。
MetronomeBack 和 Metronome Front 的代碼非常簡(jiǎn)單,它們使用了一種稱為圓形梯形的自定義形狀。 為避免使此頁(yè)面過(guò)長(zhǎng),自定義形狀的代碼在此 gist 。
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 視圖是事情開(kāi)始變得有趣的地方:
struct MetronomePendulum: View {
@State var pendulumOnLeft: Bool = false
@State var bellCounter = 0 // sound bell every 4 beats
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() // triggers the animation
bellCounter = (bellCounter + 1) % 4 // keeps count of beats, to sound bell every 4th
// sound bell or beat?
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)
}
}
}
我們的視圖需要跟蹤我們?cè)趧?dòng)畫(huà)中的位置。 我稱之為動(dòng)畫(huà)階段。 由于我們需要跟蹤這些階段,我們將使用 @State 變量:
-
pendulumOnLeft: 跟蹤鐘擺Pendulum擺動(dòng)的方向。 -
bellCounter: 記錄節(jié)拍的數(shù)量,以確定是否應(yīng)該聽(tīng)到節(jié)拍或鈴聲。
該示例使用 .animation(_:value:) 修飾語(yǔ)。 此版本的修改器,在指定值更改時(shí)應(yīng)用動(dòng)畫(huà)。 請(qǐng)注意,也可以使用顯式動(dòng)畫(huà)。 無(wú)需調(diào)用 .animation(),只需在 withAnimation 閉包內(nèi)切換 pendulumOnLeft 變量。
為了使我們的視圖在動(dòng)畫(huà)階段前進(jìn),我們使用 onChange(of:perform) 修飾符監(jiān)視日期的變化,就像我們?cè)谇懊娴?quip 示例中所做的那樣。
除了在每次日期值更改時(shí)推進(jìn)動(dòng)畫(huà)階段,我們還在 onAppear 閉包中執(zhí)行此操作。 否則,一開(kāi)始就會(huì)有停頓。
最后一段與 SwiftUI 無(wú)關(guān)的代碼是創(chuàng)建 NSSound 實(shí)例。 為了避免使示例過(guò)于復(fù)雜,筆者創(chuàng)建了幾個(gè)全局變量:
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
正如我們已經(jīng)看到的,TimelineView 需要一個(gè) TimelineScheduler 來(lái)確定何時(shí)更新其內(nèi)容。 SwiftUI 提供了一些預(yù)定義的調(diào)度器,比如我們使用的那些。 但是,我們也可以創(chuàng)建自己的自定義調(diào)度程序。 筆者將在下一節(jié)中詳細(xì)說(shuō)明。 但讓我們從已有的調(diào)度器開(kāi)始。
時(shí)間線調(diào)度器基本上是一個(gè)采用 TimelineScheduler 協(xié)議的結(jié)構(gòu)。 現(xiàn)有的類型有:
-
AnimationTimelineSchedule: 盡可能快地更新,給你繪制動(dòng)畫(huà)每一幀的機(jī)會(huì)。 它具有讓你限制更新頻率和暫停更新的參數(shù)。 在TimelineView與新的Canvas視圖結(jié)合使用時(shí),這將非常有用。 -
EveryMinuteTimelineSchedule: 顧名思義,它每分鐘更新一次,在每分鐘開(kāi)始時(shí)更新。 -
ExplicitTimelineSchedule: 可以提供一個(gè)數(shù)組,其中包含你希望時(shí)間線更新的所有時(shí)間。 -
PeriodicTimelineSchedule: 可以提供開(kāi)始時(shí)間和發(fā)生更新的頻率。
盡管你可以以這種方式創(chuàng)建 Timeline:
Timeline(EveryMinuteTimelineSchedule()) { timeline in
...
}
自 Swift 5.5 和 SE-0299 的引入以來(lái),我們現(xiàn)在已經(jīng)支持類枚舉語(yǔ)法。 這使代碼更具可讀性并改進(jìn)了自動(dòng)完成功能。 建議我們改用這種語(yǔ)法:
TimelineView(.everyMinute) { timeline in
...
}
注意:你可能聽(tīng)說(shuō)過(guò),但今年也引入了樣式。 更好的是,對(duì)于樣式,只要你使用的是 Swift 5.5,你就可以使用以前的版本進(jìn)行反向部署。
對(duì)于每個(gè)現(xiàn)有的調(diào)度程序,可能有多個(gè)類似枚舉的選項(xiàng)。 例如,這兩行代碼創(chuàng)建了 AnimationTimelineSchedule 類型的調(diào)度程序:
TimelineView(.animation) { ... }
TimelineView(.animation(minimumInterval: 0.3, paused: false)) { ... }
你甚至可以創(chuàng)建屬于自己的調(diào)度程序(不要忘記 static 關(guān)鍵字):
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
如果現(xiàn)有調(diào)度程序都不符合你的需求,可以創(chuàng)建自己的調(diào)度程序。 思考以下動(dòng)畫(huà):

在這個(gè)動(dòng)畫(huà)中,我們有一個(gè)心形表情符號(hào),它會(huì)以不規(guī)則的間隔和不規(guī)則的幅度改變其比例。
它以 1.0 的比例開(kāi)始,0.2 秒后增長(zhǎng)到 1.6,0.2 秒后增長(zhǎng)到 2.0,然后縮小到 1.0 并保持 0.4 秒,然后重新開(kāi)始。 換一種說(shuō)法:
尺度變化:1.0 → 1.6 → 2.0 → 重新開(kāi)始
變化之間的時(shí)間:0.2 → 0.2 → 0.4 → 重新開(kāi)始
我們可以創(chuàng)建一個(gè) HeartTimelineSchedule,它完全按照心臟的需要進(jìn)行更新。 但是以可重用性的名義,讓我們做一些更通用的東西,將來(lái)可以重用。
我們新調(diào)度程序?qū)⒈环Q為:CyclicTimelineSchedule,并將接收一組時(shí)間偏移量。 每個(gè)偏移值都將相對(duì)于數(shù)組中的前一個(gè)值。 當(dāng)調(diào)度程序用盡偏移量時(shí),它將循環(huán)回到數(shù)組的開(kāi)頭并重新開(kāi)始。
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
}
}
}
實(shí)現(xiàn) TimelineSchedule 有幾個(gè)要求:
- 提供
entry(from:mode:)函數(shù)。 - 我們
Entries的類型必須符合Sequence where Entries.Element == Date
有幾種方法可以符合 Sequence。 此示例實(shí)現(xiàn) IteratorProtocol 并聲明符合 Sequence 和 IteratorProtocol。 你可以在此處閱讀有關(guān)序列一致性的更多信息。
對(duì)于實(shí)現(xiàn) IteratorProtocol 的 Entries,我們必須編寫(xiě) next() 函數(shù),該函數(shù)在時(shí)間線中生成日期。 我們的調(diào)度程序會(huì)記住最后日期并添加適當(dāng)?shù)钠屏俊?當(dāng)沒(méi)有更多的偏移量時(shí),它會(huì)循環(huán)回到數(shù)組中的第一個(gè)。
最后,錦上添花的是,為我們的調(diào)度器創(chuàng)建一個(gè)類似枚舉的初始化器:
extension TimelineSchedule where Self == CyclicTimelineSchedule {
static func cyclic(timeOffsets: [TimeInterval]) -> CyclicTimelineSchedule {
.init(timeOffsets: timeOffsets)
}
}
現(xiàn)在我們已經(jīng)準(zhǔn)備好 TimelineSchedue 類型了,讓我們?yōu)槲覀兊男呐K注入一些活力:
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
}
}
你現(xiàn)在應(yīng)該熟悉這種模式,它與我們使用節(jié)拍器的模式相同。 使用 onChange 和 onAppear 推進(jìn)動(dòng)畫(huà),使用 @State 變量來(lái)跟蹤動(dòng)畫(huà),并設(shè)置一個(gè)動(dòng)畫(huà),將我們的視圖從一個(gè)時(shí)間線更新過(guò)渡到下一個(gè)。 在這種情況下,我們使用 .spring 動(dòng)畫(huà),給它一個(gè)很好的搖晃效果。
關(guān)鍵幀動(dòng)畫(huà)
心臟和節(jié)拍器示例在某種程度上是關(guān)鍵幀動(dòng)畫(huà)。 我們?cè)谡麄€(gè)動(dòng)畫(huà)中定義了幾個(gè)關(guān)鍵點(diǎn),在這里我們改變了我們視圖的參數(shù),并讓 SwiftUI 動(dòng)畫(huà)這些點(diǎn)之間的過(guò)渡。 以下示例將嘗試概括該想法,并使其更加明顯。 認(rèn)識(shí)我們的新項(xiàng)目朋友,跳躍的家伙:

如果你仔細(xì)觀察動(dòng)畫(huà),你會(huì)注意到這個(gè)表情符號(hào)角色的許多參數(shù)在不同的時(shí)間點(diǎn)發(fā)生了變化。 這些參數(shù)是:y-offset、rotation 和 y-scale。 同樣重要的是,動(dòng)畫(huà)的不同片段有不同的動(dòng)畫(huà)類型(線性、緩入和緩出)。 由于這些是我們更改的參數(shù),因此最好將它們放在一個(gè)數(shù)組中。 讓我們開(kāi)始:
struct KeyFrame {
let offset: TimeInterval
let rotation: Double
let yScale: Double
let y: CGFloat
let animation: Animation?
}
let keyframes = [
// Initial state, will be used once. Its offset is useless and will be ignored
KeyFrame(offset: 0.0, rotation: 0, yScale: 1.0, y: 0, animation: nil),
// Animation keyframes
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)),
]
重要的是要知道,當(dāng) TimelineView 出現(xiàn)時(shí),它會(huì)繪制我們的視圖,即使沒(méi)有計(jì)劃的更新,或者它們是否在將來(lái)。 當(dāng) TimelineView 出現(xiàn)時(shí),它需要顯示一些東西,以便繪制我們的視圖。 我們將使用第一個(gè)關(guān)鍵幀作為我們的視圖狀態(tài),但是當(dāng)我們循環(huán)時(shí),該幀將被忽略。 這是一個(gè)實(shí)施決策,你可能需要或想要以不同的方式進(jìn)行。
現(xiàn)在,讓我們看看我們的時(shí)間線:
struct JumpingEmoji: View {
// Use all offset, minus the first
let offsets = Array(keyframes.map { $0.offset }.dropFirst())
var body: some View {
TimelineView(.cyclic(timeOffsets: offsets)) { timeline in
HappyEmoji(date: timeline.date)
}
}
}
我們已經(jīng)從我們?cè)谇耙粋€(gè)示例中所做的工作中受益,并重用了 CyclicTimelineScheduler。 如前所述,我們不需要第一個(gè)關(guān)鍵幀的偏移量,因此我們將其丟棄。
現(xiàn)在,有趣的部分:
struct HappyEmoji: View {
// current keyframe number
@State var idx: Int = 0
// timeline update
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() {
// advance to next keyframe
idx = (idx + 1) % keyframes.count
// skip first frame for animation, which we
// only used as the initial state.
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)
}
}
}
為了更好的可讀性,我將所有變化的參數(shù)放在一個(gè)名為 Effects 的修改器中。 如你所見(jiàn),它還是相同的模式:使用 onChange 和 onAppear 來(lái)推進(jìn)我們的動(dòng)畫(huà),并為每個(gè)關(guān)鍵幀片段添加一個(gè)動(dòng)畫(huà)。 那里沒(méi)有什么新鮮事。
不要! 這是一個(gè)陷阱!
在你的 TimelineView 發(fā)現(xiàn)路徑中,你可能會(huì)遇到此錯(cuò)誤:
Action Tried to Update Multiple Times Per Frame
讓我們看一個(gè)生成此消息的示例:
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()
}
}
}
}
代碼看起來(lái)沒(méi)有問(wèn)題,它應(yīng)該每?jī)擅敫淖円淮挝谋绢伾?,在紅色和藍(lán)色之間交替。那么可能會(huì)發(fā)生什么?稍等片刻,看看你是否能找出背后的原因。
我們不是在處理一個(gè) bug。事實(shí)上,這個(gè)問(wèn)題是可以預(yù)見(jiàn)的。
重要的是要記住,時(shí)間線的第一次更新是在它第一次出現(xiàn)時(shí),然后它遵循調(diào)度程序規(guī)則來(lái)觸發(fā)以下更新。因此,即使我們的調(diào)度程序沒(méi)有產(chǎn)生更新,TimelineView` 內(nèi)容也至少會(huì)生成一次。
在這個(gè)具體的例子中,我們監(jiān)控 timeline.date 值的變化,當(dāng)它發(fā)生變化時(shí),我們切換 flag 變量,它會(huì)產(chǎn)生顏色變化。
TimelineView 將首先出現(xiàn)。兩秒后,時(shí)間線將更新(例如,由于第一次調(diào)度程序更新),觸發(fā) onChange 關(guān)閉。這將反過(guò)來(lái)改變標(biāo)志變量?,F(xiàn)在,由于我們的 TimelineView 依賴于它,它需要立即刷新,觸發(fā)標(biāo)志變量的另一個(gè)切換,強(qiáng)制另一個(gè) TimelineView 刷新,依此類推……你明白了:每幀多次更新。
那么我們?cè)撊绾谓鉀Q呢?解決方案可能會(huì)有所不同。在這種情況下,我們只需封裝內(nèi)容并將標(biāo)志變量移動(dòng)到封裝的視圖內(nèi)。現(xiàn)在 TimelineView 不再依賴它:
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()
}
}
}
探索新點(diǎn)子
每次時(shí)間線更新刷新一次:如前所述,這種模式使我們的視圖每次更新計(jì)算它們的主體兩次:第一次是在時(shí)間線更新時(shí),然后在我們推進(jìn)動(dòng)畫(huà)狀態(tài)值時(shí)再次計(jì)算。在這種類型的動(dòng)畫(huà)中,我們?cè)跁r(shí)間上間隔了關(guān)鍵點(diǎn),這非常好。
在這些時(shí)間點(diǎn)太靠近的動(dòng)畫(huà)中,你可能需要/想要避免這種情況。如果你需要更改存儲(chǔ)的值,但要避免視圖刷新……你可以使用一個(gè)技巧。使用 @StateObject 代替@State。確保你不要在 @Published 中設(shè)置這樣的值。如果在某個(gè)時(shí)候,你想要/需要告訴你的視圖刷新,你可以隨時(shí)調(diào)用objectWillChange.send()
匹配動(dòng)畫(huà)持續(xù)時(shí)間和偏移量:在關(guān)鍵幀示例中,我們?yōu)槊總€(gè)動(dòng)畫(huà)片段使用不同的動(dòng)畫(huà)。為此,我們將動(dòng)畫(huà)值存儲(chǔ)在數(shù)組中。如果你仔細(xì)觀察,你會(huì)發(fā)現(xiàn)在我們的具體示例中,偏移量和動(dòng)畫(huà)持續(xù)時(shí)間匹配!這是合理的,對(duì)吧?因此,你可以定義一個(gè)具有動(dòng)畫(huà)類型的枚舉,而不是在數(shù)組中包含 Animation 值。稍后在你的視圖中,你將根據(jù)動(dòng)畫(huà)類型創(chuàng)建動(dòng)畫(huà)值,但使用偏移值的持續(xù)時(shí)間對(duì)其進(jìn)行實(shí)例化。類似這樣:
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 = [
// Initial state, will be used once. Its offset is useless and will be ignored
KeyFrame(offset: 0.0, rotation: 0, yScale: 1.0, y: 0, animationKind: .none),
// Animation keyframes
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),
]
如果你想知道為什么我一開(kāi)始不這樣做,我只是想向你展示兩種方式都是可能的。 第一種情況更靈活,但更冗長(zhǎng)。 也就是說(shuō),我們被迫為每個(gè)動(dòng)畫(huà)指定持續(xù)時(shí)間,但是,它更靈活,因?yàn)槲覀兛梢宰杂墒褂门c偏移量不匹配的持續(xù)時(shí)間。
然而,當(dāng)使用這種新方法時(shí),你可以輕松地添加一個(gè)可自定義的因素,這可以讓你減慢或加快動(dòng)畫(huà)速度,而無(wú)需觸摸關(guān)鍵幀。
嵌套 TimelineViews:沒(méi)有什么能阻止你將一個(gè) TimelineView 嵌套在另一個(gè) TimelineView 中。 現(xiàn)在我們有了 JumpingEmoji,我們可以在 TimelineView 中放置三個(gè) JumpingEmoji 視圖,使它們一次出現(xiàn)一個(gè),并有延遲:

對(duì)于 Emoji 波浪的全部源碼,檢出這個(gè) gits。
GifImage 示例
筆者原本還有一個(gè)示例,但是它在筆者發(fā)布文章的時(shí)候廢棄了。 它沒(méi)有入選的原因是并發(fā) API 還不穩(wěn)定。 幸運(yùn)的是,現(xiàn)在可以安全地發(fā)布它。 該代碼使用 TimelineView 來(lái)實(shí)現(xiàn)動(dòng)畫(huà) gif 的視圖。 視圖從 URL(可以是本地的或遠(yuǎn)程的)異步加載 gif。 此 gist 中提供了所有代碼。
小結(jié)
恭喜閱讀到這么長(zhǎng)的一篇文章的結(jié)尾。這是一次騎行!我們從最簡(jiǎn)單的 TimelineView 示例轉(zhuǎn)到視圖的一些創(chuàng)造性使用。 在第 5 部分中,筆者將探索新的 Canvas 視圖,以及它與 TimelineView 的結(jié)合程度。 通過(guò)將它們放在一起,我們將擴(kuò)展 SwiftUI 動(dòng)畫(huà)世界中的更多可能性。
譯自 The SwiftUI Lab 的 Advanced SwiftUI Animations – Part 4: TimelineView