日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當(dāng)前位置: 首頁 > 编程资源 > 编程问答 >内容正文

编程问答

SwiftUI之深入解析高级动画的时间轴TimelineView

發(fā)布時(shí)間:2024/5/28 编程问答 42 豆豆
生活随笔 收集整理的這篇文章主要介紹了 SwiftUI之深入解析高级动画的时间轴TimelineView 小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,幫大家做個(gè)參考.

一、前言

  • 本文中將詳細(xì)探討 TimelineView,將從最常見的用法開始。然而,我認(rèn)為最大的潛力在于結(jié)合TimelineView和我們已經(jīng)知道的現(xiàn)有動(dòng)畫。通過一點(diǎn)創(chuàng)造性,這種組合將讓我們最終做出“關(guān)鍵幀類”的動(dòng)畫。

二、TimelineView 的組件

  • TimelineView 是一個(gè)容器視圖,它根據(jù)相關(guān)的調(diào)度器確定的頻率重新評(píng)估它的內(nèi)容,TimelineView 接收一個(gè)調(diào)度器作為參數(shù)。如下所示,使用一個(gè)每半秒觸發(fā)一次的調(diào)度器:
TimelineView(.periodic(from: .now, by: 0.5)) { timeline inViewToEvaluatePeriodically()}
  • 另一個(gè)參數(shù)是接收 TimelineView 的內(nèi)容閉包,上下文參數(shù)看起來像這樣:
struct Context {let cadence: Cadencelet date: Dateenum Cadence: Comparable {case livecase secondscase minutes} }
  • Cadence 是一個(gè) enum,可以使用它來決定在視圖中顯示什么內(nèi)容,值可以為 live、seconds、minutes 等。以此作為一個(gè)提示,避免顯示與節(jié)奏無關(guān)的信息,典型的例子是避免在具有以秒或分鐘為節(jié)奏的調(diào)度程序的時(shí)鐘上顯示毫秒。
  • 注意,Cadence 不是可以改變的東西,而是反映設(shè)備狀態(tài)的東西,例如在 watchOS 上,手腕下降時(shí)節(jié)奏會(huì)變慢。

三、TimelineView 工作原理

  • 如下所示,有兩個(gè)隨機(jī)變化的表情符號(hào),兩者之間的唯一區(qū)別是,一個(gè)是在內(nèi)容閉包中編寫的,而另一個(gè)被放在一個(gè)單獨(dú)的視圖中,以提高可讀性:
struct ManyFaces: View {static let emoji = ["😀", "😬", "😄", "🙂", "😗", "🤓", "😏", "😕", "😟", "😎", "😜", "😍", "🤪"]var body: some View {TimelineView(.periodic(from: .now, by: 0.2)) { timeline inHStack(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)}} }
  • 運(yùn)行代碼時(shí)效果如下:

  • 為什么左邊的表情變了,而另一個(gè)表情一直是悲傷的表情?其實(shí),SubView 沒有接收到任何變化的參數(shù),這意味著它沒有依賴關(guān)系,SwiftUI 沒有理由重新計(jì)算視圖的 body。在去年的 WWDC Demystify SwiftUI 有一個(gè)很棒的演講,那就是揭開 SwiftUI 的神秘面紗,它解釋了視圖標(biāo)識(shí)、生存期和依賴關(guān)系,所有這些主題對(duì)于理解時(shí)間軸的行為是非常重要的。
  • 為了解決這個(gè)問題,可以改變 SubView 視圖來添加一個(gè)參數(shù),這個(gè)參數(shù)會(huì)隨著時(shí)間軸的每次更新而改變。注意,我們不需要使用參數(shù),它只是必須存在:
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)
  • 最后,表情符號(hào)都可以經(jīng)歷情感的旋風(fēng):

四、作用于 Timeline

  • 大多數(shù)關(guān)于 TimelineView 的例子(在撰寫本文時(shí))通常都是關(guān)于繪制時(shí)鐘的,這是有意義的,畢竟時(shí)間軸提供的數(shù)據(jù)是一個(gè)日期。
  • 一個(gè)最簡(jiǎn)單的 TimelineView 時(shí)鐘如下:
TimelineView(.periodic(from: .now, by: 1.0)) { timeline inText("\(timeline.date)")}
  • 鐘表可能會(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 inQuipView(date: timeline.date)}}struct QuipView: View {@StateObject var quips = QuipDatabase()let date: Datevar body: some View {Text("_\(quips.sentence)_").onChange(of: date) { _ inquips.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 = 0func advance() {idx = (idx + 1) % QuipDatabase.sentences.countsentence = QuipDatabase.sentences[idx]} }
  • 需要注意的是,每次時(shí)間軸更新,QuipView 都會(huì)刷新兩次,也就是說,當(dāng)時(shí)間軸更新一次時(shí),再更新一次,因?yàn)橥ㄟ^調(diào)用 quips.advance(),將影響 quips 的 @Published 值,更改并觸發(fā)視圖更新。

五、 TimelineView 與傳統(tǒng)動(dòng)畫結(jié)合

  • 新的 TimelineView 帶來了很多新的用處,將它與 Canvas 結(jié)合起來,這是一個(gè)很好的添加,但這就把為每一幀動(dòng)畫編寫所有代碼的任務(wù)推給了我們。使用已經(jīng)知道并喜歡的動(dòng)畫來動(dòng)畫視圖從一個(gè)時(shí)間軸更新到下一個(gè),這最終將讓完全在 SwiftUI 中創(chuàng)建類似關(guān)鍵幀的動(dòng)畫。
  • 如下所示的節(jié)拍器,放大音量播放視頻,欣賞拍子的聲音是如何與鐘擺同步的,而且就像節(jié)拍器一樣,每隔幾拍就會(huì)有一個(gè)鈴聲響起:

  • 首先,來看看時(shí)間線是怎樣的:
struct Metronome: View {let bpm: Double = 60 // beats per minutevar body: some View {TimelineView(.periodic(from: .now, by: 60 / bpm)) { timeline inMetronomeBack().overlay(MetronomePendulum(bpm: bpm, date: timeline.date)).overlay(MetronomeFront(), alignment: .bottom)}} }
  • 節(jié)拍器的速度通常用 bpm 表示,上面的示例使用了一個(gè)周期調(diào)度器,它每 60/bpm 秒重復(fù)一次,bpm = 60,因此調(diào)度器每 1 秒觸發(fā)一次,也就是每分鐘 60 次。
  • Metronome 視圖由三個(gè)層組成:MetronomeBack、MetronomePendulum 和 MetronomeFront,它們是按這個(gè)順序疊加的,唯一需要在每次時(shí)間軸更新時(shí)刷新的視圖是 MetronomePendulum,它會(huì)從一邊擺動(dòng)到另一邊,其它視圖不會(huì)刷新,因?yàn)樗鼈儧]有依賴項(xiàng)。
  • MetronomeBack 和 MetronomeFront 的代碼非常簡(jiǎn)潔,它們使用了一個(gè)名為 rounded 梯形的自定義形狀:
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)} } struct RoundedTrapezoid: Shape {let pct: CGFloatlet cornerSizes: [CGSize]func path(in rect: CGRect) -> Path {return Path { path inlet (cs1, cs2, cs3, cs4) = decodeCornerSize()// Start of pathlet start = CGPoint(x: rect.midX, y: 0)// width base and toplet wb = rect.size.widthlet wt = wb * pct// angleslet angle: CGFloat = atan(Double(rect.height / ((wb - wt) / 2.0)))// Control pointslet c1 = CGPoint(x: (wb - wt) / 2.0, y: 0)let c2 = CGPoint(x: c1.x + wt, y: 0)let c3 = CGPoint(x: wb, y: rect.maxY)let c4 = CGPoint(x: 0, y: rect.maxY)// Points a and blet pa2 = CGPoint(x: c2.x - cs2.width, y: 0)let pb2 = CGPoint(x: c2.x + CGFloat(cs2.height * tan((.pi/2) - angle)), y: cs2.height)let pb3 = CGPoint(x: c3.x - cs3.width, y: rect.height)let pa3 = CGPoint(x: c3.x - (cs3.height != 0 ? CGFloat(tan(angle) / cs3.height) : 0.0), y: rect.height - cs3.height)let pa4 = CGPoint(x: c4.x + cs4.width, y: rect.height)let pb4 = CGPoint(x: c4.x + (cs4.height != 0 ? CGFloat(tan(angle) / cs4.height) : 0.0), y: rect.height - cs4.height)let pb1 = CGPoint(x: c1.x + cs1.width, y: 0)let pa1 = CGPoint(x: c1.x - CGFloat(cs1.height * tan((.pi/2) - angle)), y: cs1.height)path.move(to: start)path.addLine(to: pa2)path.addQuadCurve(to: pb2, control: c2)path.addLine(to: pa3)path.addQuadCurve(to: pb3, control: c3)path.addLine(to: pa4)path.addQuadCurve(to: pb4, control: c4)path.addLine(to: pa1)path.addQuadCurve(to: pb1, control: c1)path.closeSubpath()}}func decodeCornerSize() -> (CGSize, CGSize, CGSize, CGSize) {if cornerSizes.count == 1 {// If only one corner size is provided, use it for all cornersreturn (cornerSizes[0], cornerSizes[0], cornerSizes[0], cornerSizes[0])} else if cornerSizes.count == 2 {// If only two corner sizes are provided, use one for the two top corners,// and the other for the two bottom cornersreturn (cornerSizes[0], cornerSizes[0], cornerSizes[1], cornerSizes[1])} else if cornerSizes.count == 4 {// If four corners are provided, use one for each cornerreturn (cornerSizes[0], cornerSizes[1], cornerSizes[2], cornerSizes[3])} else {// In any other case, do not round cornersreturn (.zero, .zero, .zero, .zero)}} }
  • 在 MetronomePendulum 視圖中:
struct MetronomePendulum: View {@State var pendulumOnLeft: Bool = false@State var bellCounter = 0 // sound bell every 4 beatslet bpm: Doublelet date: Datevar 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 animationbellCounter = (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: Doublevar 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)}} }
  • 視圖需要跟蹤在動(dòng)畫中的位置,可以叫做動(dòng)畫階段,因?yàn)樾枰欉@些階段,所以將使用 @State 變量:
    • pendulumOnLeft:保持鐘擺擺動(dòng)的軌跡;
    • bellCounter:它記錄節(jié)拍的數(shù)量,以確定是否應(yīng)該聽到節(jié)拍或鈴聲。
  • 這個(gè)例子使用了 .animation(_:value:) 修飾符,此版本的修飾符,是在指定值改變時(shí)應(yīng)用動(dòng)畫。注意,也可以使用顯式動(dòng)畫,只需在 withAnimation 閉包中切換 pendulumOnLeft 變量,而不是調(diào)用 .animation()。
  • 為了讓視圖在動(dòng)畫階段中前進(jìn),我們使用 onChange(of:perform) 修飾符監(jiān)視日期的變化。除了在每次日期值改變時(shí)推進(jìn)動(dòng)畫階段外,我們還在 onAppear 閉包中這樣做,否則一開始就會(huì)有停頓。
  • 最后是創(chuàng)建 NSSound 實(shí)例,為了避免例子過于復(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) }()
  • 如果需要聲音文件,可以在 https://freesound.org/ 上找到一個(gè)大型數(shù)據(jù)庫,其中一個(gè)例子如下:
    • bell 聲音:metronome_pling;
    • beat 聲音:metronome.wav。

六、時(shí)間調(diào)度器 TimelineScheduler

  • TimelineView 需要一個(gè) TimelineScheduler 來決定何時(shí)更新它的內(nèi)容,SwiftUI 提供了一些預(yù)定義的調(diào)度器,但是也可以創(chuàng)建自己的自定義調(diào)度程序。
  • 時(shí)間軸調(diào)度器基本上是一個(gè)采用 TimelineScheduler 協(xié)議的結(jié)構(gòu)體,現(xiàn)有的類型有:
    • AnimationTimelineSchedule:盡可能快地更新,讓你有機(jī)會(huì)繪制動(dòng)畫的每一幀,它的參數(shù)允許限制更新的頻率,并暫停更新,這個(gè)在結(jié)合 TimelineView 和新的 Canvas 視圖時(shí)非常有用;
    • EveryMinuteTimelineSchedule:顧名思義,它每分鐘更新一次,在每分鐘的開始;
    • ExplicitTimelineSchedule:你可以提供一個(gè)數(shù)組,其中包含你希望時(shí)間軸更新的所有時(shí)間;
    • PeriodicTimelineSchedule:你可以提供一個(gè)開始時(shí)間和更新發(fā)生的頻率。
  • 可以這樣創(chuàng)建時(shí)間線:
Timeline(EveryMinuteTimelineSchedule()) { timeline in... }
  • 自從 Swift 5.5 和 SE-0299 的引入,支持類 enum 語法,這使得代碼更具可讀性,并提高了自動(dòng)完成功能,可以使用如下:
TimelineView(.everyMinute) { timeline in... }
  • 對(duì)于每個(gè)現(xiàn)有的調(diào)度器,可能有多個(gè)類似 enum 的選項(xiàng),如下所示,兩行代碼創(chuàng)建一個(gè) AnimationTimelineSchedule 類型的調(diào)度程序:
TimelineView(.animation) { ... }TimelineView(.animation(minimumInterval: 0.3, paused: false)) { ... }
  • 甚至可以創(chuàng)建自己的(不要忘記靜態(tài)關(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)畫:

  • 在這個(gè)動(dòng)畫中,我們有一個(gè)心形的表情符號(hào),它以不規(guī)則的間隔和不規(guī)則的振幅改變其大小:它從 1.0 開始,0.2 秒后增長(zhǎng)到 1.6,0.2 秒后增長(zhǎng)到 2.0,然后收縮到 1.0,停留 0.4 秒,然后重新開始,換句話說:
    • 縮放變化:1.0→1.6→2.0 → 重新開始;
    • 更改間隔時(shí)間:0.2→0.2→0.4→ 重新啟動(dòng)。
  • 我們可以創(chuàng)建一個(gè) HeartTimelineSchedule,它完全按照心臟的要求進(jìn)行更新,但是,在可重用性的名義下,讓我們做一些更通用的,可以在將來重用的東西。新調(diào)度器將被調(diào)用:CyclicTimelineSchedule,并將接收一個(gè)時(shí)間偏移量數(shù)組,每個(gè)偏移值將相對(duì)于數(shù)組中的前一個(gè)值,當(dāng)調(diào)度器耗盡偏移量時(shí),它將循環(huán)回到數(shù)組的開頭并重新開始:
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: Datelet offsets: [TimeInterval]var idx: Int = -1mutating func next() -> Date? {idx = (idx + 1) % offsets.countlast = last.addingTimeInterval(offsets[idx])return last}} }
  • 實(shí)現(xiàn) TimelineSchedule 有幾個(gè)要求:
    • 提供 entries(from:mode:) 函數(shù);
    • Entries 的類型符合序列:Entries.Element == Date;
  • 有幾種符合 Sequence 的方法,這個(gè)例子實(shí)現(xiàn)了 IteratorProtocol,并聲明了與 Sequence 和 IteratorProtocol 的一致性,可以參考Sequence。
  • 為了讓 Entries 實(shí)現(xiàn) IteratorProtocol,必須編寫 next() 函數(shù),該函數(shù)在時(shí)間軸中生成日期,調(diào)度程序記住了最后一個(gè)日期并添加了適當(dāng)?shù)钠屏?#xff0c;當(dāng)沒有更多的偏移量時(shí),它循環(huán)回到數(shù)組中的第一個(gè)偏移量。最后,調(diào)度器是創(chuàng)建一個(gè)類似 enum 的初始化器:
extension TimelineSchedule where Self == CyclicTimelineSchedule {static func cyclic(timeOffsets: [TimeInterval]) -> CyclicTimelineSchedule {.init(timeOffsets: timeOffsets)} }
  • 現(xiàn)在已經(jīng)準(zhǔn)備好了 TimelineSchedue 類型,繼續(xù)添加:
struct BeatingHeart: View {var body: some View {TimelineView(.cyclic(timeOffsets: [0.2, 0.2, 0.4])) { timeline inHeart(date: timeline.date)}} }struct Heart: View {@State private var phase = 0let scales: [CGFloat] = [1.0, 1.6, 2.0]let date: Datevar 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) { _ inadvanceAnimationPhase()}.onAppear {advanceAnimationPhase()}}}func advanceAnimationPhase() {phase = (phase + 1) % scales.count} }
  • 現(xiàn)在應(yīng)該熟悉這個(gè)模式,它和節(jié)拍器上用的是同一個(gè)模式,使用 onChange 和 onAppear 來推進(jìn)動(dòng)畫,使用 @State 變量來跟蹤動(dòng)畫,并設(shè)置一個(gè)動(dòng)畫,將視圖從一個(gè)時(shí)間軸更新過渡到下一個(gè),使用 .spring 動(dòng)畫,給它一個(gè)很好的抖動(dòng)效果。

八、關(guān)鍵幀動(dòng)畫 KeyFrame Animations

  • 上面的例子,在某種程度上,是關(guān)鍵幀動(dòng)畫,在整個(gè)動(dòng)畫中定義了幾個(gè)關(guān)鍵點(diǎn),在這些關(guān)鍵點(diǎn)上我們改變了視圖的參數(shù),并讓 SwiftUI 對(duì)這些關(guān)鍵點(diǎn)之間的過渡進(jìn)行動(dòng)畫處理,如下所示的示例,可以完全詮釋這種處理:

  • 如果你仔細(xì)觀察這個(gè)動(dòng)畫,會(huì)發(fā)現(xiàn)這個(gè)表情符號(hào)的許多參數(shù)在不同的時(shí)間點(diǎn)發(fā)生了變化,這些參數(shù)是 y-offset,rotation 和 y-scale。同樣重要的是,動(dòng)畫的不同部分,有不同的動(dòng)畫類型(線性,easeIn 和 easeOut),因?yàn)檫@些是要更改的參數(shù),所以最好將它們放在一個(gè)數(shù)組中:
struct KeyFrame {let offset: TimeInterval let rotation: Doublelet yScale: Doublelet y: CGFloatlet animation: Animation? }let keyframes = [// Initial state, will be used once. Its offset is useless and will be ignoredKeyFrame(offset: 0.0, rotation: 0, yScale: 1.0, y: 0, animation: nil),// Animation keyframesKeyFrame(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í),它將繪制視圖,即使沒有計(jì)劃更新,或如果他們是在未來,需要用第一個(gè)關(guān)鍵幀來表示視圖的狀態(tài),但當(dāng)循環(huán)時(shí),這個(gè)幀會(huì)被忽略。來看看時(shí)間線:
struct JumpingEmoji: View {// Use all offset, minus the firstlet offsets = Array(keyframes.map { $0.offset }.dropFirst())var body: some View {TimelineView(.cyclic(timeOffsets: offsets)) { timeline inHappyEmoji(date: timeline.date)}} }
  • 我們已經(jīng)從前面的例子中所做的工作以及重用 CyclicTimelineScheduler 中受益,正如前面提到的,不需要第一個(gè)關(guān)鍵幀的偏移量,所以丟棄它,如下所示:
struct HappyEmoji: View {// current keyframe number@State var idx: Int = 0// timeline updatelet date: Datevar 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 keyframeidx = (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: KeyFramefunc 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 的修飾符中。正如你所看到的,這又是同樣的模式:使用 onChange 和 onAppear 來推進(jìn)動(dòng)畫,并為每個(gè)關(guān)鍵幀片段添加一個(gè)動(dòng)畫。
  • 在發(fā)現(xiàn) TimelineView 的路徑中,可能會(huì)遇到這個(gè)錯(cuò)誤:
Action Tried to Update Multiple Times Per Frame
  • 來看一個(gè)生成這個(gè)錯(cuò)誤的例子:
struct ExampleView: View {@State private var flag = falsevar body: some View {TimelineView(.periodic(from: .now, by: 2.0)) { timeline inText("Hello").foregroundStyle(flag ? .red : .blue).onChange(of: timeline.date) { (date: Date) inflag.toggle()}}} }
  • 這段代碼看起來是無害的,它應(yīng)該每?jī)擅腌姼淖兾谋绢伾?#xff0c;在紅色和藍(lán)色之間交替,那么這是怎么回事呢?我們知道,時(shí)間軸的第一次更新是在它第一次出現(xiàn)時(shí),然后它遵循調(diào)度程序規(guī)則來觸發(fā)下面的更新。因此,即使調(diào)度器沒有產(chǎn)生更新,TimelineView 內(nèi)容至少會(huì)生成一次。在這個(gè)特定的示例中,我們監(jiān)視時(shí)間軸中的 timeline.date 值,當(dāng)它發(fā)生變化時(shí),切換標(biāo)志變量,這將產(chǎn)生顏色變化。
  • TimelineView 將首先出現(xiàn),兩秒鐘后,時(shí)間軸將更新(例如,由于第一次調(diào)度程序更新),觸發(fā) onChange 閉包,這將反過來改變標(biāo)志變量。現(xiàn)在,由于 TimelineView 依賴于它,它將需要立即刷新,觸發(fā)另一個(gè)標(biāo)記變量的切換,迫使另一個(gè) TimelineView 刷新等,每幀多次更新。
  • 那么該如何解決這個(gè)問題呢?在本例中,我們簡(jiǎn)單地封裝了內(nèi)容,并將標(biāo)志變量移動(dòng)到被封裝的視圖中,現(xiàn)在TimelineView不再依賴于它:
struct ExampleView: View {var body: some View {TimelineView(.periodic(from: .now, by: 1.0)) { timeline inSubView(date: timeline.date)}} }struct SubView: View {@State private var flag = falselet date: Datevar body: some View {Text("Hello").foregroundStyle(flag ? .red : .blue).onChange(of: date) { (date: Date) inflag.toggle()}} }

九、探索新思路

  • 每次時(shí)間線更新刷新一次:正如之前提到的,這個(gè)模式讓我們的視圖在每次更新時(shí)計(jì)算它們的 body 兩次:第一次是在時(shí)間軸更新時(shí),然后是在推進(jìn)動(dòng)畫狀態(tài)值時(shí),在這種類型的動(dòng)畫中,在時(shí)間上間隔了關(guān)鍵點(diǎn)。
  • 在動(dòng)畫中,時(shí)間點(diǎn)太近,也許需要避免這種情況,如果你需要更改一個(gè)存儲(chǔ)值,但避免視圖刷新……有一個(gè)技巧可以做到,使用 @StateObject 代替 @State,確保不要 @Published 這樣的值。如果在某些時(shí)候,你需要告訴視圖要刷新,可以調(diào)用 objectWillChange.send()。
  • 匹配動(dòng)畫持續(xù)時(shí)間和偏移量:在關(guān)鍵幀的示例中,為每個(gè)動(dòng)畫片段使用不同的動(dòng)畫。為此,將 Animation 的值存儲(chǔ)在數(shù)組中。如果你仔細(xì)看,可以發(fā)現(xiàn)在我們的例子中,偏移量和動(dòng)畫持續(xù)時(shí)間是匹配的,因此可以定義一個(gè)帶有動(dòng)畫類型的 enum,而不是在數(shù)組中使用 Animation 值。稍后在視圖中,將基于動(dòng)畫類型創(chuàng)建動(dòng)畫值,但使用從偏移值開始的持續(xù)時(shí)間對(duì)其進(jìn)行實(shí)例化,如下所示:
enum KeyFrameAnimation {case nonecase linearcase easeOutcase easeIn }struct KeyFrame {let offset: TimeInterval let rotation: Doublelet yScale: Doublelet y: CGFloatlet animationKind: KeyFrameAnimationvar animation: Animation? {switch animationKind {case .none: return nilcase .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 ignoredKeyFrame(offset: 0.0, rotation: 0, yScale: 1.0, y: 0, animationKind: .none),// Animation keyframesKeyFrame(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), ]
  • 如果你想知道為什么我一開始不這樣做,這里只是想告訴說明兩種方法都是可能的,第一種情況更靈活,但更冗長(zhǎng)。也就是說,需要強(qiáng)制指定每個(gè)動(dòng)畫的持續(xù)時(shí)間,但是它卻更靈活,因?yàn)榭梢宰杂傻厥褂门c偏移量不匹配的持續(xù)時(shí)間。然而,當(dāng)使用這種新方法時(shí),可以很容易地添加一個(gè)可定制的因素,它可以放慢或加快動(dòng)畫,而不需要觸碰關(guān)鍵幀。
  • 嵌套 TimelineViews:沒有什么可以阻止你將一個(gè) TimelineView 嵌套到另一個(gè) TimelineView 中,現(xiàn)在有一個(gè) JumpingEmoji,可以在 TimelineView 中放置三個(gè) JumpingEmoji 視圖,讓它們一次出現(xiàn)一個(gè),并帶有延遲:

  • emoji wave 的完整示例如下:
import SwiftUIstruct CyclicTimelineSchedule: TimelineSchedule {let timeOffsets: [TimeInterval]func entries(from startDate: Date, mode: TimelineScheduleMode) -> Entries {Entries(last: startDate, offsets: timeOffsets)}struct Entries: Sequence, IteratorProtocol {var last: Datelet offsets: [TimeInterval]var idx: Int = -1mutating func next() -> Date? {idx = (idx + 1) % offsets.countlast = last.addingTimeInterval(offsets[idx])return last}} }extension TimelineSchedule where Self == CyclicTimelineSchedule {static func cyclic(timeOffsets: [TimeInterval]) -> CyclicTimelineSchedule {.init(timeOffsets: timeOffsets)} }enum KeyFrameAnimation {case nonecase linearcase easeOutcase easeIn }struct KeyFrame {let offset: TimeInterval let rotation: Doublelet yScale: Doublelet y: CGFloatlet animationKind: KeyFrameAnimationvar animation: Animation? {switch animationKind {case .none: return nilcase .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 ignoredKeyFrame(offset: 0.0, rotation: 0, yScale: 1.0, y: 0, animationKind: .none),// Animation keyframesKeyFrame(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), ]struct ManyEmojis: View {@State var emojiCount = 0let dates: [Date] = [.now.addingTimeInterval(0.3), .now.addingTimeInterval(0.6), .now.addingTimeInterval(0.9)]var body: some View {TimelineView(.explicit(dates)) { timeline inHStack(spacing: 80) {if emojiCount > 0 {JumpingEmoji(emoji: "😃")}if emojiCount > 1 {JumpingEmoji(emoji: "😎")}if emojiCount > 2 {JumpingEmoji(emoji: "😉")}Spacer()}.onChange(of: timeline.date) { (date: Date) inemojiCount += 1}.frame(width: 400)}} }struct JumpingEmoji: View {let emoji: String// Use all offset, minus the firstlet offsets = Array(keyframes.map { $0.offset }.dropFirst())var body: some View {TimelineView(.cyclic(timeOffsets: offsets)) { timeline inHappyEmoji(emoji: emoji, date: timeline.date)}} }struct HappyEmoji: View {let emoji: String// current keyframe number@State var idx: Int = 0// timeline updatelet date: Datevar body: some View {Text(emoji).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 keyframeidx = (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: KeyFramefunc body(content: Content) -> some View {content.scaleEffect(CGSize(width: 1.0, height: keyframe.yScale)).rotationEffect(Angle(degrees: keyframe.rotation)).offset(y: keyframe.y)}} }

十、GifImage 示例

  • 使用 TimelineView 來實(shí)現(xiàn)動(dòng)畫 gif 動(dòng)畫:
import SwiftUI// Sample usage struct ContentView: View {var body: some View {VStack {GifImage(url: URL(string: "https://media.giphy.com/media/YAlhwn67KT76E/giphy.gif?cid=790b7611b26260b2ad23535a70e343e67443ff80ef623844&rid=giphy.gif&ct=g")!).padding(10).overlay {RoundedRectangle(cornerRadius: 8).stroke(.green)}}.frame(maxWidth: .infinity, maxHeight: .infinity)} }// ObservableObject that holds the data and logic to get all frames in the gif image. class GifData: ObservableObject {var loopCount: Int = 0var width: CGFloat = 0var height: CGFloat = 0var capInsets: EdgeInsets?var resizingMode: Image.ResizingModestruct ImageFrame {let image: Imagelet delay: TimeInterval}var frames: [ImageFrame] = []init(url: URL, capInsets: EdgeInsets?, resizingMode: Image.ResizingMode) {self.capInsets = capInsetsself.resizingMode = resizingModelet label = url.deletingPathExtension().lastPathComponentTask {guard let (data, _) = try? await URLSession.shared.data(from: url) else { return }guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return }let imageCount = CGImageSourceGetCount(source)guard let imgProperties = CGImageSourceCopyProperties(source, nil) as? Dictionary<String, Any> else { return }guard let gifProperties = imgProperties[kCGImagePropertyGIFDictionary as String] as? Dictionary<String, Any> else { return }loopCount = gifProperties[kCGImagePropertyGIFLoopCount as String] as? Int ?? 0width = gifProperties[kCGImagePropertyGIFCanvasPixelWidth as String] as? CGFloat ?? 0height = gifProperties[kCGImagePropertyGIFCanvasPixelHeight as String] as? CGFloat ?? 0let frameInfo = gifProperties[kCGImagePropertyGIFFrameInfoArray as String] as? [Dictionary<String, TimeInterval>] ?? []for i in 0 ..< min(imageCount, frameInfo.count) {if let image = CGImageSourceCreateImageAtIndex(source, i, nil) {var img = Image(image, scale: 1.0, label: Text(label))if let insets = capInsets {img = img.resizable(capInsets: insets, resizingMode: resizingMode)}frames.append(ImageFrame(image: img,delay: frameInfo[i][kCGImagePropertyGIFDelayTime as String] ?? 0.05))}}DispatchQueue.main.async { self.objectWillChange.send() }}} }// The GifImage view struct GifImage: View {@StateObject var gifData: GifData/// Create an animated Gif Image/// - Parameters:/// - url: the url holding the animated gif file/// - capInsets: if nil, image is not resizable. Otherwise, the capInsets for image resizing (same as the standard image .resizable() modifier)./// - resizingMode: ignored if capInsets is nil, otherwise, equivalent to the standard image .resizable() modifier parameter)init(url: URL, capInsets: EdgeInsets? = nil, resizingMode: Image.ResizingMode = .stretch) {_gifData = StateObject(wrappedValue: GifData(url: url, capInsets: capInsets, resizingMode: resizingMode))}var body: some View {Group {if gifData.frames.count == 0 {Color.clear} else {VStack {TimelineView(.cyclic(loopCount: gifData.loopCount, timeOffsets: gifData.frames.map { $0.delay })) { timeline inImageFrame(gifData: gifData, date: timeline.date)}}}}}struct ImageFrame: View {@State private var frame = 0let gifData: GifDatalet date: Datevar body: some View {gifData.frames[frame].image.onChange(of: date) { _ inframe = (frame + 1) % gifData.frames.count}}} }// A cyclic TimelineSchedule struct CyclicTimelineSchedule: TimelineSchedule {let loopCount: Int // loopCount == 0 means inifinite loops.let timeOffsets: [TimeInterval]func entries(from startDate: Date, mode: TimelineScheduleMode) -> Entries {Entries(loopCount: loopCount, last: startDate, offsets: timeOffsets)}struct Entries: Sequence, IteratorProtocol {let loopCount: Intvar loops = 0var last: Datelet offsets: [TimeInterval]var idx: Int = -1mutating func next() -> Date? {idx = (idx + 1) % offsets.countif idx == 0 { loops += 1 }if loopCount != 0 && loops >= loopCount { return nil }last = last.addingTimeInterval(offsets[idx])return last}} }extension TimelineSchedule where Self == CyclicTimelineSchedule {static func cyclic(loopCount: Int, timeOffsets: [TimeInterval]) -> CyclicTimelineSchedule {.init(loopCount: loopCount, timeOffsets: timeOffsets)} }

總結(jié)

以上是生活随笔為你收集整理的SwiftUI之深入解析高级动画的时间轴TimelineView的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問題。

如果覺得生活随笔網(wǎng)站內(nèi)容還不錯(cuò),歡迎將生活随笔推薦給好友。