【UE】リストの項目をドラッグ操作で入れ替える

## 初めに
WebUIやアプリケーションのUIでもよくあるリストの項目をドラッグで入れ替える操作をUEのウィジェットでやりたいなと思ったのがきっかけだったのですが、UEのScrollBoxには並び替えの機能はないので独自で標準機能を組み合わせて実現してみました。他の方法やもっと効率の良い実装方法はあるかもしれませんが、実装例の1つと考えていただければ幸いです。

## 環境情報
Windows 11
UE 5.3.2

## 実装方法
大まかな実装方法としてはマウスがホバーしているScrollBoxの子要素をモニターしておき、ドラッグしてマウスを離したタイミングで対象の子要素がマウスを離した時にホバーしている子要素の上になるようにScrollBox内の子要素一度削除して順番にScrollBoxに追加していきます。
説明簡略のためにI/Fなどは使用せず全てハード参照していますが悪しからず。

### ①ScrollBoxの子要素Widgetを作成する
まずはScrollBoxに子要素として追加するWidgetを作成します（WBP_Slot）
中身の構成は何でもよいのですが、一番シンプルにBorderにTextBlockを入れたものを用意しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498810/cd5dbf68-64b1-2df0-11cf-7bee3a25ba5c.png)

生成時に文字列を渡せるようにText変数を用意してExpose on Spawnにしておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498810/96c3387f-9393-6c59-ade5-b071c7347e64.png)

### ②リストとして表示するWidgetを作成する
ScrollBoxを保持しているWidgetを作成します（WBP_List）
大きさの調整のためにScaleBoxに入れてありますがここはご自身のプロジェクトに合うようにしてください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498810/53ea6677-94cb-7163-5e6e-25bb5c8b0fa1.png)

EventGraphの方でAddSlotというカスタムイベントを用意します。
ここでWBP_Slotを必要分生成してScaleBoxに追加しています。
並び替えを管理するためにSlotsというString型の配列も用意しておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498810/8452bef9-422f-35e5-4cb0-11f25cd038e2.png)

WBP_ListをAdd to Viewportして実行するとこんな感じです

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498810/0f29c3af-46e7-1cc0-0801-89cbb3352e65.png)

### ③WBP_Slotにイベントを追加する
Event DispatchersにOnSelected、OnMouseHovered、OnMouseUnhoveredという名前を付けてイベントを追加します。
OnSelectedとOnMouseHoveredには引数として自分自身（WBP_Slot）を渡すようにします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498810/96564296-eaa6-f660-f43a-c99f10d9df3d.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498810/681a563a-bfa7-95b8-837f-f237821e5295.png)

FunctionsのOverrideからOnMouseButtonDown、OnMouseEnter、OnMouseLeaveを追加します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498810/1d3f1156-3d95-2f93-1c4b-70c1e5fa1372.png)

追加したOnMouseButtonDown、OnMouseEnter、OnMouseLeaveにはそれぞれ先ほど作成したOnSelected、OnMouseHovered、OnMouseUnhoveredを呼ぶようにノードをつなぎます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498810/29d5cf99-b424-38af-165a-fe0615c8bfb9.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498810/95fd9dcc-6283-928b-0ea9-3085ccf406ff.png)

### ④WBP_ListでWBP_Slotのイベントを購読する
先ほど作成したAddSlotイベントの中でWBP_Slotを生成した後にイベントを購読しておきます。
この時に変数としてSelectedSlot(WBP_Slot)とHoveredSlot(WBP_Slot)を用意します。
OnSelectedイベントで引数をSelectedSlotに値を格納し、
OnMouseHoveredイベントで引数をHoveredSlotに格納、OnMouseUnhoveredで初期化しておきます。
SelectedSlotで現在どのSlotが選択状態か管理し、HoveredSlotで現在どのSlotにホバーが当たっているか管理します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498810/d5f879fc-4ff1-983e-d14e-367135bd8041.png)

### ⑤WBP_Slotで選択時、ホバー時の色を設定しておく
この手順は見やすくするためのものなので読み飛ばしてしまっても大丈夫です。

WBP_SlotのOnMouseButtonDownの中でBorderとTextBlockの色を変えておき、自分自身が選択状態であるかを判定するためにIsSelectedというBool値を追加します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498810/24f6fcb5-9bb4-e64d-a50c-66e86acca51c.png)

また、他のSlotが選択された時に色を元に戻せるようにDeselectイベントを作成しておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498810/3fee5e9c-28a4-042e-d7f4-b009d4d2dcd6.png)

ホバー時の色を変更するためにOnMouseEnterイベントで選択状態でなければ色を変更し、OnMouseLeaveイベントで選択状態でなければ色を戻します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498810/a78c0cb2-4342-c0a7-d7da-dcd5c6112a70.png)

WBP_Listの方でNotifySelectedSlotChangedイベントを作成して、現在選択中のSlot以外のSlotのDeselectを呼び出します。
OnSelectedイベントの最後でNotifySelectedSlotChangedを呼び出します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498810/f9974af1-b3f5-abc6-2914-63cd195c3ee0.png)

実際に動かしてみるとこんな感じです
![ダウンロード.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498810/eb14a9cd-14e2-5ff0-f421-07c28e623a50.gif)

### ⑥WBP_Listで並び替え処理を実装する
まずはScrollBoxの中身を全て削除した後で再度並び替え後のSlotを生成してScrollBoxに追加する処理を実装します。
RefleshListというイベントを作成しました。ScrollBoxの中身を削除したあとでソート済みのSlots配列を使って順番にAddSlotを呼び出します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498810/2eb89d2c-dfb5-783d-14ac-008d0e1d88d7.png)

次にSlots配列の中身を並び替える処理を実装します。
ExecuteSortというイベントを作成しました。SelectedSlotのTextと同じものをSlotsからRemoveを行い、HoveredSlotのTextと同じものをSlotsからFindし、見つけたIndexにSelectedSlotのTextをSlotsにInsertして先ほど作成したRefleshListを呼び出します。そうすることでHoveredSlotsの上にSelectedSlotが表示されるようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498810/1946be73-2d8e-3f58-8999-f033e846194d.png)

### ⑦並び替えを実行するか判定する
マウスのドラッグによって並び替えを行いますが、UEではマウスをドラッグしていることを起点にするイベントは存在しません。なのでマウスのクリックを起点に一定時間マウスがクリックされた状態であった場合に入れ替えそうさを行うようにしていきます。

マウスが離された時に入れ替えを行うかどうかの判定をIsSortModeというBool値で管理しておきます。
また、IsSortModeをtrueにするためのイベントを用意しておきます（ChangeToSortMode）。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498810/5e1d012a-0a66-2a94-fabd-42622e4bd5e6.png)

次にマウスのクリックを検知した際に入れ替えの判定を開始するイベントを追加します。
OnMousePressedというイベントを作成しました。SetTimerByFunctionNameを使って先ほど作成したChangeToSortModeを0.15秒後に実行するようにしています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498810/97e77afb-46c2-d9f5-7efd-cd4374496c06.png)

また、マウスを離した時の処理を追加します。
OnMouseReleasedというイベントを作成しました。ClearTimerByFunctionNameを呼んで0.15秒以内にマウスが離された場合はChangeToSortModeイベントを実行しないようにします。0.15秒が経過してIsSortModeがTrueになっている場合はExecuteSortを実行します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498810/27974792-1790-6898-38ec-4d37f443ae12.png)

SetTimerByFunctionNameとClearTimerByFunctionNameでタイマー管理をすることでSlotを選択だけするためにクリックしたときにも入れ替えが実行されることを防いでいます。

作成したOnMousePressedイベントはWBP_SlotのOnSelectedイベントを購読している部分で呼び出し、
OnMouseReleasedイベントはWBP_ListでOnMouseButtonUpをOverrideで追加してその中で呼びます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498810/977d7bfb-c5c2-8fae-c5b2-a7877462fc42.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498810/ed4018e0-5475-6eb6-7bf0-4eac4cc492dd.png)

これでSlotの入れ替えができるようになりました。

今回はUnrealEngineのThirdPersonサンプルを使っていてViewPortに貼り付けて実装していたため、ウィジェットに対してマウスでクリックしている最中にホバーも検知できるようにSetInputModeUIOnlyを設定しました(LevelBlueprintでやっちゃってます)。
プロジェクトやUIの作り方によっては不要な設定ですが一応コードも貼っておきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498810/171e0449-9db7-4ae3-a745-535c7e38ea70.png)



実際に動かしている様子がこちらです。
![sort.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498810/4a02a157-3a77-368c-e1fa-347ef0bc1889.gif)

以上で基本的なリストの入れ替えの実装は完了です。
この実装はホバーしているSlotの上に並び替えるものなので一番下には並び替えることはできません。
一番最後にダミーのSlotを追加するか、入れ替え先を選択するためのBorderなどで各Slotを挟む構造にすれば柔軟に並び替えを行うことができるので（今回は説明簡略のため省略しました）、ご自身の実現したいUIに合わせて試してみてください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498810/172de9de-5e13-addd-765d-97cb0787e535.png)