コンテンツへスキップ

QualiArtsengineer blog

Unity 新UIシステムUI Toolkit 触ってみた

Unity 新UIシステムUI Toolkit 触ってみた

13 min read

サイバーエージェントのゲーム・エンターテイメント事業部(SGE)に所属する子会社QualiArtsでUnityエンジニアをしている篠木です。本記事はQualiArtsの定期ブログ「QualiArts Tech Note」第9弾の記事となります。QualiArtsでは会社で使われている様々な技術の知見をブログで紹介しています。興味のある方は他の記事もチェックしてみてください。

UI Toolkit とは

「UI Toolkit(旧称:UI Elements)」とは、Unityの新UIシステムです。Editor UI、Runtime UIの両方で使えるように設計されています。EditorはUnity 2019から正式機能に、Runtimeは現在(Unity 2021.1)はPreview 機能となっています。本記事では「UI Builder」というUIをGUIベースで作成できるツールを軸に、UI Toolkitについて紹介します。

※正式機能となっているEditor UIについても不完全な部分が多くあり、今後変更される可能性が高い点に注意してください。
※本記事はUnity 2020.2.6f1の環境で動作確認をしています。

UI Builder のインストール

UI Builder は Package Manager からインストールすることができます。Preview Packageのため「Project Setting → Package Manager → Enable Preview Packages」にチェックを入れる事でPackage Managerの一覧に表示されるようになります。

インストールすると「Window → UI Toolkit → UI Builder」からUI Builderのウィンドウを開くことが出来るようになります。

UI Builder でのUI構築

「Library」にある要素を「Hierarchy」にドラッグ&ドロップすることでUIを構築できます。構築されたUIの状態は「Viewport」で確認できます。次の画像は「Label」要素をドラッグ&ドロップした際の状態です。

各要素は「Inspector」によりプロパティを変更し、表示等を変えることができます。次の画像はLabelの文字サイズを20に変更した際の状態です。

構築したUIの使用(UXML)

UI Builderで構築したUIは「UXML」形式で出力する事ができます。UI ToolkitではこのUXMLファイルを元にUIを表示するのが基本になります。前項で構築したUIを出力すると次のUXMLファイルが作成されます。

<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements">
    <ui:Label text="Label" font-size: 20px;" />
</ui:UXML>

このUXMLファイルを「Test.uxml」として保存し、UIを表示すると次のような感じになります。ここでは今後の説明に使用するため、TestScriptableObjectというScriptableObjectのカスタムエディタとして表示しています。

using UnityEditor;
using UnityEngine.UIElements;

[CustomEditor(typeof(TestScriptableObject))]
public class TestScriptableObjectEditor : Editor
{
    // UI Toolkit のカスタムエディタは CreateInspectorGUI をオーバーライドし、
    // VisualElementを戻り値として渡すことで表示を変更できます
    public override VisualElement CreateInspectorGUI()
    {
        var treeAsset = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Test.uxml");
        var container = treeAsset.Instantiate();
        return container;
    }
}
Unity Editorでの表示

UI Builder でのUSSファイルの作成

出力されたUXMLにある「font-size: 20px」のようなスタイルに関するプロパティは「USS」形式のファイルに書き出すことができます。このUSSもUI Builderの「StyleSheets」にある「+」ボタンから作成を行えます。試しに「font-size: 20px」を「Test.uss」というUSSファイルに書き出すと次の画像のようになります。

「.title」はセレクターと呼ばれる部分で、このスタイルを設定する対象を指定するために使用します。「.」が「クラス」を、「title」が「クラス名」を表してます。セレクターについての詳細は公式ドキュメントを見るのが分かり易いと思います。

このスタイルをLabel要素へ適用するのはLabelのInspectorで「title」クラスを追加することで行えます。

出力されるTest.uxml

<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements">
    <Style src="Test.uss" />
    <ui:Label text="Label" class="title" />
</ui:UXML>

出力されるTest.uss

.title {
  font-size: 20px;
}

機能の追加

UI ToolkitでUIに機能を与えたい場合の方法を紹介します。例として、ボタンをクリックしたら数値がインクリメントされ、Labelに表示されると言う機能を与えます。
まずUI BuilderでButton要素を追加します。

TestScriptableObjectのカスタムエディタでクリックされたら数値がインクリメントされてLabelに表示されるようにします。UXML内にある要素へコード上からアクセスするには「UQuery」という機能を使用します。

using UnityEditor;
using UnityEngine.UIElements;

[CustomEditor(typeof(TestScriptableObject))]
public class TestScriptableObjectEditor : Editor
{
    private int _counter;

    public override VisualElement CreateInspectorGUI()
    {
        var treeAsset = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Test.uxml");
        var container = treeAsset.Instantiate();

        // UQuery を使用して Label と Button のインスタンスを取得
        var label = container.Q<Label>();
        var button = container.Q<Button>();

        // Button がクリックされた際の動作を追加
        button.clicked += () =>
        {
            // 数値をインクリメントし、Label に反映させます
            _counter++;
            label.text = _counter.ToString();
        };

        return container;
    }
}

Unity Editor上での動作

自作要素の追加

前項の機能を「Label」や「Button」のような要素として追加する方法を紹介します。
要素を追加するのは簡単で「VisualElement」を継承したクラスを作成するだけです。作成したクラスに前項の機能を記述すれば、前項の機能を持った自作要素が使用できるようになります。

using UnityEditor;
using UnityEngine.UIElements;

// VisualElement を継承
public class TestElement : VisualElement
{
    private int _counter;

    public TestElement()
    {
        // Editor限定の取得方法です。Runtimeで使用する場合は別の取得方法が必要です
        var treeAsset = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Test.uxml");
        var container = treeAsset.Instantiate();

        var label = container.Q<Label>();
        var button = container.Q<Button>();

        button.clicked += () =>
        {
            _counter++;
            label.text = _counter.ToString();
        };

        // hierarchy に Add することで表示を行えます
        hierarchy.Add(container);
    }
}

TestScriptableObjectのカスタムエディタで次の記述をすることで前項と全く同じ動作になります。

using UnityEditor;
using UnityEngine.UIElements;

[CustomEditor(typeof(TestScriptableObject))]
public class TestScriptableObjectEditor : Editor
{
    public override VisualElement CreateInspectorGUI()
    {
        return new TestElement();
    }
}

自作要素をUI Builderで使用できるように

VisualElementを継承したクラスを作成するだけではUI Builder(UXMLでも)でその要素を使用する事ができません。UI Builderで使用するには、対応する「UxmlFactory」を継承したクラスを作らなければなりません。

例としては次のようになります。

public class TestElement : VisualElement
{
    public new class UxmlFactory : UxmlFactory<TestElement, UxmlTraits>
    {
    }

~~~~~~~~~
}

このクラスを追加することで、次のようにUI Builder上で使用できるようになります。

出力されるUXML

<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements">
    <TestElement />
</ui:UXML>

自作要素のプロパティ対応

例えば自作要素TestElementのButtonにある文字をUI Builder上で変更したい場合、現状では変更することができません。この対応としてプロパティとして受け取った値をButton内の文字へ適用することが必要です。プロパティの値を受け取るためには対応する「UxmlTraits」を継承したクラスが必要です。

例としては次のような形です。「Uxml○○AttributeDescription」で○に入るタイプで値を取得できます。今回は文字列を受け取りたいのでStringとなります。こちらのnameに対象となるプロパティ名を設定するとnameと一致するプロパティの値が取得できるようになります。

public class TestElement : VisualElement
{
    public new class UxmlTraits : VisualElement.UxmlTraits
    {
        // name へ 取得したい プロパティ名を設定
        private UxmlStringAttributeDescription _buttonText = new UxmlStringAttributeDescription() {name = "button-text"};

        public override void Init(VisualElement ve, IUxmlAttributes bag, CreationContext cc)
        {
            base.Init(ve, bag, cc);
            var testElement = (TestElement) ve;

            // GetValueFromBag で プロパティの値を受け取る
            testElement._button.text = _buttonText.GetValueFromBag(bag, cc);
        }
    }

~~~~~~~
}

クラス全体では次のようになります。一つ注意点として、プロパティ名に対応するゲッターがないとUI Builder上でプロパティの現在の値が取得できず、表示が空になってしまいます。

using UnityEditor;
using UnityEngine.UIElements;

public class TestElement : VisualElement
{
    public new class UxmlTraits : VisualElement.UxmlTraits
    {
        private UxmlStringAttributeDescription _buttonText = new UxmlStringAttributeDescription() {name = "button-text"};

        public override void Init(VisualElement ve, IUxmlAttributes bag, CreationContext cc)
        {
            base.Init(ve, bag, cc);
            var testElement = (TestElement) ve;
            testElement._button.text = _buttonText.GetValueFromBag(bag, cc);
        }
    }

    public new class UxmlFactory : UxmlFactory<TestElement, UxmlTraits>
    {
    }

    private int _counter;
    private Button _button;

    // UI Builder のインスペクターに現在の値を反映させるにはUXML上のプロパティ名と同じ getter が必要
    // button-text のような - が入っているプロパティ名の場合は変換がかかる
    private string buttonText => _button.text;

    public TestElement()
    {
        var treeAsset = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Test.uxml");
        var container = treeAsset.Instantiate();

        var label = container.Q<Label>();
        _button = container.Q<Button>();

        _button.clicked += () =>
        {
            _counter++;
            label.text = _counter.ToString();
        };

        hierarchy.Add(container);
    }
}

この状態になると、UI Builder上のTestElementのInspectorに「Button Text」が表示されます。そこに値を入力するとButton内の文字が変更されるようになります。

出力されるUXML

<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements" editor-extension-mode="False">
    <TestElement button-text="Count" />
</ui:UXML>

自作要素の値変更イベントの発行

UI Toolkitでは値が変更された際に「ChangeEvent」というイベントが発行され、これをベースに色々な機能が成り立っています。自作要素に値が変更されるものがあるなら、対応するのをオススメします。対応方法は「INotifyValueChanged」を継承し、値が変更された際にイベント発行を行います。

ボタンをクリックし、インクリメントされる数値に変更が入った時にイベントを通知すると次のようになります。INotifyValueChangedを継承すると「value」と「SetValueWithoutNotify」の実装が求められます。valueに変更が入ったらChangeEventを作成し「SendEvent」によって通知を行います。「ChangeEvent.GetPooled」の部分はChangeEventがクラスなので、余計なGCを発生させないためのUnityのキャッシュ機能です。

using UnityEditor;
using UnityEngine.UIElements;

public class TestElement : VisualElement, INotifyValueChanged<int>
{
    public new class UxmlTraits : VisualElement.UxmlTraits
    {
        private UxmlStringAttributeDescription _buttonText = new UxmlStringAttributeDescription() {name = "button-text"};

        public override void Init(VisualElement ve, IUxmlAttributes bag, CreationContext cc)
        {
            base.Init(ve, bag, cc);
            var testElement = (TestElement) ve;
            testElement._button.text = _buttonText.GetValueFromBag(bag, cc);
        }
    }

    public new class UxmlFactory : UxmlFactory<TestElement, UxmlTraits>
    {
    }

    private string buttonText => _button.text;
    private Button _button;
    private Label _label;
    private int _counter;

    public int value
    {
        get => _counter;
        set
        {
            // 変更が入っているかチェック
            if (_counter == value)
            {
                return;
            }

            // 変更があれば GetPooled で ChangeEventのインスタンスを取得 ※要 Dispose
            using (var pooled = ChangeEvent<int>.GetPooled(_counter, value))
            {
                pooled.target = this;

                // 新しい値を反映
                SetValueWithoutNotify(value);

                // イベントを発行
                SendEvent(pooled);
            }
        }
    }

    // 値が変更された際の処理を書く
    public void SetValueWithoutNotify(int newValue)
    {
        _counter = newValue;
        _label.text = newValue.ToString();
    }

    public TestElement()
    {
        var treeAsset = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Test.uxml");
        var container = treeAsset.Instantiate();

        _label = container.Q<Label>();
        _button = container.Q<Button>();

        _button.clicked += () => { value++; };

        hierarchy.Add(container);
    }
}

自作要素のBinding対応(Editor only)

「Binding」は表示されているUI上の値とオブジェクト内の値を同期する機能になります。現状は「SerializedObject」系経由のBindingしかなく、実質Editor限定機能になっています。この同期はINotifyValueChangedを通して行われるので、対応するタイプのINotifyValueChangedを継承し、実装しておく必要があります。

例としてTestScriptableObjectにInt型の変数を用意し、TestElementでインクリメントされる数値と同期するようにします。TestScriptableObjectクラスに同期用の変数「counter」を宣言します。

using UnityEngine;

public class TestScriptableObject : ScriptableObject
{
    [SerializeField]
    private int counter;
}

TestElementクラスで継承するのをVisualElementではなく、BindableElementに変更し、UxmlTraitsで継承するのもBindableElementの内のクラスを使用するようにします。

// BindableElement と 対応するタイプの INotifyValueChanged を継承
public class TestElement : BindableElement, INotifyValueChanged<int>
{
    public new class UxmlTraits : BindableElement.UxmlTraits
    {

~~~~~
}

以上でBinding対応は完了です。後は使用箇所で対象となる値との紐付けを行います。

[CustomEditor(typeof(TestScriptableObject))]
public class TestScriptableObjectEditor : Editor
{
    public override VisualElement CreateInspectorGUI()
    {
        // Test2.uxml は TestElementが入っているだけのUXMLファイルです
        var treeAsset = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Test2.uxml");
        var container = treeAsset.Instantiate();

        var testElement = container.Q<TestElement>();

        // bindingPath に 対象までのパスを設定
        // 文字列で設定していますが、 可能ならnameof(TestScriptableObject.counter) の方
        // が名前に変更が入っても追従できるのでいいかも知れません
        testElement.bindingPath = "counter";
        testElement.Bind(serializedObject);
        return container;
    }
}

これでTestScriptableObjectの値とTestElementに表示されている値が同期されるようになります。

TestScriptableObjectEditorで行っている「bindingPath」の設定はUI Builder上からも行えます。


出力されるUXML

<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements" editor-extension-mode="False">
    <TestElement button-text="Count" binding-path="counter" />
</ui:UXML>

最後に

ここまで読んで頂きありがとうございます。UI Toolkitはまだ不完全な部分が多く、機能も今までのUIシステムと比べると少ない状態です。しかし、自作の要素を簡単に使いまわせる点やUXML・USSによる構造の分かり易さ、変更のし易さは魅力を感じます。Runtime UIで使用するにはまだ不安点が多く、オススメできませんが、Editor UIならそろそろ使い始めてもいいのではと思っています(弊社でも部分的に使用を始めています)。本記事で触れていない機能も多くあるので、興味がある方は是非調べてみてください。本記事がUI Toolkitを始める上での手助けになれば幸いです。

2017年にQualiArtsにUnityエンジニアとして中途入社。現在は開発推進室に所属し、複数のプロジェクトで使用する基盤開発に携わる。