SwiftUI の Gesture
は protocol であり、準拠しているものを自分がよく使うもので挙げてみると
DragGesture
LongPressGesture
TapGesture
等がある (他の Gesture
については公式ドキュメント12を参照)。
これらの Gesture がキャンセルされたことを検出する仕組みが欲しくなったので、調べた。
この記事で取り上げる Gesture のキャンセルの定義
Gesture.onEnded(_:)
の action
は実行されないが、 Gesture 自体は終了している状態を指す。
例えば DragGesture
であれば、ドラッグを始めてから 2 本目の指のタップが検出されると onEnded
は呼ばれずに Gesture が終了する。
今回はこのような状況を想定して、このキャンセル動作を検出する。
一方、 LongPressGesture
においてタップ時間が minimumDuration
に満たない場合は Gesture が検出されないが、これはキャンセルとはみなさず、そもそも start していないので対象外とする。
この記事では以下の 2 つのステップの検出について解説する。
- Gesture のキャンセルを検出する方法
- 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 のキャンセル検出
正常終了時とキャンセル時に実行したい処理が同じであればこの対策で十分である。
- ジェスチャーが有効かどうかのフラグを
GestureState
によって持つ (このとき initial state は無効としておく) Gesture
イベントが発火したらupdating
メソッドでフラグを立てるonChange
メソッドでフラグの変化を監視して、ジェスチャーが無効であればキャンセルとみなして処理を実行する。
Property Wrapper である GestureState
はジェスチャーの終了時に初期状態にリセットするという仕様であるため、この方法でキャンセルが検出できる。
従って、以下の流れで最終的に例として作成したコードの onGestureCancelled()
が呼び出される。
- Gesture が終了する (この「終了」はキャンセルでも正常終了でも良い)
GestureState
の Transaction が走ってisGestureActive
はfalse
にリセットされる- それを
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 (ジェスチャーが終了した時) にフラグを折る
という制御がされる。
こうすれば isGestureActive
が false
というここまでの状況に加えて、
なおかつ isGestureNormally
が false
であればキャンセルであると判断できる。
@@ -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 を題材に、
- Gesture のキャンセルを検出する方法
- Gesture の正常終了とキャンセルを区別する方法
について解説した。
SwiftUI の Gesture にはあまり馴染みがなかったが、今回の実装を通して
の理解を深めることができた。
SwiftUI でジェスチャーを実装するのであれば、一度ドキュメントに目を通しておいたほうがスムーズに実装が進むと感じた。
また、今回の問題に対応するに当たっては、以下のページが参考になった。
SwiftUI ScrollView: How to modify .content.offset aka Paging? - Stack Overflow