精通 SwiftUI - iOS 17 版

第 19 章
使用手勢與動畫建立如 Tinder 般的 UI

建立一個展開式底部表不是很有趣呢?我們來繼續應用我們所學到的手勢,並將其應用到真實世界的專案中。我不確定你之前是否用過 Tinder App,但是在一些其他 App 中, 你可能會碰到如 Tinder 般的使用者介面。滑動動作是 Tinder UI 的設計重點,並已成為最流行的行動裝置 UI 模式之一。使用者向右滑動即表示喜歡某張圖片,向左滑動則表示不喜歡。

在本章中,我們要做的是建立一個具有如 Tinder 般UI 的簡單 App。這個 App 向使用者顯示一副旅遊卡,並讓他們使用滑動手勢來表示喜歡/ 不喜歡一張卡片。

圖 19.1. 建立如Tinder 般的使用者介面
圖 19.1. 建立如Tinder 般的使用者介面

請注意,我們將不會建立一個功能齊全的App,而是只著眼於如Tinder 般的UI 。

專案準備

如果你使用自己的圖片,那就太棒了。不過,為了節省你準備旅遊圖片的時間,我已經為你建立了一個起始專案,你可以至下列網址下載:https://www.appcoda.com/resources/swiftui5/SwiftUITinderTripStarter.zip。這個專案已經具有一組旅遊卡的照片,如圖 19.2 所示。

圖 19.2. 預載了一組旅遊照片
圖 19.2. 預載了一組旅遊照片

除此之外,我已經為範例 App 準備測試資料,並建立了 Trip.swift 檔來代表旅程:

struct Trip {
    var destination: String
    var image: String
}

#if DEBUG
var trips = [ Trip(destination: "Yosemite, USA", image: "yosemite-usa"),
              Trip(destination: "Venice, Italy", image: "venice-italy"),
              Trip(destination: "Hong Kong", image: "hong-kong"),
              Trip(destination: "Barcelona, Spain", image: "barcelona-spain"),
              Trip(destination: "Braies, Italy", image: "braies-italy"),
              Trip(destination: "Kanangra, Australia", image: "kanangra-australia"),
              Trip(destination: "Mount Currie, Canada", image: "mount-currie-canada"),
              Trip(destination: "Ohrid, Macedonia", image: "ohrid-macedonia"),
              Trip(destination: "Oia, Greece", image: "oia-greece"),
              Trip(destination: "Palawan, Philippines", image: "palawan-philippines"),
              Trip(destination: "Salerno, Italy", image: "salerno-italy"),
              Trip(destination: "Tokyo, Japan", image: "tokyo-japan"),
              Trip(destination: "West Vancouver, Canada", image: "west-vancouver-canada"),
              Trip(destination: "Singapore", image: "garden-by-bay-singapore"),
              Trip(destination: "Perhentian Islands, Malaysia", image: "perhentian-islands-malaysia")
            ]
#endif

假如你希望使用自己的圖片與資料,則只需替換素材目錄中的圖片,並更新 Trip.swift 檔。

建立卡片視圖與選單列

在實作滑動功能之前,我們先建立主UI,我將主螢幕分成三個部分,如圖 19.3 所示:

  1. 頂部選單列(top menu bar)。
  2. 頂部選單列(top menu bar)。
  3. 底部選單列(bottom menu bar)。
圖 19.3. 主螢幕
圖 19.3. 主螢幕

卡片視圖

首先,我們建立一個卡片視圖。若是你想挑戰自我,我強烈建議你在這裡停下來並實作它,而無須遵循本節內容,否則請繼續閱讀。

為了讓程式碼更易編寫,我們將在一個單獨的檔案中實作卡片視圖。在專案導覽器中,使用「SwiftUI View」模板來建立新檔,並將其命名為 CardView.swift

CardView 是設計用來顯示不同的照片與標題,因此宣告兩個變數來儲存這些資料:

let image: String
let title: String

主螢幕將顯示一副卡片視圖。稍後,我們將使用 ForEach 來逐一執行卡片視圖陣列並顯示它們。如果你還記得ForEach 的用法,那麼 SwiftUI 需要知道如何唯一識別陣列中的每個項目。因此,我們將使 CardView 遵循Identifiable 協定,並導入一個 id 變數,如下所示:

struct CardView: View, Identifiable {
    let id = UUID()
    let image: String
    let title: String

    .
    .
    .
}

如果您忘記什麼是 Identifiable 協定,則請參考第 10 章。

現在,我們繼續實作卡片視圖,並更新 body 變數如下:

var body: some View {
    Image(image)
        .resizable()
        .scaledToFill()
        .frame(minWidth: 0, maxWidth: .infinity)
        .cornerRadius(10)
        .padding(.horizontal, 15)
        .overlay(alignment: .bottom) {
            VStack {

                Text(title)
                    .font(.system(.headline, design: .rounded))
                    .fontWeight(.bold)
                    .padding(.horizontal, 30)
                    .padding(.vertical, 10)
                    .background(.white)
                    .cornerRadius(5)
            }
            .padding([.bottom], 20)
        }
}

卡片視圖是由一張圖片及一個疊在圖片上方的文字元件所組成。我們設定圖片為 scaleToFill 模式,並使用 cornerRadius 修飾器來為圖片加上圓角。文字元件是用來顯示旅程的目的地。

我們在第 5 章中深入討論過卡片視圖的類似實作。如果你不能完全了解程式碼,則請再次閱讀該章。

你還無法預覽卡片視圖,因為你必須在 #Preview 中同時提供 imagetitle 的值,因此更新#Preview 如下:

#Preview {
    CardView(image: "yosemite-usa", title: "Yosemite, USA")
}

我只是使用素材目錄中的其中一張圖片來進行預覽,你可以依自己的需求隨意更改圖片及標題。在預覽畫布中,你現在應該看到類似圖 19.4 的卡片視圖。

圖 19.4. 預覽卡片視圖
圖 19.4. 預覽卡片視圖

選單列與主 UI

準備好卡片視圖後,我們可以繼續實作主 UI。如前所述,主 UI 有卡片與兩個選單列, 對於這兩個選單列,我將為它們個別建立一個單獨的 struct

現在開啟 ContentView.swift 並開始實作。對於頂部選單列,建立一個新的 struct,如下所示:

struct TopBarMenu: View {
    var body: some View {
        HStack {
            Image(systemName: "line.horizontal.3")
                .font(.system(size: 30))
            Spacer()
            Image(systemName: "mappin.and.ellipse")
            .font(.system(size: 35))
            Spacer()
            Image(systemName: "heart.circle.fill")
            .font(.system(size: 30))
        }
        .padding()
    }
}

這三個圖示使用等距的水平堆疊來排列。對於底部選單列,實作幾乎相同。在 Content View.swift 中插入下列的程式碼,以建立選單列:

struct BottomBarMenu: View {
    var body: some View {
        HStack {
            Image(systemName: "xmark")
                .font(.system(size: 30))
                .foregroundColor(.black)

            Button {
                // Book the trip
            } label: {
                Text("BOOK IT NOW")
                    .font(.system(.subheadline, design: .rounded))
                .bold()
                    .foregroundColor(.white)
                    .padding(.horizontal, 35)
                    .padding(.vertical, 15)
                    .background(.black)
                    .cornerRadius(10)
            }
            .padding(.horizontal, 20)

            Image(systemName: "heart")
                .font(.system(size: 30))
                .foregroundStyle(.black)
        }

    }
}

我們不打算實作「Book Trip」功能,因此將動作區塊留空。假設你了解堆疊與圖片的工作原理,則其餘的程式碼應該無需解釋。

在建立主UI 之前,讓我教你一個預覽這兩個選單列的技巧。而在 ContentView 中放置這些列,來預覽它們的外觀及感覺,並不是強制的。

現在更新預覽的程式碼如下:

#Preview("TopBarMenu") {
    TopBarMenu()
}

#Preview("BottomBarMenu") {
    BottomBarMenu()
}

對於「TopBarMenu」和「BottomBarMenu」視圖,我們新增了兩個「#Preview」部分。 此外,我們也為每個視圖指定了不同的名稱。 如果您查看預覽畫布,可看到三個預覽:ContentView、TopBarMenu 和 BottomBarMenu。 只需單擊每個視圖即可預覽其佈局。 圖 19.5 讓您更了解預覽的樣子。

圖 19.5 預覽選單列
圖 19.5 預覽選單列

好的,我們繼續佈局主UI。更新 ContentView 如下:

struct ContentView: View {
    var body: some View {
        VStack {
            TopBarMenu()

            CardView(image: "yosemite-usa", title: "Yosemite, USA")

            Spacer(minLength: 20)

            BottomBarMenu()
        }
    }
}

在程式碼中,我們只安排了使用 VStack 建立的 UI 元件。你的預覽現在應該顯示主螢幕了,如圖 19.6 所示。

圖 19.6. 預覽主 UI
圖 19.6. 預覽主 UI

實作卡片庫

在做好所有的準備之後,終於可以實作如 Tinder 般的 UI。對於之前從未用過 Tinder App 的人,讓我先解釋一下如 Tinder 般 UI 的工作原理。

你可以將 Tinder 般 UI 想像為一副成堆的卡片,每張卡片都顯示一張照片。在我們的範例 App 中,照片是旅程的目的地。將最上面的卡片(即第一個旅程)輕微向左或向右滑動,即可揭示下一張卡片(即下一個旅程)。如果使用者放開卡片,App 就會將卡片帶回原來的位置。不過,當使用者用力滑動時,他/ 她可以丟掉這張卡片,然後App 會將第二張卡片向前移動,成為最上面的卡片,如圖 19.7 所示。

圖 19.7. Tinder 般 UI 的工作原理
圖 19.7. Tinder 般 UI 的工作原理

我們實作的主螢幕只包含一個卡片視圖,那麼我們如何實作一堆卡片視圖呢?

最直截了當的方式是,使用 ZStack 將每個卡片視圖互相堆疊,我們來試著做這個。更新 ContentView 結構如下:

struct ContentView: View {

    var cardViews: [CardView] = {

        var views = [CardView]()

        for trip in trips {
            views.append(CardView(image: trip.image, title: trip.destination))
        }

        return views
    }()

    var body: some View {
        VStack {
            TopBarMenu()

            ZStack {
                ForEach(cardViews) { cardView in
                    cardView
                }
            }

            Spacer(minLength: 20)

            BottomBarMenu()
        }
    }
}

在上面的程式碼中,我們初始化一個包含所有旅程的 cardViews 陣列(其在 Trip.swift 檔中定義)。在body 變數中,我們逐一執行所有的卡片視圖,並將它們包裹在 ZStack 中來相互重疊。

預覽畫布應該會顯示相同的UI,但使用另一張圖片,如圖19.8 所示。

圖 19.8. 建立卡片視圖庫
圖 19.8. 建立卡片視圖庫

為什麼它會顯示另一張圖片呢?如果你引用在 Trip.swift 中定義的 trips 陣列,圖片是陣列的最後一個元素。在ForEach 區塊中,第一個旅程是放在卡片庫的最下面,如此最後一個旅程便成為卡片庫的最上面照片。

當我們實作卡片庫時,實際上有兩個問題:

  1. .trips 陣列的第一個旅程應該是最上面的卡片,但是現在卻是最下面的卡片。
  2. 我們為 15 個旅程渲染了 15 個卡片視圖。如果未來有 10,000 個旅程,甚至更多時,該怎麼辦呢?我們是否應該為每個旅程建立一個卡片視圖呢?有沒有高效率的方式來實作卡片庫呢?

我們先來解決卡片順序的問題。SwiftUI 提供 zIndex修飾器,來指示 ZStack 中的視圖順序。zIndex 值較高的視圖,位於較低值的視圖之上,因此最上面的卡片應該有最大的 zIndex 值。

考慮到這一點,我們先在 ContentView 中建立以下的新函數:

private func isTopCard(cardView: CardView) -> Bool {

    guard let index = cardViews.firstIndex(where: { $0.id == cardView.id }) else {
        return false
    }

    return index == 0
}

在逐一執行卡片視圖時,我們必須找到一種識別最上面卡片的方式。上面的函式帶入一個卡片視圖,找出其索引,並告訴你卡片視圖是否位於最上面。

接下來,更新 ZStack 的程式碼區塊如下:

ZStack {
    ForEach(cardViews) { cardView in
        cardView
            .zIndex(self.isTopCard(cardView: cardView) ? 1 : 0)
    }
}

我們為每個卡片視圖加入了 zIndex 修飾器。對於最上面的卡片,我們為其指定較高的 zIndex 值。在預覽畫布中,你現在應該會看到第一個旅程的照片(即美國優勝美地國家公園)。

對於第二個問題,則更複雜些,我們的目標是確保卡片庫可支援數以萬計的卡片視圖, 而不需耗費大量資源。

我們來更深入研究一下卡片庫。我們是否真的需要為每張旅程照片初始化個別的卡片視圖呢?要建立這個卡片庫UI,我們只需建立兩個卡片視圖,並將它們互相重疊即可。

當最上面的卡片視圖被丟棄時,下面的卡片視圖將成為最上面的卡片;同時,我們立即使用不同的照片初始化一個新的卡片視圖,並將它放在最上面的卡片後面。無論你需要在卡片庫中顯示多少張照片,App 永遠只有兩個卡片視圖。不過,從使用者的角度來看,UI 是由一堆卡片所組成。

圖 19.9. 我們如何使用兩個卡片視圖來建立卡片庫
圖 19.9. 我們如何使用兩個卡片視圖來建立卡片庫

現在,你應該了解我們如何建立卡片庫,我們來繼續進行實作。

首先,更新 cardViews 陣列,我們不再需要初始化所有的旅程,而只需要初始化前兩個旅程。之後,當第一個旅程(即第一張卡片)被丟棄時,我們會加入另一張卡片。

var cardViews: [CardView] = {

    var views = [CardView]()

    for index in 0..<2 {
        views.append(CardView(image: trips[index].image, title: trips[index].destination))
    }

    return views
}()

更改程式碼之後,UI 看起來應該完全相同。但在底層架構中,你應該在卡片庫中只看到兩個卡片視圖。

實作滑動動作

在動態建立新的卡片視圖之前,我們必須先實作滑動功能。如果你忘記湍如何處理手勢,請再閱讀第17 章及第18 章。我們將會重新使用前面討論的一些程式碼。

首先,在 ContentView 中定義 DragState列舉,它表示可能的拖曳狀態:

enum DragState {
    case inactive
    case pressing
    case dragging(translation: CGSize)

    var translation: CGSize {
        switch self {
        case .inactive, .pressing:
            return .zero
        case .dragging(let translation):
            return translation
        }
    }

    var isDragging: Bool {
        switch self {
        case .dragging:
            return true
        case .pressing, .inactive:
            return false
        }
    }

    var isPressing: Bool {
        switch self {
        case .pressing, .dragging:
            return true
        case .inactive:
            return false
        }
    }

}

再一次,如果你不了解什麼是列舉,則請在此處停止,並複習一下有關手勢的章節。接下來,我們定義一個@GestureState 變數來儲存拖曳狀態,預設上設定為「inactive」:

@GestureState private var dragState = DragState.inactive

現在,更新 body 的部分如下:

var body: some View {
    VStack {
        TopBarMenu()

        ZStack {
            ForEach(cardViews) { cardView in
                cardView
                    .zIndex(self.isTopCard(cardView: cardView) ? 1 : 0)
                    .offset(x: self.dragState.translation.width, y:  self.dragState.translation.height)
                    .scaleEffect(self.dragState.isDragging ? 0.95 : 1.0)
                    .rotationEffect(Angle(degrees: Double( self.dragState.translation.width / 10)))
                    .animation(.interpolatingSpring(stiffness: 180, damping: 100), value: self.dragState.translation)
                    .gesture(LongPressGesture(minimumDuration: 0.01)
                        .sequenced(before: DragGesture())
                        .updating(self.$dragState, body: { (value, state, transaction) in
                            switch value {
                            case .first(true):
                                state = .pressing
                            case .second(true, let drag):
                                state = .dragging(translation: drag?.translation ?? .zero)
                            default:
                                break
                            }

                        })

                    )
            }
        }

        Spacer(minLength: 20)

        BottomBarMenu()
            .opacity(dragState.isDragging ? 0.0 : 1.0)
            .animation(.default, value: dragState.isDragging)
    }
}

基本上,我們將應用在手勢章節中所學的知識來實作拖曳。.gesture 修飾器有兩個手勢識別器:長按與拖曳。當偵測到拖曳手勢時,我們更新 dragState 變數,並儲存拖曳的位移量。

offsetscaleEffectrotationEffectanimation 修飾器的結合, 可建立拖曳效果。拖曳是透過更新卡片視圖的 offset來實現。當卡片視圖處於拖曳狀態時,我們會使用 scaleEffect 將它縮小一點,並應用rotationEffect 修飾器將它旋轉特定角度。動畫設定為 interpolatingSpring,但你可以自由嘗試其他動畫。

我們還對 BottomBarMenu 做一些程式碼更改。當使用者拖曳卡片視圖時,我想要隱藏底部列,因此我們應用 .opacity 修飾器,並且當它在拖曳狀態時,設定它的值為「0」。

進行更改後,在預覽畫布中執行專案來測試它。你應該能夠拖曳卡片並四處移動。而當你釋放卡片時,卡片會回到原來的位置,如圖 19.10 所示。

圖 19.10. 拖曳卡片視圖
圖 19.10. 拖曳卡片視圖

你注意到問題了嗎?當拖曳開始時,你實際上是在拖曳整個卡片庫 !假設使用者只能拖曳最上面的卡片,下面的卡片應該保持不變。而且,縮放效果應只應用於最上面的卡片。

要解決這些問題,我們需要修改 offsetscaleEffectrotationEffect 修飾器的程式碼, 如此拖曳只發生在最上面的卡片視圖。

ZStack {
    ForEach(cardViews) { cardView in
        cardView
            .zIndex(self.isTopCard(cardView: cardView) ? 1 : 0)
            .offset(x: self.isTopCard(cardView: cardView) ? self.dragState.translation.width : 0, y: self.isTopCard(cardView: cardView) ? self.dragState.translation.height : 0)
            .scaleEffect(self.dragState.isDragging && self.isTopCard(cardView: cardView) ? 0.95 : 1.0)
            .rotationEffect(Angle(degrees: self.isTopCard(cardView: cardView) ? Double( self.dragState.translation.width / 10) : 0))
            .animation(.interpolatingSpring(stiffness: 180, damping: 100), value: self.dragState.translation)
            .gesture(LongPressGesture(minimumDuration: 0.01)
                .sequenced(before: DragGesture())
                .updating(self.$dragState, body: { (value, state, transaction) in
                    switch value {
                    case .first(true):
                        state = .pressing
                    case .second(true, let drag):
                        state = .dragging(translation: drag?.translation ?? .zero)
                    default:
                        break
                    }

                })

            )
    }
}

只需要對 offsescaleEffectrotationEffect 修飾器進行修改,其餘的程式碼保持不變。對於那些修飾器,我們進行額外的檢查,以使效果只應用在最上面的卡片。

現在,如果你再次執 行 App,則應該看到其下方的卡片,並只能拖曳最上面的卡片。

圖 19.11. 拖曳效果只應用在最上面的卡片
圖 19.11. 拖曳效果只應用在最上面的卡片

顯示心形與 × 形圖示

酷 !拖曳現在可以運作了,不過它還沒有完成。使用者應該能夠向右/ 向左滑動,來丟棄最上面的卡片。而且,根據滑動的方向,卡片上應該顯示一個圖示(心形或 × 形)。

首先,我們在 ContentView 中宣告一個拖曳的界限值:

private let dragThreshold: CGFloat = 80.0

當拖曳的位移超過界限值時,我們將在卡片上重疊一個圖示(心形或× 形)。另外, 如果使用者釋放卡片,App 會從卡片庫中刪除這張卡片,並建立一張新卡片,將其放置於卡片庫的末尾。

要重疊圖示,加入一個 overlay 修飾器至 cardViews。你可以在 .zIndex 修飾器下插入下列的程式碼:

.overlay {
    ZStack {
        Image(systemName: "x.circle")
            .foregroundColor(.white)
            .font(.system(size: 100))
            .opacity(self.dragState.translation.width < -self.dragThreshold && self.isTopCard(cardView: cardView) ? 1.0 : 0)

        Image(systemName: "heart.circle")
            .foregroundColor(.white)
            .font(.system(size: 100))
            .opacity(self.dragState.translation.width > self.dragThreshold  && self.isTopCard(cardView: cardView) ? 1.0 : 0.0)
    }
}

預設上,將不透明度設定為「0」來隱藏這兩張圖片。如果向右拖曳,則位移的寬度為正值,否則其為負值。依照拖曳的方向,當拖曳的位移超過界限值時,App 將顯示其中一張圖片。

你可以執行這個專案來快速測試一下。當你的拖曳超出界限值時,心形/× 形圖示將會出現,如圖 19.12 所示。

圖 19.12. 出現心形圖示
圖 19.12. 出現心形圖示

刪除/ 插入卡片

現在,當你釋放卡片時,它仍會回到原來的位置。我們如何才能刪除最上面的卡片, 並同時加入一張新卡片呢?

首先,我們使用 @State 來標記 cardViews 陣列,以讓我們可以更新它的值,並重新更新 UI:

@State var cardViews: [CardView] = {

    var views = [CardView]()

    for index in 0..<2 {
        views.append(CardView(image: trips[index].image, title: trips[index].destination))
    }

    return views
}()

接下來,宣告另一個狀態變數來追蹤旅程的最後一個索引。假設卡片庫第一次初始化時,我們顯示儲存在 trips 陣列中的前兩個旅程,最後一個索引設定為 1

@State private var lastIndex = 1

下面是用於刪除及插入卡片視圖的核心函數。定義一個名為 moveCard 新函數:

private func moveCard() {
    cardViews.removeFirst()

    self.lastIndex += 1
    let trip = trips[lastIndex % trips.count]

    let newCardView = CardView(image: trip.image, title: trip.destination)

    cardViews.append(newCardView)
}

這個函式先從 cardViews 陣列中刪除最上面的卡片,然後它使用後續旅程的圖片來實例化一個新卡片視圖。由於 cardViews 定義為狀態屬性,因此一旦陣列的值更改時,SwiftUI 將再次渲染卡片視圖,這就是我們如何刪除最上面的卡片,並插入一張新卡片至卡片庫的方式。

針對這個範例,我想要卡片庫繼續顯示一個旅程。在 trips 陣列的最後一張圖片顯示後, App 將會回到第一個元素(注意,上列程式碼中的模數運算子%)。

接下來,更新 .gesture修飾器,並插入 .onEnded 函式:

.gesture(LongPressGesture(minimumDuration: 0.01)
    .sequenced(before: DragGesture())
    .updating(self.$dragState, body: { (value, state, transaction) in
        .
        .
        .
    })
    .onEnded({ (value) in

        guard case .second(true, let drag?) = value else {
            return
        }

        if drag.translation.width < -self.dragThreshold ||
            drag.translation.width > self.dragThreshold {

            self.moveCard()
        }
    })
)

當拖曳手勢結束時,我們檢查拖曳的位移是否超過界限值,並相應呼叫 moveCard()

現在,當你在預覽畫布中執行專案時,將圖片向右 / 左拖曳,直到圖示出現。放開拖曳,最上面的卡片應由下一張卡片取代。

圖 19.13. 刪除最上面的圖片
圖 19.13. 刪除最上面的圖片

微調動畫

這個 App 幾乎可以運作了,但是動畫效果並不如預期。不要讓卡片視圖突然消失,而是卡片丟棄後應逐漸從螢幕離開。

要微調動畫效果,我們將加上 transition 修飾器,並應用不對稱轉場至卡片視圖。

現在建立一個 AnyTransition 擴展(可以加在 ContentView.swift最後端),並定義兩個轉場效果:

extension AnyTransition {
    static var trailingBottom: AnyTransition {
        AnyTransition.asymmetric(
            insertion: .identity,
            removal: AnyTransition.move(edge: .trailing).combined(with: .move(edge: .bottom))
        )

    }

    static var leadingBottom: AnyTransition {
        AnyTransition.asymmetric(
            insertion: .identity,
            removal: AnyTransition.move(edge: .leading).combined(with: .move(edge: .bottom))
        )
    }
}

之所以使用不對稱轉場,是因為我們只想在卡片視圖被刪除時,對轉場設定動畫。當一個新卡片視圖插入卡片庫時,則不應有動畫。

當卡片視圖向螢幕右方丟棄時,使用 trailingBottom 轉場,而當卡片視圖向螢幕左方丟棄時,則使用leadingBottom 轉場。

接下來,宣告一個包含轉場類型的狀態屬性,預設是設定 trailingBottom

@State private var removalTransition = AnyTransition.trailingBottom

現在,將 .transition 修飾器加到卡片視圖。你可以將它放在 .animation 修飾器之後:

.transition(self.removalTransition)

最後,使用 onChanged 函式更新 .gesture 修飾器的程式碼,如下所示:

.gesture(LongPressGesture(minimumDuration: 0.01)
    .sequenced(before: DragGesture())
    .updating(self.$dragState, body: { (value, state, transaction) in
        switch value {
        case .first(true):
            state = .pressing
        case .second(true, let drag):
            state = .dragging(translation: drag?.translation ?? .zero)
        default:
            break
        }

    })
    .onChanged({ (value) in
        guard case .second(true, let drag?) = value else {
            return
        }

        if drag.translation.width < -self.dragThreshold {
            self.removalTransition = .leadingBottom
        }

        if drag.translation.width > self.dragThreshold {
            self.removalTransition = .trailingBottom
        }

    })
    .onEnded({ (value) in

        guard case .second(true, let drag?) = value else {
            return
        }

        if drag.translation.width < -self.dragThreshold ||
            drag.translation.width > self.dragThreshold {

            self.moveCard()
        }
    })

)

上列程式碼的作用是設定 removalTransition,轉場類型是根據滑動方向來更新。現在,你可以再次執行 App 了,當丟棄卡片時,你應該會看到動畫效果已改善。

本章小結

使用 SwiftUI,你可以輕鬆建立一些很酷的動畫與行動裝置 UI 模式,這個如 Tinder 般的 UI 就是一個例子。

我希望你能真正了解本章所介紹的內容,如此你就可以修改程式碼,來配合自己的專案。這是非常重要的一章,我想要記錄一下我的思考過程,而不僅僅是向你提供最終的解決方案。

為了方便進一步參考,您可以至下列網址下載完整專案: