
Animation can explain whatever the mind of man can conceive. This facility makes it the most versatile and explicit means of communication yet devised for quick mass appreciation.
– Walt Disney
首先,我們來闡明什麼是動畫以及動畫是如何建立的。「動畫」是透過快速一連串靜態圖片或影格(Frame )來模擬移動及形狀的變化,它會產生物件正在移動或改變大小的錯覺。
例如:逐漸變大的圓形動畫是透過顯示一連串的影格來建立的,它從一個點開始,隨後每個影格中的圓形會比前一個圓形更大一點,這一系列的畫面會產生點逐漸變大的錯覺。圖 15.1 說明了靜態圖片的序列,為了使範例簡單一點,該圖只顯示五個影格,但是要實現無縫轉場與更流暢的動畫,你需要建立更多的影格才行。

現在你已經對動畫的工作原理有了基本的了解,我們來探討如何在 SwiftUI 中建立動畫。以逐漸變大的圓形為例,我們知道動畫是從點開始(即起始狀態),並以大紅色圓形結束(即終止狀態),其挑戰在於如何在這兩個狀態之間產生影格。你是否需要設計一種演算法( Algorithm )並編寫數百行程式碼,以在兩者之間產生一連串的影格呢?絕對不需要 !SwiftUI 會負責所有這些繁重的工作,該框架可幫助你計算起始狀態與終止狀態之間的影格,從而產生無縫動畫。
你曾用過 Keynote 的瞬間移動動畫(Magic Move Animation )功能嗎?藉由瞬間移動, 你可以輕鬆在投影片間建立平滑動畫(Slick Animation )。Keynote 會自動分析兩張投影片之間的物件,並自動產生動畫。對我來說,SwiftUI 將「瞬間移動」的概念帶入應用程式開發中,使用SwiftUI 框架的動畫是自動且看起來很神奇的。當你定義一個視圖的兩個狀態,SwiftUI 會負責其餘的工作,接著以動畫顯示兩個狀態之間的變化。
要了解技術的最佳方式,莫過於以實際例子來進行研究了。在本章中,我們將在細節視圖中建立一個新的評分按鈕,當點擊評分按鈕時,App 將顯示一個評分視圖(Review View )來供使用者對餐廳進行評分。為了提升使用者體驗,我們將在評分視圖中加入模糊效果與動畫,如圖 15.2 所示。

現在我們進入評分視圖的實作,評分視圖顯示五個評分按鈕來供使用者選擇。根據你目前所學到的內容,要建立這種類型的UI 佈局應該不困難,我們先來準備圖片。
你可以下載圖片包( https://www.appcoda.com/resources/swift4/FoodPinRatingButtons.zip),並將圖示新增至素材目錄(即Assets.xcasssets ),或者你也可以在素材目錄中建立一個資料夾來儲存圖片,如圖 15.3 所示。

讓我們從模型開始,也就是 Restaurant.swift 檔。在 Swift 中有很多方式可以顯示評分, 例如:你可以使用字串來儲存評分:
var rating = "awesome"
或者你可以使用整數來顯示評分:
var rating = 5 // awesome
對於這個 App 來說,我更喜歡以列舉格式來儲存評分。Swift 中的列舉可以讓你為一組相關值定義通用型別。在Restaurant 結構中插入下列的程式碼:
enum Rating: String, CaseIterable {
case awesome
case good
case okay
case bad
case terrible
var image: String {
switch self {
case .awesome: return "love"
case .good: return "cool"
case .okay: return "happy"
case .bad: return "sad"
case .terrible: return "angry"
}
}
}
要建立列舉,先從使用 enum 關鍵字開始,後面接著列舉的名稱,這裡我們使用「Rating」這個名稱。定義在列舉中的值(即 awesome、good、okay、bad、terrible )稱為「列舉項目」(Enumeration Case ),由於我們有五種不同類型的評分,所以 Rating 列舉有5 個項目( Case )。列舉是 Swift 中的一種型別,所以你可以像這樣使用這個 Rating 型別:
var rating: Rating = .awesome
rating.image // this returns "love"
在列舉中,你可以定義函數與變數。在上列的程式碼中,我們還宣告一個 image 變數來回傳評分的圖片名稱,例如:如果評分的值設定為「.awesome」,則 image 變數回傳「love」。
String 與 CaseIterable 的用途是什麼呢?列舉的每個項目(Case)都允許有一個預設值, 也就是「原始值」(Raw Values )。String 型別表示字串用於原始值,你可以像這樣明確指定原始值:
enum Rating: String, CaseIterable {
case awesome = "awesome"
case good = "good"
case okay = "okay"
case bad = "bad"
case terrible = "terrible"
.
.
.
}
由於原始值和項目(Case)名稱一樣,我們可以省略它,並讓 Swift 為我們產生這些值。
最後,什麼是 CaseIterable 呢?當使用列舉時,通常需要去找出項目總數或者迭代項目,為此你可以採用CaseIterable 協定,接著可使用下列程式碼來計算項目:
let totalCases = Rating.allCases.count
而且,我們可以使用 ForEach 迴圈來佈局評分按鈕,如下所示:
ForEach(Restaurant.Rating.allCases, id: \.self) { rating in
// 顯示評分按鈕
}
太棒了 !你應該對於列舉有充分理解了,我們來繼續實作評分視圖。在專案導覽器中的「View」資料夾上按右鍵,並選擇「New File...」,然後選取「SwiftUI View」模板, 將檔案命名為「ReviewView.swift」。
要建立如圖 15.2 所示的評分視圖,我將使用 ZStack 來包含背景、關閉按鈕及評分按鈕集等三個視圖元件。更新ReviewView 結構如下:
struct ReviewView: View {
var body: some View {
ZStack {
Color.black
.ignoresSafeArea()
HStack {
Spacer()
VStack {
Button(action: {
}) {
Image(systemName: "xmark")
.font(.system(size: 30.0))
.foregroundColor(.white)
.padding()
}
Spacer()
}
}
VStack(alignment: .leading) {
ForEach(Restaurant.Rating.allCases, id: \.self) { rating in
HStack {
Image(rating.image)
Text(rating.rawValue.capitalized)
.font(.system(.title, design: .rounded))
.fontWeight(.bold)
.foregroundColor(.white)
}
}
}
}
}
}
Color 視圖設定為黑色,並且加上 ignoresSafeArea 修飾器來將視圖擴展為全螢幕。對於「Close」按鈕,我們使用來自 SF Symbols 且名為「xmark」的系統圖片。
最上層是包含評分按鈕的 VStack 視圖,由於 Rating 列舉採用 CaseIterable 協定,因此我們可以輕鬆迭代它的所有項目(Case )。對於每個評分按鈕,我們使用 HStack 來排列圖片與文字元件。
當你變更完成後,你應該會看到如圖 15.4 所示的 UI。

評分視圖尚未完成,如果你參考圖 15.2,視圖應該有一個模糊背景,而不是純黑色的背景,這可以透過對餐廳圖片應用模糊效果來實現。
在深入探討模糊效果的實作之前,我們先加入背景圖片。在 ReviewView 中宣告一個變數來儲存Restaurant 物件:
var restaurant: Restaurant
我們將使用該餐廳圖片作為背景。在 ZStack 的開頭插入下列的程式碼片段來顯示圖片:
Image(restaurant.image)
.resizable()
.scaledToFill()
.frame(minWidth: 0, maxWidth: .infinity)
.ignoresSafeArea()
你應該會在 #Preview 程式碼區塊中看到一個錯誤,這是因為我們加入了 restaurant 屬性。要解決這個問題,只需傳送一個範例餐廳,如下所示:
ReviewView(restaurant: Restaurant(name: "Cafe Deadend", type: "Coffee & Tea Shop", location: "G/F, 72 Po Hing Fong, Sheung Wan, Hong Kong", phone: "232-923423", description: "Searching for great breakfast eateries and coffee? This place is for you. We open at 6:30 every morning, and close at 9 PM. We offer espresso and espresso based drink, such as capuccino, cafe latte, piccolo and many more. Come over and enjoy a great meal.", image: "cafedeadend", isFavorite: true))
即使新增了背景圖片,你也可能無法看到評分畫面有任何的視覺變化,這是因為該圖片被 Color 視圖完全擋住,我們來將 Color 視圖的不透明度更改為「0.1」:
Color.black
.opacity(0.1)
.ignoresSafeArea()
當你變更完成後,結果畫面應該如圖15.5 所示。

在 iOS 15 之前,你必須切換回 UIKit 並利用 UIVisualEffect 類別,來將視覺效果應用於視圖;然而,自從 iOS 15 發布以來,SwiftUI 現在包含一個用於加入模糊效果的內建修飾器。
你只需要加入 background 修飾器,並指定材質類型。更新Color 視圖如下:
Color.black
.opacity(0.6)
.background(.ultraThinMaterial)
.ignoresSafeArea()
SwiftUI 框架提供了五種材質,每種材質都有不同的厚度,包括:
材質的厚度決定可以看到多少的背景內容,越厚的材質,則可顯示的背景內容便越少。在上列的程式碼中,我們將不透明度更新為「0.6」,並使用「.ultraThinMaterial」作為背景。
預覽程式碼後,你會立即注意到視覺模糊效果,如圖 15.6 所示。若要測試每種材質的效果,則可以修改background 修飾器並試驗各種材質選項。

現在我們回到 RestaurantDetailView.swift,並加入評分按鈕。點擊這個按鈕後,App 會顯示評分畫面。
首先,建立一個名為「showReview」的狀態變數,來控制評分視圖的外觀:
@State private var showReview = false
在 VStack 的末尾(即 NavigationLink 後面)插入下列的程式碼,以建立按鈕:
Button {
self.showReview.toggle()
} label: {
Text("Rate it")
.font(.system(.headline, design: .rounded))
.frame(minWidth: 0, maxWidth: .infinity)
}
.tint(Color("NavigationBarTitle"))
.buttonStyle(.borderedProminent)
.buttonBorderShape(.roundedRectangle(radius: 25))
.controlSize(.large)
.padding(.horizontal)
.padding(.bottom, 20)
我們使用 iOS 15 起導入的一些修飾器來實作圓角矩形按鈕。tint 修飾器設定按鈕的顏色,iOS 15 提供了三種預設的 Button 樣式。在上列的程式碼中, 我們使用.borderedProminent 樣式來顯示一個純色背景的按鈕,而另外兩種樣式是 .bordered 與 .borderless。
buttonBorderShape 修飾器可讓我們定義按鈕的邊框形狀,在本例中,我們設定按鈕為圓角矩形;而 controlSize 修飾器是定義按鈕的大小。
當點擊按鈕時,我們切換 showReview 的值,以觸發 ReviewView 的顯示。如果你在預覽窗格中執行這個App,你現在應該會在細節視圖中看到「Rate it」按鈕,如圖 15.7 所示。

要帶出 ReviewView,則將 .overlay 修飾器加到 ScrollView(如果你不確定要在哪裡插入程式碼,請參考圖15.8):
.overlay(
self.showReview ?
ZStack {
ReviewView(restaurant: restaurant)
}
: nil
)
.toolbar(self.showReview ? .hidden : .visible)
你可能想知道為什麼我們選擇將 ReviewView 顯示為覆蓋( Overlay ),而不是將其顯示為表( Sheet )?答案是靈活性。未來我們會將一些動畫轉場合併到ReviewView 中,如果我們要將視圖顯示為模態表( Modal Sheet ),則修改其預設的轉場將會具有挑戰性,這就是為何我更喜歡將評分視圖顯示為覆蓋的緣故,因為它可以更易於自訂轉場。
為了在評分視圖出現時隱藏「返回」按鈕,我們加上.hidden 修飾器,並將其設定為「.hidden」。
完成變更後,在模擬器或者預覽中執行 App,然後進入到餐廳的細節視圖,並點擊「Rate it」按鈕,以觸發評分視圖。

你是否試過點擊評分視圖的「關閉」(Close )按鈕嗎?它還無法運作。要使其正常運作,則在 ReviewView 結構的開頭宣告一個綁定:
@Binding var isDisplayed: Bool
我們需要 ReviewView 的呼叫者將綁定傳送給控制 ReviewView 可見性的狀態變數。透過綁定,我們可以將 isDisplayed 設定為「false」來關閉評分視圖。更新「xmark」按鈕的動作如下:
Button(action: {
withAnimation(.easeOut(duration: 0.3)) {
self.isDisplayed = false
}
}) {
Image(systemName: "xmark")
.font(.system(size: 30.0))
.foregroundStyle(.white)
.padding()
}
當點擊「xmark」按鈕時,我們只需設定 isDisplayed 的值為「false」,便會關閉評分視圖。
要在 SwiftUI 中對狀態變化進行動畫處理,你只需將狀態變化包裹在 withAnimation 區塊中即可。withAnimation 呼叫帶入一個動畫參數,這裡我們指定使用持續時間為 0.3 秒的 .easeOut 動畫。SwiftUI 內有幾種內建動畫,.easeOut 只是其中之一。
SwiftUI 中這種類型的動畫稱為「顯式動畫」(Explicit Animation ),因為我們明確告知 SwiftUI 對特定的狀態變化(即 isDisplayed )進行動畫處理。
由於加入了綁定,你應該會在 #Preview 中看到一個錯誤。加入i sDisplayed 綁定來更新 ReviewView 的實例:
#Preview {
ReviewView(isDisplayed: .constant(true), restaurant: Restaurant(name: "Cafe Deadend", type: "Coffee & Tea Shop", location: "G/F, 72 Po Hing Fong, Sheung Wan, Hong Kong", phone: "232-923423", description: "Searching for great breakfast eateries and coffee? This place is for you. We open at 6:30 every morning, and close at 9 PM. We offer espresso and espresso based drink, such as capuccino, cafe latte, piccolo and many more. Come over and enjoy a great meal.", image: "cafedeadend", isFavorite: true))
}
現在回到 RestaurantDetailView.swift,其為 ReviewView 的呼叫者。修改程式碼來實例化 ReviewView,以傳送 showReview 的綁定:
ReviewView(isDisplayed: $showReview, restaurant: restaurant)
如此,在模擬器或預覽窗格執行 App,「Close」按鈕應該可以正常運作,並以淡出動畫( Fade Animation )來關閉評分視圖。
「滑入動畫」( Slide-in Animation )是一種常見的動畫類型,物件從螢幕的最右側(或最左側)滑入,直到它到達特定位置。參考圖 15.9,當評分視圖出現時,評分按鈕會從最右側滑入螢幕。

為了建立滑入動畫,我們將所有的評分按鈕移到螢幕右側外,這是起始狀態,而終止狀態是按鈕的原始位置。
我們需要一個狀態變數來控制起始狀態與終止狀態。在 ReviewView 中,宣告一個狀態變數並設定其初始值為「false」:
@State private var showRatings = false
這裡的 false 值表示評分按鈕隱藏在螢幕的最右側。現在將以下的修飾器加到每一個評分按鈕:
ForEach(Restaurant.Rating.allCases, id: \.self) { rating in
HStack {
.
.
.
}
.opacity(showRatings ? 1.0 : 0)
.offset(x: showRatings ? 0 : 1000)
}
我們使用 offset 修飾器來將評分按鈕移出螢幕,正值( 即1000)是將視圖向右移動。.opacity 修飾器是一個可選型別,但是當我們稍後為這些狀態變化設定動畫時,它會提供更好的動畫效果。
透過上述的變更,所有的評分按鈕均被隱藏,評分畫面應該要顯示為空白畫面,如圖 15.10 所示。

那麼,我們要如何將評分按鈕移回原始位置呢?訣竅是使用 .onAppear 修飾器,並在視圖出現時,將showRatings 屬性設定為「true」。
將 .onAppear 修飾器加到 ZStack 視圖:
.onAppear {
showRatings.toggle()
}
插入程式碼之後,評分按鈕會再次出現在視圖中,但是狀態變化還沒有進行動畫處理,我們該如何建立滑入動畫呢?
你只需要加入 .animation 修飾器到 HStack 視圖,並將其放在 .offset 修飾器之後,如下所示:
.animation(.easeOut, value: showRatings)
之前我們使用 withAnimation 來建立動畫,animation 修飾器是指示 SwiftUI 渲染動畫的另一種方式。當 showRatings 的狀態更新時,框架會自動為所有的變化設定動畫。要測試動畫,則在預覽窗格中執行 App,如圖 15.11 所示。

我們已經成功建立了滑入動畫,然而這不是我們想要建立的確切動畫,其雖然是一個滑入動畫,但是所有的評分按鈕都會同時飛入螢幕中。為了實作如圖 15.9 所示的動畫, 我們必須為每個按鈕加入延遲時間。更新 .animation 修飾器如下:
.animation(.easeOut.delay(Double(Restaurant.Rating.allCases.firstIndex(of: rating)!) * 0.05), value: showRatings)
SwiftUI 框架提供了幾種內建動畫,例如:.easeOut,這些動畫中的每一個都可以讓你呼叫其 delay 函數,以在一定的秒數後才開始動畫。Rating 列舉的第一個項目(Case )沒有延遲動畫,列舉的第二個項目會延遲 0.05 秒,第三個項目會延遲 0.1 秒,反之亦然。
現在再次執行 App 來檢視動畫效果。
這是另一個涵蓋動畫及視覺效果的重要章節,SwfitUI 讓視圖的動畫變更變得非常簡單,你只需要告知框架:起始狀態與終止狀態為何,然後 SwiftUI 會提供所需的動畫。
在本章中,我只介紹了幾個內建動畫(例如:.easeOut )的範例,我鼓勵你探索其他類型的內建動畫,例如:彈簧動畫( Spring Animation ),以了解它們的工作原理,最後不要忘記花一些時間來完成你的作業,並進一步加強你對這些概念的理解。
在本章所準備的範例檔中,有最後完整的 Xcode 專案以及作業的解答 http://www.appcoda.com/resources/swift59/swiftui-foodpin-animation.zip 可供你參考。