精通 SwiftUI - iOS 17 版

第 34 章
ScrollViewReader 和網格動畫

在前一章,我已介紹了新的 matchedGeometryEffect 修飾器 (modifier) ,並向你展示了如何創建一些基本的視圖動畫。 在本章中,讓我們看看如何在網格視圖中使用修飾器和加入過場動畫。 此外,你還將學習另一個名為ScrollViewReader的全新 UI 組件。

範例 App

在我們開始實作之前,讓我先向你展示最終的成果。 這應該讓你對要即將構建的內容有所了解。 當你開發 App時,你可能需要以網格(Grid)形式顯示照片並讓使用者選擇其中的一些項目。

範例 App 在螢幕底部顯示一個Dock,當一個項目被選中時,它會從網格中移除並插入到 Dock 中。 當你選擇更多項目時,個 Dock 自動擴大以容納更多項目。 你可以水平滑動以瀏覽Dock中的項目。 如果你點擊 Dock 中的其中一個項目,該項目就會被移除並重新加到網格中。

圖 34.1. 範例 App
圖 34.1. 範例 App

我們將實作這個範例App,並會使用 matchedGeometryEffect 修飾符加入絢麗的過場動畫。 開始之前,請到 https://www.appcoda.com/resources/swiftui5/SwiftUIGridViewAnimationStarter.zip 下載Starter項目。 該項目已包含樣本數據和圖像。

構建照片網格(Photo Grid)

首先,讓我們建立照片網格。 在 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()

假設你已經閱讀了前面有關網格視圖的章節,你一定能夠明白當中的程式碼。 我們只需使用自適應佈局將一組照片排列在一個網格中。

圖 34.2. 照片網格(Photo Grid)
圖 34.2. 照片網格(Photo Grid)

加入 Dock

為了顯示和存放使用者所選的照片,我們將創建一個Dock。 在 VStack 中插入以下程式碼:

ScrollView(.horizontal, showsIndicators: false) {

}
.frame(height: 100)
.padding()
.background(Color(.systemGray6))
.cornerRadius(5)

這就會建立一個可滾動的矩形區域來存放選定的照片。 當然,它現在還沒有任何相片,只是一個空白區。

圖 34.3. 添加灰色區域
圖 34.3. 添加灰色區域

處理使用者所選的照片

選擇照片後,我們會將其從照片網格中移除並將它加入到 Dock 中。 為了處理使用者所選的照片,我們將創建一個狀態變數來存放所選照片。 在 ContentView 中加入以下程式碼來宣告變數:

@State private var selectedPhotos: [Photo] = []

photoSet 中的每張照片都有自己的ID,而類型是 UUID 。 要儲存當前選定的照片,請宣告另一個 UUID 類型的狀態變數:

@State private var selectedPhotoId: UUID?

要偵測照片選擇,請將 onTapGesture 修飾器附加到 LazyVGridImage 組件,如下所示:

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 中的照片將其移回照片網格。

圖 34.4. 將所選照片添加到 Dock
圖 34.4. 將所選照片添加到 Dock

使用 MatchedGeometryEffect 設置過場動畫

現在 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。

圖 34.5. 所選照片將添加到Dock中
圖 34.5. 所選照片將添加到Dock中

使用 ScrollViewReader 移動滾動視圖

動畫效果應該不錯吧? 但是你是否注意到App還有一個小問題? 當你不斷將照片加入至Dock,你會發現Dock是不會自動滾動至最近選擇的照片。 如果你選擇的照片超過 4 張,則需要自己滾動Dock以顯示其他選定的照片。

我們如何修復這個錯誤?SwiftUI 提供了一個名為 ScrollViewReader 的組件。 顧名思義,此閱讀器要與 ScrollView 一起使用。 它允許開發者以編程方式將滾動視圖(ScrollView)移動到特定位置。 要使用 ScrollViewReader,請將它包裹 ScrollView 。 每個子視圖都應該有自己的ID。 之後,你就可以使用指定 ID 並呼叫 ScrollViewProxyscrollTo 函數來將滾動視圖移動到該特定位置。

圖 34.6. 了解 ScrollViewReader
圖 34.6. 了解 ScrollViewReader

現在讓我們回到範例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進行試一試。

圖 33.7. 自動滾動Dock
圖 33.7. 自動滾動Dock

總結

在本章中,我們繼續探討 matchedGeometryEffect 的用法,並使用這個修飾器創建另一種視圖轉換效果。 只要活用 matchedGeometryEffect ,你可以輕易加入過場動畫並改善App的使用者體驗。 我們還試驗了 ScrollViewReader 以編程方式移動滾動視圖。

在本章所準備的範例檔中,有最後完整的 Xcode 專案,可供你下載參考:

https://www.appcoda.com/resources/swiftui5/SwiftUIGridViewAnimation.zip