Unityでスクロールビュー(ScrollRect)上に配置したUIオブジェクトを長押しでドラッグさせる

スクロールビュー上に配置したUIオブジェクトをドラッグしたいと思ったことありませんでしょうか？
たとえば、リスト上にあるアイテムを削除するためにゴミ箱アイコンの上までドラッグするだったり、アイテムをビュー間で移動させるなどゲームで考えられるシチュエーションはあると思います。

これを実現するために、スクロールビュー上に配置したUIオブジェクトに対して`IBeginDragHandler`,`IDragHandler`,`IEndDragHandler`を実装するとスクロールビューを構成する`ScrollRect`がスクロールできないという問題に直面してしまいます。
実はこの問題に頭を悩ます人が多いのではないかと勝手に推測しています。
これをどうにかしようというのが今回の内容となります。

## 参考画像
今回の内容で、どういうことが実現できるのかわかりやすいように画像を用意しました。
スクロールビューを上下にスクロールできることを確認しつつ、４番と６番のパネルを長押ししてドラッグを行っています。
![scroll.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2743276/a65c220a-6d7d-32f0-2f45-488d37f2656e.gif)


## ScrollRectがスクロールできなくなる理由
UnityのEventSystem(InputModule)の実装にあります。
StandaloneInputModule.csを確認したところ、マウスダウンやタッチした際に、UIオブジェクトに対してレイキャスト（当たり判定）がIPointerHandler系とIDragHandler系に分かれて判定されます。
マウスダウン、タッチされた座標にある`ScrollRect`の上に`IBeginDragHandler`インターフェイスを持ったUIオブジェクトがある場合は、当たり判定として`ScrollRect`より手前にあるUIオブジェクトが優先されます。
`IBeginDragHandler`インターフェイスがなければ、UIオブジェクトには`IPointerDownHandler`が呼び出され、`ScrollRect`にはドラッグのしきい値を超えれば`IBeginDragHandler`の呼び出しが行われてスクロールが可能ということになるのです。

それぞれのハンドラごとに当たり判定が行われることから、`ScrollRect`より手前に`IBeginDragHandler`インターフェイスを持ったオブジェクトが存在してれば`ScrollRect`が当たり判定にヒットせずスクロールができなくなるということになります。

おそらくですが、スクロールビューが他のUIに干渉せずスクロールできるようにこのような設計になっているのだと思います。

## ドラッグもスクロールもしたい
何かしらの条件で、`IBeginDragHandler`の対象を切り替える必要があります。
実装する画面仕様やレイアウトにもよるので必ずしも使えるテクニックとは限りませんが、私は今回の記事のタイトルにもあるように、`ScrollRect`上のUIオブジェクトを長押しすることでドラッグを実現させました。

やり方としては、`ScrollRect`上のUIオブジェクトに対して`IBeginDragHandler`,`IDragHandler`,`IEndDragHandler`を実装して、長押し判定となる条件までにドラッグが行われれば、`ScrollRect`にDragHandlerが飛ぶようにしてスクロールを実現します。

## ドラッグ対象を切り替える方法
今回ご紹介するメインの部分となります。
`ScrollRect`上のUIオブジェクトがドラッグイベントを受けて、長押し判定前にドラッグイベントが発生すれば`ScrollRect`にドラッグ対象を入れ替えます。

ドラッグ対象を入れ替えるにはPointerEventDataの内容を書き換えることで実現可能です。
※Unity 2022.3.4f1にて動作確認しています

```CSharp:PointerEventDataの書き換え
//選択されているオブジェクトをScrollRectへ変更する
var scrollRect = GetComponentInParent<ScrollRect>();
eventData.pointerDrag = scrollRect.gameObject;
EventSystem.current.SetSelectedGameObject(scrollRect.gameObject);

//ドラッグの初期化
scrollRect.OnInitializePotentialDrag(eventData);
//次のフレームからIdragHandlerの呼び出しが始まるのでBeginさせる
scrollRect.OnBeginDrag(eventData);
```
ソースコードの説明としては、ドラッグ対象だったUIオブジェクトの`IDragHandler`内で`PointerEventData`の中身を親の`ScrollRect`が選択された状態へ書き換えています。
UnityのEventSystemが保持するドラッグ対象が`ScrollRect`になるので、次フレームより`ScrollRect`に対して`IDragHandler`が送られることになります。


## せっかくなので長押しも
長押しの実現は色々ありますが、私は以下のサイトを参考にさせていただきました。
UniRXで簡潔に記述されており、説明もあるのでわかりやすいです。

https://zenn.dev/nekomimi_daimao/articles/932e1f20ffca04

## コード全体
以下が今回`ScrollRect`上に配置したUIオブジェクトを長押しでドラッグできるコードとなります。
このスクリプトをスクロールビューのドラッグしたいUIオブジェクトにAddComponentすることで長押しドラッグできるようになります。
注意点として、ドラッグする最低限なコードにしかなっていないのでドラッグ後のロジックやエラー処理など必要に応じて追加が必要な点と、インタラクティブなUIがドラッグ対象のUIの上に乗っている場合はインタラクティブなUI上での長押しなど検討する必要がある点にご注意ください。

また、CanvasのRenderModeは`Screen Space-Camera`を想定しています。

```CSharp:DragObject.cs
using System;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using UniRx;
using UniRx.Triggers;

public class DragObject : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler, IPointerDownHandler, IPointerUpHandler
{
    /// 自分がドラッグ対象か？
    private bool isSelfDrag = false;

    /// しきい値計算に使用するダウン時の座標
    private Vector2 downPosition;

    /// オブジェクトをドラッグしたときの表示位置オフセット
    private Vector2 offset;

    /// ScrollRectへドラッグを切り替えるしきい値
    [SerializeField]
    private float changeScrollDragThreshold = 10.0f;

    /// 自身のRectTransform
    private RectTransform rectTransform = null;

    /// スクロール対象となるScrollRect(基本的に親）
    private ScrollRect scrollRect = null;

    /// 所属するキャンバス
    private Canvas canvas = null;

    /// ドラッグ中のオフセット計算に必要なCanvasのRectTransform
    private RectTransform canvasRectTransform = null;

    /// ドラッグ開始処理
    public void OnBeginDrag(PointerEventData eventData)
    {
    }

    /// ドラッグ中処理（位置変更もしくはドラッグかスクロールかの切り替え）
    public void OnDrag(PointerEventData eventData)
    {
        if(isSelfDrag)
        {
            //自分がドラッグ中なら位置を更新
            gameObject.transform.position = eventData.position - offset;
            return;
        }

        //長押し判定が行われる前に指定量動けばスクロールとみなす
        if (Mathf.Abs(downPosition.x - eventData.position.x) >= changeScrollDragThreshold ||
            Mathf.Abs(downPosition.y - eventData.position.y) >= changeScrollDragThreshold)
        {
            //選択されているオブジェクトをScrollRectへ変更する            
            eventData.pointerDrag = scrollRect.gameObject;
            EventSystem.current.SetSelectedGameObject(scrollRect.gameObject);

            //ドラッグの初期化
            scrollRect.OnInitializePotentialDrag(eventData);
            //次のフレームからIdragHandlerの呼び出しが始まるのでBeginさせる
            scrollRect.OnBeginDrag(eventData);
        }
    }

    /// ドラッグ終了
    public void OnEndDrag(PointerEventData eventData)
    {
    }

    /// ポインターダウン処理
    public void OnPointerDown(PointerEventData eventData)
    {
        isSelfDrag = false;
        downPosition = eventData.position;
    }

    /// 長押し
    private void OnLongClick(Unit unit)
    {
        //長押し判定後は自分がドラッグ対象とする
        isSelfDrag = true;

        //ドラッグ準備
        this.gameObject.transform.SetParent(canvas.transform);

        RectTransformUtility.ScreenPointToLocalPointInRectangle(
            rectTransform,
            downPosition,
            canvas.worldCamera,
            out offset);
    }

    /// ポインターアップ処理
    public void OnPointerUp(PointerEventData eventData)
    {
    }

    // Start is called before the first frame update
    void Start()
    {
        //GetComponentを必要以上に呼ばないよう事前にキャッシュする
        rectTransform = GetComponent<RectTransform>();
        scrollRect = GetComponentInParent<ScrollRect>();
        canvas = GetComponentInParent<Canvas>();
        canvasRectTransform = canvas.GetComponent<RectTransform>();

        //長押し判定(0.5秒)
        var eventTrigger = this.gameObject.AddComponent<ObservableEventTrigger>();

        //以下を参考
        // https://zenn.dev/nekomimi_daimao/articles/932e1f20ffca04　
        eventTrigger.OnPointerDownAsObservable().Select(_ => true)
            .Merge(eventTrigger.OnPointerUpAsObservable().Select(_ => false))
            .Throttle(TimeSpan.FromSeconds(0.5))
            .Where(b => b)
            .AsUnitObservable()
            .TakeUntilDestroy(this)
            .Subscribe(OnLongClick);

    }

}
```

## まとめ
UnityのEventSystem周りを簡単に説明しつつ、スクロールビュー上のUIオブジェクトを長押しでドラッグさせる方法を紹介しました。
・当たり判定は`IPointerDownHandler`と`IBeginDragHandler`を持ったオブジェクト別々になる
・ドラッグ対象のオブジェクトは切り替えれる
この２点が今回重要なポイントでした。

これがなにかの参考になれば幸いです。