見出し画像

UnityのUI ToolKitを使ってC#のみでエディタ拡張してみた

※この記事は2023年12月4日に弊社運営の技術情報サイト「ギャップロ」に掲載した記事です。

はじめに

Unityの UI ToolKit を使ってC#でエディタ拡張をしてみました。
従来のエディタ拡張と同じような感覚で始めたら躓いたポイントがあったので、それも踏まえて書いていきます。

開発環境

  • Unity 2023.1.17f1

今回作るもの

今回は MonoBehaviour を継承したコンポーネント SampleBehaviour と、 SampleBehaviour がメンバ変数として持っているデータクラス SampleData のエディタ拡張を実装していきます。

拡張対象のソースコードは以下の通りです。

using System;
using UIToolKitSample.DataModel;
using UIToolKitSample.Enum;
using UnityEngine;

namespace UIToolKitSample.Behaviour
{
    /// <summary>
    /// サンプルBehaviourクラス
    /// </summary>
    public class SampleBehaviour : MonoBehaviour
    {
        /// <summary>
        /// データの種類
        /// </summary>
        [SerializeField] private DataType _dataType;

        /// <summary>
        /// データ1
        /// </summary>
        [SerializeField] private SampleData _data1;

        /// <summary>
        /// データ2
        /// </summary>
        [SerializeField] private SampleData _data2;

        /// <summary>
        /// データ3
        /// </summary>
        [SerializeField] private SampleData _data3;

        /// <summary>
        /// 出力する
        /// </summary>
        public void Output()
        {
            var log = _dataType switch
            {
                DataType.Data1 => _data1.ToString(),
                DataType.Data2 => _data2.ToString(),
                DataType.Data3 => _data3.ToString(),
                DataType.None => "データが選択されていません。",
                _ => throw new ArgumentOutOfRangeException()
            };

            Debug.Log(log);
        }
    }
}
using System;
using System.Text;
using UnityEngine;

namespace UIToolKitSample.DataModel
{
    /// <summary>
    /// サンプルデータクラス
    /// </summary>
    [Serializable]
    public class SampleData
    {
        /// <summary>
        /// ID
        /// </summary>
        [SerializeField] private int _id;

        /// <summary>
        /// 名前
        /// </summary>
        [SerializeField] private string _name;

        /// <summary>
        /// 有効か?
        /// </summary>
        [SerializeField] private bool _enable;

        /// <summary>
        /// スコア
        /// </summary>
        [SerializeField] private float _score;

        /// <summary>
        /// 文字列に変換
        /// </summary>
        /// <returns></returns>
        public override string ToString()
        {
            var sb = new StringBuilder();
            sb.AppendLine($"ID : {_id}");
            sb.AppendLine($"名前 : {_name}");
            sb.Append($"有効か? : {_enable}");
            if (_enable)
            {
                sb.AppendLine().Append($"スコア : {_score}");
            }

            return sb.ToString();
        }
    }
}

やりたいこと

SampleBehaviour

  • インスペクタに Output メソッドを実行する出力ボタンを追加する。

  • _dataType で選択しているデータのみ表示する。

SampleData

  • _enable が True の時のみ _score を表示する。

実装方法

SampleBehaviourのエディタ拡張クラスを作成

まず SampleBehaviour のエディタ拡張クラスを作成します。
従来のエディタ拡張と同様に UnityEditor.Editor を継承し、 CustomEditor 属性で拡張対象の SampleBehavour を指定します。

従来のエディタ拡張では OnInspectorGUI 関数を使用しますが、 UI ToolKit では CreateInspectorGUI 関数を使用します。
CreateInspectorGUI 関数は OnInspectorGUI 関数と違い、エディタ更新のたびには呼ばれず、拡張対象のインスペクタが表示される時に1回のみ呼ばれます。
CreateInspectorGUI 関数では、戻り値として全ての VisualElement の親となる VisualElement を返す必要があります。

using UIToolKitSample.Behaviour;
using UnityEditor;
using UnityEngine.UIElements;

namespace UIToolKitSample.Editor.Inspector
{
    /// <summary>
    /// SampleBehaviourのエディタ拡張クラス
    /// </summary>
    [CustomEditor(typeof(SampleBehaviour))]
    public class SampleBehaviourEditor : UnityEditor.Editor
    {
        /// <summary>
        /// CreateInspectorGUI
        /// </summary>
        /// <returns></returns>
        public override VisualElement CreateInspectorGUI()
        {
            var root = new VisualElement();

            // ここでインスペクタに表示する要素を作成していく
            
            return root;
        }
    }
}

ボタンを追加

次に、出力ボタンを追加していきます。
VisualElement を継承した Button を生成し、 root の子要素として追加します。
VisualElement は親の VisualElement に追加した順に上から表示されます。

/// <summary>
/// CreateInspectorGUI
/// </summary>
/// <returns></returns>
public override VisualElement CreateInspectorGUI()
{
    var root = new VisualElement();

    // Output関数を取得
    var instance = (SampleBehaviour)target;
    const string OUTPUT_METHOD = "Output";
    var outputMethod = instance.GetType().GetMethod(OUTPUT_METHOD, BindingFlags.Instance | BindingFlags.Public);
    if (outputMethod == null) throw new Exception($"{nameof(SampleBehaviour)} クラスに {OUTPUT_METHOD} がありません。");

    // 出力ボタンElementを作成
    var outputButton = new Button(() => outputMethod.Invoke(instance, null));
    outputButton.text = "出力する";
    outputButton.style.width = 70;
    // ルートに出力ボタンElementを追加
    root.Add(outputButton);

    return root;
}

SampleBehaviour のインスペクタに 出力する ボタンが表示されました。

DataTypeのプルダウンを追加

次に DataType のプルダウンを出力ボタンの下に追加します。

/// <summary>
/// CreateInspectorGUI
/// </summary>
/// <returns></returns>
public override VisualElement CreateInspectorGUI()
{
    var root = new VisualElement();

    // 中略...
    // ルートに出力ボタンElementを追加
    root.Add(outputButton);

    // DataTypeプルダウンElementを作成
    var dataTypeField = new EnumField("データの種類", DataType.None);
    // DataTypeプロパティを取得
    var dataTypeProp = serializedObject.FindProperty("_dataType");
    // フィールドにプロパティを紐付け
    dataTypeField.BindProperty(dataTypeProp);
    // ルートにDataTypeプルダウンElementを追加
    root.Add(dataTypeField);

    return root;
}

DataTypeのEnumFieldを生成します。
第1引数にはプルダウンのラベル、第2引数には初期値を渡しています。

// DataTypeプルダウンElementを作成
var dataTypeField = new EnumField("データの種類", DataType.None);

作成したElementに、対応する SerializedProperty を紐づけることで、インスタンスが持つ値とインスペクタの表示が相互に同期されるようになります。

// フィールドにプロパティを紐付け
dataTypeField.BindProperty(dataTypeProp);

SampleBehaviour のインスペクタに データの種類 プルダウンが表示されました。

SampleDataのVisualElementクラスを作成

まだ データの種類 を変更しても何も起きないので、 選択した データの種類 に対応する SampleData を表示するようにします。
このまま SampleBehaviourEditor に書いていくこともできますが、 SampleData 専用のElementクラスを作成して、そのElementを SampleBehaviourEditor から呼び出すようにした方が、見やすく、取り回しやすいコードになると思います。

VisualElement の派生クラスとして SampleDataElement を作っていきます。

using UnityEngine.UIElements;

namespace UIToolKitSample.Editor.Element
{
    /// <summary>
    /// SampleDataのElementクラス
    /// </summary>
    public class SampleDataElement : VisualElement
    {
        /// <summary>
        /// コンストラクタ
        /// </summary>
        public SampleDataElement()
        {
            // ここでインスペクタに表示する要素を作成していく
        }
    }
}

コンストラクタでSampleDataのElementを作成・追加

SampleDataElement クラスのコンストラクタで SampleData クラスのメンバ変数に対応するElementを作成し、 SampleDataElement 自身に追加していきます。

using UnityEngine.UIElements;

namespace UIToolKitSample.Editor.Element
{
    /// <summary>
    /// SampleDataのElementクラス
    /// </summary>
    public class SampleDataElement : VisualElement
    {
        /// <summary>
        /// IDフィールドElement
        /// </summary>
        private readonly IntegerField _idField;

        /// <summary>
        /// 名前フィールドElement
        /// </summary>
        private readonly TextField _nameField;

        /// <summary>
        /// 有効か?トグルElement
        /// </summary>
        private readonly Toggle _enableToggle;

        /// <summary>
        /// スコアフィールドElement
        /// </summary>
        private readonly FloatField _scoreField;
        
        /// <summary>
        /// コンストラクタ
        /// </summary>
        public SampleDataElement()
        {
            // IDフィールドElementを作成
            _idField = new IntegerField("ID");
            // IDフィールドElementを自身に追加
            this.Add(_idField);

            // 名前フィールドElementを作成
            _nameField = new TextField("名前");
            // 名前フィールドElementを自身に追加
            this.Add(_nameField);
            
            // 有効か?トグルElementを作成
            _enableToggle = new Toggle("有効か?");
            // 有効か?トグルElementを作成
            this.Add(_enableToggle);
            
            // スコアフィールドElementを作成
            _scoreField = new FloatField("スコア");
            // スコアフィールドElementを自身に追加
            this.Add(_scoreField);
        }
    }
}

有効か?でスコアを出しわけ

_enableToggle が True の時のみ _scoreField が表示されるようにします。
従来のエディタ拡張では、エディタ更新のたびに _enableToggle の値によって _scoreField を描画するか判定していましたが、 UI ToolKit では、 _enableToggle の値の変更をコールバックで受け取って _scoreField の描画をするか判定します。

/// <summary>
/// コンストラクタ
/// </summary>
public SampleDataElement()
{
    // 中略...
    // スコアフィールドElementを自身に追加
    this.Add(_scoreField);

    // 有効か?トグルの変更時
    _enableToggle.RegisterValueChangedCallback(arg =>
    {
        // 変更後の値を取得
        var enable = arg.newValue;
        // スコアフィールドElementの有効/無効を設定
        _scoreField.SetEnabled(enable);
        // スコアフィールドElementの高さを設定
        _scoreField.style.maxHeight = enable ? float.MaxValue : 0;
    });
}

今回は、_enableField が False の時は _scoreField を無効化し、高さを0にするようにしました。
_scoreField.visible = false; でも非表示にできますが、こちらの方法だと _scoreField の高さ分空白ができてしまいます。

visible による表示/非表示

★クリックして動画を見る★

maxHeight による表示/非表示

★クリックして動画を見る★

フィールドにプロパティを紐づける

作成したフィールドを SampleData クラスの各メンバ変数と紐づける。
コンストラクタの引数で SerializedProperty を受け取って、紐づけまでまとめて行うこともできますが、 ListView クラスなど引数なしのコンストラクタを想定しているものがあったので、紐づけはコンストラクタと分けておいた方が良さそうです。

/// <summary>
/// プロパティを紐づける
/// </summary>
/// <param name="property"></param>
public void BindProperty(SerializedProperty property)
{
    // SampleData._idプロパティを取得
    var idProp = property.FindPropertyRelative("_id");
    // _idプロパティをIDフィールドElementに紐づけ
    _idField.BindProperty(idProp);

    // SampleData._nameプロパティを取得
    var nameProp = property.FindPropertyRelative("_name");
    // _nameプロパティを名前フィールドElementに紐づけ
    _nameField.BindProperty(nameProp);
    
    // SampleData._enableプロパティを取得
    var enableProp = property.FindPropertyRelative("_enable");
    // _enableプロパティを有効か?トグルElementに紐づけ
    _enableToggle.BindProperty(enableProp);
    
    // SampleData._scoreプロパティを取得
    var scoreProp = property.FindPropertyRelative("_score");
    // _scoreプロパティをスコアフィールドElementに紐づけ
    _scoreField.BindProperty(scoreProp);
}

SampleDataをインスペクタに表示

これで SampleDataElement が完成したので、 SampleBehaviourEditor から呼び出していきます。

/// <summary>
/// CreateInspectorGUI
/// </summary>
/// <returns></returns>
public override VisualElement CreateInspectorGUI()
{
    serializedObject.Update();

    var root = new VisualElement();

    // 中略...
    // ルートにDataTypeプルダウンElementを追加
    root.Add(dataTypeField);

    // Data1フィールドElementを作成
    var data1Field = new SampleDataElement();
    // Data1プロパティを取得
    var data1Prop = serializedObject.FindProperty("_data1");
    // フィールドにプロパティを紐づけ
    data1Field.BindProperty(data1Prop);
    // ルードにData1フィールドElementを追加
    root.Add(data1Field);

    // Data2フィールドElementを作成
    var data2Field = new SampleDataElement();
    // Data2プロパティを取得
    var data2Prop = serializedObject.FindProperty("_data2");
    // フィールドにプロパティを紐づけ
    data2Field.BindProperty(data2Prop);
    // ルードにData2フィールドElementを追加
    root.Add(data2Field);

    // Data3フィールドElementを作成
    var data3Field = new SampleDataElement();
    // Data3プロパティを取得
    var data3Prop = serializedObject.FindProperty("_data3");
    // フィールドにプロパティを紐づけ
    data3Field.BindProperty(data3Prop);
    // ルードにData3フィールドElementを追加
    root.Add(data3Field);
            
    serializedObject.ApplyModifiedProperties();

    return root;
}

SampleBehaviour のインスペクタに Data1~3 が表示されました。
ついでにData1~3のデータを設定しました。

データの種類でデータを出しわけ

データの種類 プルダウンで選択したデータのみ表示されるようにしてみます。
dataTypeField.RegisterValueChangedCallback でDataTypeプルダウンの変更時にコールバックを受け取ることができます。

/// <summary>
/// CreateInspectorGUI
/// </summary>
/// <returns></returns>
public override VisualElement CreateInspectorGUI()
{
    var root = new VisualElement();

    // 中略...
    // ルードにData3フィールドElementを追加
    root.Add(data3Field);

    // DataTypeプルダウンの変更時
    dataTypeField.RegisterValueChangedCallback(arg =>
    {
        var dataType = (DataType)arg.previousValue;
        var newValue = arg.newValue;
        if(newValue != null)
        {
            dataType = (DataType)newValue;
        }
        
        var isData1 = dataType is DataType.Data1;
        data1Field.SetEnabled(isData1);
        data1Field.style.maxHeight = isData1 ? float.MaxValue : 0;
        
        var isData2 = dataType is DataType.Data2;
        data2Field.SetEnabled(isData2);
        data2Field.style.maxHeight = isData2 ? float.MaxValue : 0;
        
        var isData3 = dataType is DataType.Data3;
        data3Field.SetEnabled(isData3);
        data3Field.style.maxHeight = isData3 ? float.MaxValue : 0;
    });

    return root;
}

インスペクタ表示時に初回のコールバックを受け取ることができるが、この時は previousValue に設定値、 newValue は null となるので、以下のように、 newValue が null の場合は previousValue を使用するようにしました。

var dataType = (DataType)arg.previousValue;
var newValue = arg.newValue;
if(newValue != null)
{
    dataType = (DataType)newValue;
}

選択したデータの種類でデータを出しわけることができました。

★クリックして動画を表示★

完成

完成したエディタ拡張のコードがこちらです。

using System;
using System.Reflection;
using UIToolKitSample.Behaviour;
using UIToolKitSample.Editor.Element;
using UIToolKitSample.Enum;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;

namespace UIToolKitSample.Editor.Inspector
{
    /// <summary>
    /// SampleBehaviourのエディタ拡張クラス
    /// </summary>
    [CustomEditor(typeof(SampleBehaviour))]
    public class SampleBehaviourEditor : UnityEditor.Editor
    {
        /// <summary>
        /// CreateInspectorGUI
        /// </summary>
        /// <returns></returns>
        public override VisualElement CreateInspectorGUI()
        {
            var root = new VisualElement();

            // Output関数を取得
            var instance = (SampleBehaviour)target;
            const string OUTPUT_METHOD = "Output";
            var outputMethod = instance.GetType().GetMethod(OUTPUT_METHOD, BindingFlags.Instance | BindingFlags.Public);
            if (outputMethod == null) throw new Exception($"{nameof(SampleBehaviour)} クラスに {OUTPUT_METHOD} がありません。");

            // 出力ボタンElementを作成
            var outputButton = new Button(() => outputMethod.Invoke(instance, null));
            outputButton.text = "出力する";
            outputButton.style.width = 70;
            // ルートに出力ボタンElementを追加
            root.Add(outputButton);

            // DataTypeプルダウンElementを作成
            var dataTypeField = new EnumField("データの種類", DataType.None);
            // DataTypeプロパティを取得
            var dataTypeProp = serializedObject.FindProperty("_dataType");
            // フィールドにプロパティを紐づけ
            dataTypeField.BindProperty(dataTypeProp);
            // ルートにDataTypeプルダウンElementを追加
            root.Add(dataTypeField);

            // Data1フィールドElementを作成
            var data1Field = new SampleDataElement();
            // Data1プロパティを取得
            var data1Prop = serializedObject.FindProperty("_data1");
            // フィールドにプロパティを紐づけ
            data1Field.BindProperty(data1Prop);
            // ルードにData1フィールドElementを追加
            root.Add(data1Field);

            // Data2フィールドElementを作成
            var data2Field = new SampleDataElement();
            // Data2プロパティを取得
            var data2Prop = serializedObject.FindProperty("_data2");
            // フィールドにプロパティを紐づけ
            data2Field.BindProperty(data2Prop);
            // ルードにData2フィールドElementを追加
            root.Add(data2Field);

            // Data3フィールドElementを作成
            var data3Field = new SampleDataElement();
            // Data3プロパティを取得
            var data3Prop = serializedObject.FindProperty("_data3");
            // フィールドにプロパティを紐づけ
            data3Field.BindProperty(data3Prop);
            // ルードにData3フィールドElementを追加
            root.Add(data3Field);

            // DataTypeプルダウンの変更時
            dataTypeField.RegisterValueChangedCallback(arg =>
            {
                var dataType = (DataType)arg.previousValue;
                var newValue = arg.newValue;
                if(newValue != null)
                {
                    dataType = (DataType)newValue;
                }
                
                var isData1 = dataType is DataType.Data1;
                data1Field.SetEnabled(isData1);
                data1Field.style.maxHeight = isData1 ? float.MaxValue : 0;
                
                var isData2 = dataType is DataType.Data2;
                data2Field.SetEnabled(isData2);
                data2Field.style.maxHeight = isData2 ? float.MaxValue : 0;
                
                var isData3 = dataType is DataType.Data3;
                data3Field.SetEnabled(isData3);
                data3Field.style.maxHeight = isData3 ? float.MaxValue : 0;
            });

            return root;
        }
    }
}
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;

namespace UIToolKitSample.Editor.Element
{
    /// <summary>
    /// SampleDataのElementクラス
    /// </summary>
    public class SampleDataElement : VisualElement
    {
        /// <summary>
        /// IDフィールドElement
        /// </summary>
        private readonly IntegerField _idField;

        /// <summary>
        /// 名前フィールドElement
        /// </summary>
        private readonly TextField _nameField;

        /// <summary>
        /// 有効か?トグルElement
        /// </summary>
        private readonly Toggle _enableToggle;

        /// <summary>
        /// スコアフィールドElement
        /// </summary>
        private readonly FloatField _scoreField;
        
        /// <summary>
        /// コンストラクタ
        /// </summary>
        public SampleDataElement()
        {
            // IDフィールドElementを作成
            _idField = new IntegerField("ID");
            // IDフィールドElementを自身に追加
            this.Add(_idField);

            // 名前フィールドElementを作成
            _nameField = new TextField("名前");
            // 名前フィールドElementを自身に追加
            this.Add(_nameField);
            
            // 有効か?トグルElementを作成
            _enableToggle = new Toggle("有効か?");
            // 有効か?トグルElementを作成
            this.Add(_enableToggle);
            
            // スコアフィールドElementを作成
            _scoreField = new FloatField("スコア");
            // スコアフィールドElementを自身に追加
            this.Add(_scoreField);

            // 有効か?トグルの変更時
            _enableToggle.RegisterValueChangedCallback(arg =>
            {
                // 変更後の値を取得
                var enable = arg.newValue;
                // スコアフィールドElementの有効/無効を設定
                _scoreField.SetEnabled(enable);
                // スコアフィールドElementの高さを設定
                _scoreField.style.maxHeight = enable ? float.MaxValue : 0;
            });
        }

        /// <summary>
        /// プロパティを紐づける
        /// </summary>
        /// <param name="property"></param>
        public void BindProperty(SerializedProperty property)
        {
            // SampleData._idプロパティを取得
            var idProp = property.FindPropertyRelative("_id");
            // _idプロパティをIDフィールドElementに紐づけ
            _idField.BindProperty(idProp);

            // SampleData._nameプロパティを取得
            var nameProp = property.FindPropertyRelative("_name");
            // _nameプロパティを名前フィールドElementに紐づけ
            _nameField.BindProperty(nameProp);
            
            // SampleData._enableプロパティを取得
            var enableProp = property.FindPropertyRelative("_enable");
            // _enableプロパティを有効か?トグルElementに紐づけ
            _enableToggle.BindProperty(enableProp);
            
            // SampleData._scoreプロパティを取得
            var scoreProp = property.FindPropertyRelative("_score");
            // _scoreプロパティをスコアフィールドElementに紐づけ
            _scoreField.BindProperty(scoreProp);
        }
    }
}

おわりに

個人的には従来のエディタ拡張よりも UI ToolKit の方が作りやすいかなー、と感じました。
XMLやCSSは苦手だから UI ToolKit に手を出せていない方も、まずはC#で書くところから始めてみてはいかがでしょうか?

この記事が気に入ったらサポートをしてみませんか?