在前一章,我已介紹了新的 matchedGeometryEffect
修飾器 (modifier) ,並向你展示了如何創建一些基本的視圖動畫。 在本章中,讓我們看看如何在網格視圖中使用修飾器和加入過場動畫。 此外,你還將學習另一個名為ScrollViewReader
的全新 UI 組件。
在我們開始實作之前,讓我先向你展示最終的成果。 這應該讓你對要即將構建的內容有所了解。 當你開發 App時,你可能需要以網格(Grid)形式顯示照片並讓使用者選擇其中的一些項目。
範例 App 在螢幕底部顯示一個Dock,當一個項目被選中時,它會從網格中移除並插入到 Dock 中。 當你選擇更多項目時,個 Dock 自動擴大以容納更多項目。 你可以水平滑動以瀏覽Dock中的項目。 如果你點擊 Dock 中的其中一個項目,該項目就會被移除並重新加到網格中。
我們將實作這個範例App,並會使用 matchedGeometryEffect
修飾符加入絢麗的過場動畫。 開始之前,請到 https://www.appcoda.com/resources/swiftui5/SwiftUIGridViewAnimationStarter.zip 下載Starter項目。 該項目已包含樣本數據和圖像。
首先,讓我們建立照片網格。 在 ContentView
結構體中,宣告一個狀態變數,如下所示:
@State private var photoSet = samplePhotos
在Starter項目中,我已經預先準備好 samplePhotos
常數用於存放示範照片。而 photoSet
被定義為狀態變數,主要原因是,隨著使用者的選擇,我們會更新它存放的照片。
為了用網格形式佈局照片,我們會使用LazyVGrid
組件。 在 body
中加入以下程式碼:
VStack {
ScrollView {
HStack {
Text("Photos")
.font(.system(.title, design: .rounded))
.fontWeight(.heavy)
Spacer()
}
LazyVGrid(columns: [ GridItem(.adaptive(minimum: 50)) ]) {
ForEach(photoSet) { photo in
Image(photo.name)
.resizable()
.scaledToFill()
.frame(minWidth: 0, maxWidth: .infinity)
.frame(height: 60)
.cornerRadius(3.0)
}
}
}
}
.padding()
假設你已經閱讀了前面有關網格視圖的章節,你一定能夠明白當中的程式碼。 我們只需使用自適應佈局將一組照片排列在一個網格中。
為了顯示和存放使用者所選的照片,我們將創建一個Dock。 在 VStack
中插入以下程式碼:
ScrollView(.horizontal, showsIndicators: false) {
}
.frame(height: 100)
.padding()
.background(Color(.systemGray6))
.cornerRadius(5)
這就會建立一個可滾動的矩形區域來存放選定的照片。 當然,它現在還沒有任何相片,只是一個空白區。
選擇照片後,我們會將其從照片網格中移除並將它加入到 Dock 中。 為了處理使用者所選的照片,我們將創建一個狀態變數來存放所選照片。 在 ContentView
中加入以下程式碼來宣告變數:
@State private var selectedPhotos: [Photo] = []
photoSet
中的每張照片都有自己的ID,而類型是 UUID
。 要儲存當前選定的照片,請宣告另一個 UUID
類型的狀態變數:
@State private var selectedPhotoId: UUID?
要偵測照片選擇,請將 onTapGesture
修飾器附加到 LazyVGrid
的 Image
組件,如下所示:
Image(photo.name)
.resizable()
.scaledToFill()
.frame(minWidth: 0, maxWidth: .infinity)
.frame(height: 60)
.cornerRadius(3.0)
.onTapGesture {
selectedPhotos.append(photo)
selectedPhotoId = photo.id
if let index = photoSet.firstIndex(where: { $0.id == photo.id }) {
photoSet.remove(at: index)
}
}
在 onTapGesture
中,我們將所選照片添加到 selectedPhotos
陣列並更新 selectedPhotoId
。 此外,我們從 photoSet
中刪除所選擇的照片。 由於 photoSet
是一個狀態變數,一旦從陣列中刪除選定的照片,它就會自動從網格中刪除。
App 會將所選的照片添加到Dock中。 因此,像這樣更新 Dock 的 ScrollView
:
ScrollView(.horizontal, showsIndicators: false) {
LazyHGrid(rows: [ GridItem() ]) {
ForEach(selectedPhotos) { photo in
Image(photo.name)
.resizable()
.scaledToFill()
.frame(minWidth: 0, maxWidth: .infinity)
.frame(height: 100)
.cornerRadius(3.0)
.onTapGesture {
photoSet.append(photo)
if let index = selectedPhotos.firstIndex(where: { $0.id == photo.id }) {
selectedPhotos.remove(at: index)
}
}
}
}
}
我們創建一個水平網格(Horizontal Grid)來顯示選定的照片。 對於每張照片,我們將 onTapGesture
修飾器附加到它上面。 當有人點擊 Dock 中的照片時,我們會將照片放回網格並且從selectedPhotos
中刪除。 換句話說,照片將從Dock中刪除。
如果你在預覽畫面中運行App,你應該能夠選擇網格中的任何照片。 當你點擊一張照片時,它會自動添加到 Dock 中,並且該照片將從網格中刪除。 相反,你可以點擊 Dock 中的照片將其移回照片網格。
現在 App 的圖片選擇效果已很不錯,但我們可以通過建立動畫再進一步提升使用者體驗。 目前,所選照片會立即出現在 Dock 中。 我想要做的為這個選擇動作加入動畫。 選擇後,照片應該看起來像是從照片網格飛到 Dock。
要做出這種類型的動畫,最簡單就是使用 matchedGeometryEffect
修飾器。 首先,在 ContentView
中,宣告一個Namespace變數:
@Namespace private var photoTransition
接下來,將 .matchedGeometryEffect
修飾器附加到兩個 Image
視圖:
.matchedGeometryEffect(id: photo.id, in: photoTransition)
這個實作的技巧是為每張圖片分配一個不同的 ID,這樣App只會對所選照片的變化作動畫化處理。
要啟用動畫,請將 .animation
修飾器附加到 VStack
並在 .padding()
下插入以下程式碼:
.animation(.interactiveSpring(), value: selectedPhotoId)
在模擬器或預覽畫面上執行App。 當你點擊網格中的照片時,就可以在將它添加到 Dock ,而照片應該看起來像是從照片網格飛到 Dock。
動畫效果應該不錯吧? 但是你是否注意到App還有一個小問題? 當你不斷將照片加入至Dock,你會發現Dock是不會自動滾動至最近選擇的照片。 如果你選擇的照片超過 4 張,則需要自己滾動Dock以顯示其他選定的照片。
我們如何修復這個錯誤?SwiftUI 提供了一個名為 ScrollViewReader
的組件。 顧名思義,此閱讀器要與 ScrollView
一起使用。 它允許開發者以編程方式將滾動視圖(ScrollView)移動到特定位置。 要使用 ScrollViewReader
,請將它包裹 ScrollView
。 每個子視圖都應該有自己的ID。 之後,你就可以使用指定 ID 並呼叫 ScrollViewProxy
的 scrollTo
函數來將滾動視圖移動到該特定位置。
現在讓我們回到範例App。 要以編程方式滾動Dock的ScrollView
,我們首先要給每張照片指定一個ID。就是透過這個 ID,我們才可以指定滾動位置。 scrollTo
函數內的參數就是指這個ID。 由於每張照片本身都已經有一個ID,我們就直接使用它吧。
要在 Dock 中設置 Image
視圖的ID,請附加 .id
修飾器:
.id(photo.id)
完成後,將ScrollViewReader
包裹整個ScrollView
,如下所示:
ScrollViewReader { scrollProxy in
ScrollView(.horizontal, showsIndicators: false) {
LazyHGrid(rows: [ GridItem() ]) {
ForEach(selectedPhotos) { photo in
Image(photo.name)
.resizable()
.scaledToFill()
.frame(minWidth: 0, maxWidth: .infinity)
.frame(height: 100)
.cornerRadius(3.0)
.id(photo.id)
.matchedGeometryEffect(id: photo.id, in: photoTransition)
.onTapGesture {
photoSet.append(photo)
if let index = selectedPhotos.firstIndex(where: { $0.id == photo.id }) {
selectedPhotos.remove(at: index)
}
}
}
}
}
.frame(height: 100)
.padding()
.background(Color(.systemGray6))
.cornerRadius(5)
}
最後,將.onChange
函數附加到dock的ScrollView
,如下所示:
.onChange(of: selectedPhotoId) { oldValue, newValue in
withAnimation {
scrollProxy.scrollTo(newValue)
}
}
我們使用.onChange
來偵測selectedPhotoId
的變動。 每當使用者選擇照片時,我們就使用該照片 ID 並呼叫scrollTo
,這樣滾動視圖就會滾動到該相片位置。而最重要的是,確保 Dock 永遠都顯示最近選擇的照片。 你可以再次運行App進行試一試。
在本章中,我們繼續探討 matchedGeometryEffect
的用法,並使用這個修飾器創建另一種視圖轉換效果。 只要活用 matchedGeometryEffect
,你可以輕易加入過場動畫並改善App的使用者體驗。 我們還試驗了 ScrollViewReader
以編程方式移動滾動視圖。
在本章所準備的範例檔中,有最後完整的 Xcode 專案,可供你下載參考:
https://www.appcoda.com/resources/swiftui5/SwiftUIGridViewAnimation.zip