建立一個展開式底部表不是很有趣呢?我們來繼續應用我們所學到的手勢,並將其應用到真實世界的專案中。我不確定你之前是否用過 Tinder App,但是在一些其他 App 中, 你可能會碰到如 Tinder 般的使用者介面。滑動動作是 Tinder UI 的設計重點,並已成為最流行的行動裝置 UI 模式之一。使用者向右滑動即表示喜歡某張圖片,向左滑動則表示不喜歡。
在本章中,我們要做的是建立一個具有如 Tinder 般UI 的簡單 App。這個 App 向使用者顯示一副旅遊卡,並讓他們使用滑動手勢來表示喜歡/ 不喜歡一張卡片。
請注意,我們將不會建立一個功能齊全的App,而是只著眼於如Tinder 般的UI 。
如果你使用自己的圖片,那就太棒了。不過,為了節省你準備旅遊圖片的時間,我已經為你建立了一個起始專案,你可以至下列網址下載:https://www.appcoda.com/resources/swiftui5/SwiftUITinderTripStarter.zip。這個專案已經具有一組旅遊卡的照片,如圖 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 所示:
首先,我們建立一個卡片視圖。若是你想挑戰自我,我強烈建議你在這裡停下來並實作它,而無須遵循本節內容,否則請繼續閱讀。
為了讓程式碼更易編寫,我們將在一個單獨的檔案中實作卡片視圖。在專案導覽器中,使用「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
中同時提供 image
與 title
的值,因此更新#Preview
如下:
#Preview {
CardView(image: "yosemite-usa", title: "Yosemite, USA")
}
我只是使用素材目錄中的其中一張圖片來進行預覽,你可以依自己的需求隨意更改圖片及標題。在預覽畫布中,你現在應該看到類似圖 19.4 的卡片視圖。
準備好卡片視圖後,我們可以繼續實作主 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 讓您更了解預覽的樣子。
好的,我們繼續佈局主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 所示。
在做好所有的準備之後,終於可以實作如 Tinder 般的 UI。對於之前從未用過 Tinder App 的人,讓我先解釋一下如 Tinder 般 UI 的工作原理。
你可以將 Tinder 般 UI 想像為一副成堆的卡片,每張卡片都顯示一張照片。在我們的範例 App 中,照片是旅程的目的地。將最上面的卡片(即第一個旅程)輕微向左或向右滑動,即可揭示下一張卡片(即下一個旅程)。如果使用者放開卡片,App 就會將卡片帶回原來的位置。不過,當使用者用力滑動時,他/ 她可以丟掉這張卡片,然後App 會將第二張卡片向前移動,成為最上面的卡片,如圖 19.7 所示。
我們實作的主螢幕只包含一個卡片視圖,那麼我們如何實作一堆卡片視圖呢?
最直截了當的方式是,使用 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 所示。
為什麼它會顯示另一張圖片呢?如果你引用在 Trip.swift
中定義的 trips
陣列,圖片是陣列的最後一個元素。在ForEach
區塊中,第一個旅程是放在卡片庫的最下面,如此最後一個旅程便成為卡片庫的最上面照片。
當我們實作卡片庫時,實際上有兩個問題:
trips
陣列的第一個旅程應該是最上面的卡片,但是現在卻是最下面的卡片。 我們先來解決卡片順序的問題。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 是由一堆卡片所組成。
現在,你應該了解我們如何建立卡片庫,我們來繼續進行實作。
首先,更新 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
變數,並儲存拖曳的位移量。
offset
、scaleEffect
、rotationEffect
與 animation
修飾器的結合, 可建立拖曳效果。拖曳是透過更新卡片視圖的 offset
來實現。當卡片視圖處於拖曳狀態時,我們會使用 scaleEffect
將它縮小一點,並應用rotationEffect
修飾器將它旋轉特定角度。動畫設定為 interpolatingSpring
,但你可以自由嘗試其他動畫。
我們還對 BottomBarMenu
做一些程式碼更改。當使用者拖曳卡片視圖時,我想要隱藏底部列,因此我們應用 .opacity
修飾器,並且當它在拖曳狀態時,設定它的值為「0」。
進行更改後,在預覽畫布中執行專案來測試它。你應該能夠拖曳卡片並四處移動。而當你釋放卡片時,卡片會回到原來的位置,如圖 19.10 所示。
你注意到問題了嗎?當拖曳開始時,你實際上是在拖曳整個卡片庫 !假設使用者只能拖曳最上面的卡片,下面的卡片應該保持不變。而且,縮放效果應只應用於最上面的卡片。
要解決這些問題,我們需要修改 offset
、scaleEffect
與 rotationEffect
修飾器的程式碼, 如此拖曳只發生在最上面的卡片視圖。
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
}
})
)
}
}
只需要對 offse
、scaleEffect
與 rotationEffect
修飾器進行修改,其餘的程式碼保持不變。對於那些修飾器,我們進行額外的檢查,以使效果只應用在最上面的卡片。
現在,如果你再次執 行 App,則應該看到其下方的卡片,並只能拖曳最上面的卡片。
酷 !拖曳現在可以運作了,不過它還沒有完成。使用者應該能夠向右/ 向左滑動,來丟棄最上面的卡片。而且,根據滑動的方向,卡片上應該顯示一個圖示(心形或 × 形)。
首先,我們在 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 所示。
現在,當你釋放卡片時,它仍會回到原來的位置。我們如何才能刪除最上面的卡片, 並同時加入一張新卡片呢?
首先,我們使用 @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()
。
現在,當你在預覽畫布中執行專案時,將圖片向右 / 左拖曳,直到圖示出現。放開拖曳,最上面的卡片應由下一張卡片取代。
這個 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 就是一個例子。
我希望你能真正了解本章所介紹的內容,如此你就可以修改程式碼,來配合自己的專案。這是非常重要的一章,我想要記錄一下我的思考過程,而不僅僅是向你提供最終的解決方案。
為了方便進一步參考,您可以至下列網址下載完整專案: