【Unity】UI Toolkitのドラッグアンドドロップ実装を分かりやすく解説してみた

[【Unity】UI ToolkitのPointerEventBase<T>のpositionは何を指すのか？](https://qiita.com/ohbashunsuke/items/27fb3a00a7d3c7939c6a)
前回執筆したコチラの続きでUI Toolkitで**ドラッグアンドドロップ**の実装をして生きます。普通にやると落ちる落とし穴付きで解説しています。

## ドラッグする対象にイベントを登録

```cs
var document = GetComponent<UIDocument>();
var rootVisualElement = document.rootVisualElement;

// ドラッグターゲットとなるVisualElementを取得
var dragTarget = rootVisualElement.Q<VisualElement>("dragTarget");
dragTarget.RegisterCallback<PointerDownEvent>(OnPointerDown);
dragTarget.RegisterCallback<PointerMoveEvent>(OnPointerMove);
dragTarget.RegisterCallback<PointerUpEvent>(OnPointerUp);
```

- PointerDownEvent
- PointerMoveEvent
- PointerUpEvent

3種のイベントを登録してコールバック関数をセットしています。

PointerMoveEventはMouseDown・MouseUp関わらず、マウスが動くたびに処理が走ってしまうので、`_isPointerDown`変数を用意してMouseDown or MouseUpの状態を保持するようにしました。


普通に書くと以下のようなコードになりましたが、これは**失敗**です。

```cs
private bool _isPointerDown = false;

private void OnPointerDown(PointerDownEvent evt)
{
    // ドラッグ状態にする
    _isPointerDown = true;
}

private void OnPointerMove(PointerMoveEvent evt)
{
    if (_isPointerDown)
    {
        var pos = new Vector2(evt.position.x, evt.position.y);
        var draggedTarget = (VisualElement) evt.target;
        // ドラッグターゲットの座標を更新
        draggedTarget.style.left = pos.x;
        draggedTarget.style.top = pos.y;
    }
}

private void OnPointerUp(PointerUpEvent evt)
{
    // ドラッグ状態解除
    _isPointerDown = false;
}
```

## 出来たもの(失敗作)

![D&D_Fail.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/328771/b1fc6d3c-fac2-9513-c8b8-10150d2a37bd.gif)

なんか挙動がおかしい...

1. ドラッグ対象の基準点
2. イベントの未発火

上記2点問題があるのです。

## 1.ドラッグ対象の基準点
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/328771/4a9abbdb-e100-5f95-95cc-ee1377be58c9.png" width=450 />

VisualElementの基準点は左上です。


```cs
private bool _isPointerDown = false;
private Vector2 _dragOffset;

private void OnPointerDown(PointerDownEvent evt)
{
    _isPointerDown = true;
    var draggedTarget = (VisualElement) evt.target;
    // MouseDownしたタイミングのドラッグターゲットの座標
    var dragTargetPos = new Vector2(
        dragTarget.resolvedStyle.left, 
        dragTarget.resolvedStyle.top);

    // MouseDownしたタイミングのMouseの座標
    var mouseDownPosition = new Vector2(evt.position.x, evt.position.y);
    // ドラッグターゲットの調整値を保持
    _dragOffset = dragTargetPos - mouseDownPosition;
}

private void OnPointerMove(PointerMoveEvent evt)
{
    if (_isPointerDown)
    {
        var mousePosition = new Vector2(evt.position.x, evt.position.y);

        // 現在のマウス座標に対してオフセット値を加算して座標を調整
        var pos = mouseDownPosition + _dragOffset;
        var draggedTarget = (VisualElement) evt.target;
        draggedTarget.style.left = pos.x;
        draggedTarget.style.top = pos.y;
    }
}

private void OnPointerUp(PointerUpEvent evt)
{
    _isPointerDown = false;
}
```

## 出来たもの(失敗その2)

![D&D_Fail2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/328771/8e4eeb9e-72fe-d1ca-af0c-b8cf4e1c2085.gif)

一見うまく動いてそうです。
しかし、マウスを早く動かしてドラッグターゲットより**マウスカーソルをはみ出す**と、ドラッグターゲットがマウスについて来なくなりました。
その後MouseDown状態でもないのにマウスにくっついて来るようにもなっています。

はい、失敗です。


## 2.イベントの未発火

原因はイベントの伝播が出来ていないことです。

ドラッグターゲットからマウスを外しても**PointerMoveEvent**、**PointerUpEvent**は発火してもらわないと困ります。

>その後MouseDown状態でもないのにマウスにくっついて来るようにもなっています。

しかしこの状態ではPointerUpEventが発火していません。
そのため内部的にはまだドラッグ状態ということになります。
**※ _isPointerDownの更新がされない**

よってMouseDown状態でなくてもマウスにくっついて来てしまうという状態になるわけです。

### CapturePointerを使う

>When a VisualElement captures a pointer, all pointer events are sent to the element, regardless of which element is under the pointer.

[Unity - Scripting API: UIElements.PointerCaptureHelper.CapturePointer](https://docs.unity3d.com/2020.2/Documentation/ScriptReference/UIElements.PointerCaptureHelper.CapturePointer.html)より

早い話が、**ポインター先がどのVisualElementだろうがイベント発火する**という事です。

MouseDownのタイミングでCapturePointerを実行して、ポインターのキャプチャをします。  
**※イベント実行時のクリックIDを内部的に保持する**

```cs
private void OnPointerDown(PointerDownEvent evt)
{
    // pointerIdをキャプチャ
    evt.target.CapturePointer(evt.pointerId);
   
// ~~~~ 略 ~~~~
```

すると、MouseMove時にドラッグターゲットからマウスが外れてもイベントは発火し続けます。

### MouseDown判定にキャプチャ状態を使用

```cs
private void OnPointerMove(PointerMoveEvent evt)
{
    // // MouseDown判定をポインターをキャプチャしているかどうかで判定する
    if (evt.target.HasPointerCapture(evt.pointerId))
    {
       // ドラッグオブジェクトの座標を更新する処理

// ~~~~ 略 ~~~~
```


### MouseUpしたらキャプチャを解除する

```cs
private void OnPointerUp(PointerUpEvent evt)
{
    if (evt.target.HasPointerCapture(evt.pointerId))
    {
        // ポインターのキャプチャを解放
        evt.target.ReleasePointer(evt.pointerId);
    }
}
```

## 出来たもの(成功)
![D&D.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/328771/b58d64ef-c4fd-b4bb-4b60-863baeb74822.gif)

意図通りのドラッグアンドドロップが完成しました。

## 最後に

全体ソースは[Gist](https://gist.github.com/baobao/11f77052ce6f3f9dc65829dc807552cd)にアップしています。



## 環境
- Unity2020.2.0f1
- UI Toolkit 1.0.0-Preview.13