你使用過 Apple 電子錢包 App 嗎?我相信應該有,在上一章中,我們建立了一個如 Tinder UI 的簡單 App,而本章要做的是建立一個動態 UI,類似於你在電子錢包 App 中看到的 UI。當你在電子錢包 App 中長按信用卡時,則可使用拖曳手勢來重新排列卡片。如果你沒有使用過這個 App,請開啟電子錢包快速瀏覽一下,或者你可以訪問這個URL (https://link.appcoda.com/swiftui-wallet)來了解我們將建立的動畫。

在電子錢包 App 中,點擊其中一張信用卡,就會帶出交易歷史紀錄。我們還建立一個類似的動畫,以讓你更了解視圖轉場與水平滾動視圖。
為了讓你專注於學習動畫與視圖轉場,你可以從起始專案開始(https://www.appcoda.com/resources/swiftui4/SwiftUIWalletStarter.zip)。這個起始專案已經綁定了所需的信用卡圖片,並且帶有內建的交易歷史紀錄視圖,如果想要使用自己的圖片,請在素材目錄中替換它們,如圖20.2 所示。

在專案導覽器中,你應該會發現一些 .swift 檔:
Transaction 結構代表電子錢包 App中的交易。每一筆交易有一個唯一的 ID、交易商、金額、日期與圖示。除了 Transaction 結構之外,作為示範之用,我們還宣告一個測試交易的陣列。Card 的結構。Card 表示信用卡的資料,包含卡號、類型、有效日期、圖片與客戶姓名,除此之外,你可以在檔案中找到一個測試信用卡的陣列。需要注意的一點是,卡片圖片中不包含任何的個人資訊,而只包含卡片品牌(例如: Visa )。稍後,我們將為信用卡建立一個視圖。.horizontal 值。請看一下圖 20.3 或 Swift 檔來了解詳細資訊 。
如上一節所述,所有的卡片圖片皆不包含任何個人資訊與卡號。再次開啟素材目錄, 並看一下圖片,每張卡片圖片只具有卡片標誌。我們將很快建立一個卡片視圖,來佈局個人資訊與卡號,如圖 20.4 所示。

要建立卡片視圖,則在專案導覽器中,右鍵點選 View 群組,然後建立一個新檔案。選取「SwiftUI View」模板,檔案名稱命名為 CardView.swift。接下來,更新程式碼如下:
struct CardView: View {
var card: Card
var body: some View {
Image(card.image)
.resizable()
.scaledToFit()
.overlay(
VStack(alignment: .leading) {
Text(card.number)
.bold()
HStack {
Text(card.name)
.bold()
Text("Valid Thru")
.font(.footnote)
Text(card.expiryDate)
.font(.footnote)
}
}
.foregroundColor(.white)
.padding(.leading, 25)
.padding(.bottom, 20)
, alignment: .bottomLeading)
.shadow(color: .gray, radius: 1.0, x: 0.0, y: 1.0)
}
}
我們宣告一個 card 屬性來帶入卡片資料。為了在卡片圖片上顯示個人資料與卡號,我們使用 overlay 修飾器,並以垂直堆疊視圖與水平堆疊視圖來佈局文字元件。
要預覽卡片,則更新 CardView_Previews結構如下:
struct CardView_Previews: PreviewProvider {
static var previews: some View {
ForEach(testCards) { card in
CardView(card: card).previewDisplayName(card.type.rawValue)
}
}
}
testCards 變數在 Card.swift 中定義,因此我們使用 ForEach 來逐一執行卡片,並呼叫 previewDisplayName 來設定預覽的名稱。Xcode 將如圖 20.5 所示佈局卡片。

我們現在已經實作了卡片視圖,讓我們開始建立電子錢包視圖。如果你忘記電子錢包視圖的外觀,請看一下圖 20.6。在進行手勢與動畫之前,我們將先佈局卡片庫。

在專案導覽器中,你應該會看到 ContentView.swift 檔。刪除它,然後右鍵點選 View 資料夾,以建立一個新檔案。在對話方塊中,選取「SwiftUI View」作為模板,並將檔案命名為 WalletView.swift。
如果你預覽 WalletView 或在模擬器中執行 App,Xcode 應該會顯示一個錯誤,因為 ContentView 設定為初始視圖,並且其被刪除了。要修正這個錯誤,則開啟 SwiftUIWallet App.swift,並將 WindowGroup 中下列這行程式碼:
ContentView()
更改為:
WalletView()
切換回 WalletView.swift。當你更改後,將可修正編譯錯誤,現在我們繼續佈局電子錢包視圖。首先,我們從標題列開始,在 WalletView.swift 檔中,為標題列插入一個新結構:
struct TopNavBar: View {
var body: some View {
HStack {
Text("Wallet")
.font(.system(.largeTitle, design: .rounded))
.fontWeight(.heavy)
Spacer()
Image(systemName: "plus.circle.fill")
.font(.system(.title))
}
.padding(.horizontal)
.padding(.top, 20)
}
}
程式碼非常簡單,我們使用水平堆疊來佈局標題與加號圖片。
接下來,是針對卡片庫。首先,在 WalletView 結構中,為信用卡陣列宣告一個屬性:
var cards: [Card] = testCards
為了示範,我們只將預設值設定為 Card.swift 檔中定義的 testCards。要佈局電子錢包視圖,我們同時使用VStack 與 ZStack,更新 body 變數如下:
var body: some View {
VStack {
TopNavBar()
.padding(.bottom)
Spacer()
ZStack {
ForEach(cards) { card in
CardView(card: card)
.padding(.horizontal, 35)
}
}
Spacer()
}
}
如果你在模擬器中執行這個 App 或直接預覽 UI,則應該只看到卡片庫中的最後一張卡片,如圖 20.7 所示。

目前的實作有兩個問題:
Card.swift 中的 testCards 陣列,第一張卡片是 Visa 卡,最後一張卡片是 Discover 卡。那麼,我們要如何修正這個問題呢?對於第一個問題,我們可以使用 offset修飾器來展開一副卡片。而對於第二個問題,我們顯然可以更改每個 CardView 的 zIndex,以改變卡片的順序。圖 20.8 說明了這個解決方案是如何工作的。

我們先討論一下 z-index。每張卡片的 z-index 是其在 cards 陣列中索引的負值,如此最後一個項目擁有陣列索引的最大值,也將會有最小的 z-index。對於實際的實作,我們將建立一個單獨的函數來處理 z-index 的計算。在WalletView 中,插入下列的程式碼:
private func zIndex(for card: Card) -> Double {
guard let cardIndex = index(for: card) else {
return 0.0
}
return -Double(cardIndex)
}
private func index(for card: Card) -> Int? {
guard let index = cards.firstIndex(where: { $0.id == card.id }) else {
return nil
}
return index
}
這兩個函數可以一起找出給定卡片的正確 z-index。要計算正確的 z-index,我們首先需要取得 cards 陣列中卡片的索引值,index(for:) 函數是為了找出給定卡片的陣列索引值而設計的。當我們有了索引值後,就可以將其變成負值,這就是 zIndex(for:) 函數的作用。
現在,你可以將 zIndex 修飾器加到 CardView,如下所示:
CardView(card: card)
.padding(.horizontal, 35)
.zIndex(self.zIndex(for: card))
當你更改後,Visa 卡片應該移到卡片庫的最上方。
接下來,我們修正第一個問題來展開卡片,每張卡片都應偏移一定的垂直距離。而這個距離是使用卡片的索引值來計算的。假設我們將預設的垂直偏移量設定為 50 點,最後一張卡片將會位移 200 點(50×4)。
現在,你應該了解我們將如何展開卡片了,我們來編寫程式碼。在 WalletView 中宣告預設的垂直偏移量:
private static let cardOffset: CGFloat = 50.0
接下來,建立一個名為 offset(for:)的新函數,用於計算給定卡片的垂直偏移量:
private func offset(for card: Card) -> CGSize {
guard let cardIndex = index(for: card) else {
return CGSize()
}
return CGSize(width: 0, height: -50 * CGFloat(cardIndex))
}
最後,將 offset 修飾器加入到 CardView:
CardView(card: card)
.padding(.horizontal, 35)
.offset(self.offset(for: card))
.zIndex(self.zIndex(for: card))
這就是我們使用 offset 修飾器來展開卡片的方式。若是一切正確,你應該會看到如圖 20.9 所示的預覽。

我們現在已經完成了電子錢包視圖的佈局,是時候加入一些動畫了,我要加入的第一個動畫是滑入動畫。當第一次開啟 App 時,每張卡片都從螢幕最左側滑入,你可能認為這個動畫是不必要的,但是我想藉此機會教你如何建立動畫以及開啟 App 時的視圖轉場。

首先,我們需要一種觸發過渡動畫的方法, 先在 CardView 的開頭聲明一個狀態變數:
@State private var isCardPresented = false
此變數指示卡片是否應顯示在螢幕上。 在預設的情況下,它設置為false。 稍後,我們將此值設置為 true 以啟動視圖轉換。
每張卡片都是一個視圖。要實作如圖 20.10 所示的動畫,我們需要將 transition 與 animation 修飾器加到CardView 上,如下所示:
CardView(card: card)
.offset(self.offset(for: card))
.padding(.horizontal, 35)
.zIndex(self.zIndex(for: card))
.transition(AnyTransition.slide.combined(with: .move(edge: .leading)).combined(with: .opacity))
.animation(self.transitionAnimation(for: card), value: isCardPresented)
對於轉場,我們將預設的滑動轉場與移動轉場結合在一起。如前所述,若是沒有 animation 修飾器,則轉場將不會動畫化,這就是為何我們還要加入 animation 修飾器的緣故。由於每張卡片都有自己的動畫,我們建立一個名為 transitionAnimation(for:) 函數來計算動畫。插入下列的程式碼來建立函數:
private func transitionAnimation(for card: Card) -> Animation {
var delay = 0.0
if let index = index(for: card) {
delay = Double(cards.count - index) * 0.1
}
return Animation.spring(response: 0.1, dampingFraction: 0.8, blendDuration: 0.02).delay(delay)
}
事實上,所有的卡片都有相似的動畫(即彈簧動畫),差別在於「延遲」(delay )。卡片庫的最後一張卡片將先出現,因此延遲值應該最小。下面是我們如何計算每張卡片的延遲的公式,索引值越小,延遲越長。
delay = Double(cards.count - index) * 0.1
哪我們要如何在 App 啟動的時候觸發轉場?訣竅就是為每一個卡視圖加入id 修飾器。
CardView(card: card)
.
.
.
.id(isCardPresented)
.
.animation(self.transitionAnimation(for: card), value: isCardPresented)
id 的值設定為 isCardPresented 。之後,加入 onAppear 修飾器,並將它加到 ZStack:
.onAppear {
isCardPresented.toggle()
}
當 ZStack 出現時,我們將 isCardPresented 的值從 false 更改為 true,這將觸發卡片的視圖動畫。應用更改後,在預覽畫布中點墼「Play」按鈕,來進行測試。
更改後,點擊 Play 按鈕在模擬器中測試App,App啟動時就會呈現動畫。
當使用者點擊卡片時,App 會向上移動所選的卡片,並且顯示歷史交易紀錄。對於其他沒有選到的卡片,它們會被移出螢幕。
要實作這個功能,我們還需要兩個狀態變數。在 WalletView 中宣告這些變數:
@State var isCardPressed = false
@State var selectedCard: Card?
isCardPressed 變數指示是否選擇卡片,而 selectedCard 變數儲存使用者選擇的卡片。
.gesture(
TapGesture()
.onEnded({ _ in
withAnimation(.easeOut(duration: 0.15).delay(0.1)) {
self.isCardPressed.toggle()
self.selectedCard = self.isCardPressed ? card : nil
}
})
)
要處理點擊手勢,我們可以將上述的 gesture 修飾器加到 CardView,並使用內建的 TapGesture 來捕捉點擊事件。在程式碼區塊中,我們只需切換isCardPressed的狀態,並將目前的卡片設定為 selectedCard 變數。
要將所選的卡片(及其下方的卡片)向上移動,並讓其餘的卡片移出螢幕的話,則更新 offset(for:) 函數如下:
private func offset(for card: Card) -> CGSize {
guard let cardIndex = index(for: card) else {
return CGSize()
}
if isCardPressed {
guard let selectedCard = self.selectedCard,
let selectedCardIndex = index(for: selectedCard) else {
return .zero
}
if cardIndex >= selectedCardIndex {
return .zero
}
let offset = CGSize(width: 0, height: 1400)
return offset
}
return CGSize(width: 0, height: -50 * CGFloat(cardIndex))
}
我們加入了一個 if 語句來檢查卡片是否被選中。如果給定的卡片是使用者選擇的卡片, 則我們將偏移量設定為.zero。對於所選卡片正下方的那些卡片,我們也將向上移動, 這就是為什麼我們將偏移量設定為 .zero。而其餘的卡片,我們將它們移出螢幕,因此垂直偏移量設定為 1400 點。
現在,我們已經準備好編寫用於帶出交易歷史紀錄視圖的程式碼。正如一開始所述, 起始專案已經提供這個交易歷史紀錄視圖。因此,你不需要自己建立它。
藉由 isCardPressed 狀態變數,我們可以使用它來確定是否顯示交易歷史紀錄視圖。在 Spacer() 前面插入下列程式碼:
if isCardPressed {
TransactionHistoryView(transactions: testTransactions)
.padding(.top, 10)
.transition(.move(edge: .bottom))
}
在上列的程式碼中,我們設定轉場為 .move,以從螢幕底部帶入視圖,你可以依照自己的喜好來隨意更改它。

現在,來到本章的核心部分,我們來看如何讓使用者以拖曳手勢重新排列卡片庫。首先,我詳細描述此功能的工作原理:

現在你應該了解我們將要做什麼,我們來繼續實作。如果你忘記我們如何使用 SwiftUI 處理手勢,則請回頭閱讀第 17 章,該章已經討論了我們將使用的大多數技術。
首先,在 WalletView.swift 中插入下列的程式碼,來建立 DragState 列舉,以使我們可以輕鬆追蹤拖曳狀態:
enum DragState {
case inactive
case pressing(index: Int? = nil)
case dragging(index: Int? = nil, translation: CGSize)
var index: Int? {
switch self {
case .pressing(let index), .dragging(let index, _):
return index
case .inactive:
return nil
}
}
var translation: CGSize {
switch self {
case .inactive, .pressing:
return .zero
case .dragging(_, let translation):
return translation
}
}
var isPressing: Bool {
switch self {
case .pressing, .dragging:
return true
case .inactive:
return false
}
}
var isDragging: Bool {
switch self {
case .dragging:
return true
case .inactive, .pressing:
return false
}
}
}
接下來,在 WalletView 中宣告一個狀態變數,來持續追蹤拖曳狀態:
@GestureState private var dragState = DragState.inactive
如果你之前閱讀過第 17 章,那麼你應該已經知道如何偵測長按與拖曳手勢。然而,這次有點不同,我們需要同時處理點擊手勢、拖曳與長按手勢。而且,如果偵測到長按手勢,則 App 應該忽略點擊手勢。
現在更新 CardView 的 gesture 修飾器如下:
.gesture(
TapGesture()
.onEnded({ _ in
withAnimation(.easeOut(duration: 0.15).delay(0.1)) {
self.isCardPressed.toggle()
self.selectedCard = self.isCardPressed ? card : nil
}
})
.exclusively(before: LongPressGesture(minimumDuration: 0.05)
.sequenced(before: DragGesture())
.updating(self.$dragState, body: { (value, state, transaction) in
switch value {
case .first(true):
state = .pressing(index: self.index(for: card))
case .second(true, let drag):
state = .dragging(index: self.index(for: card), translation: drag?.translation ?? .zero)
default:
break
}
})
.onEnded({ (value) in
guard case .second(true, let drag?) = value else {
return
}
// 重新排列卡片
})
)
)
SwiftUI 讓你可專門組合多種手勢。在上列的程式碼中,我們告訴 SwiftUI 捕捉點擊手勢或長按手勢,換句話說,當偵測到點擊手勢時,SwiftUI 將忽略長按手勢。
點擊手勢的程式碼與我們之前編寫的程式碼完全相同,而拖曳手勢是排列在長按手勢之後。在 updating 函數中,我們將拖曳的狀態、轉場與卡片的索引值設定為之前定義的 dragState 變數。我將不會像第 17 章那樣詳細解釋程式碼。
在拖曳卡片之前,你必須更新 offset(for:) 函數如下:
private func offset(for card: Card) -> CGSize {
guard let cardIndex = index(for: card) else {
return CGSize()
}
if isCardPressed {
guard let selectedCard = self.selectedCard,
let selectedCardIndex = index(for: selectedCard) else {
return .zero
}
if cardIndex >= selectedCardIndex {
return .zero
}
let offset = CGSize(width: 0, height: 1400)
return offset
}
// Handle dragging
var pressedOffset = CGSize.zero
var dragOffsetY: CGFloat = 0.0
if let draggingIndex = dragState.index,
cardIndex == draggingIndex {
pressedOffset.height = dragState.isPressing ? -20 : 0
switch dragState.translation.width {
case let width where width < -10: pressedOffset.width = -20
case let width where width > 10: pressedOffset.width = 20
default: break
}
dragOffsetY = dragState.translation.height
}
return CGSize(width: 0 + pressedOffset.width, height: -50 * CGFloat(cardIndex) + pressedOffset.height + dragOffsetY)
}
我們加入一段程式碼區塊來處理拖曳。請謹記,只有選定的卡片是可拖曳的。因此, 在更改偏移量之前,我們需要檢查給定的卡片是否為使用者拖曳的卡片。
之前,我們將卡片索引值儲存在 dragState 變數中,因此我們可輕鬆比較給定的卡片索引值與儲存在 dragState 中的卡片索引值,以找出拖曳的卡片。
對於拖曳的卡片,我們在水平與垂直方向上都加入了額外的偏移量。
現在,你可以執行App 來進行測試,長按卡片並任意拖曳,如圖20.13 所示。

現在,你應該可以拖曳卡片,不過卡片的 z-index 並沒有相應做更改,例如:如果你拖曳Visa 卡,它總是停留在卡片庫的最上層,我們透過更新 zIndex(for:) 函數來修正它:
private func zIndex(for card: Card) -> Double {
guard let cardIndex = index(for: card) else {
return 0.0
}
// 卡片的預設 z-index 設定為卡片索引值的負值,
// 因此第一張卡片具有最大的 z-index
let defaultZIndex = -Double(cardIndex)
// 如果它是拖曳的卡片
if let draggingIndex = dragState.index,
cardIndex == draggingIndex {
// 我們根據位移的高度來計算新的 z-index
return defaultZIndex + Double(dragState.translation.height/Self.cardOffset)
}
// 否則我們回傳預設的 z-index
return defaultZIndex
}
預設的 z-index 仍設定為卡片索引值的負值。對於拖曳的卡片,當使用者在卡片庫上拖曳時,我們需要計算一個新的 z-index。更新後的 z-index 是根據位移的高度與卡片的預設偏移量(即 50 點)來計算。
執行 App 並嘗試再次拖曳 Visa 卡片。現在,當你拖曳卡片時,z-index 會不斷更新。

當你放開卡片時,它現在會回到原來的位置,那麼我們如何在拖曳之後重新排序卡片的位置呢?
這裡的技巧是更新 cards陣列的項目,以觸發 UI 更新。首先,我們需要將 cards 變數標記為狀態變數,如下所示:
@State var cards: [Card] = testCards
接下來,我們建立另一個新函數來重新排列卡片:
private func rearrangeCards(with card: Card, dragOffset: CGSize) {
guard let draggingCardIndex = index(for: card) else {
return
}
var newIndex = draggingCardIndex + Int(-dragOffset.height / Self.cardOffset)
newIndex = newIndex >= cards.count ? cards.count - 1 : newIndex
newIndex = newIndex < 0 ? 0 : newIndex
let removedCard = cards.remove(at: draggingCardIndex)
cards.insert(removedCard, at: newIndex)
}
當你將卡片拖曳到相鄰的卡片上時,一旦拖曳的位移量大於預設的偏移量,我們便需要更新 z-index。圖 20.15 顯示了拖曳的預期行為。

這是我們計算更新後的 z-index 的公式:
var newIndex = draggingCardIndex + Int(-dragOffset.height / Self.cardOffset)
一旦我們有了更新後的索引值,最後一步就是透過移除拖曳的卡片,並將其插入新位置,以更新在 cards 陣列中的項目。由於 cards 陣列現在是一個狀態變數,因此 SwiftUI 更新卡片庫且自動渲染動畫。
最後,在「// 重新排列卡片」的下面插入下列這行程式碼來呼叫函數:
withAnimation(.spring()) {
self.rearrangeCards(with: card, dragOffset: drag.translation)
}
之後,你可以執行 App 來測試它。你已經建立了如電子錢包般的動畫。
閱讀完本章後,我希望你對於 SwiftUI 動畫與視圖轉場有更深入的了解。如果你將 SwiftUI 與原來的UIKit 框架進行比較,你會發現 SwiftUI 讓「使用動畫」變得非常容易。你還記得如何為使用者放開拖曳的卡片來渲染卡片動畫嗎?你需要做的是更新狀態變數, 而 SwiftUI 就會處理這些繁重的工作,這就是 SwiftUI 的力量。
為了方便進一步參考,您可以至下列網址下載完整專案: