この記事は 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の特徴は以下です。
- DOMのようなオブジェクトのツリー構造をもつ
- HTML/CSSのような言語でツリー/スタイルを構築することができる
- SerializeFieldへのバインド機能
順に見ていきましょう
1. DOMのようなオブジェクトのツリー構造をもつ
従来のIMGUIはメソッドの呼び出しがGUIの構築になっていました。
以下はFoldoutの中に10個のボタンを並べる例です。
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の構築は以下のようになります。
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ブラウザの開発者ツールに似ていて、ツリーやスタイルの状況を確認することができます。
2. HTML/CSSのような言語でツリー/スタイルを構築することができる
C#のコードだけでなく、UXML/USS
というHTML/CSSに似た言語で記述できます。
先のFoldout + ボタン10の例をUXMLを用いて表現した例は以下の通りです。
※UIElementsのツールで出力する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ファイルを元にツリーを構築するようコードを書く必要があります。
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のコードを以下のように書き換えます。
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片を部品として定義し、それを使いまわすことができます。
<?xml version="1.0" encoding="utf-8"?>
<UXML xmlns="UnityEngine.Experimental.UIElements"
xmlns:editor="UnityEditor.Experimental.UIElements">
<Box>
<editor:Vector3Field />
</Box>
</UXML>
<?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>
TemplateとSlot
実体化されるTemplate内に外から要素を差し込むことができます。
差し込み対象にslot-nameを定義し、Instanceの子要素にslotを定義しておけば、
一致するslot-nameの要素の子として、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>
<?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>
USS(スタイルシート)
USSは、UIElements向けのスタイルシートで、CSSととても似た記法を持っています。
<?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>
/* 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;
}
USSの詳細な仕様は公式マニュアルに網羅されています。
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の編集が可能でした。
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;
}
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する必要があります。
<?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);
}
}
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」を選択します。
<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ファイルを開くとプロパティとしてスキーマを指定できます。
このようにエディタで直接設定してもいいですが、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対応エディタは要素や属性を補完してくれるようになります。
Unity2018→Unity2019での変更点
脱Experimentalに伴いAPIの変更が約束されています。
下記のものだけではないと思いますが、特に触っていて誰もがぶつかるであろうものものをリストアップしました。
脱Experimentalによる名前空間の変更
UnityEngine.Experimental.UIElements
→ UnityEngine.UIElements
UnityEditor.Experimental.UIElements
→ UnityEditor.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の辛いところをカバーした面白い技術だと思います。
その面白さの一端でも感じてもらえれば幸いです。
でもまだ人類には早すぎる!