【Unity】UIElements入門 - 概念~基本的な使い方まとめ

UnityのUIElementsの基本的な使い方をまとめました。

Unity2019.3.0

UIElementsとは?

UIElementsの概念を説明する上で、まずUnityでUIを組み立てる方法について振り返ります。
UnityでUIを組み立てる方法には以下の3つがあります。

  1. IMGUIを使う
  2. uGUIを使う
  3. UIElementsを使う

まずIMGUIは最も古い手法で、以下のようにソースコードベースでUIを組んでいきます。

private void OnGUI()
{
    if (GUILayout.Button("ボタン")) {
        Debug.Log("クリック");
    }
}

今日ではランタイムで使われることはほぼありませんが、エディタ拡張ではこれを使ってUIを組みます。

次にuGUIです。
uGUIを使うとヒエラルキーを使って直感的にUIを組めます。
ランタイムのUIを構築するためには基本的にこれを使います。

f:id:halya_11:20200220123814p:plain:w450

そしてUnity2019.1からUIElementsが登場しました。
UIElementsはWebでよく用いられる、CSSXMLを使ったUIの構築方法によく似た手法です。
見た目の装飾にはUSSと呼ばれるスタイルシートを使い、階層構造の定義にはUXMLという形式を使います。

f:id:halya_11:20200220125840p:plain

この各ファイルにより責務が分離され、より複雑なUIがよりシンプルに組めるようになりました。
特にエディタではIMGUIしか使えなかったので、これらすべてを同じcsファイルに書く必要があり複雑になりがちだったので、UIElementsの導入は大きなメリットがあるといえそうです。

UIElementsは現時点ではエディタ用のUIにのみ使えますが、今後はランタイムのUIにも使用できるようにする方針のようです。
またIMGUIやuGUIに関しても引き続きメンテナンスは行われ、近い将来に廃止するという予定はないとのことです。

UIElementsを使ったエディタウィンドウを作成する

さてそれでは早速UIElementsを使ってみます。
まずはUSSもUXMLも使わずに、ソースコードベースでエディタウィンドウを組んでみます。

using UnityEditor;
using UnityEngine.UIElements;

public class Example : EditorWindow
{
    [MenuItem("Window/Example")]
    public static void ShowWindow() => GetWindow<Example>("Example");
    
    private void OnEnable()
    {
        // ルートとなるVisualElementを取得
        var root = rootVisualElement;

        // Boxを生成
        var box = new Box();

        // Boxの子としてボタンを追加
        box.Add(new Button(){
            text = "Example Button"
        });

        // Boxの子としてラベルを追加
        box.Add(new Label(){
            text = "Example Label"
        });

        // ルートの子としてBoxを追加
        root.Add(box);
    }
}

UnityEngine.UIElementsをusingすることで、BoxやButtonなどのUIElementsのUI要素を表すクラスが使えるようになります。
上記のソースコードでは、Boxの中にButtonとLabelが入っているUIを組んでいます。

f:id:halya_11:20200220005552p:plain

UIの階層構造をUXMLファイルに分離する

次にUIの階層構造の定義をUXMLファイルに分離していきます。

UXMLファイルを作るにはProjectウィンドウで右クリック > Create > UI Elements > UXML Templateを選択します。
下記のような内容のファイルが生成されるので、名前をuxml_exampleとして、Assets/Editor/Resources配下に配置しておきます。

<?xml version="1.0" encoding="utf-8"?>
<engine:UXML
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:engine="UnityEngine.UIElements"
    xmlns:editor="UnityEditor.UIElements"
    xsi:noNamespaceSchemaLocation="../../UIElementsSchema/UIElements.xsd"
>
    
</engine:UXML>

この自動生成のコードはテンプレなのでひとまずは理解しなくても大丈夫です。
次にこのUXMLファイルを以下のように変更します。

<?xml version="1.0" encoding="utf-8"?>
<engine:UXML
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:engine="UnityEngine.UIElements"
    xmlns:editor="UnityEditor.UIElements"
    xsi:noNamespaceSchemaLocation="../../UIElementsSchema/UIElements.xsd"
>

  <!-- 階層構造の定義を追加 -->
  <engine:Box>
    <engine:Button text="Example Button"/>
    <engine:Label text="Example Label"/>
  </engine:Box>

</engine:UXML>

Boxの中にButtonとLabelを定義しています。

engine:と書いているのはnamespace prefixという名前空間を明示する機能です。
今回の例では、Boxであれば本来はUnityEngine.UIElements:Boxと書かなければいけないところを、
xmlns:engine="UnityEngine.UIElements"を定義することでengine:Boxと書けるようにしています。

次にスクリプトを以下のように修正します。

using UnityEngine;
using UnityEditor;
using UnityEngine.UIElements;

public class Example : EditorWindow
{
    [MenuItem("Window/Example")]
    public static void ShowWindow() => GetWindow<Example>("Example");
    
    private void OnEnable()
    {
        var root = rootVisualElement;

        // 先ほど作ったUXMLファイルを読み込む
        var visualTree = Resources.Load<VisualTreeAsset>("uxml_example");

        // UXMLファイルで定義した階層構造を適用する
        visualTree.CloneTree(root);
    }
}

まずResources.Load<VisualTreeAsset>()で先ほど作ったUXMLファイルを読み込んでいます。
AssetDatabaseで読み込んでもOKですが、配置場所の変更に弱くなるのでResourcesがおススメです。

次にVisualTreeAsset.CloneTree()によりこの階層構造をウィンドウのUIとして適用しています。

ウィンドウを開くと以下のように正常に表示されることが確認できます。

f:id:halya_11:20200220005552p:plain

Styleを設定する

さて次にこれらのUI要素を装飾していきます。
装飾するにはUXMLでstyle属性を指定します。

<?xml version="1.0" encoding="utf-8"?>
<engine:UXML
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:engine="UnityEngine.UIElements"
    xmlns:editor="UnityEditor.UIElements"
    xsi:noNamespaceSchemaLocation="../../UIElementsSchema/UIElements.xsd"
>

  <engine:Box style="width: 200px"> <!-- Boxの横幅を200pxに -->
    <engine:Button text="Example Button"/>
    <engine:Label text="Example Label" style="color: red"/> <!-- Labelの色を赤に -->
  </engine:Box>

</engine:UXML>

コメントの通りですが、ここではBoxの横幅を200pxにしてLabelの色を赤くしました。

ウィンドウを開きなおすと、以下のようにStyleが適用されていることが確認できます。

f:id:halya_11:20200220011344p:plain

StyleをUSSファイルに切り出す

次に前節で設定したStyleを別のUSSファイルとして切り出します。

まずProjectウィンドウで右クリック > Create > UI Elements > USS FileからUSSファイルを作成します。
uss_exampleと命名し、UXMLファイルと同様Assets/Editor/Resourcesフォルダ配下に格納します。

ファイルの中身は以下のように書き換えます。

.example_box {
    width: 200px;
}

.example_label {
    color: red;
}

上記のコードにおいてexample_boxやexample_labelはStyleの名前を示し、中括弧内がスタイルの定義を表します。
次にUXMLで各UIの要素とこのStyleを紐づけます。

<?xml version="1.0" encoding="utf-8"?>
<engine:UXML
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:engine="UnityEngine.UIElements"
    xmlns:editor="UnityEditor.UIElements"
    xsi:noNamespaceSchemaLocation="../../UIElementsSchema/UIElements.xsd"
>

  <engine:Box class="example_box"> <!-- example_boxスタイルと紐づけ -->
    <engine:Button text="Example Button"/>
    <engine:Label text="Example Label" class="example_label"/> <!-- example_labelスタイルと紐づけ -->
  </engine:Box>

</engine:UXML>

Styleを指定するには上記のようにclass属性を使います。
他の方法もありますが、ひとまずclassを覚えておけばよいかと思います。

最後に先ほど作ったUSSファイルをスクリプトから読み込みます。

using UnityEngine;
using UnityEditor;
using UnityEngine.UIElements;

public class Example : EditorWindow
{
    [MenuItem("Window/Example")]
    public static void ShowWindow() => GetWindow<Example>("Example");
    
    private void OnEnable()
    {
        var root = rootVisualElement;

        // スタイルシートを読み込んで適用する
        root.styleSheets.Add(Resources.Load<StyleSheet>("uss_example"));

        var visualTree = Resources.Load<VisualTreeAsset>("uxml_example");
        visualTree.CloneTree(root);
    } 
}

スタイルシートはStyleSheet型でUSSファイルをロードすることで取得できます。
取得したスタイルシートVisualElement.styleSheeets.Add()で適用できます。

ウィンドウを開きなおすと、以下のようにStyleが正常に適用されていることが確認できます。

f:id:halya_11:20200220011344p:plain

要素をスクリプトから取得して操作する

次に要素をスクリプトからコントロールしてみます。
今回はボタンを取得して、クリック時のイベントを登録します。

ボタンを取得するにはまずUXMLのname属性を使ってボタンに名前を付けます。

<?xml version="1.0" encoding="utf-8"?>
<engine:UXML
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:engine="UnityEngine.UIElements"
    xmlns:editor="UnityEditor.UIElements"
    xsi:noNamespaceSchemaLocation="../../UIElementsSchema/UIElements.xsd"
>

  <engine:Box class="example_box">
    <engine:Button text="Example Button" name="button01"/> <!-- button01という名前を付ける -->
    <engine:Label text="Example Label" class="example_label"/>
  </engine:Box>

</engine:UXML>

次にこの名前を使ってスクリプトから呼び出します。

using UnityEngine;
using UnityEditor;
using UnityEngine.UIElements;

public class Example : EditorWindow
{
    [MenuItem("Window/Example")]
    public static void ShowWindow() => GetWindow<Example>("Example");
    
    private void OnEnable()
    {
        var root = rootVisualElement;
        root.styleSheets.Add(Resources.Load<StyleSheet>("uss_example"));
        var visualTree = Resources.Load<VisualTreeAsset>("uxml_example");
        visualTree.CloneTree(root);

        // Queryを使うと特定の型の要素を全て取得できる
        root.Query<Button>()
            .ForEach(x => {
                if(x.name == "button01"){
                    x.clickable.clicked += () => Debug.Log(x.name);
                }
            });

        // 上記のクエリは以下のようにも書ける
        //var button = root.Q<Button>("button01");
        //button.clickable.clicked += () => Debug.Log(button.name);
    }
}

UI要素を取得するにはVisualElement.Query<T>()を使います。
取得後はLINQのようにして使えます。

また上記ソースコードの下部に書いたように、一つの要素だけを取得したい場合にはVisualElement.Q<T>()を使うと便利です。

このウィンドウを開いてボタンを押下すると以下のようにログ出力されることが確認できます。

f:id:halya_11:20200220013852p:plain

UXMLの一部の要素をさらに別ファイルに分割する

ここで、前節のUXMLのボタン部分をさらに別ファイルに分割してみます。
このやり方を知っておくと、UIが複雑になってきたときに非常に便利です。

まず新しくUXMLファイルを作成し、uxml_example_button.uxmlと命名します。
これをAssets/Editor/Resourcesに配置して、中身を以下のように書き換えます。

<?xml version="1.0" encoding="utf-8"?>
<engine:UXML
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:engine="UnityEngine.UIElements"
    xmlns:editor="UnityEditor.UIElements"
    xsi:noNamespaceSchemaLocation="../../../../UIElementsSchema/UIElements.xsd"
>

  <engine:Button text="Example Button" name="button01"/>

</engine:UXML>

ボタンが一つ定義されているだけのUXMLファイルです。
次にuxml_example.uxmlを以下のように書き換えます。

<?xml version="1.0" encoding="utf-8"?>
<engine:UXML
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:engine="UnityEngine.UIElements"
    xmlns:editor="UnityEditor.UIElements"
    xsi:noNamespaceSchemaLocation="../../UIElementsSchema/UIElements.xsd"
>

  <!-- Template要素のpath属性に読み込みたいUXMLファイルの名前を指定する -->
  <engine:Template path="uxml_example_button" name="button-template" />
  <engine:Box class="example_box">
    <!-- Instance要素を使って上記で読み込んだテンプレートを展開する -->
    <engine:Instance template="button-template"/>
    <engine:Label text="Example Label" class="example_label"/>
  </engine:Box>

</engine:UXML>

上記ではまずTemplate要素を使うことで、ボタン用のUXMLファイルを読み込んでいます。
このテンプレートにはname属性を使ってbutton-templateという名前を付けておきます。
そしてボタンを配置したい場所でInstance要素のname属性にbutton-templateを指定することで、このテンプレートを展開します。

ちなみに今回のようにTemplate要素のpath属性にUXMLファイル名を指定すると、Resourcesからそのファイルを探します。
この場合には指定するファイル名に拡張子を付けてはいけないので注意してください。

またpathとは別にsrcという属性もあり、これを使うとプロジェクトまたはUXMLファイルからの相対パスでファイルを指定できます。
src属性を使う場合には、pathとは異なりファイルパスに拡張子を付けないとエラーになるので注意が必要です。

さてこのウィンドウを開くと以下のように表示されます。

f:id:halya_11:20200220014610p:plain

正常に表示されていることが確認できました。

データバインディングする

ここで、ボタンを押すたびにラベルのテキストが切り替わるようにしてみます。

f:id:halya_11:20200220162711g:plain

このようなケースではデータバインディングを使って、UI要素と変数を紐づけます。

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

public class Example : EditorWindow
{
    [MenuItem("Window/Example")]
    public static void ShowWindow() => GetWindow<Example>("Example");

    // この文字列とラベルのテキストを紐づける
    [SerializeField]
    private string _bindingText;

    private int _clickCount = 0;
    
    private void OnEnable()
    {
        var root = rootVisualElement;
        root.styleSheets.Add(Resources.Load<StyleSheet>("uss_example"));
        var visualTree = Resources.Load<VisualTreeAsset>("uxml_example");
        visualTree.CloneTree(root);
        
        // このクラスのSerializeFieldとVisualElementをバインドする
        root.Bind(new SerializedObject(this));
        // クリックされるたびにバインドされた文字列に変更を加える
        root.Q<Button>().clickable.clicked += () => {
            _bindingText = $"{_clickCount++}";
        };
    }

データバインディングを行うにはまずバインドするためのシリアライズ可能な変数を定義します。
そしてVisualElement.Bind()を使うことでバインド処理を行います。

もちろんUXML側にもこの変数名の情報を持たせておく必要があります。

<?xml version="1.0" encoding="utf-8"?>
<engine:UXML
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:engine="UnityEngine.UIElements"
    xmlns:editor="UnityEditor.UIElements"
    xsi:noNamespaceSchemaLocation="../../UIElementsSchema/UIElements.xsd"
>

  <engine:Box class="example_box">
    <engine:Button text="Example Button"/>
    <!-- バインド対象の変数名をbinding-path属性で指定 -->
    <engine:Label text="Example Label" class="example_label" binding-path="_bindingText"/>
  </engine:Box>

</engine:UXML>

バインド対象の変数名をUXMLで指定するには上記のようにbinding-path属性を使います。
ここまで実装したらウィンドウの挙動を確認してみます。

f:id:halya_11:20200220162711g:plain

正常に変数がバインドされテキストが切り替わることが確認できました。

IMGUIと組み合わせて使う

UIElementsは従来のIMGUIと組み合わせて使うこともできます。
IMGUIと組み合わせるには、以下のようにIMGUIContainerクラスを使用します。

using UnityEngine;
using UnityEditor;
using UnityEngine.UIElements;

public class Example : EditorWindow
{
    [MenuItem("Window/Example")]
    public static void ShowWindow() => GetWindow<Example>("Example");
    
    private void OnEnable()
    {
        var root = rootVisualElement;

        // IMGUIContainerを使ってIMGUIでUIを描画
        var imguiContainer = new IMGUIContainer(() => {
            GUILayout.Button("Example");
        });

        root.Add(imguiContainer);
    }
}

IMGUIContainerはVisualElementを継承しているクラスなので、上記のようにこれを他のVisualElementにAddすればOKです。

見た目上の状態を保存するviewDataKey

さて、UIElementのFoldoutクラスを使うと以下のように折り畳みのビューが表示できます。

f:id:halya_11:20200220112546p:plain

ここで、コンパイルが走ったりエディタを再起動したりするとこのビューの折り畳み状態がリセットされてしまいます。
この折り畳み状態やスクロール位置など、ビューだけに関わる状態を保持したい場合にはUIElement.viewDataKeyを使います。

using UnityEngine;
using UnityEditor;
using UnityEngine.UIElements;

public class Example : EditorWindow
{
    [MenuItem("Window/Example")]
    public static void ShowWindow() => GetWindow<Example>("Example");
    
    private void OnEnable()
    {
        var root = rootVisualElement;
        var foldout = new Foldout(){ text = "Example Foldout" };

        // viewDataKeyに任意のキーを入れて折り畳み状態を保持させる
        foldout.viewDataKey = "example_foldout";

        for (int i = 0; i < 10; i++) {
            foldout.Add(new Button() { text = $"Example Button "});
        }
        root.Add(foldout);        
    }

上記のようにVisualElement.viewDataKeyに任意の文字列を入れておくことで、それをキーにして状態が保持されます。
コンパイルを走らせたりエディタを再起動しても下図のように折り畳み状態が保持されていることが確認できました。

f:id:halya_11:20200220112601p:plain

Inspector拡張にUIElementsを使う

ここまではEditorWindowに対してUIElementを使う方法を紹介してきましたが、UIElementsはInspectorの拡張にも使えます。
Inspector拡張に使うには従来のEditor.OnInspectorGUI()ではなくEditor.CreateInspectorGUI()をオーバーライドします。

using UnityEngine;
using UnityEditor;
using UnityEngine.UIElements;

public class InspectorExample : MonoBehaviour
{
    public int test;
}

[CustomEditor(typeof(InspectorExample))]
public class InspectorExampleEditor : Editor
{
    // OnInspectorGUIではなくCreateInspectorGUIを使う
    public override VisualElement CreateInspectorGUI()
    {
        // Inspector拡張の場合、VisualElementはnewする
        var root = new VisualElement();

        // デフォルトのInspector表示を追加
        IMGUIContainer defaultInspector = new IMGUIContainer(() => DrawDefaultInspector());
        root.Add(defaultInspector);

        // カスタム用のVisualElementを追加
        root.styleSheets.Add(Resources.Load<StyleSheet>("uss_example"));
        var visualTree = Resources.Load<VisualTreeAsset>("uxml_example");
        visualTree.CloneTree(root);

        return root;
    }
}

CreateInspectorGUIの中ではVisualElementをnewして、そこにVisualElementをAddしていきます。
上記のソースコードを適用すると以下のようなInspector表示になります。

f:id:halya_11:20200220014659p:plain

正常にInspectorが拡張できていることを確認できました。

参考

blogs.unity3d.com

docs.unity3d.com