繪圖動畫基礎-文字篇
-
跑馬燈 Text Animation
所謂「跑馬燈(Marquee)」就是一段文字從右到左跑過顯示器的效果,經常會在銀行、醫院、車站等公眾場所看到,對於有限的顯示空間(例如LED顯示器)特別好用。本單元模擬火車站月台上看到的「南下列車將於1分鐘後進站」當做範例,用SwiftUI寫一個跑馬燈的程式。
在第2單元介紹過SwiftUI基本觀念與物件,從核心觀念「視圖(View)」,到各類視圖物件與視圖修飾語(View modifier),不過限於篇幅,著重介紹版面編排(Layout)相關物件,包括VStack/HStack/ZStack、LazyVGrid、ForEach、List等。
接下來我們準備學習SwiftUI另一個重要組成:動畫與平面繪圖,核心物件包括Animation, TimelineView, Canvas, Path等,如果說第2、第3單元是以靜態的資料圖表為主,那麼第4單元則開始「動態視覺化」,並適時加入按鈕、觸控手勢等互動元件,讓使用者的互動體驗更完善。
操作步驟如下:
選取畫面左方的「+」號新增電子書頁面。
將新增的電子書面頁命名為「(26)SwiftUI動畫-1文字篇」。
在「Main」模組中撰寫程式:
// 4-1a 跑馬燈 Text Animation // Created by Philip, Heman, Jean 2022/02/27 // Revised by Jean 2025/01/20 import PlaygroundSupport import SwiftUI var 訊息 = "南下列車即將於1分鐘後進站" struct 跑馬燈: View { @State var 位移: CGFloat = 450 let 動畫效果 = Animation.linear(duration: 10.0) var body: some View { Text(訊息) .foregroundColor(.purple) .font(.system(size: 36)) .frame(width: 800.0) .lineLimit(1) .offset(x: 位移, y: 0) .onAppear { withAnimation(動畫效果.delay(2).repeatForever(autoreverses: false)) { 位移 = -450 } } } } PlaygroundPage.current.setLiveView(跑馬燈())
程式執行結果,如下圖。
這段文字「南下列車即將於1分鐘後進站」之所以能動起來,主要就是利用SwiftUI的「Animation物件」,使用方法分為兩個步驟。
第一,先定義一個Animation物件實例:
let 動畫效果 = Animation.linear(duration: 10.0)
Animation 是物件類型(Type),有多種類型方法(Type method)可用來產出物件實例,這幾種方法分別對應不同的時間曲線,我們採用的 linear(duration: 10.0) 是線性變化,設定變化時間是10秒。
SwiftUI Animation 物件支援的時間曲線可參考下圖,包括線性 linear、緩入 easeIn、緩出 easeOut、緩入緩出 easeInOut、彈簧 spring、可自行定義時間曲線 timingCurve...等等。只有linear是定速,變化最平均,其他非線性的時間曲線,速度都會有所變化,產生更豐富的動畫效果。
圖片來源:https://www.objc.io/blog/2019/09/26/swiftui-animation-timing-curves/
第二步驟是在視圖一出現時(.onAppear),呼叫 withAnimation() 啟用動畫效果,withAnimation() 是一個「全域函式」(global function),意思是函式的有效範圍最大,在程式任何地方均可呼叫,同時也表示 withAnimation() 不屬於任何物件,這在SwiftUI物件庫中是比較少見的。
withAnimation() 需要一個Animation物件實例當作參數,之後再套一層「匿名函式」:
.onAppear { withAnimation(動畫效果.repeatForever(autoreverses: false)) { 位移 = -450 } }
這裡參數就用剛定義出來的Animation物件實例「動畫效果」,每個Animation物件實例都帶有若干實例方法(Instance method),可用來調整動畫行為,用法與視圖修飾語(View modifier)類似。Animation的實例方法包括:
- .delay(1.5) — 延遲1.5秒後再開始動畫
- .repeatCount(3) — 反覆動畫3次
- .repeatForever() — 無限次反覆
- .speed(0.5) — 動畫速度降為原來的0.5倍其中repeatCount()與repeatForever()還可再加一個參數 autoreverses,表示要不要「倒轉」,預設是要倒轉(autoreverses: true)。如果4-1a範例程式用了預設倒轉,文字就會變成左右來回跑,而不是單向從右到左的跑馬燈,所以要加上(autoreverses: false)參數。
在後面跟隨的匿名函式中,我們將狀態變數(@State var)「位移」從原本的 450(單位是畫素「點」)改為-450,這個改變會觸發視圖的狀態變化,從而啟動動畫效果,關鍵的幾行程式碼如下:
@State var 位移: CGFloat = 450 let 動畫效果 = Animation.linear(duration: 10.0) Text(訊息) .offset(x: 位移, y: 0) .onAppear { withAnimation(動畫效果.repeatForever(autoreverses: false)) { 位移 = -450 } }
當「Text(訊息)」的位移(offset)從螢幕中心點右移450點(x=450)變成左移450點(x=-450)時,withAnimation() 的作用就在這裡啟動。
Animation 物件會用內插法計算10秒鐘內「位移」值的變化,由於我們選擇線性(linear)時間曲線,所以平均每秒鐘要變化90點((450 - (-450)) / 10),一開始是右移450點(x=450)、第1秒後變右移360點、第2秒變右移270點...,每秒往左移動90點。
為了讓動畫順暢,當然不會是一秒鐘才變化一次,我們都知道視覺暫留的原理,電影大約是每秒30幅畫面,有些動作快的遊戲還要求每秒60幅或120幅,這個速度稱為 frame per second (fps)。
如果要達成每秒30幅畫面(30 fps),Animation 每秒鐘需要計算30次,產生30幅視圖畫面,也就是每0.0333秒就計算一次並更新視圖畫面,這樣動畫效果才會順暢。
基本的6種文字動畫
上一節提到產生動畫效果的兩個步驟,第一步是產出Animation的物件實例,第二步是呼叫 withAnimation() 來啟動動畫效果,第二步除了用全域函式withAnimation()之外,也可用視圖修飾語 .animation()。兩個步驟相關參數歸納如下圖:
步驟一要產出Animation物件類型,除了用類型方法(Type method)之外,SwiftUI 也定義了同名的類型屬性(Type property),也就是語法上,用 Animation.linear() 或 Animation.linear 都可以,但後者沒有參數,只能用預設值(如 duration 1秒鐘左右)。
從上圖可以歸納出,要控制Animation動畫行為可利用以下幾個參數,前2個參數在步驟一設定,後4個在步驟二可選擇改變:
1. 時間曲線(timing curve)
2. 變化時間(duration)
3. 是否延遲(delay)
4. 加速或減速(speed)
5. 重複多次或無限循環(repeatCount/repeatForever)
6. 是否倒轉(autoreverses)若不用循環(repeatCount/repeatForever),則動畫預設只會執行一次。
步驟二用全域函式 withAnimation() 或修飾語 .animation() 啟動動畫效果,兩者語法稍有差異,withAnimation()後面可接匿名函式,.animation() 則只能設定參數,實際用法參考以下範例程式4-1b。
從上面的歸納可以看出,Animation物件跟我們想像中的「動畫片」不完全相同,Animation只是做出我們平常看到的動畫片中,一小段基本的動態效果,甚至可說只是做出兩個視圖畫面之間的銜接過程。
至於視圖畫面實際發生什麼動作,則可透過以下視圖修飾語(View modifier)來完成:
- 位移 .offset()
- 平面旋轉 .rotationEffect()
- 立體旋轉 .rotation3DEffect()
- 模糊 .blur()
- 縮放 .scaleEffect()
- 透明度 .opacity()以下範例利用這6種視圖修飾語製作動畫效果,程式結構大致相同,只有對Animation步驟一與步驟二的參數稍加改變:
// 4-1b 動態文字 Text Animations // Created by Philip, Heman, Jean 2022/03/02 // Revised by Jean 2025/01/20 import PlaygroundSupport import SwiftUI struct 移動: View { var 文字: String init(_ x: String) { 文字 = x } @State var 位移: CGFloat = -100 let 動畫效果 = Animation.easeInOut(duration: 1.5) var body: some View { Text(文字) .offset(x: 位移, y: 0) .animation(動畫效果.repeatForever(), value: 位移) .onAppear { 位移 = -(位移) } } } struct 旋轉: View { var 文字: String init(_ x: String) { 文字 = x } @State var 角度: Angle = .degrees(0.0) let 動畫效果 = Animation.easeIn(duration: 5.3) var body: some View { Text(文字) .rotationEffect(角度) .onAppear { withAnimation(動畫效果.repeatForever(autoreverses: false)) { 角度 = .degrees(360.0) } } } } struct 立體旋轉: View { var 文字: String init(_ x: String) { 文字 = x } @State var 角度: Angle = .degrees(0.0) let 動畫效果 = Animation.easeOut(duration: 4.7) var body: some View { Text(文字) .rotation3DEffect(角度, axis: (x: 0.0, y: 1.0, z: 0.0)) .onAppear { withAnimation(動畫效果.repeatForever(autoreverses: false)) { 角度 = .degrees(-360.0) } } } } struct 模糊: View { var 文字: String init(_ x: String) { 文字 = x } @State var 半徑: CGFloat = 10.0 let 動畫效果 = Animation.spring(response: 0.55, dampingFraction: 0.825, blendDuration: 0) var body: some View { Text(文字) .blur(radius: 半徑) .onAppear { withAnimation(動畫效果.repeatForever()) { 半徑 = 0.0 } } } } struct 縮放: View { var 文字: String init(_ x: String) { 文字 = x } @State var 倍數: CGFloat = 0.1 let 動畫效果 = Animation.default.speed(0.2) var body: some View { Text(文字) .scaleEffect(倍數) .onAppear { withAnimation(動畫效果.repeatForever()) { 倍數 = 1.5 } } } } struct 淡出淡入: View { var 文字: String init(_ x: String) { 文字 = x } @State var 透明度: CGFloat = 0.1 let 動畫效果 = Animation.spring() var body: some View { Text(文字) .opacity(透明度) .onAppear { withAnimation(動畫效果.repeatForever()) { 透明度 = 1.2 } } } } struct 動態文字: View { var body: some View { VStack { Group { 移動("移動") 旋轉("旋轉") 立體旋轉("立體旋轉") 模糊("模糊清晰") 縮放("縮小放大") 淡出淡入("閃爍(淡出淡入)") } .frame(width: 300) .border(Color.red) .foregroundColor(.purple) .font(.system(size: 36)) .padding() } } } PlaygroundPage.current.setLiveView(動態文字())
程式執行結果,如下圖。
雨燕飛翔(組合動畫)
上節提到一個Animation物件事實上只負責一段從A點到B點的動畫效果,相當於動畫片的一個動作分解,可能只有幾秒鐘,若以一部10分鐘的動畫片來算,可能就需要成百上千個Animation物件才能達成。
另一方面,動畫效果也可以用多個基本動畫組合起來,例如球體的「滾動」,同時需要「旋轉」與「移動」兩個動畫物件,當旋轉的周長等於移動的距離時,就顯現出滾動的效果。
以下範例4-1c就是利用基本動畫的組合,來呈現新的動畫效果。先利用上一節「立體旋轉」讓Swift雨燕圖案拍動翅膀,然後再加上「移動」,兩個Animation物件搭配,讓雨燕飛翔起來。
在「Main」模組中撰寫程式:
// 4-1c 雨燕飛翔(組合動畫) // Created by Philip, Heman, Jean 2022/03/10 // Revised by Jean 2025/01/20 import PlaygroundSupport import SwiftUI struct 雨燕飛翔: View { @State var 旋轉角度: Angle = .degrees(-45.0) @State var 位移: CGFloat = -250.0 let 旋轉 = Animation.easeIn(duration: 0.2) let 移動 = Animation.linear(duration: 2.0) var body: some View { Image(systemName: "swift") .font(.system(size: 64)) .foregroundColor(.orange) .rotation3DEffect(旋轉角度, axis: (x: 1.0, y: 1.0, z: 0.0)) .offset(x: 位移, y: 位移) .animation(旋轉.repeatForever(), value: 旋轉角度) .onAppear { 旋轉角度 = -(旋轉角度) withAnimation(移動.repeatForever(autoreverses: false)) { 位移 = -(位移) } } } } PlaygroundPage.current.setLiveView(雨燕飛翔())
程式執行結果,如下圖。