【Unity】【UI Toolkit】ScrollViewとListをバインドする

UnityのUI ToolkitでScrollViewListをバインドする方法についてまとめました。

Unity2021.3.16f1

やりたいこと

まず、ListViewを使うと、以下の記事のようにBinding Pathを使ってListとUI要素をバインドできます。

light11.hatenadiary.com

一方、ScrollViewにはListViewのようなBinding Pathが存在せず、パスによるバインドができません。
本記事ではこのScrollViewを使った場合のListとのバインド方法についてまとめます。

上述の記事と同様、以下のScriptableObjectのカスタムInspectorを作ることを目標とします。

using System;
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu]
public sealed class Example : ScriptableObject
{
    public List<Item> items = new();

    public void Reset()
    {
        items = new List<Item>
        {
            new() { enabled = true, name = "Item1" },
            new() { enabled = false, name = "Item2" },
            new() { enabled = true, name = "Item3" }
        };
    }

    [Serializable]
    public struct Item
    {
        public bool enabled;
        public string name;
    }
}

ScrollViewを作る

まずはScrollViewを持つUXMLファイルを作ります。

UI Builderで、containerという名前のScrollViewをとadd-buttonという名前のButtonを配置しておきます。 UI Builderにおける設定は下図の通りです。

UI Builder

UXMLは以下の通りです。

<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements" xmlns="UnityEngine.UIElements" ue="UnityEditor.UIElements" editor-extension-mode="False">
    <ui:ScrollView name="container" />
    <ui:Button text="Add" display-tooltip-when-elided="true" name="add-button" />
</ui:UXML>

これをeditor_layout.uxmlとして保存しておきます。

ScrollViewの要素を作る

次にListViewの各要素となるUIのUXMLファイルを作ります。

下図のようにToggleTextFieldを横一列に並べておきます。

レイアウトを作成

UXMLは以下の通りです。
Binding Pathにはそれぞれのバインド対象の変数名であるenablednameを入力しています。

<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements" xsi="http://www.w3.org/2001/XMLSchema-instance" engine="UnityEngine.UIElements" editor="UnityEditor.UIElements" noNamespaceSchemaLocation="../../UIElementsSchema/UIElements.xsd" editor-extension-mode="False">
    <ui:VisualElement style="flex-direction: row; flex-shrink: 1;">
        <ui:Toggle />
        <ui:TextField picking-mode="Ignore" value="filler text" text="filler text" style="flex-grow: 1; margin-left: 8px; margin-right: 8px;" />
    </ui:VisualElement>
</ui:UXML>

これをitem_layout.uxmlとして保存しておきます。

CustomEditorを作る

最後にInspector表示用のスクリプトを以下のように作成します。
まずスクリプトの全貌を掲載します。

using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;

[CustomEditor(typeof(Example))]
public sealed class ExampleEditor : Editor
{
    [SerializeField] private VisualTreeAsset itemLayout;
    [SerializeField] private VisualTreeAsset editorLayout;

    public override VisualElement CreateInspectorGUI()
    {
        var root = editorLayout.CloneTree();
        var container = root.Q<ScrollView>("container");

        // リストのSerializedPropertyを監視して配列サイズが変わったらバインド処理をする
        var arr = serializedObject.FindProperty("items.Array");
        arr.Next(true); // 配列サイズを取得するためにNextを呼ぶ
        root.TrackPropertyValue(arr, prop => BindList(container));

        // ボタンを押したら要素を追加する
        root.Q<Button>("add-button").RegisterCallback<ClickEvent>(OnClick);

        // 初期化
        BindList(container);

        return root;
    }

    private void BindList(VisualElement container)
    {
        var property = serializedObject.FindProperty("items.Array");

        var endProperty = property.GetEndProperty();

        // 最初の子を展開する
        property.NextVisible(true);

        var childIndex = 0;

        while (property.NextVisible(false))
        {
            // 最後の要素の到達したら終了
            if (SerializedProperty.EqualContents(property, endProperty))
                break;

            // 配列サイズプロパティはスキップ
            if (property.propertyType == SerializedPropertyType.ArraySize)
                continue;

            // 現在のインデックスに既につくられた要素があれば使い回すためにそれを取得、なければ新規作成して追加
            VisualElement element;
            if (childIndex < container.childCount)
            {
                element = container[childIndex];
            }
            else
            {
                element = itemLayout.CloneTree();
                container.Add(element);
            }

            // バインド
            var toggle = element.Q<Toggle>();
            var textField = element.Q<TextField>();
            var enabledProperty = property.FindPropertyRelative("enabled");
            var nameProperty = property.FindPropertyRelative("name");
            toggle.Unbind();
            textField.Unbind();
            toggle.BindProperty(enabledProperty);
            textField.BindProperty(nameProperty);

            childIndex++;
        }

        // 余分な要素を削除
        while (childIndex < container.childCount)
            container.RemoveAt(container.childCount - 1);
    }

    private void OnClick(ClickEvent evt)
    {
        var property = serializedObject.FindProperty("items");
        property.arraySize += 1;
        serializedObject.ApplyModifiedProperties();
    }
}

基本的に説明はコメントとして記述しています。

ポイントとしては、ScrollViewListViewと違ってバインドする機能がないため、BindPropertyを使って手動でバインドをおこなっています。
BindPropertyの使い方については以下の記事を参照してください。

light11.hatenadiary.com

また、リストに変更が加わるたびにバインドをし直す必要があるため、リストの要素数TrackPropertyValueを使ってトラッキングしています。 こちらについては以下の記事で解説しています。

light11.hatenadiary.com

動作確認

Projectウィンドウで先ほどスクリプトを選択し、Inspectorから以下のように設定します。

  • Item Layout: 上で作ったitem_layout.uxmlをアサイ
  • Editor Layout: 上で作ったeditor_layout.uxmlをアサイ

アサイ

次にCreate > ExampleからScriptableObjectを作成します。
作成したらそれを選択してInspectorを表示すると、正常に表示されていることが確認できます。

動作確認

関連

light11.hatenadiary.com

light11.hatenadiary.com

light11.hatenadiary.com

参考

docs.unity3d.com