Help us understand the problem. What is going on with this article?

まだ人類には早すぎるUIElements事始め

More than 1 year has passed since last update.

この記事は gumi Inc. Advent Calendar 2018の12/22の記事です。

UIElementsとは

UIElementsとは、エディタ拡張でのUI構築に使われているIMGUIの後継となるUnityのUIフレームワークです。
Unity2018.3現在はExperimentalで、Unity2019でEditor向けは正式機能となりExperimentalが取れます。
その後、Editor拡張以外のランタイム実行にも拡張されていく予定です。

Unity2018.3.0f2現在では、EditorWindowをUIElementsで構築することができますが、最重要機能と思われるInspectorへの対応はUnity2019からとなります。バグも多く、Unity2019への移行時にAPIの破壊的変更も発生するため、まだちょっと人類には早すぎる技術となっております。

本気で取り組むのはUnity2019リリースタイミングがベストだと考えます。
ですが面白味を感じたのでフライング気味に紹介してしまおう、というのがこの記事の主旨となります。

Unity公式マニュアルのUIElements Developer Guideに詳しいドキュメントがあります。

対象読者

CustomEditor, CustomInspector, EditorWindowあたりのUnityエディタ拡張を書いたことがある人向けです。

UIElementsの特徴 - VisualTree

UIElementsの特徴は以下です。

  1. DOMのようなオブジェクトのツリー構造をもつ
  2. HTML/CSSのような言語でツリー/スタイルを構築することができる
  3. SerializeFieldへのバインド機能

順に見ていきましょう

1. DOMのようなオブジェクトのツリー構造をもつ

従来のIMGUIはメソッドの呼び出しがGUIの構築になっていました。
以下はFoldoutの中に10個のボタンを並べる例です。
MyIMGUIWindow.PNG

IMGUI
using UnityEngine;
using UnityEditor;

public class MyIMGUIWindow : EditorWindow
{
    [MenuItem("Window/MyIMGUIWindow")]
    public static void Open()
    {
        GetWindow(typeof(MyIMGUIWindow));
    }

    private bool _foldOpen = true;

    void OnGUI()
    {
        _foldOpen = EditorGUILayout.Foldout(_foldOpen, "Foldout");
        if(_foldOpen)
        {
            for (int i = 0; i < 10; i++)
            {
                if (GUILayout.Button($"ボタン{i}"))
                {
                    Debug.Log($"ボタン{i}が押された");
                }
            }
        }
    }
}

OnGUIの中でまずFoldoutメソッドを呼び、その結果をもとに折りたたまれているかどうかを判断して、後続のボタンを表示するかを分岐しています。
折りたたまれているかどうかをメソッドの引数に渡すために、private変数にとっておく必要があります。また、ボタンが押されたかどうかはButtonメソッドの戻り値で判断します。

一方、UIElementsを用いたGUIの構築は以下のようになります。

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

public class MyUIElementWindow : EditorWindow
{
    [MenuItem("Window/MyUIElementWindow")]
    public static void Open()
    {
        GetWindow(typeof(MyUIElementWindow));
    }

    private void OnEnable()
    {
        var root = CreateGUI();
        this.GetRootVisualContainer().Add(root);
    }

    private VisualElement CreateGUI()
    {
        var foldout = new Foldout();
        foldout.text = "Foldout";

        for (int i = 0; i < 10; i++)
        {
            var index = i; //lambdaのキャプチャが…
            var button = new Button(() => Debug.Log($"ボタン{index}が押された"));
            button.text = $"ボタン{i}";
            foldout.Add(button);
        }
        return foldout;
    }
}

Foldoutオブジェクトの子にButtonオブジェクトを10個ぶら下げています。
現在折りたたまれているかどうかをオブジェクトの利用者ではなくFoldoutオブジェクトが保持し、状態に応じて見た目を変更します。
また、Buttonには押されたときの処理をActionデリゲートで渡しておけば、押されたときにそのデリゲートの処理が自動で呼ばれます。

RepaintのためのOnGUIのコードは不要です。オブジェクトのツリーが必要に応じて再描画を行います。

現在ツリーがどのように構築されているかはUIElements Debuggerで確認することができます。
これはWebブラウザの開発者ツールに似ていて、ツリーやスタイルの状況を確認することができます。

UIElementsDebugger.PNG

2. HTML/CSSのような言語でツリー/スタイルを構築することができる

C#のコードだけでなく、UXML/USSというHTML/CSSに似た言語で記述できます。
先のFoldout + ボタン10の例をUXMLを用いて表現した例は以下の通りです。
※UIElementsのツールで出力するUXMLは初見だとドギツく見えるのでマイルドにしたものを載せています。

UXML
<?xml version="1.0" encoding="utf-8"?>
<UXML xmlns="UnityEngine.Experimental.UIElements">  
  <Foldout name="foldout">
    <Button text="ボタン0" />
    <Button text="ボタン1" />
    <Button text="ボタン2" />
    <Button text="ボタン3" />
    <Button text="ボタン4" />
    <Button text="ボタン5" />
    <Button text="ボタン6" />
    <Button text="ボタン7" />
    <Button text="ボタン8" />
    <Button text="ボタン9" />
  </Foldout>
</UXML>

Assets/Editor/UXML/FoldoutExample.uxmlに保存します。
EditorWindow側でもこのUXMLファイルを元にツリーを構築するようコードを書く必要があります。

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

public class MyUIElementWindow2 : EditorWindow
{
    [MenuItem("Window/MyUIElementWindow2")]
    public static void Open()
    {
        GetWindow(typeof(MyUIElementWindow2));
    }

    private void OnEnable()
    {
        var root = CreateGUI();
        this.GetRootVisualContainer().Add(root);
    }

    private VisualElement CreateGUI()
    {
        //コードでツリーを構築するのではなく、UXMLのアセットを用いる
        var asset = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Editor/UXML/FoldoutExample.uxml");
        return asset.CloneTree(null);
    }
}

イベントへの対応

上記のコードにはボタンクリック時の処理が含まれていないので、C#のコード側から各Buttonにクリック動作を紐づける必要があります。CreateGUIのコードを以下のように書き換えます。

UIElements(UQuery)
private VisualElement CreateGUI()
{
    var asset = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Editor/UXML/FoldoutExample.uxml");
    var root = asset.CloneTree(null);

    int i = 0;
    root.Query<Foldout>("foldout")
        .Children<Button>()
        .ForEach(button =>
        {
            int x = i++;
            button.clickable.clicked += () => Debug.Log($"ボタン{x}が押された");
        });

    return root;
}

name属性が"foldout"である要素の子のButton全てにclickedイベントを登録しています。
要素の抽出については公式マニュアルのUQueryおよびスクリプトリファレンスのUQuery.QueryBuilderを参照下さい。

name属性について

Foldout要素にしれっとつけていたname属性ですが、これはHTMLのIDに近いものでUQueryを用いた要素の抽出に用いたりUSS内でセレクタとして記述できたります。ですがIDとは異なりツリー内で値が単一になることを保証するものではないようです。というのもUQueryでのnameを用いたノード取得APIは複数の値を返すからです。

なぜ厳密にIDに寄せないのかというと、おそらくIDの重複を防げないから割り切ったのではないかと考えています。
たとえば上記のサンプルで、UIElementsDebuggerを見る限りFoldoutの折り畳みのToggleである▼の画像はCheckmarkというnameを持っていますが、一つのツリーの中に二つのFoldoutがあれば、Checkmarkは一意になりません。
そのような現実的な事情から、IDという属性名を避けてnameにし、UQueryではnameで絞っても複数要素を取得するAPIにしたのだと思われます。

UXMLによるTemplateとInstance

UXML片を部品として定義し、それを使いまわすことができます。

Template(呼ばれる側)
<?xml version="1.0" encoding="utf-8"?>
<UXML xmlns="UnityEngine.Experimental.UIElements"
      xmlns:editor="UnityEditor.Experimental.UIElements">
  <Box>
    <editor:Vector3Field />
  </Box>
</UXML>
Template(呼ぶ側)
<?xml version="1.0" encoding="utf-8"?>
<UXML xmlns="UnityEngine.Experimental.UIElements">
  <!-- 使用するTemplateに名前を付けておく。これだけだと実体化はされない -->
  <Template name="template1" path="Assets/Editor/UXML/Template1.uxml" />

  <!-- 上記の名前で指定してTemplateを実体化 -->
  <Instance template="template1" />
  <Instance template="template1" />
</UXML>

TemplateExample.PNG

TemplateとSlot

実体化されるTemplate内に外から要素を差し込むことができます。
差し込み対象にslot-nameを定義し、Instanceの子要素にslotを定義しておけば、
一致するslot-nameの要素の子として、slot名をもつ要素がぶら下がります。

Slot(呼ばれる側)
<?xml version="1.0" encoding="utf-8"?>
<UXML xmlns="UnityEngine.Experimental.UIElements"
      xmlns:editor="UnityEditor.Experimental.UIElements">
  <Box slot-name="box">
    <editor:Vector3Field />
  </Box>
  <Foldout slot-name="foldout" />
</UXML>
Slot(呼ぶ側)
<?xml version="1.0" encoding="utf-8"?>
<UXML xmlns="UnityEngine.Experimental.UIElements">
  <!-- 使用するTemplateを定義しておく -->
  <Template name="slottemplate" path="Assets/Editor/UXML/SlotTemplate.uxml" />

  <!-- 定義されたTemplateを実体化 -->
  <Instance template="slottemplate">
    <Button slot="box" text="Boxにボタン追加" />
    <Button slot="foldout" text="Foldoutにボタン追加" />
  </Instance>
</UXML>

SlotExample.PNG

USS(スタイルシート)

USSは、UIElements向けのスタイルシートで、CSSととても似た記法を持っています。

USSが適用されるUXML
<?xml version="1.0" encoding="utf-8"?>
<UXML xmlns="UnityEngine.Experimental.UIElements">
  <Box name="box">
    <!-- このclassはHTMLのclassと同じようなもの -->
    <Label class="header" text="Unity-Chan!" />
  </Box>
</UXML>
USS(適用するスタイルシート)
/* Box要素(UnityEngine.Experimental.UIElements.Box型)のnameがboxなものに対して適用される */
Box#box {
  height: 500;

  /* urlはこのussファイルからの相対パスか、プロジェクトルートからの絶対パス
     絶対パスを使用する場合は先頭の/を忘れないこと */
  background-image: url("/Assets/Editor/Images/Queen_and_King.png");

  /* urlの代わりにresouceを使うこともできる
     Resouces.Load("<path>")と似たようなもの。Editor Default Resoucesも読める

     background-image: resouce("<path>");
  */

  /*
    Unity独自のプロパティもある
    Unity2019じゃないと動作しなかった…

    -unity-background-scale-mode: scale-to-fit;
  */
}

/* 全てのheaderクラスに適用される */
.header {
  padding-left: 20px;
  padding-top: 20px;
  color: ##FF0000;
  font-size: 20px;
}

USSを適用するには、C#のコードでVisualElementにUSSのパスを指定します。
つまり、ツリーをコードで組んでもUXMLで組んでもUSSは使用可能ということです。

private VisualElement CreateGUI()
{
    var asset = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Editor/UXML/USSExample.uxml");
    var root = asset.CloneTree(null);
    root.AddStyleSheetPath("Assets/Editor/USS/USSExample.uss");

    return root;
}

USSExample.PNG
痛UnityEditorが捗ります。

USSの詳細な仕様は公式マニュアルに網羅されています。
- セレクタ記法
- 値の種類(色, urlなど)
- サポートするプロパティ

2018.3.0f2では、CSSと同名なプロパティでも非互換があるので注意が必要です。
たとえば、marginやpaddingを一括で書くことはできません。

#nantoka {
  /* margin: 10px; とは書けない!バラす必要がある */
  margin-left:   10px;
  margin-top:    10px;
  margin-right:  10px;
  margin-bottom: 10px;
}

CSS感覚で書くとまだまだ躓くので、是非、将来改善していって欲しいと思います。

UXML/USSの利点、欠点

UXML/USSを用いるとファイルは増えてしまうため、小さなエディタ拡張程度ならC#コードだけで完結させたほうが手軽でしょう。しかし、UXML/USSは編集してもコンパイルを待たずにすぐ結果を確認できるという大きな利点があります。巨大なUnityプロジェクトになるほどコンパイル時間は無視できません。よって凝ったエディタ拡張を書く場合はUXML/USSに分があると思います。

3. SerializeFieldへのバインド機能

IMGUIでは、ComponentやScriptableObjectのSerializeFieldの編集が可能でした。

編集するSerializeFieldの例
using System;
using UnityEngine;

[CreateAssetMenu(menuName = "MyObject")]
public class MyObject : ScriptableObject
{
    [SerializeField]
    private Vector3 _position = Vector3.zero;
    public Vector3 Positon => _position;

    [SerializeField]
    private Sprite[] _sprites = null;
    public Sprite[] Sprites => _sprites;
}
IMGUIによるSerializeFieldの編集
SeriarizedProperty _position = null;
SeriarizedProperty _sprites = null;
void OnEnable()
{
    _position = serializedObject.FindProperty("_position");
    _sprites = serializedObject.FindProperty("_sprites ");
}
void OnGUI()
{
    EditorGUILayout.PropertyFiend(_position);
    EditorGUILayout.PropertyFiend(_sprites);
}

UIElementsでは、binding-pathを使って同じことをします。
C#のコード側では、SerializedObjectをVisualElementにBindする必要があります。

UXML
<?xml version="1.0" encoding="utf-8"?>
<UXML xmlns="UnityEngine.Experimental.UIElements"
      xmlns:editor="UnityEditor.Experimental.UIElements">
  <!-- binding-pathにSerializedFieldの名前を書く -->
  <editor:PropertyField binding-path="_position" />
  <editor:PropertyField binding-path="_sprites" />
</UXML>
using UnityEngine;
using UnityEditor;
using UnityEngine.Experimental.UIElements;
using UnityEditor.Experimental.UIElements;

public class MyUIElementWindow2 : EditorWindow
{
    [MenuItem("Window/MyUIElementWindow2")]
    public static void Open()
    {
        GetWindow(typeof(MyUIElementWindow2));
    }

    private void OnEnable()
    {
        var root = CreateGUI();
        var target = new SerializedObject(AssetDatabase.LoadAssetAtPath<MyObject>("Assets/Editor/MyObject.asset"));
        //対象となるSerializedObjectをBind
        root.Bind(target);
        this.GetRootVisualContainer().Add(root);
    }

    private VisualElement CreateGUI()
    {
        var asset = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Editor/UXML/BindingExample.uxml");
        return asset.CloneTree(null);
    }
}

BindingExample.PNG

Inspectorでの使用 (Unity2019.1.0a12)

Unity2019から、UIElementsをInspectorにも使用できます。
CreateInspectorGUI()メソッドをoverrideして自由にVisualElementを返すだけです。
なお、Inspectorが編集対象とするSerializedObjectは自動でBindされます。

//このコードを実行するには
//Unity2019以上が必要です
using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;
using UnityEditor.UIElements;

[CustomEditor(typeof(MyObject))]
public class MyObjectEditor : UnityEditor.Editor
{
    public override VisualElement CreateInspectorGUI()
    {
        var asset = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Editor/UXML/MyObjectEditor.uxml");
        return asset.CloneTree();
    }
}

なお、CreateInspectorがnullを返すと従来通りのInspectorの挙動になります。

IMGUIとの相互運用

IMGUIContainerを使うと、UIElementsのツリーの中にIMGUIの機能を混ぜることが可能です。

public override VisualElement CreateInspectorGUI()
{
    return new IMGUIContainer(() =>
    {
        if(GUILayout.Button("IMGUIButton"))
        {
            Debug.Log("GUILayout.Buttonが押された");
        }
    });
}

既存のIMGUIのコード資産や経験を活かすことができるため、UIElementsへの移行を小さく始めることができます。

また、デフォルトのInspector表示に対してUIElementsでButtonなどをちょっと追加したい、といったケースもIMGUIContainerでカバーできます。IMGUIContainerを用いてDrawDefaultInspectorを呼べばデフォルトのInspectorを使えるためです。

public override VisualElement CreateInspectorGUI()
{
    var root = new VisualElement();

    //DefaultInspectorを追加
    root.Add(new IMGUIContainer(() => DrawDefaultInspector()));

    //他のUIElementsを追加
    root.Add(new Button());

    return root;
}

エディタでのUXMLの補完

UXMLはXMLドキュメントなので、VisualStudioのようなXML対応エディタであれば、XMLスキーマを指定することでコード補完が可能です。

やり方ですが、まずUIElementsのXMLスキーマを出力します。
メニューの「Assets → Update UIElements Schema」を選択します。

UpdateUIElementsSchema.png

<Unityプロジェクトディレクトリ>/UIElementsSchemaフォルダにXMLスキーマが出力されます。

<Unityプロジェクトディレクトリ>
├─Assets
├─Library
├─Packages
├─ProjectSettings
└─UIElementsSchema
  ├─UIElements.xsd
  ├─UnityEditor.Experimental.UIElements.xsd
  ├─UnityEditor.PackageManager.UI.xsd
  └─UnityEngine.Experimental.UIElements.xsd

次にスキーマとエディタを紐づけます。
たとえばVisual Studioであれば、XMLファイルを開くとプロパティとしてスキーマを指定できます。

schema-editor.PNG

このようにエディタで直接設定してもいいですが、UIElementsのSchemaはUnityプロジェクト毎に生成するので、プロジェクトを作るたびにエディタでスキーマ指定するのも手間です。
なのでスキーマの位置は、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"
  xsi:schemaLocation="UnityEngine.UIElements ../../../UIElementsSchema/UnityEngine.UIElements.xsd
                      UnityEditor.UIElements ../../../UIElementsSchema/UnityEditor.UIElements.xsd">

</engine:UXML>

※なお、Unity2019でUnityに生成させるUXMLファイルにはスキーマ情報が含まれます。
そのため、初めてUXMLファイルを見たときに文字量が多くてウッとなりますが、あると便利なものです

スキーマを教えてあげれば、XML対応エディタは要素や属性を補完してくれるようになります。

hokan.png

Unity2018→Unity2019での変更点

脱Experimentalに伴いAPIの変更が約束されています。
下記のものだけではないと思いますが、特に触っていて誰もがぶつかるであろうものものをリストアップしました。

脱Experimentalによる名前空間の変更

UnityEngine.Experimental.UIElementsUnityEngine.UIElements
UnityEditor.Experimental.UIElementsUnityEditor.UIElements

EditorWindow.GetRootVisualContainer()は廃止

EditorWindow.rootVisualElementを用いる

VisualElement.AddStyleSheetPathは廃止

スタイルシートを適用するためのコードは以下のようになる

var styleSheet = AssetDatabase.LoadAssetAtPath<StyleSheet>("<path>");
element.styleSheets.Add(styleSheet);

まとめ

  • UIElementsは、IMGUIの後継となるUIフレームワーク
    • 現時点ではExperimental。Unity2019に向けて大絶賛開発中。破壊的変更上等。
  • HTML/CSSのような言語でレイアウトやスタイルを記述できる
  • SerializeFieldとBindできる

まだまだ未来の技術ですが、IMGUIの辛いところをカバーした面白い技術だと思います。
その面白さの一端でも感じてもらえれば幸いです。

でもまだ人類には早すぎる!

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away