【コピペで簡単】UIToolkitで詳細の見れるリストを作る

# 概要
何かしらの一覧を表示して、その詳細も見れるようにしたいことがある。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100503/867b35f2-f854-3bb3-f361-675bc8a9e994.png" width=50%>



↑こんなイメージ

いつかの自分が実装に迷わないよう、作成手順を残しておく。

# 手順
### 1. 適当なプロジェクトを用意
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100503/3ccb51f1-270d-4b8a-5fa4-5e0205758a14.png" width=50%>

### 2. テンプレート作成
自前でUXMLファイルを作ってもいいが、慣れていないうちはテンプレートから作ると実装のブレが少なくて楽
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100503/61cfb950-46e0-7aff-6385-24ee8bc0d57f.png" width=50%>
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100503/47eec006-d114-0025-1444-2ae5855043ea.png" width=50%>

3つのファイルが生成される
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100503/058042a4-aef4-8d74-98a4-6c576fa1dbfa.png" width=50%>

```:ざっくり
C#スクリプト => UIをスクリプトで制御することができる
`{}` => スタイルシートという見た目を指定できるファイル. CSSみたいなもの
`</>` => UXMLファイル(VisualTreeAsset). UIの構造を指定する.
```

<img src="" width=50%>
### 3. 必要なUXMLを作る

UXMLファイルを3つコピペ
(Createの中にUXMLが見つからない:cry:)

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100503/7d49879f-8336-9d89-bca9-123c5a3cb092.png" width=50%>

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100503/67fa2188-d6ac-d7fc-7fae-9267bcb358f7.png" width=50%>

名前を用途に合わせて変更

### 4. スクリプト
以下のように修正
コメントましましにしたので読めばわかるはず

```csharp:ListAndDetailTool.cs
using System.Linq;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;

public class ListAndDetailTool : EditorWindow
{
    // スクリプトファイルのインスペクタからパーツを指定する
    [SerializeField] private VisualTreeAsset listAndDetailToolVta;
    [SerializeField] private VisualTreeAsset listViewVta;
    [SerializeField] private VisualTreeAsset detailViewVta;
    [SerializeField] private VisualTreeAsset listContentVta;

    private VisualElement _leftPane;
    private VisualElement _rightPane;
    private string _filterString = "";

    [MenuItem("Tools/ListAndDetailTool")]
    public static void ShowWindow()
    {
        ListAndDetailTool wnd = GetWindow<ListAndDetailTool>();
        wnd.titleContent = new GUIContent("ListAndDetailTool");
    }

    // Windowが開かれたり閉じたりした時の処理はOnEnableとOnDisableで行う
    void OnEnable()
    {
        ListDataContainer.OnListDataListChanged += RefreshListView;
    }

    void OnDisable()
    {
        ListDataContainer.OnListDataListChanged -= RefreshListView;
    }

    public void CreateGUI()
    {
        // 検索用のヘッダーを追加
        var toolbar = new Toolbar();
        var searchField = new ToolbarSearchField();
        searchField.RegisterValueChangedCallback(e =>
        {
            // 検索文字列が変更されたらリストビューを更新
            _filterString = e.newValue;
            RefreshListView();
        });
        toolbar.Add(searchField);
        rootVisualElement.Add(toolbar);

        // ベースとなる要素を作成
        var baseEl = listAndDetailToolVta.Instantiate();
        // Instantiate時にTemplateContainerが親として生成される
        // この親のStyleはエディタ上から変更できないため、スクリプトから設定する必要がある:cry:
        baseEl.style.flexGrow = 1;
        rootVisualElement.Add(baseEl);

        // 水平分割のためにTwoPaneSplitViewを使う
        var splitView = new TwoPaneSplitView(0, 200, TwoPaneSplitViewOrientation.Horizontal);
        baseEl.Add(splitView);

        // 左右のペインを作成
        _leftPane = new VisualElement();
        _rightPane = new VisualElement();
        splitView.Add(_leftPane);
        splitView.Add(_rightPane);

        // 左ペインにリストビューを追加
        var listView = listViewVta.Instantiate();
        _leftPane.Add(listView);

        // 右ペインに詳細ビューを追加
        var detailView = detailViewVta.Instantiate();
        _rightPane.Add(detailView);

        // 初期化
        SetupListView();
        RefreshListView();
        RefreshDetailView();
    }

    private void SetupListView()
    {
        var listView = _leftPane.Q<ListView>();

        // 要素が選択された時の処理
        listView.selectionChanged += selections =>
        {
            var selection = selections.FirstOrDefault();

            // 何も選択されなかった時
            if (selection == null)
                return;

            // 選択されたら詳細ビューを更新
            var listData = (ListData)selection;
            RefreshDetailView(listData);
        };

        // 要素の生成をする
        // 要素自体は使いまわされるため、ここでデータを入れることはしない
        // 表示時にbindItemでデータをバインドされる
        listView.makeItem = () => listContentVta.Instantiate();

        // リストビューの要素にデータをバインド
        // indexごとにListDataContainer.ListDataListからデータを取得してバインドする
        listView.bindItem = (el, index) =>
        {
            // 検索時のフィルタ結果はitemsSourceに入れて使い回すようにする
            var listData = (ListData)listView.itemsSource[index];
            el.Q<Label>().text = listData.Name;
        };
    }

    private void RefreshListView()
    {
        var listView = _leftPane.Q<ListView>();
        // データ自体を指定
        // 内部で要素数の取得や、フッターによる要素の追加などに使われる
        listView.itemsSource = _filterString == "" ? ListDataContainer.ListDataList : ListDataContainer.FilterListData(_filterString);

        listView.Rebuild();
    }

    private void RefreshDetailView(ListData listData = null)
    {
        var detailView = _rightPane.Q<VisualElement>();
        detailView.Clear();

        // 選択されたデータがない場合は何も表示しない
        if (listData == null)
            return;

        // 詳細ビューの内容を更新
        var nameLabel = new Label("Name");
        var nameField = new TextField { value = listData.Name };
        // テキストフィールドの値が変更されたらデータも更新
        nameField.RegisterValueChangedCallback(e =>
        {
            listData.Name = e.newValue;
            RefreshListView();
        });

        // 余白用の要素
        var space = new VisualElement { style = { height = 10 } };

        var descriptionLabel = new Label("Description");
        var descriptionField = new TextField { value = listData.Description };
        descriptionField.RegisterValueChangedCallback(e =>
        {
            listData.Description = e.newValue;
        });

        detailView.Add(nameLabel);
        detailView.Add(nameField);
        detailView.Add(space);
        detailView.Add(descriptionLabel);
        detailView.Add(descriptionField);
    }
}

```


### 5. インスペクタからUXMLをセット
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100503/2d07470b-8f84-ae0b-66fe-882bc818e886.png" width=50%>

:warning: セットした後はウィンドウを開き直さないと反映されません

### 6. UXMLを書き換え
```xml:DatailView.uxml
<engine:UXML xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:engine="UnityEngine.UIElements" xmlns:editor="UnityEditor.UIElements" noNamespaceSchemaLocation="../../../UIElementsSchema/UIElements.xsd" editor-extension-mode="False">
    <Style src="project://database/Assets/Editor/ListAndDetailTool.uss?fileID=7433441132597879392&amp;guid=45f3cb131b09748238ee8214092a9491&amp;type=3#ListAndDetailTool" />
</engine:UXML>
```

```xml:ListView.uxml
<engine:UXML xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:engine="UnityEngine.UIElements" xmlns:editor="UnityEditor.UIElements" noNamespaceSchemaLocation="../../../UIElementsSchema/UIElements.xsd" editor-extension-mode="False">
    <Style src="project://database/Assets/Editor/ListAndDetailTool.uss?fileID=7433441132597879392&amp;guid=45f3cb131b09748238ee8214092a9491&amp;type=3#ListAndDetailTool" />
    <engine:ListView allow-add="false" allow-remove="false" show-bound-collection-size="true" virtualization-method="DynamicHeight" show-border="true" show-foldout-header="true" show-alternating-row-backgrounds="ContentOnly" reorderable="false" horizontal-scrolling="true" show-add-remove-footer="false" style="flex-grow: 1; min-height: auto; max-height: none;" />
</engine:UXML>
```

```xml:ListContent.uxml
<engine:UXML xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:engine="UnityEngine.UIElements" xmlns:editor="UnityEditor.UIElements" noNamespaceSchemaLocation="../../../UIElementsSchema/UIElements.xsd" editor-extension-mode="False">
    <Style src="project://database/Assets/Editor/ListAndDetailTool.uss?fileID=7433441132597879392&amp;guid=45f3cb131b09748238ee8214092a9491&amp;type=3#ListAndDetailTool" />
    <engine:Label text="Label" />
</engine:UXML>
```

### 7. 表示用データを作る
見たままstaticのListですが、リスト更新用のイベントも合わせて用意します
また、フィルタも実装したいので、そのためのメソッドも追加します
```csharp:ListData.cs
using System.Collections.Generic;

public class ListData
{
    public string Name { get; set; } = "New ListData";
    public string Description { get; set; } = "New Description";
}

public static class ListDataContainer
{
    public static List<ListData> ListDataList { get; private set; } = new();

    // ListDataListの要素が変更された時に発火するイベント
    public static event System.Action OnListDataListChanged;

    public static void AddListData(ListData listData)
    {
        ListDataList.Add(listData);
        OnListDataListChanged?.Invoke();
    }

    public static List<ListData> FilterListData(string filter)
    {
        return ListDataList.FindAll(data => data.Name.Contains(filter));
    }
}
```

Aを押した時に、適当なデータが追加されるようにします
このスクリプトをシーン上の適当なオブジェクトにつけておく
```csharp:DataAdder.cs
using UnityEngine;

public class DataAdder : MonoBehaviour
{
    void Update()
    {
        // Aが押されたら適当なデータを追加
        if (Input.GetKeyDown(KeyCode.A))
            ListDataContainer.AddListData(new ListData()
            {
                Name = $"{(int)(System.DateTimeOffset.Now.ToUnixTimeMilliseconds())}_{System.Guid.NewGuid().ToString().Substring(0, 5)}",
                Description = System.Guid.NewGuid().ToString().Substring(0, 30)
            });
    }
}
```

### 8. 完成！
画面上部のメニューからウィンドウを開く
![スクリーンショット 2024-12-04 0.03.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100503/869247fb-b3fb-05ce-3acb-7b8fe6e5f157.png)

エディタを再生モードにしてAを何回か押せば、テスト用のデータを確認することができる
![スクリーンショット 2024-12-04 0.03.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100503/49179b59-4171-b321-c879-00c3c697c296.png)


# 未来の自分へTips
 - VisualTreeAssetをInstantiateした時はTemplateContainerという要素に入っている
「UI Builderでflex-growを1にしたのに要素が広がらない！」というときは、そっちを確認すること

 - PaneはPanelの打ち間違えではなく、"ペイン"という要素！！！！
 - TwoPaneSplitViewの大きさを変える部分がバグってる
バーを左右ギリギリまで動かしたりすると、その後操作できなくなるのでウィンドウを開き直して直す
 - UI Toolkit Debuggerがめっちゃ便利
UI Toolkit DebuggerのUI Toolkit Debuggerを開いたりできるので参考にすること