如果你曾試過使用 SwiftUI 框架,你可能已對手勢操作有初步認識。最常見的,就是用 onTapGesture
修飾器來處理使用者的觸控並做出相對的回應。此教學,我們將會深入來看如何在 SwiftUI 中處理不同的手勢。
這個框架提供幾個內建手勢,像是之前用過的點按 (tap) 手勢。不止這些,還有像 DragGesture、 MagnificationGesture 與 LongPressGesture 是一些可以馬上使用的手勢。我們會看幾個手勢,並看看如何在 SwiftUI 中處理它。此外,你將學會如何建立一個可以支援拖曳手勢的通用視圖。
手勢修飾器的使用
要使用 SwiftUI 框架辨識特定手勢的話,只要使用 .gesture
修飾器將一個手勢辨識器 (gesture recognizer) 加到一個視圖即可。如以下程式中,使用了 .gesture
修飾器,將 TapGesture
加到視圖中。
var body: some View {
Image(systemName: "star.circle.fill")
.font(.system(size: 200))
.foregroundColor(.green)
.gesture(
TapGesture()
.onEnded({
print("Tapped!")
})
)
}
如果你想要測試一下程式,使用 Single View Application 來建立一個新專案,確認你有選取 SwiftUI 為 UI 選項。然後在 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)
.foregroundColor(.green)
.gesture(
TapGesture()
.onEnded({
self.isPressed.toggle()
})
)
}
}
當你在畫布或模擬器中執行程式時,你應該會見到縮放效果,這是如何使用 .gesture
修飾器來偵測與回應某種觸控事件的方法。
長按手勢的運用
內建手勢其中一項是 LongPressGesture
,這個手勢辨識器可以讓你偵測一個長按事件。舉例來說,如果你要調整為使用者長按星型圖片一秒時來調整星型圖片大小,你可以使用 LongPressGesture
來偵測觸控事件。
在 .gesture
修飾器修改程式如下來實作 LongPressGesture
:
.gesture(
LongPressGesture(minimumDuration: 1.0)
.onEnded({ _ in
self.isPressed.toggle()
})
)
在預覽畫布中執行這個專案來快速測試。現在,在切換它的大小之前,你至少要長按一秒。
@GestureState 屬性包裹器
當你按住星形圖片,這個圖片不會讓使用者有任何感覺,除非偵測到長按事件圖片才會有反應。很清楚地,我們可以改善這種使用者體驗。我想要在使用者按下圖片時給予使用者立即的回應。任何一種回應都可以協助改善這種狀況。譬如說,我們可以在使用者點按圖片時將圖片變暗淡一點。這單純是讓使用者知道我們的 App 捕捉到觸控事件,並且正在運作中。下圖為這個動畫的分解動作。
要實作這個動畫,其中一項工作是持續追蹤手勢的狀態。長按手勢的執行期間,我們必須區分點按與長按事件,那麼該如何做呢?
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)
.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)
.foregroundColor(.green)
.gesture(
DragGesture()
.updating($dragOffset, body: { (value, state, transaction) in
state = value.translation
})
)
}
}
要辨識一個拖曳手勢,先初始化一個 DragGesture
實例,並監聽以更新。在 update
函示,我們傳遞一個手勢狀態屬性來追蹤拖曳事件。跟長按手勢類似,這個 update
函示的閉包,接收三個參數。在這個範例中, value 參數儲存了目前包括位移 (translation) 的拖曳資料。這也是為何我們設定 state
變數為value.translation
,其實就是 dragOffset
。
在預覽畫布執行這個專案,你可以隨意拖曳圖片。當你放開時,圖片會回到原來的位置。
你知道為什麼圖片會回到它起始位置嗎?如前幾一節的說明,使用 @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)
.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) 來計算圖片的新位置。 .offset
修飾器也進行了更新,將目前的位置列入計算。
現在你可以執行專案並任意拖曳圖片,拖曳結束後,圖片會停留在最後的位置。
複合手勢
在某些情況下,同一個視圖可能會用到多種手勢辨識器,舉例來說,你想要使用者在開始拖曳之前先按著圖片不放,我們便需要結合長按與拖曳手勢。SwiftUI 可以讓你很容易地結合不同手勢來執行一些複雜的互動。它提供三種複合手勢型態,包括,同時 (simultaneous)、依序 (sequenced) 與專有 (exclusive) 的型態。
當你需要同時間偵測多種手勢的話,你可以使用 simultaneous 複合型態。如果你以專有型態來結合不同手勢的話, SwiftUI 會辨識所有妳指定的手勢,不過當其中一個手勢被偵測到後,它會忽略剩下的。
如同名稱的含義,如果你使用依序複合手勢型態來結合不同手勢,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)
.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 來找出要處理的手勢。因為長按手勢應該要在拖曳手勢之前被辨識, 這裡的 first 手勢即是長按手勢。程式中,我們只有印出 Tapping 訊息作為參考而已。
長按手勢確認之後,我們會進到 .second
case,在這裏,我們取出拖曳資料,並以對應的位移來更新 dragOffset
。
拖曳結束後,onEnded
函示將會被呼叫。同樣的,我們設定拖曳資料(也就是 .second
case)來更新最終的位置。
現在你可以準備測試這個複合手勢了。在預覽畫布中使用 debug 來執行這個 App,因此你可以在主控台中見到訊息,除非按住星形圖片至少一秒鐘,否則你無法拖曳它。
使用列舉來重構程式
有一個組織拖曳狀態的更好方式即是使用列舉。這可以讓你結合 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 狀態,它與拖曳的位移 (translation) 有關。
有了 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)
.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
修飾器中,我們從拖曳狀態的相關值中取得拖曳偏移量。
程式的執行結果是相同的,不過使用列舉來追蹤複雜的手勢狀態是一個很不錯的作法,
建立一個通用的拖曳視圖
到目前為止,我們建立了一個可以拖曳的圖片視圖。那麼如果我們要建立一個可以拖曳的文字視圖呢?或者一個可以拖曳的圓呢?是否複製所有程式並貼上來建立文字視圖或圓呢?
總是有更好的實作方式,我們來看如何建立一個通用可拖曳視圖。
宣告 DragState
列舉,並更新 DraggableView
結構如下:
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
}
}
}
struct DraggableView<Content>: View where Content: View {
@GestureState private var dragState = DragState.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)
.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
屬性。這個屬性接收任何的視圖 (view),然後我們賦予這個內容 (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)
}
}
}
在閉包中,我們建立一個文字視圖來取代圖片視圖。如果你在預覽畫布中執行這個專案,你可以拖任意拖曳文字視圖,是不是很酷呢?
如果你要建立一個可以拖曳的圓,程式可以更新如下:
struct DraggableView_Previews: PreviewProvider {
static var previews: some View {
DraggableView() {
Circle()
.frame(width: 100, height: 100)
.foregroundColor(.purple)
}
}
}
以上為建立一個通用拖曳視圖的方式。試著玩玩看,將圓以其他視圖來代替,建立你自己的可拖曳視圖。
你的作業
本章內容中,我們已經探索三種內建手勢,包括了點按,拖曳與長按手勢,不過還有一些我們沒有試過的,請試著建立一個通用可以縮放的視圖,這個視圖可以辨識 MagnificationGesture
,並且可以縮放任何給定的視圖。下圖為其範例內容。
本文小結
SwiftUI 框架讓手勢的運用非常容易。如本文所學過的內容,這個框架提供幾個可以馬上使用的手勢辨識器。要啟動一個視圖來支援某種形態的手勢,你只需要加上一個 .gesture
修飾器即可。多個手勢的結合也非常容易。
以手勢為主的使用者介面在 App 運用中已經逐漸成為趨勢,有了這些容易使用的 API,試著讓你的 App 透過一些更有用的手勢來強化使用者的滿意度。
如你想深入學習 SwiftUI 和下載本文所準備的範例檔,可以到這裡購買《Mastering SwiftUI》。中文版三月推出!