生活随笔
收集整理的這篇文章主要介紹了
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
in ViewToEvaluatePeriodically ( ) }
另一個(gè)參數(shù)是接收 TimelineView 的內(nèi)容閉包,上下文參數(shù)看起來像這樣:
struct Context { let cadence
: Cadence let date
: Date enum Cadence : Comparable { case live
case seconds
case 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
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 ) } }
}
為什么左邊的表情變了,而另一個(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 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è)最簡單的 TimelineView 時(shí)鐘如下:
TimelineView ( . periodic ( from
: . now
, by
: 1.0 ) ) { timeline
in Text ( "\( 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
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ì)刷新兩次,也就是說,當(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è)鈴聲響起:
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
) } }
}
節(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 的代碼非常簡潔,它們使用了一個(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
: CGFloat let cornerSizes
: [ CGSize ] func path ( in rect
: CGRect ) - > Path { return Path { path
in let ( cs1
, cs2
, cs3
, cs4
) = decodeCornerSize ( ) let start
= CGPoint ( x
: rect
. midX
, y
: 0 ) let wb
= rect
. size
. width
let wt
= wb
* pct
let angle
: CGFloat = atan ( Double ( rect
. height
/ ( ( wb
- wt
) / 2.0 ) ) ) let 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
) let 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 { return ( cornerSizes
[ 0 ] , cornerSizes
[ 0 ] , cornerSizes
[ 0 ] , cornerSizes
[ 0 ] ) } else if cornerSizes
. count == 2 { return ( cornerSizes
[ 0 ] , cornerSizes
[ 0 ] , cornerSizes
[ 1 ] , cornerSizes
[ 1 ] ) } else if cornerSizes
. count == 4 { return ( cornerSizes
[ 0 ] , cornerSizes
[ 1 ] , cornerSizes
[ 2 ] , cornerSizes
[ 3 ] ) } else { return ( . zero
, . zero
, . zero
, . zero
) } }
}
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 ) } }
}
視圖需要跟蹤在動(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è)例子如下:
六、時(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 秒后增長到 1.6,0.2 秒后增長到 2.0,然后收縮到 1.0,停留 0.4 秒,然后重新開始,換句話說: 更改間隔時(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 : 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è)要求: 提供 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
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)該熟悉這個(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
: 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 ) ) ,
]
當(dāng) TimelineView 出現(xiàn)時(shí),它將繪制視圖,即使沒有計(jì)劃更新,或如果他們是在未來,需要用第一個(gè)關(guān)鍵幀來表示視圖的狀態(tài),但當(dāng)循環(huán)時(shí),這個(gè)幀會(huì)被忽略。來看看時(shí)間線:
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
) } }
}
我們已經(jīng)從前面的例子中所做的工作以及重用 CyclicTimelineScheduler 中受益,正如前面提到的,不需要第一個(gè)關(guān)鍵幀的偏移量,所以丟棄它,如下所示:
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
) } }
}
為了更好的可讀性,可以將所有更改的參數(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
= 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 ( ) } } }
}
這段代碼看起來是無害的,它應(yīng)該每兩秒鐘改變文本顏色,在紅色和藍(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è)問題呢?在本例中,我們簡單地封裝了內(nèi)容,并將標(biāo)志變量移動(dòng)到被封裝的視圖中,現(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 ( ) } }
}
九、探索新思路
每次時(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 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
) ,
]
如果你想知道為什么我一開始不這樣做,這里只是想告訴說明兩種方法都是可能的,第一種情況更靈活,但更冗長。也就是說,需要強(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è),并帶有延遲:
import SwiftUI 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 } }
} extension TimelineSchedule where Self == CyclicTimelineSchedule { static func cyclic ( timeOffsets
: [ TimeInterval ] ) - > CyclicTimelineSchedule { . init ( timeOffsets
: timeOffsets
) }
} 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
) ,
] struct ManyEmojis : View { @
State var emojiCount
= 0 let dates
: [ Date ] = [ . now
. addingTimeInterval ( 0.3 ) , . now
. addingTimeInterval ( 0.6 ) , . now
. addingTimeInterval ( 0.9 ) ] var body
: some
View { TimelineView ( . explicit ( dates
) ) { timeline
in HStack ( spacing
: 80 ) { if emojiCount
> 0 { JumpingEmoji ( emoji
: "😃" ) } if emojiCount
> 1 { JumpingEmoji ( emoji
: "😎" ) } if emojiCount
> 2 { JumpingEmoji ( emoji
: "😉" ) } Spacer ( ) } . onChange ( of
: timeline
. date
) { ( date
: Date ) in emojiCount
+ = 1 } . frame ( width
: 400 ) } }
} struct JumpingEmoji : View { let emoji
: String let offsets
= Array ( keyframes
. map { $
0 . offset
} . dropFirst ( ) ) var body
: some
View { TimelineView ( . cyclic ( timeOffsets
: offsets
) ) { timeline
in HappyEmoji ( emoji
: emoji
, date
: timeline
. date
) } }
} struct HappyEmoji : View { let emoji
: String @
State var idx
: Int = 0 let date
: Date var 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 ( ) { 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
) } }
}
十、GifImage 示例
使用 TimelineView 來實(shí)現(xiàn)動(dòng)畫 gif 動(dòng)畫:
import SwiftUI
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
) }
}
class GifData : ObservableObject { var loopCount
: Int = 0 var width
: CGFloat = 0 var height
: CGFloat = 0 var capInsets
: EdgeInsets ? var resizingMode
: Image . ResizingMode struct ImageFrame { let image
: Image let delay
: TimeInterval } var frames
: [ ImageFrame ] = [ ] init ( url
: URL , capInsets
: EdgeInsets ? , resizingMode
: Image . ResizingMode ) { self . capInsets
= capInsets
self . resizingMode
= resizingMode
let label
= url
. deletingPathExtension ( ) . lastPathComponent
Task { 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 ? ? 0 width
= gifProperties
[ kCGImagePropertyGIFCanvasPixelWidth as String ] as ? CGFloat ? ? 0 height
= gifProperties
[ kCGImagePropertyGIFCanvasPixelHeight as String ] as ? CGFloat ? ? 0 let 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 ( ) } } }
}
struct GifImage : View { @
StateObject var gifData
: GifData 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
in ImageFrame ( gifData
: gifData
, date
: timeline
. date
) } } } } } struct ImageFrame : View { @
State private var frame
= 0 let gifData
: GifData let date
: Date var body
: some
View { gifData
. frames
[ frame
] . image
. onChange ( of
: date
) { _ in frame
= ( frame
+ 1 ) % gifData
. frames
. count } } }
}
struct CyclicTimelineSchedule : TimelineSchedule { let loopCount
: Int let timeOffsets
: [ TimeInterval ] func entries ( from startDate
: Date , mode
: TimelineScheduleMode ) - > Entries { Entries ( loopCount
: loopCount
, last : startDate
, offsets
: timeOffsets
) } struct Entries : Sequence , IteratorProtocol { let loopCount
: Int var loops
= 0 var last : Date let offsets
: [ TimeInterval ] var idx
: Int = - 1 mutating func next ( ) - > Date ? { idx
= ( idx
+ 1 ) % offsets
. count if 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 的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
如果覺得生活随笔 網(wǎng)站內(nèi)容還不錯(cuò),歡迎將生活随笔 推薦給好友。