動態

詳情 返回 返回

高級 SwiftUI 動畫進階 —— Part4:TimelineView - 動態 詳情

前言

前三篇高級 SwiftUI 動畫系列是作者在 WWDC 2021 之前實戰總結的內容。對 2021 年 WWDC 介紹的 TimelineViewCanvas 感到激動。這開啓了一個全新的可能性,筆者將試圖在這一部分和下一部分的系列中闡釋這些可能性。

 title=

在這篇文章中,我們將詳細地探索 TimelineView 。我們將從最常見的用途緩慢開始。然而筆者認為,最大的可能性來自於 TimelineView 和我們已知現有的動畫相結合。在其他事物中,通過一點創意,這樣的組合將讓我們最終實現“關鍵幀類似”的動畫。

在第 5 部分,我們將探索 Canvas 視圖,以及它和我們的新朋友 TimelineView 相結合是如此的優秀。

上文中展示的動畫,是使用本文中介紹的技術創建的。該動畫的完整代碼可在此 gist 中找到。

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 是一個枚舉類型,我們可以使用它來決定在我們的視圖中顯示什麼。 可能的值是:live、seconds 和 minutes。 以此為提示,避免顯示與 Cadence 無關的信息。 典型的例子,是避免在具有秒或分鐘節奏的調度程序的時鐘上顯示毫秒。

請注意,Cadence 不是你可以更改的東西,而是反映設備狀態的東西。文檔僅提供了一個例子。 在 watchOS 上,降低手腕時 Cadence 會減慢。 如果你發現了 Cadence 發生變化的其他情況,筆者非常想知道。 請在下方發表評論。

好吧,這一切看起來都很棒,但是我們應該注意許多微妙之處。 讓我們開始構建我們的第一個 TimelineView 動畫,看看它們是什麼。

理解 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.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)
        }
    }
}

現在,讓我們看下運行代碼會發生什麼:

 title=

驚了? 為什麼左邊的 emoji 會變,而另一個總是悲傷? 事實證明, SubView 沒有接收到任何變化的參數,這意味着它沒有依賴關係。 SwiftUI 沒有理由重新計算視圖的主體。 2021 年 WWDC 的一個精彩演講是 Demystify SwiftUI。 它解釋了視圖標識、生命週期和依賴關係。 所有這些主題對於理解時間線為何如此運行都非常重要。

為了解決這個問題,我們更改了 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.randomElement() ?? ""

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

現在 SubView 是這樣創建的:

SubView(date: timeline.date)

最後,我們的兩個表情都可以體驗到情緒的狂飆:

 title=

按照時間線執行

大多數關於 TimelineView 的示例(截至編寫本文)通常是關於繪製時鐘的。 這就説得通了。 時間線提供的數據畢竟是一個日期類型實例。

有史以來最簡單的 TimelineView 時鐘:

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

}

時鐘可能會變得更加精緻。 例如,使用帶有形狀的模擬時鐘,或使用新的 Canvas 視圖繪製時鐘。

但是,TimelineView 不僅僅用於時鐘。 在許多情況下,我們希望每次時間線更新我們的視圖時,視圖處理一些事情。 放置此代碼的最佳位置是 onChange(of:perform) 閉包。

在以下示例中,我們使用此技術每 3 秒更新一次模型。

 title=

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@Published 值發生變化並觸發視圖更新。 這很好,但需要注意,因為稍後它會變得更加重要。

我們從中得出的一個重要概念是,儘管時間線可能會產生一定數量的更新,但視圖的內容很可能會更新更多次。

TimelineView 與傳統動畫相結合

新的 TimelineView 帶來了很多新的機會。 正如我們將在以後的文章中看到的那樣,將它與 Canvas 結合起來是一個很好的補充。 但為動畫的每一幀編寫所有代碼給了我們帶來了很多負擔。 筆者將在本節中介紹的技術,使用我們已熟知的動畫並且熱衷於視圖動畫從一個時間線更新到下一個時間線。 這最終將讓我們在純 SwiftUI 中創建我們自己的類似關鍵幀的動畫。

但是讓我們慢慢開始,從我們的小項目開始:如下所示的節拍器。 調高音量播放視頻,欣賞節拍聲如何與鐘擺同步。 此外,就像節拍器一樣,每隔幾拍就會響起一次鈴聲:

https://swiftui-lab.com/wp-content/uploads/2021/06/metronome.mp4

首先,讓我們看看我們的時間線是什麼樣的:

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

節拍器速度通常以 bpm(每分鐘節拍數)指定。 該示例使用週期性調度程序,每 60/bpm 秒重複一次。 對於我們的例子,bpm = 60,所以調度程序每 1 秒觸發一次。 即每分鐘 60 次。

Metronome 視圖由三層組成:MetronomeBackMetronomePendulumMetronomeFront。 它們按此順序疊加。 每次時間線更新都必須刷新的唯一視圖是 MetronomePendulum,它可以左右擺動。 其他視圖不會刷新,因為它們沒有依賴關係。

MetronomeBack 和 Metronome Front 的代碼非常簡單,它們使用了一種稱為圓形梯形的自定義形狀。 為避免使此頁面過長,自定義形狀的代碼在此 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 視圖是事情開始變得有趣的地方:

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

我們的視圖需要跟蹤我們在動畫中的位置。 我稱之為動畫階段。 由於我們需要跟蹤這些階段,我們將使用 @State 變量:

  1. pendulumOnLeft: 跟蹤鐘擺 Pendulum 擺動的方向。
  2. bellCounter: 記錄節拍的數量,以確定是否應該聽到節拍或鈴聲。

該示例使用 .animation(_:value:) 修飾語。 此版本的修改器,在指定值更改時應用動畫。 請注意,也可以使用顯式動畫。 無需調用 .animation(),只需在 withAnimation 閉包內切換 pendulumOnLeft 變量。

為了使我們的視圖在動畫階段前進,我們使用 onChange(of:perform) 修飾符監視日期的變化,就像我們在前面的 quip 示例中所做的那樣。

除了在每次日期值更改時推進動畫階段,我們還在 onAppear 閉包中執行此操作。 否則,一開始就會有停頓。

最後一段與 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)
}()

如果你需要聲音文件,可以到 freesound 下載:https://freesound.org/

示例代碼中的聲音為:

  • 鐘聲: metronome_pling 根據許可證 CC BY 3.0 (m1rk0)
  • 節拍聲: metronome.wav 根據 CC0 1.0

TimelineScheduler

正如我們已經看到的,TimelineView 需要一個 TimelineScheduler 來確定何時更新其內容。 SwiftUI 提供了一些預定義的調度器,比如我們使用的那些。 但是,我們也可以創建自己的自定義調度程序。 筆者將在下一節中詳細説明。 但讓我們從已有的調度器開始。

時間線調度器基本上是一個採用 TimelineScheduler 協議的結構。 現有的類型有:

  • AnimationTimelineSchedule: 儘可能快地更新,給你繪製動畫每一幀的機會。 它具有讓你限制更新頻率和暫停更新的參數。 在 TimelineView 與新的 Canvas 視圖結合使用時,這將非常有用。
  • EveryMinuteTimelineSchedule: 顧名思義,它每分鐘更新一次,在每分鐘開始時更新。
  • ExplicitTimelineSchedule: 可以提供一個數組,其中包含你希望時間線更新的所有時間。
  • PeriodicTimelineSchedule: 可以提供開始時間和發生更新的頻率。

儘管你可以以這種方式創建 Timeline

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

自 Swift 5.5 和 SE-0299 的引入以來,我們現在已經支持類枚舉語法。 這使代碼更具可讀性並改進了自動完成功能。 建議我們改用這種語法:

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

注意:你可能聽説過,但今年也引入了樣式。 更好的是,對於樣式,只要你使用的是 Swift 5.5,你就可以使用以前的版本進行反向部署。

對於每個現有的調度程序,可能有多個類似枚舉的選項。 例如,這兩行代碼創建了 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

如果現有調度程序都不符合你的需求,可以創建自己的調度程序。 思考以下動畫:

在這個動畫中,我們有一個心形表情符號,它會以不規則的間隔和不規則的幅度改變其比例。
它以 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 有幾個要求:

  • 提供 entry(from:mode:) 函數。
  • 我們 Entries 的類型必須符合 Sequence where Entries.Element == Date

有幾種方法可以符合 Sequence。 此示例實現 IteratorProtocol 並聲明符合 SequenceIteratorProtocol。 你可以在此處閲讀有關序列一致性的更多信息。

對於實現 IteratorProtocolEntries,我們必須編寫 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
    }
}

你現在應該熟悉這種模式,它與我們使用節拍器的模式相同。 使用 onChangeonAppear 推進動畫,使用 @State 變量來跟蹤動畫,並設置一個動畫,將我們的視圖從一個時間線更新過渡到下一個。 在這種情況下,我們使用 .spring 動畫,給它一個很好的搖晃效果。

關鍵幀動畫

心臟和節拍器示例在某種程度上是關鍵幀動畫。 我們在整個動畫中定義了幾個關鍵點,在這裏我們改變了我們視圖的參數,並讓 SwiftUI 動畫這些點之間的過渡。 以下示例將嘗試概括該想法,並使其更加明顯。 認識我們的新項目朋友,跳躍的傢伙:

如果你仔細觀察動畫,你會注意到這個表情符號角色的許多參數在不同的時間點發生了變化。 這些參數是:y-offsetrotationy-scale。 同樣重要的是,動畫的不同片段有不同的動畫類型(線性、緩入和緩出)。 由於這些是我們更改的參數,因此最好將它們放在一個數組中。 讓我們開始:

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)),
]

重要的是要知道,當 TimelineView 出現時,它會繪製我們的視圖,即使沒有計劃的更新,或者它們是否在將來。 當 TimelineView 出現時,它需要顯示一些東西,以便繪製我們的視圖。 我們將使用第一個關鍵幀作為我們的視圖狀態,但是當我們循環時,該幀將被忽略。 這是一個實施決策,你可能需要或想要以不同的方式進行。

現在,讓我們看看我們的時間線:

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

我們已經從我們在前一個示例中所做的工作中受益,並重用了 CyclicTimelineScheduler。 如前所述,我們不需要第一個關鍵幀的偏移量,因此我們將其丟棄。

現在,有趣的部分:

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

為了更好的可讀性,我將所有變化的參數放在一個名為 Effects 的修改器中。 如你所見,它還是相同的模式:使用 onChangeonAppear 來推進我們的動畫,併為每個關鍵幀片段添加一個動畫。 那裏沒有什麼新鮮事。

不要! 這是一個陷阱!

在你的 TimelineView 發現路徑中,你可能會遇到此錯誤:

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 關閉。這將反過來改變標誌變量。現在,由於我們的 TimelineView 依賴於它,它需要立即刷新,觸發標誌變量的另一個切換,強制另一個 TimelineView 刷新,依此類推……你明白了:每幀多次更新。

那麼我們該如何解決呢?解決方案可能會有所不同。在這種情況下,我們只需封裝內容並將標誌變量移動到封裝的視圖內。現在 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()
            }
    }
}

探索新點子

每次時間線更新刷新一次:如前所述,這種模式使我們的視圖每次更新計算它們的主體兩次:第一次是在時間線更新時,然後在我們推進動畫狀態值時再次計算。在這種類型的動畫中,我們在時間上間隔了關鍵點,這非常好。

在這些時間點太靠近的動畫中,你可能需要/想要避免這種情況。如果你需要更改存儲的值,但要避免視圖刷新……你可以使用一個技巧。使用 @StateObject 代替@State。確保你不要在 @Published 中設置這樣的值。如果在某個時候,你想要/需要告訴你的視圖刷新,你可以隨時調用 objectWillChange.send()

匹配動畫持續時間和偏移量:在關鍵幀示例中,我們為每個動畫片段使用不同的動畫。為此,我們將動畫值存儲在數組中。如果你仔細觀察,你會發現在我們的具體示例中,偏移量和動畫持續時間匹配!這是合理的,對吧?因此,你可以定義一個具有動畫類型的枚舉,而不是在數組中包含 Animation 值。稍後在你的視圖中,你將根據動畫類型創建動畫值,但使用偏移值的持續時間對其進行實例化。類似這樣:

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),
]

如果你想知道為什麼我一開始不這樣做,我只是想向你展示兩種方式都是可能的。 第一種情況更靈活,但更冗長。 也就是説,我們被迫為每個動畫指定持續時間,但是,它更靈活,因為我們可以自由使用與偏移量不匹配的持續時間。

然而,當使用這種新方法時,你可以輕鬆地添加一個可自定義的因素,這可以讓你減慢或加快動畫速度,而無需觸摸關鍵幀。

嵌套 TimelineViews:沒有什麼能阻止你將一個 TimelineView 嵌套在另一個 TimelineView 中。 現在我們有了 JumpingEmoji,我們可以在 TimelineView 中放置三個 JumpingEmoji 視圖,使它們一次出現一個,並有延遲:

對於 Emoji 波浪的全部源碼,檢出這個 gits。

GifImage 示例

筆者原本還有一個示例,但是它在筆者發佈文章的時候廢棄了。 它沒有入選的原因是併發 API 還不穩定。 幸運的是,現在可以安全地發佈它。 該代碼使用 TimelineView 來實現動畫 gif 的視圖。 視圖從 URL(可以是本地的或遠程的)異步加載 gif。 此 gist 中提供了所有代碼。

小結

恭喜閲讀到這麼長的一篇文章的結尾。這是一次騎行!我們從最簡單的 TimelineView 示例轉到視圖的一些創造性使用。 在第 5 部分中,筆者將探索新的 Canvas 視圖,以及它與 TimelineView 的結合程度。 通過將它們放在一起,我們將擴展 SwiftUI 動畫世界中的更多可能性。

user avatar josie_68d213f999ae8 頭像 georgegcs 頭像 aixiaodekaomianbao_ddkwvd 頭像 segfal_coder 頭像
點贊 4 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.