在 iOS 14 中,Apple 為 SwiftUI 框架引入了很多新功能,像是 LazyVGrid 以及 LazyHGrid。其中 matchedGeometryEffect
非常引人注目,這個功能讓開發者只需要幾行程式碼,就能夠創造絢麗的視圖動畫。SwiftUI 框架已經讓開發者可以簡單地使用動畫來呈現視圖的變化,而 matchedGeometryEffect
修飾器 (modifier) 更將視圖動畫 (view animations) 的實作提升到另一個境界。
對所有手機 App 來說,我們經常需要在多個視圖之間轉換,因此一個令人喜歡的視圖轉換絕對可以提昇整體的使用者體驗。有了 matchedGeometryEffect
,你只需要描述兩個視圖的外觀,修飾器就會自動計算兩個視圖的差異,並且自動為大小和位置的變化加上動畫。
可能你會覺得十分困惑,但別擔心,介紹完整個範例 App 之後,你就會明白我在說什麼了。
在我們開始介紹 matchedGeometryEffect
之前,讓我們先來看一下如何使用 SwiftUI 來實作動畫。下面的圖片顯示了一個視圖開始和結束的狀態。當你點擊左邊的圓形視圖,它應該會變大且往上移動;相反地,當你點擊右邊的視圖時,它就會回到原本的大小和位置。
要實作這個可點擊的圓形視圖非常簡單。開啟一個新的 SwiftUI 專案後,如此更新 ContentView
結構:
struct ContentView: View {
@State private var expand = false
var body: some View {
Circle()
.fill(Color.green)
.frame(width: expand ? 300 : 150, height: expand ? 300 : 150)
.offset(y: expand ? -200 : 0)
.animation(.default, value: expand)
.onTapGesture {
self.expand.toggle()
}
}
}
我們用一個狀態變數 expand
,來記錄 Circle
視圖目前的狀態。當狀態改變時,我們會透過 .frame
和 .offset
這兩個修飾器,來修改視圖框 (frame) 的大小和位移 (offset)。如果在預覽畫面中執行這個 App ,你應該可以在點擊圓形視圖時看到動畫效果。
那麼到底什麼是 matchedGeometryEffect
呢?這個功能如何簡化實作視圖動畫的步驟?讓我們再來看一下第一張圖片、以及圓形視圖動畫的程式碼。我們需要找出開始及結束狀態時的確切數值差異。在這個例子當中,就是視圖框的大小及位移。
有了 matchedGeometryEffect
修飾器,你不再需要找出兩個狀態之間的差異了。你只需要描述兩個視圖:一個是開始的狀態,而另一個是結束的狀態,matchedGeometryEffect
會自動添加在兩個視圖之間大小和位置的差異。
要使用 matchedGeometryEffect
建立跟之前一樣的動畫效果,你需要先宣告一個命名空間 (namespace) 變數:
@Namespace private var shapeTransition
然後,如此重寫 body
的部分:
var body: some View {
if expand {
// Final State
Circle()
.fill(.green)
.matchedGeometryEffect(id: "circle", in: shapeTransition)
.frame(width: 300, height: 300)
.offset(y: -200)
.onTapGesture {
withAnimation(.easeIn) {
expand.toggle()
}
}
} else {
// Start State
Circle()
.fill(.green)
.matchedGeometryEffect(id: "circle", in: shapeTransition)
.frame(width: 150, height: 150)
.offset(y: 0)
.onTapGesture {
withAnimation(.easeIn) {
expand.toggle()
}
}
}
}
在這段程式碼中,我們建立了兩個圓形視圖,一個表示開始狀態,而另一個表示結束狀態。當它一開始被初始化之後,我們會得到一個 Circle
視圖,位置置中以及寬度為 150 點。當 expand
狀態變數從 false
變成 true
時, App 會顯示另一個 Circle
視圖,其位置是從中間往上 200 點以及寬度為 300 點。
對於兩個 Circle
視圖,我們都添加了 matchedGeometryEffect
修飾器,並且指定了相同的 ID 和命名空間。透過這樣的設定,SwiftUI 可以計算兩個視圖之間大小和位置的差異,並且添加視圖轉換。隨著後續加上的 animation
修飾器,SwiftUI 框架會自動動畫化視圖轉換。
ID 以及命名空間的用途,是用來標記屬於同一個轉換的視圖,所以兩個 Circle
視圖會使用相同的 ID 和命名空間。
以上我們介紹了如何使用 matchedGeometryEffect
,來實作兩個視圖之間的動畫轉換效果。如果你有使用過Keynote 的 Magic Move,這個新的修飾器跟 Magic Move非常類似。要測試動畫,只需點擊預覽畫布中的圓圈即可。
現在,讓我們來試試實作另一個視圖轉換動畫。這一次,我們會將一個圓形變化成為一個圓角長方形 (rounded rectangle)。圓形位置於螢幕的上端,而圓角長方形則位於螢幕的底端。
我們可以使用剛剛學到的技巧,準備兩個視圖:一個圓形視圖和一個圓角長方形視圖,matchedGeometryEffect
修飾器就會處理視圖轉換的部分。現在,讓我們將 body
變數的 ContentView
結構改成這樣:
VStack {
if expand {
// Rounded Rectangle
Spacer()
RoundedRectangle(cornerRadius: 50.0)
.matchedGeometryEffect(id: "circle", in: shapeTransition)
.frame(minWidth: 0, maxWidth: .infinity, maxHeight: 300)
.padding()
.foregroundColor(Color(.systemGreen))
.onTapGesture {
withAnimation {
expand.toggle()
}
}
} else {
// Circle
RoundedRectangle(cornerRadius: 50.0)
.matchedGeometryEffect(id: "circle", in: shapeTransition)
.frame(width: 100, height: 100)
.foregroundColor(Color(.systemOrange))
.onTapGesture {
withAnimation {
expand.toggle()
}
}
Spacer()
}
}
我們同樣使用 expand
狀態變數,來切換圓形視圖和圓角長方形視圖。這段程式碼跟之前的範例非常相似,只是我們在這邊加上了 VStack
和 Spacer
來定位視圖。或許你會問,為什麼要使用 RoundedRectangle
來建立圓形物件呢?主要原因是這樣可以讓視圖轉換就會更順暢。
在這兩個視圖中,我們都加上了 matchedGeometryEffect
修飾器,並且指定了一樣的 ID 以及命名空間,我們要做的事就完成了。修飾器會自動比較兩個視圖的差異,並且使用動畫呈現這個改變。如果你在預覽畫面或是 iPhone 模擬器上執行這個 App,會看到視圖在圓形和圓角長方形之間完美的轉換。這就是 matchedGeometryEffect
的威力。
不過,你可能注意到這個修飾器無法執行改變顏色的動畫。沒錯,matchedGeometryEffect
只能用來處理位置和大小的變化。
讓我們來做一個小小的練習,來測試你對 matchedGeometryEffect
的了解。你的任務是要建立如下圖的動畫視圖轉換。開始時,它是一個橘色的圓形視圖,圓形視圖被點擊後,就會轉換成全螢幕的背景圖。你可以在專案檔中找到完整程式碼。
現在你應該對 matchedGeometryEffect
有了基礎的認識,讓我們繼續來看看它如何幫助我們建立一些絢麗的動畫。在這個範例中,我們會交換兩個圓形視圖的位置,並且套用修飾器來建立順暢的視圖轉換。
我們會使用一個狀態變數,來儲存交換的狀態,並建立一個命名空間變數給 matchedGeometryEffect
來使用。讓我們在 ContentView
宣告這些參數:
@State private var swap = false
@Namespace private var dotTransition
橘色圓形預設位在螢幕的左邊,而綠色圓形則在螢幕的右邊。當使用者點擊任意一個圓形圖案,就會觸發互換的動畫。使用 matchedGeometryEffect
時,你不用了解交換動畫是如何達成的。要建立視圖轉換,你只需要做到以下事情:
如果你要將版面配置轉換成程式碼,你可以如此編寫 body
變數:
if swap {
// After swap
// Green dot on the left, Orange dot on the right
HStack {
Circle()
.fill(.green)
.frame(width: 30, height: 30)
.matchedGeometryEffect(id: "greenCircle", in: dotTransition)
Spacer()
Circle()
.fill(.orange)
.frame(width: 30, height: 30)
.matchedGeometryEffect(id: "orangeCircle", in: dotTransition)
}
.frame(width: 100)
.onTapGesture {
withAnimation {
swap.toggle()
}
}
} else {
// Start state
// Orange dot on the left, Green dot on the right
HStack {
Circle()
.fill(.orange)
.frame(width: 30, height: 30)
.matchedGeometryEffect(id: "orangeCircle", in: dotTransition)
Spacer()
Circle()
.fill(.green)
.frame(width: 30, height: 30)
.matchedGeometryEffect(id: "greenCircle", in: dotTransition)
}
.frame(width: 100)
.onTapGesture {
withAnimation {
swap.toggle()
}
}
}
我們使用 HStack
來將兩個圓形配置成水平排列,並且利用 Spacer
在兩個圓形中間建立一些空間。當 swap
變數設定成 true
之後,綠色圓形會被放在橘色圓形的左邊。相反地,綠色圓形則會被放在橘色圓形的右邊。
如你所見,我們只需要描述不同狀態下圓形視圖的配置,matchedGeometryEffect
就會處理餘下的事情。我們在每個 Circle
視圖加上修飾器,不過這次有一點不同,因為我們有兩個不同的 Circle
視圖需要配置,我們使用了兩個不同的 ID 來建立 matchedGeometryEffect
修飾器。我們將橘色圓形的識別名稱設定為 orangeCircle
,而綠色圓形則是設定為 greenCircle
。
現在,如果你在模擬器執行這個 App,應該可以在點擊任何一個圓形時看到互換的動畫。
在剛剛的練習中,我們在兩個圓形上使用了 matchedGeometryEffect
,並交換它們的位置。這個練習會使用到一樣的技巧,不過這次是要使用兩張圖片。圖33.7展示了這個範例,點擊交換按鈕時,這個 App 就會用漂亮的動畫來交換這兩張圖片。
你可以隨意使用自己的圖片。我在範例中使用了這些 Unsplash.com 的免費圖片:
除了從一種形狀轉換到另一種形狀之外,你還可以使用 matchedGeometryEffect
修改器來創建基本的英雄動畫。 給你一個例子,圖 33.8 顯示了一個圖像和一段文字的堆棧視圖。 點擊視圖時,圖像和文字都會展開以佔據全螢幕。 這種類型的動畫通常被稱為英雄動畫(Hero Animation)。
同樣地,我們可以應用 matchedGeometryEffect
技巧來創建這種類型的過場動畫。 如果參考圖 33.8,視圖轉換中有兩個視圖:
首先,宣告一個狀態變數來控制視圖模式的狀態:
@State private var showDetail = false
當 showDetail
設定為 false 時,App就會顯示具有較小圖像的文章視圖。 如果為 true,則將顯示整個畫面的文章視圖。 要使用 matchedGeometryEffect
修飾器,我們必須宣告一個namespace
變數:
@Namespace private var articleTransition
接下來,像這樣更新 body
變數:
// Display an article view with smaller image
if !showDetail {
VStack {
Spacer()
VStack {
Image("latte")
.resizable()
.scaledToFill()
.frame(minWidth: 0, maxWidth: .infinity)
.frame(height: 200)
.matchedGeometryEffect(id: "image", in: articleTransition)
.cornerRadius(10)
.padding()
.onTapGesture {
withAnimation(.interactiveSpring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.2)) {
showDetail.toggle()
}
}
Text("The Watertower is a full-service restaurant/cafe located in the Sweet Auburn District of Atlanta.")
.matchedGeometryEffect(id: "text", in: articleTransition)
.padding(.horizontal)
}
}
}
// Display the article view in a full screen
if showDetail {
ScrollView {
VStack {
Image("latte")
.resizable()
.scaledToFill()
.frame(minWidth: 0, maxWidth: .infinity)
.frame(height: 400)
.clipped()
.matchedGeometryEffect(id: "image", in: articleTransition)
.onTapGesture {
withAnimation(.interactiveSpring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.4)) {
showDetail.toggle()
}
}
Text("The Watertower is a full-service restaurant/cafe located in the Sweet Auburn District of Atlanta. The restaurant features a full menu of moderately priced \"comfort\" food influenced by African and French cooking traditions, but based upon time honored recipes from around the world. The cafe section of The Watertower features a coffeehouse with a dessert bar, magazines, and space for live performers.\n\nThe Watertower will be owned and operated by The Watertower LLC, a Georgia limited liability corporation managed by David N. Patton IV, a resident of the Empowerment Zone. The members of the LLC are David N. Patton IV (80%) and the Historic District Development Corporation (20%).\n\nThis business plan offers financial institutions an opportunity to review our vision and strategic focus. It also provides a step-by-step plan for the business start-up, establishing favorable sales numbers, gross margin, and profitability.\n\nThis plan includes chapters on the company, products and services, market focus, action plans and forecasts, management team, and financial plan.")
.matchedGeometryEffect(id: "text", in: articleTransition)
.animation(nil, value: showDetail)
.padding(.all, 20)
Spacer()
}
}
.edgesIgnoringSafeArea(.all)
}
在上面的程式碼中,我們將視圖佈局為不同的狀態。 當 showDetail
設為 false
時,我們使用 VStack
來佈局文章的圖片和節錄。 圖像的高度設定為 200 點以使其變小。
文章視圖的佈局在全螢幕模式下非常相似, 主要分別在於 VStack
視圖嵌入在 ScrollView
中以使內容可滾動。 圖像的高度設定為 400 點,這樣圖像就會變大一點。 為了將圖像和文字視圖擴展出螢幕安全區域以外,我們將 .edgesIgnoringSafeArea
修飾器加到滾動視圖並將其值設置為 .all
。
由於我們在過場效果有兩個不同的視圖,因此我們在 matchedGeometryEffect
修飾器要使用兩個不同的 ID。 對於圖像,我們將 ID 設定為image
:
.matchedGeometryEffect(id: "image", in: articleTransition)
另一方面,我們將文字視圖的 ID 設定為 text
:
.matchedGeometryEffect(id: "text", in: articleTransition)
此外,我們為文字和圖像視圖使用了兩種不同的動畫。 圖像視圖使用 .interactiveSpring
動畫,至於文字視圖,我們使用 .easeOut
動畫。
現在,可以在模擬器中運行App並試一下。 當你點擊圖像視圖時,App會顯示漂亮的動畫並以全螢幕顯示文章。
參考前面的例子,我們可以將兩個不同的堆棧視圖分成為子視圖讓程式碼整理得更好。 但問題是我們如何在視圖之間傳遞@Namespace
變數, 讓我們可以怎樣做。
首先,按住command鍵並單擊第一個堆棧視圖的VStack
。 從選單中選擇 Extract Subview 並將子視圖命名為ArticleExcerptView
。
你應該會在 ArticleExcerptView
結構中看到很多錯誤,說缺少namespace 和 showDetail
變數。 要改正 showDetail
變數的錯誤,你可以在 ArticleExcerptView
中宣告一個綁定,如下所示:
@Binding var showDetail: Bool
要從另一個視圖接受namespace,訣竅是宣告一個具有 Namespace.ID
類型的變數,如下所示:
var articleTransition: Namespace.ID
現在應該可以修復 ArticleExcerptView
中的所有錯誤。 回到 ContentView
並將 ArticleExcerptView()
替換為:
ArticleExcerptView(showDetail: $showDetail, articleTransition: articleTransition)
我們將綁定傳給showDetail
,並將namespace變數傳遞給子視圖。 這就是你在不同視圖之間共享namespace的方式。 重複以上的程序將 ScrollView
抽出到另一個子視圖中,並將子視圖命名為ArticleDetailView
。
另外,你需要在 ArticleDetailView
中宣告以下變數和綁定以解決所有錯誤:
@Binding var showDetail: Bool
var articleTransition: Namespace.ID
你還要像這樣更新 ArticleDetailView()
:
ArticleDetailView(showDetail: $showDetail, articleTransition: articleTransition)
在所有更改之後,ContentView
結構現在被簡化如下:
struct ContentView: View {
@State private var showDetail = false
@Namespace private var articleTransition
var body: some View {
// Display an article view with smaller image
if !showDetail {
ArticleExcerptView(showDetail: $showDetail, articleTransition: articleTransition)
}
// Display the article view in a full screen
if showDetail {
ArticleDetailView(showDetail: $showDetail, articleTransition: articleTransition)
}
}
}
App的運作和之前一樣,但程式碼現在就更容易閱讀。
引進 matchedGeometryEffect
修飾器之後,視圖動畫的實作提昇到了另一個層次。你可以用更少的程式碼來創造漂亮的視圖動畫。即便你是 SwiftUI 的新手,你也可以從這個新修飾器中得益,並且讓你的 App 更棒。
在本章所準備的範例檔中,有最後完整的 Xcode 專案,可供你下載參考:
https://www.appcoda.com/resources/swiftui5/SwiftUIMatchedGeometry.zip