UIElementsで簡単なエディタ拡張を作る

UIElementsとお友達になりたいので、簡単なエディタ拡張を作ってみました。

今回作るサンプルは次の画像のようにインスペクターで ScriptableObject の要素を表示して、同オブジェクトの関数を実行するボタンを表示するものです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/31480/ed9cfbdd-a534-1efc-8a3b-7cf6ac1b10a1.png)
元となる ScriptableObject は次のようなものです。

```SpreadSheetImporter.cs
using UnityEngine;

namespace Gok.SpreadSheetImporter
{
    [CreateAssetMenu(menuName = "Create Spread Sheet Importer")]
    public class SpreadSheetImporter : ScriptableObject
    {
        [SerializeField] private string account = "";
        [SerializeField] private string password = "";

        public void Connect()
        {
            Debug.Log("connect");
        }
    }
}
```

### 注意事項

- マニュアルを読みきらずに書いているのでもっと良いやり方があるかもしれません
- HTMLなどの知識全然ないのでもっと良い（ry
- エディタ拡張全く触ったことない方は読むの辛いかもしれません

## これまでの方法を流用する場合

今回は専用のウィンドウを作成するのではなく、インスペクターの拡張を行います。
UIElements で通常の表示を行う最小のコードは（多分）次の通りです。

```SpreadSheetImporterEditor.cs
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine.UIElements;

namespace Gok.SpreadSheetImporter
{
    [CustomEditor(typeof(SpreadSheetImporter))]
    public class SpreadSheetImporterEditor : Editor
    {        
        public override VisualElement CreateInspectorGUI()
        {
            var root = new VisualElement();
            root.Add(new IMGUIContainer(() => DrawDefaultInspector()));         
            return root;
        }
    }
}
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/31480/e2f6ddd9-502a-b7cb-003c-d6cce8d2d9aa.png)
`IMGUIContainer` は今までのエディタ拡張で使っている IMGUI のパーツを入れるためのコンテナで、これを使うと IMGUI のパーツを VisualElement として扱えます。今回はこちらを VisualTree に追加してそれを返しています。 `CreateInspectorGUI` で VisualElement を返すと、その要素が描画に使われます。

Visual Tree とは、UIElements とは…みたいな概要は次のスライドにまとまっています。この後登場する UXML や USS の話もあります。

[【Unite 2018 Tokyo】エディター拡張マニアクス2018](https://www.slideshare.net/UnityTechnologiesJapan/unite-2018-tokyo2018-96373327/19)

また、次の記事も参考にさせていただいています。こちらもまず読んでいただいた方が良いと思います。

[まだ人類には早すぎるUIElements事始め](https://qiita.com/enrike3/items/286977f46d842e4a90e5)

今回はこちらの方式は使わず、 UXML を読み込んで表示することにします。

## UXMLの利用とバインド

どのような要素を表示するか決める UXML と、余白など指定する USS は専用のウィンドウから作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/31480/278245b5-c1fa-9512-b20f-5487d9ededd6.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/31480/f3c619e2-a6cc-41f7-1d0d-c6406ecf14d5.png)

私の場合すでに C# のスクリプトは作成していたので下の2つだけ作りました。Editor フォルダの下にそれぞれ専用のフォルダを作って移動したら、まずは `SpreadSheetImporter.uxml` の内容を書き換えます。

```SpreadSheetImporter.uxml
<?xml version="1.0" encoding="utf-8"?>
<UXML xmlns:engine="UnityEngine.UIElements"
      xmlns:editor="UnityEditor.UIElements">
    
    <engine:Box>
        <editor:PropertyField binding-path="account" />
        <engine:PropertyField binding-path="password" />
        <engine:Button name="connect-button" text="Connect" />
    </engine:Box>
</UXML>
```

定型文なども含まれていますが、いくつかポイントかもと思ったところをピックアップします。

まず、 `xmlns:engine="UnityEngine.UIElements"`。その下にある editor もそうですが、これはエイリアスです。各GUI要素は UnityEngine.UIElements か UnityEditor.UIElements の下にあり、それを使う時に毎回フルパスを書くのはめんどくさいので、最初に略してこういう名称で呼び出すぞ、というのが指定できるようです。 C# の名前空間の using みたいなものかなと。

各 GUI パーツがどこに属しているかは次のマニュアルに載っています。
[UXML elements reference - Unity Manual](https://docs.unity3d.com/Manual/UIE-ElementRef.html)

`Namespace` の列がそれです。その要素を呼び出すためには `対応するエイリアス:要素名` という感じで書きます。今回は関連する要素をまとめるために < engine:Box> というように、 Box 要素で囲ってみました。

PropertyField は EditorGUILayout でも同名の関数があるのでなんとなく分かるかもしれませんが、バインドされたプロパティに基づいた表示をよしなにしてくれます。 string ならプロパティ名とテキストフィールドがセットで表示してくれる、という感じです。どのような要素があるかも、上のマニュアルに記載されています。

その中に書かれている `binding-path` は以前の方式でいうところの FindProperty ですが、詳しくは上記の UIElements事始め 記事を読んでいただくのが良いと思います。

あと、上記の記事ではメニューから `Update UI Elements Schema` を選択して、uxml側でスキーマのパスを指定すると Visual Studio などで補完が効くようになるとのことでした。これは 2019.2 現在、スキーマのパスはファイル作成時に自動的に入力されるようになっているようです。ただ、 Rider がまだ対応していないっぽいので今回は該当部分は消しました。
各要素は末尾に / を入れないとエラーで表示されなくなりますが、補完が効いたらそうした事故を防げると思います（１敗)

次に C# スクリプトの修正です。

```SpreadSheetImporterEditor.cs
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine.UIElements;

namespace Gok.SpreadSheetImporter
{
    [CustomEditor(typeof(SpreadSheetImporter))]
    public class SpreadSheetImporterEditor : Editor
    {
        private const string UxmlPath = "Assets/Editor/UXML/SpreadSheetImporter.uxml";
        
        public override VisualElement CreateInspectorGUI()
        {
            var root = new VisualElement();
            root.Bind(serializedObject);

            var tree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(UxmlPath);
            root.Add(tree.CloneTree());

            return root;
        }
    }
}

```

root.Bind で今回インスペクターの表示を変更する対象の SerializedObject をバインドしています。
あとは、表示に使う uxml を読み込んで、 Visual Tree に追加しています。

ここまでで表示はこうなります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/31480/e7d44d30-9b8c-e9c0-cced-c57f24d68192.png)

ボックスで囲えてはいるのですが、余白が全くなくてアレなので次は USS でここを改善してみます。

## USS で見た目を調整

USS では見た目についてピクセル単位で調整したりフォントサイズを変更できたりします。
サポートされているプロパティ一覧は次のマニュアルにまとまっています。

[USS supported properties - Unity Manual](https://docs.unity3d.com/Manual/UIE-USS-SupportedProperties.html)

英語だとちょっと読むのがしんどいですが、2020.xで UIElements がランタイムでサポートされるようになったときにかなりお世話になりそうな感じがしています。

さて、先ほど作成した `SpreadSheetImporter.uss` を次のように修正します。

```SpreadSheetImporter.uss
.box__field {
    padding: 4px 4px 2px
}
```

`.box__field` というクラスを利用する uxml 要素では余白を 上、左右では4px、下には2px 確保してね、という内容が書かれています。

uxml も修正します。

```SpreadSheetImporter.uxml
<?xml version="1.0" encoding="utf-8"?>
<UXML xmlns:engine="UnityEngine.UIElements"
      xmlns:editor="UnityEditor.UIElements">
    
    <engine:Box class="box__field">
        <editor:PropertyField binding-path="account" />
        <editor:PropertyField binding-path="password" />
        <engine:Button name="connect-button" text="Connect" />
    </engine:Box>
</UXML>
```

`class="box__field"` という部分が追加されました。

最後に C# の修正です。

```SpreadSheetImporterEditor.cs
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine.UIElements;

namespace Gok.SpreadSheetImporter
{
    [CustomEditor(typeof(SpreadSheetImporter))]
    public class SpreadSheetImporterEditor : Editor
    {
        private const string UxmlPath = "Assets/Editor/UXML/SpreadSheetImporter.uxml";
        private const string UssPath = "Assets/Editor/USS/SpreadSheetImporter.uss";
        
        /// <summary>
        /// インスペクターの要素を記述
        /// Serialized Object のバインドなどの適用順は結果に影響しない模様
        /// </summary>
        public override VisualElement CreateInspectorGUI()
        {
            var root = new VisualElement();
            root.Bind(serializedObject);

            var tree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(UxmlPath);
            root.Add(tree.CloneTree());
            
            var style = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
            root.styleSheets.Add(style);

            return root;
        }
    }
}

```

スタイルの適用方法が 2018.3 と比較して `root.styleSheets.Add()` という感じに変わっているので注意が必要です。

ここまでで表示はこうなります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/31480/1160369d-3af7-e6af-f6e6-f65848c90de7.png)
Password がモロに見えているのはまずいので修正します。

## TextField への置き換え

uxml を次のように書き換えます。

```SpreadSheetImporter.uxml
<?xml version="1.0" encoding="utf-8"?>
<UXML xmlns:engine="UnityEngine.UIElements"
      xmlns:editor="UnityEditor.UIElements">
    
    <engine:Box class="box__field">
        <editor:PropertyField binding-path="account" />
        <engine:TextField label="Password" password="true" binding-path="password" />
        <engine:Button name="connect-button" text="Connect" />
    </engine:Box>
</UXML>
```

PropertyField が `<engine:TextField label="Password" password="true" binding-path="password" />` というように変わりました。利用する空間が editor から engine に変わっているのが地味に注意ポイントです。
TextField では `password` 属性を true にすると文字列が隠されるようになります。  TextField では label を指定しないと要素名が表示されなくなるので注意が必要です。各属性については先ほども紹介した[マニュアル](https://docs.unity3d.com/Manual/UIE-ElementRef.html)の Attribute の列に説明があります。

先ほどの余白の時もそうですが uxml uss は編集後コンパイルなしで結果が確認できるので、比較的ストレスなく試行錯誤できるのが良いなーと弄っていて思いました。

ここまでで表示はこうなります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/31480/8c3500e0-296a-d3e4-f010-f556bf7510ac.png)

見た目はこれで完了です。
次で最後ですが、飾りになっているボタンから関数を実行できるようにします。

## ボタンにイベントを登録

SpreadSheetImporterEditor を次のように修正します。

```SpreadSheetImporterEditor.cs
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine.UIElements;

namespace Gok.SpreadSheetImporter
{
    [CustomEditor(typeof(SpreadSheetImporter))]
    public class SpreadSheetImporterEditor : Editor
    {
        private const string UxmlPath = "Assets/Editor/UXML/SpreadSheetImporter.uxml";
        private const string UssPath = "Assets/Editor/USS/SpreadSheetImporter.uss";
        
        /// <summary>
        /// インスペクターの要素を記述
        /// Serialized Object のバインドなどの適用順は結果に影響しない模様
        /// </summary>
        public override VisualElement CreateInspectorGUI()
        {
            var root = new VisualElement();
            root.Bind(serializedObject);

            var tree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(UxmlPath);
            root.Add(tree.CloneTree());
            
            // ボタンイベントの登録
            root.Q<Button>("connect-button")
                .clickable.clicked += Connect;
            
            var style = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
            root.styleSheets.Add(style);

            return root;
        }

        private void Connect()
        {
            var importer = serializedObject.targetObject as SpreadSheetImporter;
            if (importer != null) importer.Connect();
        }
    }
}
```

`root.Q<Button>("connect-button").clickable.clicked += Connect;`

の行でボタンを探し、クリックイベントに実行する関数を登録しています。

Query<Button>("connect-button").First() と Q<Button>("connect-button") は処理結果は同じようです。登録された VisualElement の中から、 connect-button という名前の最初のボタンを探すっぽいです。

この要素を探す機能は UQuery といいます。詳しくは[マニュアル](https://docs.unity3d.com/Manual/UIE-UQuery.html)や、UIElements事始め記事を参照してください。

これでボタンを押したら元の ScriptableObject にある関数が呼ばれるようになりました！

### 感想

あまり日本語の情報がないのが辛いのでマニュアルが日本語化されて日本語記事が増えると嬉しいなーと思いました。
あと、ランタイムが対応したら遅かれ早かれ触ることになると思い触ってみたのですが、以外とこれまでのエディタ拡張の知識は役に立つかもという印象でした。HTMLとかもいまのうちに触って慣れておきたいです。