SwiftUI で Gesture キャンセルを検出する

SwiftUI の Gesture は protocol であり、準拠しているものを自分がよく使うもので挙げてみると

  • DragGesture
  • LongPressGesture
  • TapGesture

等がある (他の Gesture については公式ドキュメント12を参照)。

これらの Gesture がキャンセルされたことを検出する仕組みが欲しくなったので、調べた。

この記事で取り上げる Gesture のキャンセルの定義

Gesture.onEnded(_:)action は実行されないが、 Gesture 自体は終了している状態を指す。

例えば DragGesture であれば、ドラッグを始めてから 2 本目の指のタップが検出されると onEnded は呼ばれずに Gesture が終了する。
今回はこのような状況を想定して、このキャンセル動作を検出する。

一方、 LongPressGesture においてタップ時間が minimumDuration に満たない場合は Gesture が検出されないが、これはキャンセルとはみなさず、そもそも start していないので対象外とする。

この記事では以下の 2 つのステップの検出について解説する。

  1. Gesture のキャンセルを検出する方法
  2. Gesture の正常終了とキャンセルを区別する方法

Gesture のキャンセルを発生させるための実験コード

下記のコードで問題が再現できる。
このコードを実行して実機で確かめると、円をドラッグしてからその指を離した場合は “gesture ENDED 🎬” に表示が変わるが、ドラッグを開始してから別の指で円をタップすると “gesture CHANGED 🔁” から表示が変化しない。

struct GestureCancelView: View {
    @GestureState(resetTransaction: Transaction(animation: .easeInOut)) var translation: CGSize = .zero
    @State var stateDescription: String = "idle ⏸"

    var gestureValueDescription: String {
        "translation: "
        + (round(translation.width * 100) / 100).description
        + ", "
        + (round(translation.height * 100) / 100).description
    }

    var dragGesture: some Gesture {
        DragGesture()
            .updating($translation) { value, state, transaction in
                state = value.translation
            }
            .onChanged { value in
                onGestureChanged()
            }
            .onEnded { value in
                onGestureEnded()
            }
    }

    var body: some View {
        VStack {
            Text(gestureValueDescription)
            Text("last state: " + stateDescription)

            Spacer()

            Circle()
                .foregroundColor(.pink)
                .frame(width: 200, height: 200)
                .offset(translation)
                .padding(.bottom, 100)
                .gesture(dragGesture)
        }
    }

    func onGestureChanged() {
        stateDescription = "gesture CHANGED 🔁"
        print("onChanged")
    }

    func onGestureEnded() {
        stateDescription = "gesture ENDED 🎬"
        print("onEnded")
    }
}

Gesture のキャンセル検出

正常終了時とキャンセル時に実行したい処理が同じであればこの対策で十分である。

  1. ジェスチャーが有効かどうかのフラグを GestureState によって持つ (このとき initial state は無効としておく)
  2. Gesture イベントが発火したら updating メソッドでフラグを立てる
  3. onChange メソッドでフラグの変化を監視して、ジェスチャーが無効であればキャンセルとみなして処理を実行する。

Property Wrapper である GestureState はジェスチャーの終了時に初期状態にリセットするという仕様であるため、この方法でキャンセルが検出できる。
従って、以下の流れで最終的に例として作成したコードの onGestureCancelled() が呼び出される。

  1. Gesture が終了する (この「終了」はキャンセルでも正常終了でも良い)
  2. GestureState の Transaction が走って isGestureActivefalse にリセットされる
  3. それを onChanged で検知し、 if 文の中に入るので onGestureCancelled が呼ばれる
 struct GestureCancelView: View {
     @GestureState(resetTransaction: Transaction(animation: .easeInOut)) var translation: CGSize = .zero
     // MARK: - 1️⃣
+    @GestureState var isGestureActive: Bool = false
     @State var stateDescription: String = "idle ⏸"

     var gestureValueDescription: String {
@@ -23,11 +25,14 @@ struct GestureCancelView: View {
             .onEnded { value in
                 onGestureEnded()
             }
             // MARK: - 2️⃣
+            .updating($isGestureActive) { value, state, transaction in
+                state = true
+            }
     }

@@ -44,11 +49,31 @@ struct GestureCancelView: View {
                 .offset(translation)
                 .padding(.bottom, 100)
                 .gesture(dragGesture)
                 // MARK: - 3️⃣
+                .onChange(of: isGestureActive) { newValue in
+                    if !newValue {
+                        onGestureCancelled()
+                    }
+                }
         }
     }
+
+    func onGestureCancelled() {
+        stateDescription = "gesture CANCELED ⛔️"
+        print("onCancelled")
+    }
 }

ただし、この実装には 2 つ問題点がある。
上記のコードを実行してコンソールを見ると分かるのだが、キャンセルの際は

onChanged
onChanged
...
onChanged
onCancelled

と表示されるのに対して、正常終了の際は

onChanged
onChanged
...
onChanged
onEnded
onCancelled

のように onEnded の後に onCancelled が表示されている。
同じ処理を実行したい場合は 2 重に処理が走ってしまう。

また、次の問題として、どちらの場合も onCancelled が呼ばれていることから分かるように、このままだと完全に処理を分離できない。

これらを解決する方法は色々考えられるが、今回は更にフラグを増やして状態を判定することでこの問題を解決した。

Gesture の正常終了とキャンセルを区別する

状態管理のためにフラグを増やすというのは、組み合わせ爆発があり処理も追いにくいので避けたいが、今回はフラグによって判定する。

isGestureNormally というフラグを定義した。
このフラグは、

  • onEnded (正常終了) の際にフラグを立て
  • フラグが立っている、かつ、isGestureActive が false (ジェスチャーが終了した時) にフラグを折る

という制御がされる。

こうすれば isGestureActivefalse というここまでの状況に加えて、
なおかつ isGestureNormallyfalse であればキャンセルであると判断できる。

@@ -13,6 +13,11 @@ struct GestureCancelView: View {
     @State var dragOffset: CGSize = .zero
     @State var stateDescription: String = "idle ⏸"

+    // idea: onEnded と onCancelled の判定だけフラグを使うのが微妙であれば enum で状態を網羅して onChanged 等も一括で状態管理にしてもいいかもしれない
+    /// ジェスチャーが正常終了した際に onEnded の後に onCancelled も呼ばれてしまうのでこのフラグで制御する
+    /// onEnded でフラグを立て、フラグ状態によって onCancelled を呼ぶのかフラグを折るのかを判定する
+    @State var isGestureTerminatedNormally: Bool = false
+
     var gestureValueDescription: String {
         "translation: "
         + (round(translation.width * 100) / 100).description
@@ -50,8 +55,10 @@ struct GestureCancelView: View {
                 .padding(.bottom, 100)
                 .gesture(dragGesture)
                 .onChange(of: isGestureActive) { newValue in
-                    if !newValue {
+                    if !newValue, !isGestureTerminatedNormally {
                         onGestureCancelled()
+                    } else if !newValue {
+                        isGestureTerminatedNormally = false
                     }
                 }
         }
@@ -63,6 +70,8 @@ struct GestureCancelView: View {
     }

     func onGestureEnded() {
+        isGestureTerminatedNormally = true
+
         stateDescription = "gesture ENDED 🎬"
         print("onEnded")
     }

まとめ

SwiftUI の Gesture を題材に、

  1. Gesture のキャンセルを検出する方法
  2. Gesture の正常終了とキャンセルを区別する方法

について解説した。

SwiftUI の Gesture にはあまり馴染みがなかったが、今回の実装を通して

  • GestureState3
  • Gesture.updating(_:body:)4

の理解を深めることができた。
SwiftUI でジェスチャーを実装するのであれば、一度ドキュメントに目を通しておいたほうがスムーズに実装が進むと感じた。

また、今回の問題に対応するに当たっては、以下のページが参考になった。

SwiftUI ScrollView: How to modify .content.offset aka Paging? - Stack Overflow


  1. Gesture | Apple Developer Documentation ↩︎

  2. Gestures | Apple Developer Documentation ↩︎

  3. GestureState | Apple Developer Documentation ↩︎

  4. updating(_:body:) | Apple Developer Documentation ↩︎

Built with Hugo
テーマ StackJimmy によって設計されています。