【Unity】UIToolkit～バインディング編～ MultiColumnListViewを添えて

## はじめに
**この記事はGCC Advent Calendar 2024の10日目の記事です。**

UIToolkitの中で分かりづらい部分も多いように感じたMultiColumnListViewを題材にUIToolkitにおけるバインディングを行う方法をご紹介いたします。

## 前提

理解している前提で進めさせていただくことを以下にまとめます。

- `UXML`・`USS`・`C#VisualElements名前空間`を使用したエディター作成の基礎知識

想定読者様を以下にまとめます。

- `UIToolkit(UIElements)`を使用した際のバインディング方法を知りたい方
- `Unity6ではないMultiColumnListView`の扱い方の一例を知りたい方


## 目指すもの

今回目指すものは`Serializable`な`SampleEnemy`クラスをバインディングしたMultiColmunListViewです。

具体的には以下の画像のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3843677/62090c64-238f-9482-44dc-bff3702b8929.png)

そして、今回バインディングする`SampleEnemy`クラスは以下の通りです。

```c#:SampleEnemy.cs
using System;
using UnityEngine;

namespace GarbageWay.Editor
{

    [Serializable]
    public struct EnemyParameter
    {
        public int HP;
        public int AttackPoint;
    }

    [Serializable]
    public class SampleEnemy
    {
        [SerializeField]
        private string _name = "NEW_ENEMY";

        [SerializeField]
        private EnemyParameter _parameter;
    }
}
```

## 作成手順

1. `EditorWindow`に`List<SampleEnemy>`なメンバーを定義する
2. `EditorWindow`に`MultiColumnListView`を追加する
3. `MultiColumnListView`の設定を行う
4. `MultiColumnListView`に1のメンバーをバインディングする

以上の手順で作成していきます。

## 1. `EditorWindow`に`List<SampleEnemy>`なメンバーを定義する

`EditorWindow`を作成するコードを書き、そこに`List<SampleEnemy>`を追加します。

```c#:
#if UNITY_EDITOR
using System.Collections.Generic;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;

namespace GarbageWay.Editor
{
    /// <summary>
    /// MultiColumnListViewのサンプルコードです。
    /// </summary>
    public class MultiColumnListViewSampleWindow : EditorWindow
    {
        [SerializeField]
        private List<SampleEnemy> _enemies = new();
        
        [MenuItem("Window/CustomWindow/MultiColumnListViewSampleWindow")]
        private static void Window()
        {
            var window = GetWindow<MultiColumnListViewSampleWindow>();
            window.Show();
        }
    }
}
#endif
```

Unityのツールバーから`Window→CustomWindow→MultiColumnListViewSampleWindow`を起動すると以下のような結果が表示されるかと思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3843677/7927eea5-20a4-1d06-6e29-8d33a4a3e619.png)

ここまで出来たら1は完了です。

## 2. `EditorWindow`に`MultiColumnListView`を追加する

ここでタイトルにもある`MultiColumnListView`が登場します。
以下のようなコードを追加することでMultiColumnListViewを追加することができます。

```c#:
...
        private static void Window()
        {
            var window = GetWindow<MultiColumnListViewSampleWindow>();
            window.Show();
        }

        private void OnEnable()
        {
            // VisualElementを描画するためパネルのrootを取得
            var root = rootVisualElement;

            var listView = new MultiColumnListView();
            {
                // わかりやすいように幅をWindow幅と同様に
                listView.style.width = Length.Percent(100);
                // リストを表示するため表示に使用する元を設定
                listView.itemsSource = _enemies;
            }
            
            // Sampleなのでビジュアルツリー的正しさは考慮しない
            root.Add(listView);
        }

...
```

以下のような結果になるかと思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3843677/c3ad29f7-ccd5-b7a4-421b-6337350e594a.png)

## 3. `MultiColumnListView`の設定を行う

ここからコード量が増えます。
ほとんどは描画設定を行っているので、要点だけに存在するコメントを参考に見ていただくと見やすいかと思います。

```c#:
...

        private void OnEnable()
        {
            // VisualElementを描画するためパネルのrootを取得
            var root = rootVisualElement;

            var listView = new MultiColumnListView();
            {
                // わかりやすいように幅をWindow幅と同様に
                listView.style.width = Length.Percent(100);
                // リストを表示するため表示に使用する元を設定
                listView.itemsSource = _enemies;
                listView.showAddRemoveFooter = true;
                listView.showBorder = true;
                listView.reorderable = true;
                listView.reorderMode = ListViewReorderMode.Animated;
                // 複数選択を可能にするために必要
                listView.selectionType = SelectionType.Multiple;
                // リストに所属するアイテムの高さに合わせてリストの高さを変更するために必要
                listView.virtualizationMethod = CollectionVirtualizationMethod.DynamicHeight;

                // 表示するフィールドの列を追加するためにColumnを作成
                var nameColumn = new Column()
                {
                    // 列の名前を表示を指定
                    // わかりやすくするため日本語で設定
                    title = "敵の名前",
                    // リストがAddされたときに作成するビジュアル要素を指定
                    // 今回は名前入力のためTextFieldを作成するように指定
                    makeCell = () => new TextField()
                    {
                        multiline = true
                    },
                    // 他の列の中で同じ行の最も高いものに合わせる
                    stretchable = true
                };
                var parameterColumn = new Column()
                {
                    title = "敵パラメーター",
                    // パラメーター入力はPropertyFieldで作成するように指定
                    makeCell = () => new PropertyField()
                    {
                        label = "パラメーター"
                    },
                    stretchable = true
                };

                // 列を追加
                listView.columns.Add(nameColumn);
                listView.columns.Add(parameterColumn);
            }

            // Sampleなのでビジュアルツリー的正しさは考慮しない
            root.Add(listView);
        }
        
...
```

このようにすることで以下のような結果が得られます。

▼アイテム追加なし
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3843677/11782db5-8685-e9ae-73e3-3a2d4a01647a.png)
▼アイテム追加あり
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3843677/5be1b1d7-74ab-e53f-f400-9f56c26ae92e.png)

ここから紐づけることでパラメーターも表示できるようになります。

## 4. `MultiColumnListView`に1のメンバーをバインディングする

ようやく本題のバインディングです。

`MultiColumnListView`は複雑なバインディングを使用する関係上、この記事で書かせていただいたバインディングを覚えればほとんどのバインディングができるようになるかと思います。

以下ソースコードです。

```c#:
...

        private void OnEnable()
        {
            // VisualElementを描画するためパネルのrootを取得
            var root = rootVisualElement;
            // バインディングパスを使用できるようにするため
            // rootにこのEditorWindowのSerializedObjectをバインディング
            var serializedObject = new SerializedObject(this);
            root.Bind(serializedObject);

            var listView = new MultiColumnListView();
            {
                // わかりやすいように幅をWindow幅と同様に
                listView.style.width = Length.Percent(100);
                // リストを表示するため表示に使用する元を設定
                listView.itemsSource = _enemies;
                listView.showAddRemoveFooter = true;
                listView.showBorder = true;
                listView.reorderable = true;
                listView.reorderMode = ListViewReorderMode.Animated;
                // 複数選択を可能にするために必要
                listView.selectionType = SelectionType.Multiple;
                // リストに所属するアイテムの高さに合わせてリストの高さを変更するために必要
                listView.virtualizationMethod = CollectionVirtualizationMethod.DynamicHeight;

                // 設定されているSerializedObject(上記でrootに登録したこのEditorWindow)
                // の相対的なシリアライズされているフィールド名を入力することでバインディング
                // 今回は_enemiesをバインディングするためstringで変数名"_enemies"を指定
                listView.bindingPath = "_enemies";

                // 変更のたびにあらかじめSerializedObjectを更新しないと
                // 新たな要素が反映されておらず、範囲外アクセスエラーが発生します
                listView.itemsAdded += collection => serializedObject.Update();

                // 表示するフィールドの列を追加するためにColumnを作成
                var nameColumn = new Column()
                {
                    // 列の名前を表示を指定
                    // わかりやすくするため日本語で設定
                    title = "敵の名前",
                    // リストがAddされたときに作成するビジュアル要素を指定
                    // 今回は名前入力のためTextFieldを作成するように指定
                    makeCell = () =>
                    {
                        var textField = new TextField()
                        {
                            // バインディングによりフィールド名が表示されるため消しておく
                            label = "",
                            multiline = true
                        };
                        // styleの入れ子になっているので文字の折り返しは外側で設定
                        textField.style.whiteSpace = WhiteSpace.Normal;
                        return textField;
                    },
                    // ここでビジュアル要素のバインディング
                    bindCell = (VisualElement elem, int index) =>
                    {
                        if (elem is TextField textField)
                        {
                            // バインディングするためにSerializedPropertyを取得
                            SerializedProperty serializedProperty = serializedObject
                            .FindProperty("_enemies")       // 敵のリストを取得
                            .GetArrayElementAtIndex(index)  // 敵のリストの特定IndexのSerializedProperty(SampleEnemy)を取得
                            .FindPropertyRelative("_name"); // SampleEnemyのバインディングパスに相当する"_name"を指定して名前フィールドを取得
                            // SerializedPropertyを使用したバインディングにはBindPropertyを使用する必要がある
                            textField.BindProperty(serializedProperty);
                        }
                        serializedObject.ApplyModifiedProperties(); // 変更を適用
                    },
                    // 他の列の中で同じ行の最も高いものに合わせる
                    stretchable = true
                };
                var parameterColumn = new Column()
                {
                    title = "敵パラメーター",
                    // パラメーター入力はPropertyFieldで作成するように指定
                    makeCell = () => new PropertyField()
                    {
                        label = "パラメーター"
                    },
                    bindCell = (VisualElement elem, int index) =>
                    {
                        if (elem is PropertyField propertyField)
                        {
                            // パラメーターをバインディングするためにSerializedPropertyを取得
                            SerializedProperty serializedProperty = serializedObject.FindProperty("_enemies").GetArrayElementAtIndex(index).FindPropertyRelative("_parameter");
                            propertyField.BindProperty(serializedProperty);
                        }
                        serializedObject.ApplyModifiedProperties(); // 変更を適用
                    },
                    stretchable = true
                };

                // 列を追加
                listView.columns.Add(nameColumn);
                listView.columns.Add(parameterColumn);
            }

            // Sampleなのでビジュアルツリー的正しさは考慮しない
            root.Add(listView);
        }

...
```

このコードから以下の結果が得られます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3843677/1989dd96-2294-cb96-159b-60bdda58ffd1.png)

パラメーターの`FoldOut`を開くと高さが変更され内部フィールドの設定項目が確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3843677/4305998f-c42f-2f8b-532e-8aa2880fee3c.png)

## さいごに
ここまで、お読みいただきありがとうございます。

お疲れさまでした。

`MultiColumnListView`は`Column`を入れ替えたり、`Column`の横幅を変更したりできます。
柔軟なEditor拡張の手段の一つとしてこの機会に覚えていただけましたらと思います。

`UI Toolkit`についてのご意見を数人にお聞かせいただいたところ「UXMLって何？」という返答が多かったので次は`UI Toolkit`の初歩について書かせていただこうと考えています。