第 17 章
了解手勢

在前面的章節中,你已經對使用 SwiftUI 建立手勢有所了解。我們使用 onTapGesture 修飾器來處理使用者的觸控,並做出相對的回應。而在本章中,我們更深入了解如何在 SwiftUI 中處理各種類型的手勢。

這個框架提供一些內建手勢, 例如: 我們之前使用過的點擊手勢。除此之外, 「DragGesture」、「MagnificationGesture」與「LongPressGesture」等都是現成可用的手勢。我們將研究其中幾個手勢,並看看如何在 SwiftUI 中使用。最重要的是,你將學習如何建立一個可以支援拖曳手勢的通用視圖。

圖 17.1. 範例展示可拖曳視圖
圖 17.1. 範例展示可拖曳視圖

使用手勢修飾器

要使用 框架識別特定手勢,你需要做的就是使用 .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 章。

圖 17.2. 簡單的縮放效果
圖 17.2. 簡單的縮放效果

使用長按手勢

其中一個是 LongPressGesture,這個手勢識別器可以讓你偵測長按事件。舉例而言,如果你想只有當使用者長按星形圖片一秒時可調整其大小,你可以使用 LongPressGesture 來偵測觸控事件。

修改 .gesture 修飾器中的程式碼如下,以實作 LongPressGesture

.gesture(
    LongPressGesture(minimumDuration: 1.0)
        .onEnded({ _ in
            self.isPressed.toggle()
        })
)

在預覽畫布中執行專案來快速測試。現在,你必須至少長按星形圖片一秒鐘,才能切換其大小。

@GestureState 屬性包裹器

當你按住星形圖片時,在偵測到長按事件之前,圖片不會給使用者任何回應。顯然地,我們可以採取一些措施來改善使用者體驗,我想要做的是在使用者點擊圖片時給予即時回饋。任何形式的回饋都將有助於改善情況,例如:當使用者點擊圖片時,我們可將圖片調暗一點,這只是讓使用者知道我們的 App 捕捉到觸控事件,並且正在進行工作。圖 17.3 說明了動畫如何工作。

圖 17.3. 點擊圖片時應用暗淡效果
圖 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,以使圖片變暗。

其次是 LongPressGestureupdating 方法。執行長按手勢的期間,將呼叫此方法,並接收 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
                })
        )
}

我們在程式碼中做了一些更改:

  1. 除了 update 函數之外,我們還實作了 onEnded 函數,其在拖曳手勢結束時呼叫。在閉包中,我們加入拖曳偏移來計算圖片的新位置。
  2. .offset 修飾器也已更新,如此我們將目前的位置列入計算。

現在,當你執行專案並拖曳圖片時,拖曳結束後,圖片會停留在最後的位置,如圖 17.4 所示。

圖 17.4. 拖曳圖片
圖 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 之前發生。

updatingonEnded 函數中的程式碼看起來非常相似,不過 value 參數現在實際上包含了兩個手勢(即長按與拖曳),這就是為何我們使用 switch 敘述來區分手勢。你可以使用 .first.second case 來找出要處理的手勢。由於我們應該要在拖曳手勢之前識別長按手勢,因此這裡的第一個手勢是長按手勢。在程式碼中,我們只印出「點擊」(Tapping )訊息供你參考。

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

當拖曳結束後,將呼叫 onEnded 函數。同樣的,我們透過計算拖曳資料(也就是 .second case )來更新最終的位置。

現在,你可以測試手勢組合了。在預覽畫布中,使用 debug 來執行 App,如此你可以在主控台中看到訊息。你必須按住星形圖片至少一秒鐘,才能拖曳它。

圖 17.5. 只有當使用者長按圖片至少一秒時,才能開始拖曳
圖 17.5. 只有當使用者長按圖片至少一秒時,才能開始拖曳

使用列舉重構程式碼

編寫拖曳狀態的更好方式是使用列舉(Enum),這可讓你將 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
        }
    }
}

這裡有三種狀態:「靜止」(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 而不是使用 isPresseddragOffset。舉例而言,對於 .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 所示),則可以拖曳文字視圖來移動它,是不是很酷呢?

圖 17.6. 可拖曳的文字視圖
圖 17.6. 可拖曳的文字視圖

如果你想要建立一個可拖曳的圓形,則可以替換程式碼如下:

struct DraggableView_Previews: PreviewProvider {
    static var previews: some View {
        DraggableView() {
            Circle()
                .frame(width: 100, height: 100)
                .foregroundColor(.purple)
        }
    }
}

這便是建立通用的可拖曳的方式。試著以其他視圖替換圓形,來建立你自己的可拖曳視圖,並享受其中的樂趣。

作業

在本章中,我們探索了三個內建手勢,包括:點擊、拖曳與長按,不過有一些手勢我們還沒試過。作為練習,請試著建立一個通用的可縮放視圖,它可以識別 MagnificationGesture,並且可相應縮放任何給定的視圖。圖 17.7 顯示了一個範例結果。

圖 17.7. 可縮放的圖片視圖
圖 17.7. 可縮放的圖片視圖

本章小結

SwiftUI 框架讓手勢處理變得非常容易。正如你在本章所學到的內容,這個框架提供幾個可以立即使用的手勢識別器。要使視圖支援某個類型的手勢,你需要做的是將其加上 .gesture 修飾器。組合多個手勢從未如此簡單。

為行動應用程式建立手勢驅動的使用者介面,是一種日益增長的趨勢。藉由易於使用的 API,試著使用一些有用的手勢來增強 App 的功能,以使你的使用者滿意。


想更深入學習SwiftUI和下載完整程式碼?你可以從 AppCoda網站購買《精通 SwiftUI》完整電子版。

results matching ""

    No results matching ""