你使用過 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 的力量。
為了方便進一步參考,您可以至下列網址下載完整專案: