精通 SwiftUI - iOS 17 版

第 27 章
如何建立圖像輪播(Image Carousel)

Carousel (輪播)是你在大多數手機和 Web App中看到最常見的 UI 模式之一。 有些人稱它為圖像滑塊(Image Slider)或旋轉器(Rotator)。 但是,無論你如何稱呼它,輪播的設計都旨在在有限的螢幕空間顯示一組數據。 例如,圖像輪播會顯示陣列或集合中的其中一個圖片,使用者可以滑動螢幕來瀏覽圖像集的其他圖片。 Instagram 也有用這種輪播設計,當要顯示多張圖片時,使用者就要滑動螢幕來瀏覽圖像集。 另外,你也可以在 Apple 的 Music App 和 App Store App 找到類似的輪播應用。

圖 27.1. Music, App Store 和 Instagram app 都有用輪播設計
圖 27.1. Music, App Store 和 Instagram app 都有用輪播設計

在本章中,你將學習如何實作圖像輪播,當然是用 SwiftUI。有多種方法可以實現輪播,其中一種方法是整合 UIKit 內的 UIPageViewController 。 但是,我們將探討另一種方法,並完全以 SwiftUI 框架來建立輪播。

讓我們開始吧。

旅行範例 App 簡介

像其他章節一樣,我通過構建一個範例 App來引導你了解當中的技術問題。 今次的 App 以輪播形式顯示不同的旅行目的地。 要瀏覽行程,用戶可以向右滑動查看下一個目的地,或者向左滑動查看之前的行程。 為了使這個範例 App更具吸引力,使用者可以點擊一個目的地來查看它的詳細資料。 因此,除了實作輪播外,你還會學習一些動畫技術。 圖 27.2 顯示了範例 App的一些螢幕截圖。想知它實際的運作,你可以到 https://link.appcoda.com/carousel-demo 上觀看示範影片。

圖 27.2. 範例 App
圖 27.2. 範例 App

為了節省你的時間並專注於開發輪播,我已為建立了一個Starter項目。 請從 https://www.appcoda.com/resources/swiftui5/SwiftUICarouselStarter.zip 下載並解壓檔案。

圖 27.3. Starter 項目
圖 27.3. Starter 項目

Starter項目具有以下特點:

  1. 它已包含所需的圖像並加至Assets 中。
  2. ContentView.swift 是 Xcode 自動建立的預設視圖。
  3. Trip.swift 包含 Trip 結構,它代表App內的旅行目的地。 出於測試目的,該文件還包含一些測試數據(sampleTrips)。 你可以隨便修改這些測試資料。
  4. TripCardView.swift 已建立了卡片視圖的 UI。 每個卡片視圖都旨在顯示目的地的圖像。 isShowDetails 綁定控製文字標籤的外觀。 當 isShowDetails 設定為 true 時,標籤將被隱藏。

滾動視圖的問題

那麼,你將如何在 SwiftUI 中實作輪播呢? 你或會想到使用滾動視圖來創建輪播。 可能你會像這樣在 ContentView.swift 中加入以下程式碼:

struct ContentView: View {

    @State private var isCardTapped = false

    var body: some View {
        GeometryReader { outerView in
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(alignment: .center) {
                    ForEach(sampleTrips.indices, id: \.self) { index in
                        GeometryReader { innerView in
                            TripCardView(destination: sampleTrips[index].destination, imageName: sampleTrips[index].image, isShowDetails: self.$isCardTapped)
                        }
                        .padding(.horizontal, 20)
                        .frame(width: outerView.size.width, height: 450)
                    }
                }
            }
            .frame(width: outerView.size.width, height: outerView.size.height, alignment: .leading)
        }
    }
}

在上面的程式碼中,我們嵌入了一個帶有水平 ScrollView 的 HStack 來創建圖像滑塊。 在 HStack 中,我們為每個行程創建一個 TripCardView。 為了更好地控制卡片大小,我們有兩個 GeometryReader:outerViewinnerView,其中outerView存放裝置螢幕的大小,innerView包裹著卡片視圖以控制其大小。 如果你不明白什麼是GeometryReader,請先讀第26章。

這看起來很簡單,對吧? 如果你在預覽畫面中運行程式碼,它應該會產生一個水平滾動視圖。 你可以滑動螢幕瀏覽所有卡片視圖。

圖 27.4. 滑動螢幕瀏覽圖片
圖 27.4. 滑動螢幕瀏覽圖片

這是否意味著我們已經實作了輪播? 還未。 有兩個主要問題:

  1. 當前的設計並不支援分頁(Paging)。 換句話說,你可以滑動螢幕以無間斷地滾動內容。 滾動視圖可以在任何位置停止。 舉例,看一下圖 27.4,使用者可以按製在兩個卡片視圖之間停止滾動。 這不是我們想要的特點, 當滾動停止,螢幕只會顯示其中一個內容視圖。
  2. 當一個卡片視圖被點擊時,我們需要找出它的索引並在一個單獨的視圖中顯示它的細節。 問題是要怎樣得知使用者點擊了哪個卡片視圖?

這兩個問題都與內置的ScrollView有關。 UIKit 版本的滾動視圖支持分頁。 然而,Apple 並未將該功能引入 SwiftUI 框架的 ScrollView。 為了解決這個問題,我們需要構建我們自己的支持分頁(Paging)的滾動視圖。

使用 HStack 和 DragGesture 建立輪播

起初,你可能認為很難開發我們自己的滾動視圖。 但實際上,這並不難。 如果你了解 HStackDragGesture 的用法,就可以構建一個支援分頁的滾動視圖。

想法是將所有卡片視圖(即行程)佈局在水平堆棧(HStack)中。 HStack 要有足夠長度以容納所有卡片視圖,但在任何時候只顯示一個卡片視圖。 在預設情況下,水平堆棧是不支援滾動的。 因此,我們需要將拖動手勢識別器附加到堆棧視圖並自行處理拖動。 圖 27.5 以圖解方式說明了如何實作水平滾動視圖。

圖 27.5. 了解如何使用 HStack 和 DragGesture 創建水平滾動視圖
圖 27.5. 了解如何使用 HStack 和 DragGesture 創建水平滾動視圖

自建ScrollView

現在讓我們看看如何將這個想法轉化為程式碼。 請容忍一下,因為你需要多次更改程式碼。 我想將每一個步驟都展現出來。 打開 Content.swift 並像這樣更新 body

var body: some View {
    HStack {
        ForEach(sampleTrips.indices) { index in
            TripCardView(destination: sampleTrips[index].destination, imageName: sampleTrips[index].image, isShowDetails: self.$isCardTapped)
        }
    }
}

在上面的程式碼,我們首先在 HStack 中佈置所有卡片視圖。 水平堆棧會盡力在可用的螢幕空間容納所有卡片視圖。 你應該在預覽畫面中看到類似於圖 27.6 的畫面。

圖 27.6. 擠壓所有卡片視圖以填滿整個螢幕
圖 27.6. 擠壓所有卡片視圖以填滿整個螢幕

這顯然不是我們想要構建的水平堆棧。 我們希望每個卡片視圖都佔據螢幕的寬度。 為此,我們必須將 HStack 嵌入在 GeometryReader 中以讀取螢幕尺寸。 像這樣更新 body 中的程式碼:

var body: some View {
    GeometryReader { outerView in
        HStack {
            ForEach(sampleTrips.indices, id: \.self) { index in
                GeometryReader { innerView in
                    TripCardView(destination: sampleTrips[index].destination, imageName: sampleTrips[index].image, isShowDetails: self.$isCardTapped)
                }
                .frame(width: outerView.size.width, height: 500)
            }
        }
        .frame(width: outerView.size.width, height: outerView.size.height)
    }
}

outerView 參數為我們提供了螢幕的寬度和高度,而innerView 參數可以讓我們更好地控制卡片視圖的大小和位置。

在上面的程碼中,我們將.frame修飾器附加到卡片視圖並將其寬度設定為螢幕寬度(即outerView.size.width)。 這樣可以確保每個卡片視圖佔據整個螢幕。 對於卡片視圖的高度,我們將其設置為 500 點以使其更小一些。 進行更改後,你應該會看到「London」圖像的卡片視圖。

圖 27.7. 水平堆棧現在只顯示一個卡片視圖
圖 27.7. 水平堆棧現在只顯示一個卡片視圖

為什麼是「London」卡片視圖? 如果你將預覽模式轉至 Selectable,預覽畫面應顯示如圖 27.8 所示的內容。我們在 sampleTrips 數組中有 13 個項目。 由於每個卡片視圖的寬度都等於螢幕寬度,因此水平堆棧視圖必須擴展到螢幕之外。 碰巧「London」卡片視圖是陣列的中間(第 7 個)項。 這就是你看到「London」卡片視圖的原因。

圖 27.8. 這個水平堆棧其實包含 13 個卡片視圖
圖 27.8. 這個水平堆棧其實包含 13 個卡片視圖

更改對齊方式

那麼,我們如何才能顯示陣列的第一項而不是中間(第 7 個)項? 訣竅是將.frame修飾器附加到HStack,對齊設定為.leading,如下所示:

.frame(width: outerView.size.width, height: outerView.size.height, alignment: .leading)

SwiftUI 預設的對齊方式為.center。 這就是為什麼水平視圖的第 7 個項目會顯示在螢幕上的原因。 將alignment更改為 .leading 後,你就會看到第一張圖。

圖 27.9. 現在顯示第一張圖
圖 27.9. 現在顯示第一張圖

如果你想了解alignment如何影響水平堆棧視圖,可以將其值更改為 .center.trailing 試一下。 圖 27.10 顯示了堆棧視圖在不同對齊設置下的樣子。

圖 27.10. 堆棧視圖在不同對齊設置下的樣子
圖 27.10. 堆棧視圖在不同對齊設置下的樣子

你是否注意到每個卡片視圖之間的差距? 這也與HStack的預設值有關。 要減少間距,你可以更新 HStack 並將spacing參數設定為0,如下所示:

HStack(spacing: 0)

加入 padding

想做得好一點,你可以為圖像添加padding, 我認為這會使卡片視圖看起來更好。 加入以下程式碼並將它附加到包裹卡片視圖的GeometryReader(在.frame(width:outerView.size.width,height:500)之前):

.padding(.horizontal, self.isCardTapped ? 0 : 20)

雖然現在談論詳細視圖的實作還是有點過早,但我們為padding添加了一個條件。 當使用者點擊卡片視圖時,水平填充將被刪除。

圖 27.11. 加入 padding
圖 27.11. 加入 padding

讓卡片視圖滾動

現在我們已經建立了一個能顯示第一個卡片視圖的水平堆棧,下一個問題是我們如何移動堆棧以顯示特定卡片圖?

這只是簡單的數學! 卡片視圖的寬度等於螢幕的寬度。 假設螢幕寬度為 300 點,我們要顯示第三個卡片視圖,我們可以將堆棧向左移動 600 點(300 x 2)。 圖 27.12 顯示了結果。

圖 27.12. 將堆棧向左移動
圖 27.12. 將堆棧向左移動

要將上面的描述翻譯成程式碼,我們首先宣告一個狀態變數來存放顯示於螢幕卡片視圖的索引:

@State private var currentTripIndex = 2

在預設情況下,我想顯示第三張卡片視圖。 這就是我將 currentTripIndex 變數設定為 2 的原因。你可以更改它為其他值。

要將堆棧向左移動,我們可以將 .offset 修飾器附加到 HStack,如下所示:

.offset(x: -CGFloat(self.currentTripIndex) * outerView.size.width)

outerView 的寬度實際上是螢幕的寬度。 為了顯示第三個卡片視圖,如前所述,我們需要將堆棧移動 「2 x 螢幕寬度」。 這就是我們將 currentTripIndexouterView 的寬度相乘的原因。 如果水平的 offset 值為負數的話,就會將堆棧視圖向左移動。

進行更改後,你應該會在預覽畫面中看到「Amsterdam」卡片視圖。

圖 27.13. 預覽畫面顯示Amsterdam卡片視圖
圖 27.13. 預覽畫面顯示Amsterdam卡片視圖

添加拖動手勢

現在,我們已可通過改變 currentTripIndex 的值來改變螢幕顯示的卡片視圖。 請記住,水平堆棧(HStack)不允許用戶拖動視圖。 這就是我們要自己建立此功能的原因。以下的內容是假設你已經了解 SwiftUI 手勢的運作原理。 如果你不理解手勢或@GestureState,請先閱讀第 17 章。

堆棧是這樣處理使用者的拖動手勢:

  1. 使用者可以點擊圖像並開始拖動。
  2. 使用者可以向左或右移動視圖。
  3. 當拖動距離超過一定數值時,堆棧會根據拖動方向移動到下一個或上一個卡片視圖。
  4. 否則,堆棧視圖返回原本的位置並顯示當前卡片視圖。

要將上面的描述翻譯成程式碼,我們首先宣告一個變數來保存拖動的偏移值:

@GestureState private var dragOffset: CGFloat = 0

接下來,我們將.gesture修飾器附加到HStack並像這樣初始化DragGesture

.gesture(
    !self.isCardTapped ?

    DragGesture()
        .updating(self.$dragOffset, body: { (value, state, transaction) in
            state = value.translation.width
        })
        .onEnded({ (value) in
            let threshold = outerView.size.width * 0.65
            var newIndex = Int(-value.translation.width / threshold) + self.currentTripIndex
            newIndex = min(max(newIndex, 0), sampleTrips.count - 1)

            self.currentTripIndex = newIndex
        })

    : nil
)

當你拖動水平堆棧時,App會自動呼叫updating函數。 我們將水平拖動距離存放到 dragOffset 變數中。 當拖動結束時,就檢查拖動距離是否超過螢幕寬度的 65%,並計算新的索引(index)。 當計算了 newIndex,我們會先確定它是否在 sampleTrips 陣列的範圍內。 最後,我們將 newIndex 的值指定至 currentTripIndex。 然後, SwiftUI 會自動更新 UI 並顯示相應的卡片視圖。

請注意,我們有一個啟用拖動手勢的條件。 點擊卡片視圖時,沒有手勢識別器。

要在拖動過程中移動堆棧視圖,我們必須再進行一項更改。 附加一個額外的 .offset 修飾器到 HStack(緊跟在前一個 .offset 之後),如下所示:

.offset(x: self.dragOffset)

在這裡,我們將堆棧視圖的水平偏移值更新為拖動偏移值。 現在你可以準備測試App。 在模擬器或預覽畫面中運行App,你應該能夠拖動堆棧視圖。 當你的拖動超過一定距離時,堆棧視圖會顯示下一個行程。

圖 27.14. 拖動堆棧視圖
圖 27.14. 拖動堆棧視圖

加入動畫

為了提升使用者體驗,我想在App從一個卡片視圖移動到另一個卡片視圖時添加一個漂亮的動畫。 首先,修改以下這行程式碼:

.frame(width: outerView.size.width, height: 500)

將它改寫成:

.frame(width: outerView.size.width, height: self.currentTripIndex == index ? (self.isCardTapped ? outerView.size.height : 450) : 400)

我們將螢幕顯示的卡片視圖稍為放大一點。 另外,將 .opacity 修飾器附加到卡片視圖,如下所示:

.opacity(self.currentTripIndex == index ? 1.0 : 0.7)

除了卡片視圖的高度,我們還想為可見和隱藏的卡片視圖設置不同的opacity值。 以上這些變化還沒有動畫化, 現在將以下程式碼加入到外部視圖的 GeometryReader 中:

.animation(.interpolatingSpring(.bouncy), value: dragOffset)

就是這樣, SwiftUI 會自動為卡片視圖的移動加入動畫。 在預覽畫布中運行App以測試更改。

圖 27.15. 加入動畫效果
圖 27.15. 加入動畫效果

加入標題

既然我們已經構建了圖像輪播,現在就再為這 App 實作詳細視圖, 讓我們由添加標題開始。

按Control鍵再點擊外部視圖(outerView)的GeometryReader並選擇embed in ZStack

圖 27.16. 選擇 Embed in Stack
圖 27.16. 選擇 Embed in Stack

接下來,在 ZStack 的開頭插入以下程式碼:

VStack(alignment: .leading) {
    Text("Discover")
        .font(.system(.largeTitle, design: .rounded))
        .fontWeight(.black)

    Text("Explore your next destination")
        .font(.system(.headline, design: .rounded))
}
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading)
.padding(.top, 25)
.padding(.leading, 20)
.opacity(self.isCardTapped ? 0.1 : 1.0)
.offset(y: self.isCardTapped ? -100 : 0)

上面的程式碼應該不用解釋吧,但我想特別指出兩行程式碼。 .opacity.offset 都是可有可無的。 .opacity 修飾器的目的是在點擊卡片視圖時隱藏標題,而.offset會進一步提升使用者體驗。

圖 27.17. 加入標題
圖 27.17. 加入標題

練習:處理詳細視圖

讓我們通過一個練習來開始詳細視圖的實作。 我假設你對 SwiftUI 有一定的經驗,並且應該能夠建立圖 27.18 的詳細視圖。你可以建立一個名為TripDetailView.swift的文件並在那裡編寫程式碼。

圖 27.18. 行程詳細視圖
圖 27.18. 行程詳細視圖

為簡單起見,評級和描述都只是一些 hardcode 的資料。 Book Now 按鈕也是如此,它不能運作的。 這個詳細視圖的框架大概就是這樣:

struct TripDetailView: View {
    let destination: String

    var body: some View {
        .
        .
        .
    }
}

請花一些時間來開發詳細視圖, 我將在後面的部分中介紹我的解決方案。

實作行程詳細視圖

你是否能夠開發詳細視圖? 我希望能完成這個練習。 讓我講解一下我的解決方案。 首先,使用 SwiftUI View 模板建立一個名為 TripDetailView.swift 的新文件。

接下來,像這樣寫 TripDetailView 結構:

struct TripDetailView: View {
    let destination: String

    var body: some View {
        GeometryReader { geometry in
            ScrollView {
                ZStack {
                    VStack(alignment: .leading, spacing: 5) {

                        VStack(alignment: .leading, spacing: 5) {
                            Text(self.destination)
                                .font(.system(.title, design: .rounded))
                                .fontWeight(.heavy)

                            HStack(spacing: 3) {
                                ForEach(1...5, id: \.self) { _ in
                                    Image(systemName: "star.fill")
                                        .foregroundStyle(.yellow)
                                        .font(.system(size: 15))
                                }

                                Text("5.0")
                                    .font(.system(.headline))
                                    .padding(.leading, 10)
                            }

                        }
                        .padding(.bottom, 30)


                        Text("Description")
                            .font(.system(.headline))
                            .fontWeight(.medium)

                        Text("Growing up in Michigan, I was lucky enough to experience one part of the Great Lakes. And let me assure you, they are great. As a photojournalist, I have had endless opportunities to travel the world and to see a variety of lakes as well as each of the major oceans. And let me tell you, you will be hard pressed to find water as beautiful as the Great Lakes.")
                            .padding(.bottom, 40)

                        Button(action: {
                            // tap me
                        }) {
                            Text("Book Now")
                                .font(.system(.headline, design: .rounded))
                                .fontWeight(.heavy)
                                .foregroundStyle(.white)
                                .padding()
                                .frame(minWidth: 0, maxWidth: .infinity)
                                .background(Color(red: 0.97, green: 0.369, blue: 0.212))
                                .cornerRadius(20)
                        }
                    }
                    .padding()
                    .frame(width: geometry.size.width, height: geometry.size.height, alignment: .topLeading)
                    .background(.white)
                    .cornerRadius(15)

                    Image(systemName: "bookmark.fill")
                        .font(.system(size: 40))
                        .foregroundStyle(Color(red: 0.97, green: 0.369, blue: 0.212))
                        .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topTrailing)
                        .offset(x: -15, y: -5)
                }
                .offset(y: 15)
            }
        }
    }
}

基本上,我們將整個內容嵌入到滾動視圖中。 在滾動視圖中,我們使用 ZStack 來佈局內容和書籤圖像。 由於 TripDetailView 需要提供destination 參數才能正常運作,因此你需要像這樣更新預覽程式碼:

#Preview {
    TripDetailView(destination: "London").background(.black)
}

另外,我還將背景顏色更改為黑色,以便我們可以看到詳細視圖的圓角。

圖 27.19. 預覽圖
圖 27.19. 預覽圖

彈出詳細視圖

現在讓我們回到 ContentView.swift ,我們要作少少修改。 當使用者點擊卡片視圖時,我們將顯示帶有動畫過渡的詳細視圖。 由於ContentView有一個 ZStack,因此我們很容易與詳細視圖整合。

ZStack 中插入以下程式碼:

if self.isCardTapped {
    TripDetailView(destination: sampleTrips[currentTripIndex].destination)
        .offset(y: 200)
        .transition(.move(edge: .bottom))

    Button(action: {
        withAnimation {
            self.isCardTapped = false
        }
    }) {
        Image(systemName: "xmark.circle.fill")
            .font(.system(size: 30))
            .foregroundStyle(.black)
            .opacity(0.7)
            .contentShape(Rectangle())
    }
    .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topTrailing)
    .padding(.trailing)

}

TripDetailView 只有在卡片視圖被點擊時才會出現, 而詳細視圖將從螢幕底部出現並以動畫方式向上移動。 這就是我們將 .transition.animation 修飾器附加到詳細視圖的原因。 為了讓使用者能關閉詳細視圖,我們還添加了一個關閉按鈕。該按鈕顯示在螢幕的右上角。 如果你不確定在那裡加入上面的程式碼,請參考圖 27.20。

圖 27.20. 就在這裡加入程式碼
圖 27.20. 就在這裡加入程式碼

還未完成喔!因為我們還沒有加入程式碼偵測點擊手勢。 將 .onTapGesture 函數附加到卡片視圖,如下所示:

.onTapGesture {
    withAnimation(.interpolatingSpring(.bouncy, initialVelocity: 0.3)) {
        self.isCardTapped = true
    }
}

當使用者點擊卡片視圖時,我們只需將 isCardTapped 狀態變數更改為 true。 運行App並點擊任何卡片視圖,它應顯示詳細視圖。

圖 27.21. 加入 onTapGesture
圖 27.21. 加入 onTapGesture

成功了! 但是,動畫效果並不太好。 當詳細視圖彈出時,卡片視圖會變大一點,這是下面程式碼做成的:

.frame(width: outerView.size.width, height: self.currentTripIndex == index ? (self.isCardTapped ? outerView.size.height : 450) : 400)

為了讓動畫看起來更流暢,讓我們在詳細視圖出現時將圖像向上移動。 將 .offset 修飾器附加到 TripCardView

.offset(y: self.isCardTapped ? -innerView.size.height * 0.3 : 0)

我將垂直 offset 設定為卡片視圖高度的 30%, 你可以自由更改該值。 現在再次執行App,你應該會看到更流暢的動畫。

圖 27.22. 在 TripCardView 添加offset修飾器
圖 27.22. 在 TripCardView 添加offset修飾器

總結

酷! 你已經構建了一個支援分頁的客製化滾動視圖,並學習了如何加入過場動畫。 該技術不限於圖像輪播。 實際上,你可以修改程式碼以建立一組onboarding screens。 我希望你喜歡本章所介紹的東西,並將它應用到你的下一個App。

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

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