ScrollView が DragGesture を中断して onEnded が呼ばれない問題

どうもこんにちは。GW の混雑を回避するため休みをずらした亀山です。 SwiftUI の DragGesture は ScrollView のスクロールが起きると onEnded が呼ばれないままキャンセルされてしまいます。この問題の詳細と解決方法について解説します。

以下のサンプルコードはドラッグで横に移動できる縦スクロールの ScrollView です。

import SwiftUI

struct DraggableView: View {
    @State private var offsetX: CGFloat = 0

    var body: some View {
        let dragGesture = DragGesture()
            .onChanged { value in
                offsetX = value.translation.width
            }
            .onEnded { _ in
                withAnimation {
                    offsetX = 0
                }
            }

        ScrollView {
            Text("scroll")
        }
            .frame(width: 200, height: 300)
            .background(.gray)
            .offset(x: offsetX)
            .gesture(dragGesture)
    }
}

struct ContentView: View {
    var body: some View {
        ZStack {
            Color.red
                .frame(width: 200, height: 300)
            DraggableView()
        }
    }
}

一見うまくいっているように見えますが、最後に ScrollView と同じ位置に重ねた背景の赤色が見えてしまっています。斜めにスワイプするとわかりやすく次のような半端な状態で止まってしまいます。

横スワイプと縦スクロールが競合して onEnded が呼ばれないためです。おそらく、DragGesture がジェスチャ認識開始 -> ScrollView のパンジェスチャの認識開始 -> DragGesture がキャンセルということが起きていると思われます。

そこで GestureState を使ってキャンセルを検知するワークアラウンドがあります。ジェスチャ終了時にリセットされる GestureState を使い、updating でジェスチャ中のフラグを立て、onChanged でこのフラグがリセットされるタイミングを検知することで、onEnded を使わずにジェスチャ終了を検知することができます。

struct DraggableView: View {
    @GestureState private var isGestureActive = false
    @State private var offsetX: CGFloat = 0

    var body: some View {
        let dragGesture = DragGesture()
            .updating($isGestureActive) { _, state, _ in
                state = true
            }
            .onChanged { value in
                offsetX = value.translation.width
            }

        ScrollView {
            Text("scroll")
        }
            .frame(width: 200, height: 300)
            .background(.gray)
            .offset(x: offsetX)
            .gesture(dragGesture)
            .onChange(of: isGestureActive) { value in
                // ジェスチャ終了
                guard !value else { return }

                withAnimation {
                    offsetX = 0
                }
            }
    }
}

確かにこれはジェスチャのキャンセルに対応できています。スワイプした状態で別の指で DraggableView をタップしたときに、最初のコードでは元の位置に戻りませんが、このコードではちゃんと元の位置に戻ります。

しかし、ScrollView の斜めスワイプではこれでも onChange が呼ばれずに解決できないのです。そこで色々試した結果見つけた解決法はこちらです。

            .onChanged { value in
+                withAnimation(.interactiveSpring()) {
                    offsetX = value.translation.width
+                }
            }

なぜ?

全然わかりませんが withAnimation で囲うことで解決されます。ちゃんと .onChange(of: isGestureActive) が呼ばれるようになります。ちなみに、onEnded はこの場合でも呼ばれません。

アニメーションする必要がないので、先程の withAnimation を次のように withTransaction に書き換えることができます。

var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
    offsetX = value.translation.width
}

ちなみに Transaction.isContinuous が関係してそうですが、false でも true でも変わりませんでした。

まとめ

DragGesture がスクロールで中断されて onEnded が呼ばれない問題は、@GestureState と onChanged 中の withTransaction で解決することができました。

最後に

SwiftUI の DragGesture は簡単そうに見えますが細かいところがいじれないため、他のジェスチャと競合した場合どうにもならないことがよくあります。今回は頑張って SwiftUI でジェスチャを実装してみましたが、解決できないことも多いため、複雑なインタラクションを実装したい場合はジェスチャだけは UIKit 側で実装して、ドラッグイベントを SwiftUI 側に伝搬する方法を取っています。