Swift 程式語言

SwiftUI 教學:認識手勢 (Gestures) 和 @GestureState

如果你曾試過使用 SwiftUI 框架,你可能已對手勢操作有初步認識。最常見的,就是用 onTapGesture 修飾器來處理使用者的觸控並做出相對的回應。此教學,我們將會深入來看如何在 SwiftUI 中處理不同的手勢(Gestures)。
SwiftUI 教學:認識手勢 (Gestures) 和 @GestureState
SwiftUI 教學:認識手勢 (Gestures) 和 @GestureState
In: Swift 程式語言, SwiftUI 框架

如果你曾試過使用 SwiftUI 框架,你可能已對手勢操作有初步認識。最常見的,就是用 onTapGesture 修飾器來處理使用者的觸控並做出相對的回應。此教學,我們將會深入來看如何在 SwiftUI 中處理不同的手勢。

編者按:此教學節錄自《精通 SwiftUI》一書。

這個框架提供幾個內建手勢,像是之前用過的點按 (tap) 手勢。不止這些,還有像 DragGestureMagnificationGestureLongPressGesture 是一些可以馬上使用的手勢。我們會看幾個手勢,並看看如何在 SwiftUI 中處理它。此外,你將學會如何建立一個可以支援拖曳手勢的通用視圖。

swiftui-gestures-demo

手勢修飾器的使用

要使用 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 ,所以圖片會變暗淡。

第二,是 LongPressGestureupdating 方法。在長按手勢執行期間,這個方法將會被呼叫,並且接收三個參數: valuestatetransaction

  • 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
                })
        )
}

程式中,我們做了兩個改變:

  1. 除了update 函示外,我們也實作了 onEnded 函示,在手勢結束時呼叫用。閉包中,我們加入圖片的偏移值 (offset) 來計算圖片的新位置。
  2. .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 之前發生。

updatingonEnded 函示內的程式看起來非常相似,不過 value 參數現在其實是包含兩個手勢(也就是長按與拖曳)。這也是為何我們使用 switch 敘述來區分手勢。你可以使用 .first.second case 來找出要處理的手勢。因為長按手勢應該要在拖曳手勢之前被辨識, 這裡的 first 手勢即是長按手勢。程式中,我們只有印出 Tapping 訊息作為參考而已。

長按手勢確認之後,我們會進到 .second case,在這裏,我們取出拖曳資料,並以對應的位移來更新 dragOffset

拖曳結束後,onEnded 函示將會被呼叫。同樣的,我們設定拖曳資料(也就是 .second case)來更新最終的位置。

現在你可以準備測試這個複合手勢了。在預覽畫布中使用 debug 來執行這個 App,因此你可以在主控台中見到訊息,除非按住星形圖片至少一秒鐘,否則你無法拖曳它。

使用列舉來重構程式

有一個組織拖曳狀態的更好方式即是使用列舉。這可以讓你結合 isPresseddragOffset 狀態至單一個屬性。我們呼叫 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
        }
    }
}

這裡有三種狀態:inactivepressingdragging.。這些狀態足夠呈現長按與拖曳手勢期間的狀態。對於 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 來替代 isPresseddragOffset 外,其他幾乎相同。舉例來說, 在 .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》。中文版三月推出!

譯者簡介:王豪勳 -渥合數位服務創辦人,畢業於台灣大學應用力學研究所,曾在半導體產業服務多年,近年來專注於協助客戶進行 App 軟體以及網站開發,平常致力於研究各式最軟硬體技術,擁有多本譯作。

原文Working with SwiftUI Gestures and @GestureState

作者
Simon Ng
軟體工程師,AppCoda 創辦人。著有《iOS 17 App 程式設計實戰心法》、《iOS 17 App程式設計進階攻略》以及《精通SwiftUI》。曾任職於HSBC, FedEx等跨國企業,專責軟體開發、系統設計。2012年創立AppCoda技術部落格,定期發表iOS程式教學文章。現時專注發展AppCoda業務,致力於iOS程式教學、產品設計及開發。你可以到推特與我聯絡。
評論
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。