第 17 章
了解手勢
在前面的章節中,你已經對使用 SwiftUI 建立手勢有所了解。我們使用 onTapGesture
修飾器來處理使用者的觸控,並做出相對的回應。而在本章中,我們更深入了解如何在 SwiftUI 中處理各種類型的手勢。
這個框架提供一些內建手勢, 例如: 我們之前使用過的點擊手勢。除此之外, 「DragGesture」、「MagnificationGesture」與「LongPressGesture」等都是現成可用的手勢。我們將研究其中幾個手勢,並看看如何在 SwiftUI 中使用。最重要的是,你將學習如何建立一個可以支援拖曳手勢的通用視圖。
使用手勢修飾器
要使用 框架識別特定手勢,你需要做的就是使用 .gesture
修飾器將手勢識別器加到視圖上。下面是使用 .gesture
修飾器加到 TapGesture
的範例程式碼片段:
var body: some View {
Image(systemName: "star.circle.fill")
.font(.system(size: 200))
.foregroundColor(.green)
.gesture(
TapGesture()
.onEnded({
print("Tapped!")
})
)
}
如果你想要測試程式碼,則使用 「App」模板來建立一個新專案, 並確認你有選取 「Interface 」選項中的「SwiftUI」,然後在ContentView.swift 中貼上程式碼。
透過修改上列的程式碼,並導入一個狀態變數,我們可以在星形圖片被點擊時,建立一個簡單的縮放動畫。下列為更新後的程式碼:
struct ContentView: View {
@State private var isPressed = false
var body: some View {
Image(systemName: "star.circle.fill")
.font(.system(size: 200))
.scaleEffect(isPressed ? 0.5 : 1.0)
.animation(.easeInOut, value: isPressed)
.foregroundColor(.green)
.gesture(
TapGesture()
.onEnded({
self.isPressed.toggle()
})
)
}
}
當你在畫布或模擬器中執行程式碼時,應該會看到縮放效果,這就是如何使用 .gesture
修飾器來偵測與回應某些觸控事件的方法。如果你忘記動畫的工作原理,可以回頭閱讀第 9 章。
使用長按手勢
其中一個是 LongPressGesture
,這個手勢識別器可以讓你偵測長按事件。舉例而言,如果你想只有當使用者長按星形圖片一秒時可調整其大小,你可以使用 LongPressGesture
來偵測觸控事件。
修改 .gesture
修飾器中的程式碼如下,以實作 LongPressGesture
:
.gesture(
LongPressGesture(minimumDuration: 1.0)
.onEnded({ _ in
self.isPressed.toggle()
})
)
在預覽畫布中執行專案來快速測試。現在,你必須至少長按星形圖片一秒鐘,才能切換其大小。
@GestureState 屬性包裹器
當你按住星形圖片時,在偵測到長按事件之前,圖片不會給使用者任何回應。顯然地,我們可以採取一些措施來改善使用者體驗,我想要做的是在使用者點擊圖片時給予即時回饋。任何形式的回饋都將有助於改善情況,例如:當使用者點擊圖片時,我們可將圖片調暗一點,這只是讓使用者知道我們的 App 捕捉到觸控事件,並且正在進行工作。圖 17.3 說明了動畫如何工作。
要實作這個動畫,其中一項任務是追蹤手勢的狀態。在長按手勢的執行期間,我們必須區分點擊與長按事件,那麼我們該如何做呢?
SwiftUI 提供一個名為 @GestureState
的屬性包裹器,它可以方便地追蹤手勢的狀態變化,並讓開發者決定對應的動作。要實作我們剛才描述的動畫,我們可以使 用 @GestureState
宣告一個屬性:
@GestureState private var longPressTap = false
這個手勢狀態變數表示「執行長按手勢期間是否偵測到點擊事件」。當你定義了變數後,你可以修改 Image
視圖的程式碼,如下所示:
Image(systemName: "star.circle.fill")
.font(.system(size: 200))
.opacity(longPressTap ? 0.4 : 1.0)
.scaleEffect(isPressed ? 0.5 : 1.0)
.animation(.easeInOut, value: isPressed)
.foregroundColor(.green)
.gesture(
LongPressGesture(minimumDuration: 1.0)
.updating($longPressTap, body: { (currentState, state, transaction) in
state = currentState
})
.onEnded({ _ in
self.isPressed.toggle()
})
)
我們只在上列的程式碼中做了一些修改。首先,加入了 .opacity
修飾器。當偵測到點擊事件後,我們將不透明度值設定為 0.4
,以使圖片變暗。
其次是 LongPressGesture
的 updating
方法。執行長按手勢的期間,將呼叫此方法,並接收 value、state 與transaction 等三個參數:
- 「value」 參數是手勢的目前狀態。這個值會依照手勢而有所不同,但對於長按手勢,
true
值表示偵測到點擊事件。 - 「state」參數實際上是一個 in-out參數,可以讓你更新
longPressTap
屬性的值。在上列的程式碼中,我們設定state
的值為currentState
。換句話說,longPressTap
屬性始終追蹤長按手勢的最新狀態。 - 「transaction」 參數儲存了目前狀態處理更新的內容。
更改程式碼後,在預覽畫布中執行專案來進行測試。當你點擊圖片時,圖片會立即變暗,而持續按住一秒後,圖片會自己調整尺寸。
當使用者放開手指時,圖片的不透明度會自動重置為正常狀態,你是否想知道為什麼呢?這是 @GestureState
的優點,當手勢結束時,它會自動將手勢狀態屬性的值設定為初始值,而在我們的範例中為 false
。
使用拖曳手勢
現在你應該了解如何使用 .gesture
修飾器與 @GestureState
,我們來看另一個常見的「拖曳」手勢。我們要做的是,修改現有的程式碼來支援拖曳手勢,讓使用者拖曳星形圖片來移動它。
現在更換 ContentView
結構如下:
struct ContentView: View {
@GestureState private var dragOffset = CGSize.zero
var body: some View {
Image(systemName: "star.circle.fill")
.font(.system(size: 100))
.offset(x: dragOffset.width, y: dragOffset.height)
.animation(.easeInOut, value: dragOffset)
.foregroundColor(.green)
.gesture(
DragGesture()
.updating($dragOffset, body: { (value, state, transaction) in
state = value.translation
})
)
}
}
要識別拖曳手勢,你初始化一個 DragGesture
實例,並監聽更新。在 update
函數中,我們傳送一個手勢狀態屬性來追蹤拖曳事件。與長按手勢類似,update
函數的閉包接收三個參數。在這個範例中,value 參數儲存拖曳的目前資料(包含移動),這就是為什麼我們將 state
變數(實際上是 dragOffset
)設定為value.translation
的緣故。
在預覽畫布中執行專案,你可以拖曳圖片,而當你放開圖片時,它會返回原始位置。
你知道為什麼圖片會回到它的起點嗎?如前一節所述,使用 @GestureState
的優點是, 當手勢結束時,它會重置屬性值為原始值。因此,當你放開手指結束拖曳時,dragOffset
會重置為.zero
,即原始位置。
不過,如果你想讓圖片停留在拖曳的終點,該如何做呢?給自己幾分鐘的時間來思考如何實作。
由於 @GestureState
屬性包裹器將重置屬性為原始值,我們需要另一個狀態屬性來儲存最終的位置。因此,我們宣告一個新的狀態屬性如下:
@State private var position = CGSize.zero
接下來,更新 body
變數如下:
var body: some View {
Image(systemName: "star.circle.fill")
.font(.system(size: 100))
.offset(x: position.width + dragOffset.width, y: position.height + dragOffset.height)
.animation(.easeInOut, value: dragOffset)
.foregroundColor(.green)
.gesture(
DragGesture()
.updating($dragOffset, body: { (value, state, transaction) in
state = value.translation
})
.onEnded({ (value) in
self.position.height += value.translation.height
self.position.width += value.translation.width
})
)
}
我們在程式碼中做了一些更改:
- 除了
update
函數之外,我們還實作了onEnded
函數,其在拖曳手勢結束時呼叫。在閉包中,我們加入拖曳偏移來計算圖片的新位置。 .offset
修飾器也已更新,如此我們將目前的位置列入計算。
現在,當你執行專案並拖曳圖片時,拖曳結束後,圖片會停留在最後的位置,如圖 17.4 所示。
組合手勢
在某些情況下,你需要在同一個視圖中使用多個手勢識別器。舉例而言,我們想讓使用者在開始拖曳之前按住圖片,則必須結合長按與拖曳手勢。SwiftUI 可以讓你輕鬆組合手勢,來執行一些複雜的互動。它提供三種手勢組合類型,包括:「同時」(simultaneous )、「依序」(sequenced )與「專門」(exclusive )。
當你需要同時偵測多個手勢時,可以使用「同時」(simultaneous )組合類型。而當你專門組合多個手勢為一個手勢時,SwiftUI 會識別你指定的所有手勢,但當偵測到其中一個手勢後,它會忽略其他手勢。
顧名思義,如果你使用「依序」(sequenced )組合類型來組合多個手勢,SwiftUI 會以特定順序來識別手勢,這正是我們將用來對長按與拖曳手勢進行排序的組合類型。
要使用多個手勢,程式碼可以更新如下:
struct ContentView: View {
// 長按手勢
@GestureState private var isPressed = false
// 拖曳手勢
@GestureState private var dragOffset = CGSize.zero
@State private var position = CGSize.zero
var body: some View {
Image(systemName: "star.circle.fill")
.font(.system(size: 100))
.opacity(isPressed ? 0.5 : 1.0)
.offset(x: position.width + dragOffset.width, y: position.height + dragOffset.height)
.animation(.easeInOut, value: dragOffset)
.foregroundColor(.green)
.gesture(
LongPressGesture(minimumDuration: 1.0)
.updating($isPressed, body: { (currentState, state, transaction) in
state = currentState
})
.sequenced(before: DragGesture())
.updating($dragOffset, body: { (value, state, transaction) in
switch value {
case .first(true):
print("Tapping")
case .second(true, let drag):
state = drag?.translation ?? .zero
default:
break
}
})
.onEnded({ (value) in
guard case .second(true, let drag?) = value else {
return
}
self.position.height += drag.translation.height
self.position.width += drag.translation.width
})
)
}
}
你應該對部分程式碼片段非常熟悉,因為我們結合已建立的長按手勢與拖曳手勢。
我來逐行解釋一下 .gesture
修飾器。我們要求使用者在開始拖曳之前,至少長按圖片一秒鐘,因此我們從建立LongPressGesture
來開始,與我們之前所實作的內容類似,我們有一個 isPressed
手勢狀態屬性,當某人點擊圖片時,我們將變更圖片的不透明度。
sequenced
關鍵字可將長按與拖曳手勢連結在一起。我們告訴 SwiftUI,LongPressGesture
應該在DragGesture
之前發生。
updating
與 onEnded
函數中的程式碼看起來非常相似,不過 value
參數現在實際上包含了兩個手勢(即長按與拖曳),這就是為何我們使用 switch
敘述來區分手勢。你可以使用 .first
與 .second
case 來找出要處理的手勢。由於我們應該要在拖曳手勢之前識別長按手勢,因此這裡的第一個手勢是長按手勢。在程式碼中,我們只印出「點擊」(Tapping )訊息供你參考。
當長按手勢確認之後,我們會進到 .second
case。在這裡,我們取出拖曳資料,並以對應的位移來更新dragOffset
。
當拖曳結束後,將呼叫 onEnded
函數。同樣的,我們透過計算拖曳資料(也就是 .second
case )來更新最終的位置。
現在,你可以測試手勢組合了。在預覽畫布中,使用 debug 來執行 App,如此你可以在主控台中看到訊息。你必須按住星形圖片至少一秒鐘,才能拖曳它。
使用列舉重構程式碼
編寫拖曳狀態的更好方式是使用列舉(Enum),這可讓你將 isPressed
與 dragOffset
狀態結合為單個屬性。我們宣告一個名為 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 isPressing: Bool {
switch self {
case .pressing, .dragging:
return true
case .inactive:
return false
}
}
}
這裡有三種狀態:「靜止」(inactive )、「按下」(pressing )與「拖曳」(dragging ), 這些狀態足以表示長按與拖曳手勢執行期間的狀態。對於「拖曳」(dragging )狀態,它與拖曳的位移有關。
使用 DragState
列舉,我們可以修改原來的程式碼如下:
struct ContentView: View {
@GestureState private var dragState = DragState.inactive
@State private var position = CGSize.zero
var body: some View {
Image(systemName: "star.circle.fill")
.font(.system(size: 100))
.opacity(dragState.isPressing ? 0.5 : 1.0)
.offset(x: position.width + dragState.translation.width, y: position.height + dragState.translation.height)
.animation(.easeInOut, value: dragState.translation)
.foregroundColor(.green)
.gesture(
LongPressGesture(minimumDuration: 1.0)
.sequenced(before: DragGesture())
.updating($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
}
})
.onEnded({ (value) in
guard case .second(true, let drag?) = value else {
return
}
self.position.height += drag.translation.height
self.position.width += drag.translation.width
})
)
}
}
我們現在宣告一個 dragState
屬性來追蹤拖曳狀態。預設上, 它設定為 DragState. inactive
。程式碼幾乎相同,除了它修改為使用dragState
而不是使用 isPressed
與 dragOffset
。舉例而言,對於 .offset
修飾器,我們從拖曳狀態的相關值中取得拖曳偏移量。
程式碼的結果是相同的,但是使用列舉追蹤手勢的複雜狀態是較好的作法。
建立通用的可拖曳視圖
到目前為止,我們已建立了一個可拖曳的圖片視圖,若是我們想要建立可拖曳的文字視圖呢?或者我們想要建立可拖曳的圓形呢?是否應複製並貼上所有的程式碼,來建立文字視圖或圓形呢?
總是會有更好的方式來實作它,我們來看如何建立通用的可拖曳視圖。
在專案導覽器中,右鍵點擊 SwiftUIGesture
資料夾,選擇「New File」,接著選取「SwiftUI View」模板,然後將檔案命名為 DraggableView
。
宣告 DragState
列舉,並更新 DraggableView
結構如下:
enum DraggableState {
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 isPressing: Bool {
switch self {
case .pressing, .dragging:
return true
case .inactive:
return false
}
}
}
struct DraggableView<Content>: View where Content: View {
@GestureState private var dragState = DraggableState.inactive
@State private var position = CGSize.zero
var content: () -> Content
var body: some View {
content()
.opacity(dragState.isPressing ? 0.5 : 1.0)
.offset(x: position.width + dragState.translation.width, y: position.height + dragState.translation.height)
.animation(.easeInOut, value: dragState.translation)
.gesture(
LongPressGesture(minimumDuration: 1.0)
.sequenced(before: DragGesture())
.updating($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
}
})
.onEnded({ (value) in
guard case .second(true, let drag?) = value else {
return
}
self.position.height += drag.translation.height
self.position.width += drag.translation.width
})
)
}
}
所有的程式碼都與你之前編寫的程式碼非常相似。技巧是將 DraggableView
宣告為通用視圖, 並建立一個content
屬性,此屬性接收任何視圖,並且我們使用長按與拖曳手勢為 content
視圖提供支援。
現在,你可透過替換 DraggableView_Previews
來測試這個通用視圖,如下所示:
struct DraggableView_Previews: PreviewProvider {
static var previews: some View {
DraggableView() {
Image(systemName: "star.circle.fill")
.font(.system(size: 100))
.foregroundColor(.green)
}
}
}
在程式碼中,我們初始化一個 DraggableView
,並提供我們自己的內容(即星形圖片)。在這個範例中,你應該完成支援長按與拖曳手勢的相同星形圖片。
那麼,如果我們要建立一個可拖曳的文字視圖呢?你可以將程式碼片段替換為下列程式碼:
struct DraggableView_Previews: PreviewProvider {
static var previews: some View {
DraggableView() {
Text("Swift")
.font(.system(size: 50, weight: .bold, design: .rounded))
.bold()
.foregroundColor(.red)
}
}
}
在閉包中,我們建立了一個文字視圖而不是圖片視圖。如果你在預覽畫布中執行這個專案(如圖 17.6 所示),則可以拖曳文字視圖來移動它,是不是很酷呢?
如果你想要建立一個可拖曳的圓形,則可以替換程式碼如下:
struct DraggableView_Previews: PreviewProvider {
static var previews: some View {
DraggableView() {
Circle()
.frame(width: 100, height: 100)
.foregroundColor(.purple)
}
}
}
這便是建立通用的可拖曳的方式。試著以其他視圖替換圓形,來建立你自己的可拖曳視圖,並享受其中的樂趣。
作業
在本章中,我們探索了三個內建手勢,包括:點擊、拖曳與長按,不過有一些手勢我們還沒試過。作為練習,請試著建立一個通用的可縮放視圖,它可以識別 MagnificationGesture
,並且可相應縮放任何給定的視圖。圖 17.7 顯示了一個範例結果。
本章小結
SwiftUI 框架讓手勢處理變得非常容易。正如你在本章所學到的內容,這個框架提供幾個可以立即使用的手勢識別器。要使視圖支援某個類型的手勢,你需要做的是將其加上 .gesture
修飾器。組合多個手勢從未如此簡單。
為行動應用程式建立手勢驅動的使用者介面,是一種日益增長的趨勢。藉由易於使用的 API,試著使用一些有用的手勢來增強 App 的功能,以使你的使用者滿意。
想更深入學習SwiftUI和下載完整程式碼?你可以從 AppCoda網站購買《精通 SwiftUI》完整電子版。