はじめに
本記事では、Unityの最新のUIシステムである「UI Toolkit」を用いてランタイムUIを作成する手法について解説します。
はじめにUnityのランタイムUIのいける「UI Toolkit」の位置付けについて軽く触れた後、UI作成・ランタイムでの使用のための基本的な手順を解説します。実践的なテクニックや注意点なども適宜解説しています。
コードを再現しやすいように、チュートリアル形式で解説を行っていますが、内容ごとに章単位で独立に参照できるような形にしています。
Unityのバージョンは 2022.2.4f1
を使用します。
目次
UI Toolkitについて
2023年3月現在、Unityが提供しているUIシステムには「UI Toolkit」, 「Unity UI (uGUI)」, 「IMGUI」 の3種類があります。
「UI Toolkit」は一番新しいシステムで、HTML/CSSライクなUIの設計手法や、パフォーマンス性能、UIの可搬性などの多くの強力なメリットがあります。
ただし、「UI Toolkit」は開発途上のため、2023年の3月現在ではランタイムにおいてはまだ「Unity UI (uGUI)」に対する代替手段の扱いです。しかし、将来的には「UI Toolkit」がランタイムにおいても公式に推奨のシステムになる方針1のようです。
「UI Toolkit」は元々「UIElements」という名称のエディタ限定の機能でしたが、2020年にランタイムでも使える機能としてアップグレードされた際に「UI Toolkit」と改称されました。
また、本記事では「UI Toolkit」を実際に使用するための知識を紹介していますが、「UI Toolkit」のシステムの概要や現在の立ち位置を把握したい場合は、以下の資料が参考になると思います。
参考
公式ドキュメント: UnityのUIシステムの比較
公式のサンプルゲーム
UI ToolKitを導入して効率よくUIを構築する
Unity UI Toolkit is better than expected (※英文)
ランタイムUIを作成する方法
「UI Toolkit」のUIアセットであるUXMLを作成するには、UI Builder を利用するのが一般的です。
【UI Builderとは】
UI Builderは「UI Toolkit」用のWYSIWYGエディタです。「UI Toolkit」ではUXML/USSというHTML/CSSライクな形式でUIの内容を記述することでUIを表現しますが、UI Builderを使用することで直接UXMLを記述することなく直感的にUIを作成できます。細かく調整するためにはある程度HTMLとCSSの記法やルールを理解する必要があります。
準備: 空のプロジェクトを作成する
準備として、「2D」タイプの空のプロジェクトをUnityで作成します。プロジェクト名は何でも良いです。
UI Builder によるUI作成
まずは試しにUI Builderを使って簡単な2UIを作成してみます。
UIアセット (UXML) の作成
まずはメニューバーから「Window->UI Toolkit -> UI Builder」とクリックすると、UI Builderのウィンドウが立ち上がります。
ウィンドウが開いたら、
-
左下の「Library」から「VisualElement」(画像赤色部分)をウィンドウ右側のViewport(画像水色部分)にドラッグ&ドロップして追加します。見た目が透明なので分かりにくいですが、画像黄色部分でも反映を確認できます。
-
「Libarary」の「Button」(画像赤色部分)を選択して、同じようにドラッグ&ドロップで追加します。
-
画像黄色部分のように、「Hierarchy」でVisualElementの配下にButtonが配置されいることを確認します。
-
最後に、Ctrl + S (Winの場合) で保存するとファイル名の入力が求められるので、
SimpleButton
というファイル名で「Assets/UI」に保存します。(「Assets/UI」は新規ディレクトリとして作成します。)
【Visual Elementについて】
「UI Toolkit」では、全てのUIの構成要素はVisual Elementから成ります。
ランタイムにおいては、UIDocumentを介してGameObjectにrootVisualElementがアタッチされ、各UIの構成要素は、rootVisualElementを根とした、VisualElmentをノードとする木構造として表現されます。
作成したUIをSceneに設置する
-
Hierarchyウィンドウ内で右クリックし、「UIToolkit->UIDocument」とクリックすると、UIDocumentが設置されます。3名前は適当に
MyUIDocument
としておきます。 -
MyUIDocument
をクリックし、InspectorウィンドウからSource Asset
のフィールドに先程作成したSimpleButton.uxml
をドラッグ&ドロップしてセットします。下の画像のようにセットされていることを確認します。
UIを編集する
次に、UIのレイアウトを変更してみます。UnityのProjectウィンドウから、先程保存した SimpleButton.uxml
を開くと再びUI Builderが立ち上がります。
まずはUI Builderの「Hierarchy」から 、「Visual Element」をクリックします。すると、右側に「Inspector」が表示されます。各項目はそのVisual Element要素の各属性を指定するものです。
CSSのように Style Shhet (USS) を用いて属性を指定することも可能ですが、今回はUXMLに直接記述します。USSについては公式ドキュメントを参照して下さい。
(1) Inspector View から、「BackGround->Color」を編集し、RGBAで適当な色4を指定します。ViewPortに反映されるのが確認できます。
(2) 続けて、「Boarder->Width」の値と「Boarder->Radius」の値を10にセットします。さらに、「Boarder->Color」にも適当な色を指定します。
(3) この時点でUnityのGameViewを確認すると、指定した背景や外枠が確認できます。現状は下の画像のようにゲーム画面全体を基準に表示されています。
(4) もう少し手を加えてみます。UI Builderに戻り、「Library」のLabelを「Hierarchy」または「ViewPort」にドラッグ&ドロップして、Label要素を追加します。階層構造がButtonと並列になっているか注意して下さい。
(5) Hierarchyから Visual Element
を選択し、「Flex -> Grow」の値に0をセットします。さらに、「Align -> Align Items」に「center」をセットし、「Align -> Align Self」に「Flex Start」をセットします。
(6) 最後に、「Hierarchy」から Label
を選択し、「Attributes -> Text」の値に「Simple Button Here」と入力します。
(7) この時点でUnityのゲームビューを確認すると、画面左上に局所的に表示されていることを確認できます。
UIの属性は基本的にHTML準拠のため、ここでは各属性の意味や詳細などには触れていません。もしHTMLやCSSの記法に詳しくなければ、こちらの記事がおすすめです。
次のステップへの準備
UIの作り方を学んだので、次はランタイムのスクリプトからの生成・イベントの設定・移動などの基本操作について解説していきます。ここでは、Scriptで操作するための下準備を行います。
(1) SimpleButton.uxmlをUI Builder で開き、それぞれの要素の名前を画像のように変更します。(後ほどScriptから要素単位でアクセスするため)
(2) Hierarchyウィンドウの MyUIDocument
を、Projectウィンドウの Assets/UI
フォルダ内に ドラッグ&ドロップ して、Prefab化します。
(3) Prefab化したら、Sceneに元々設置していたオブジェクトは一旦削除します。
UIDocumentをSceneにセットしたままMonoBehaviourをアタッチして制御しても良いですが、Prefabを利用するケースも実用上は多いので、ここではScriptからPrefabをInstantiateする方針を採用しています。
ランタイムUIの基本操作
「UI Toolkit」のランタイムUIにおける主要な基本操作を解説します。
この章では、UIアセット (UXML) として前の章で作成した MyUIDocument.prefab
を使用することを想定しています。
スクリプトからインスタンス化する方法
「UI Toolkit」は、UIDocumentをPrefab化することで、動的にScene内に生成できます。
【UIDocumentについて】
UIDocumentはUIを構成するVisualElementの親要素をGameObjectとしてSceneの画面にアタッチする役割を持っています。したがって、SceneにUIを追加する場合は、UIDocumentをインスタンス化するか、もしくは既存のVisualElementに子要素として追加する必要があります。
チュートリアル
実際にUIDocumentはUIをインスタンス化するUIControllerクラスを作成してみます。
(1) Hierarchyウィンドウを右クリックし、「Create Empty」を選択します。生成されたGameObjectに適当に UIController
と命名します。
(2) ProjectウィンドウでAssets/Scripts
のフォルダを作成し、Unityからフォルダ内を右クリックして「Create->C# Script」からUIController.cs
を作成します。
(3) 以下のスクリプトを作成します。
public class UIController : MonoBehaviour
{
[SerializeField] GameObject sampleButtonUI;
private UIDocument _uiDocument;
void Start()
{
// PrefabからUIを生成
var buttonObject = Instantiate(sampleButtonUI);
// UIDocumentの参照を保存
_uiDocument = buttonObject.GetComponent<UIDocument>();
}
}
(4) UIControllerのInspectorウィンドウから、sampleButtonUI
のフィールドにUIDocumentのPrefabをセットします。
(5) Sceneを再生すると、PrefabとしてセットしたUIが表示されていることが確認できます。
表示テキストやボタンのイベントを設定する方法
UIの表示テキストやボタンのクリックイベントをセットするには、まずUIDocumentのrootVisualElement
から、Q<T>(...)
関数でボタンのVisualElementにアクセスします。
ボタンのクリックイベントをセットする場合は、以下のようにButtonのclicked
にサブスクライブします。
var buttonElement = _uiDocument.rootVisualElement.Q<Button>("Button1"); // 引数にVisualElementの名前を指定
buttonElement.clicked += () => { /* クリック時の処理 */ };
チュートリアル
上で作成した UIController
クラスの Start()
関数の末尾に以下の行を追加してみましょう。
var buttonElement = _uiDocument.rootVisualElement.Q<Button>("Button1"); // 引数にVisualElementの名前を指定
buttonElement.clicked += () =>
{
var labelElement = _uiDocument.rootVisualElement.Q<Label>("Label1");
labelElement.text = "Clicked!";
};
ボタンのクリック時に、同様の手法でUIのLabel1
のテキストを変更するようにしています。実行すると、以下のようになります。
サイズや位置を操作する方法
UIのサイズや位置の変更もUIのVisualElementにアクセスして行います。
サイズや位置の変更を行いたい場合は、UXMLにベースとなるVisualElementを一つ配置し、残りの要素はそのVisualElementの配下に設置するのが良いでしょう。
uiDocument.rootVisualElement
はPanel Settings依存のVisualElementで、UIではなく(デフォルトでは)スクリーン全体にマッチしているので、通常はこちらを操作することはありません。
サイズや位置の変更は以下のようなコードで実現できます。
// 操作対象のVisualElementを取得 (※ rootVisualElementは基本的に不適当)
var baseElement = _uiDocument.rootVisualElement.Q<VisualElement>("Base"); // 引数にVisualElementの名前を指定
// UI のサイズ変更
baseElement.style.width = 200; // UIの横幅を変更
baseElement.style.height = 200; // UIの縦幅を変更
// UIの位置変更
baseElement.transform.position = new Vector3(200f, 100f, 0); // UIの座標を変更
チュートリアル
先程設定したボタンイベントの処理部分で、UIサイズ変更と、ランダムな座標への移動を行ってみます。
buttonElement.clicked += () =>
{
// UIのBaseのVisual Elementを取得
var baseElement = _uiDocument.rootVisualElement.Q<VisualElement>("Base");
// UIのサイズを変更
baseElement.style.width = 150;
baseElement.style.height = 150;
// UIの位置を変更
baseElement.transform.position = GetRandomScreenPosition();
};
すると、以下のような、ボタンを押すとランダムな位置に移動するモーダルが実現できます。
リストを作成する方法
「UI Toolkit」のリストの実装であるListViewを利用してリストUIを作成する方法を解説します。
リストは使う頻度が多い&扱い方がやや複雑なので、個別の項目で取り上げています。
ここではベーシックな方法を紹介していますが、後ほど楽をする方法も紹介しています。
また、そもそもListViewの代替手段として、直接親要素にAddする方法もあります。
リスト作成に必要なもの
リストを作成する際はListViewの以下の3つのプロパティからリスト内容の情報を渡します。(必須のもののみ記載)
-
makeItem
: リスト要素となるVisualElementを生成するファクトリを渡す -
bindItem
: リストの各要素を操作するための関数を渡す -
itemSource
: リスト要素の内容を既定するViewModel的なものを渡す。リストなら中身の型や値は何でも良い。
チュートリアル
(1) 次のような、内部にリストを含むUIアセット (UXML) を作成します。
画像を表示
こちらは UIを作成する方法 で作成したUIから、Buttonの部分をList Viewに変更し、UIのサイズを適当に調整したものです。
(2) 作成したUIをUIDocumentにアタッチします。以前の手順で作成したMyUIDocumentのSourceAssetを差し替えた前提で進めます。
(3) UIController.cs
の Start()
も以下のように書き換えます。
void Start()
{
// PrefabからUIを生成
var buttonObject = Instantiate(sampleButtonUI);
// UIDocumentの参照を保存
_uiDocument = buttonObject.GetComponent<UIDocument>();
// リスト内容を作成する
ListView listViewElement = _uiDocument.rootVisualElement.Q<ListView>("List1"); // 引数にVisualElementの名前を指定
listViewElement.makeItem = () => new Label(); // ここではラベルを要素として生成する(VisualElementなら何でも可)
listViewElement.bindItem = (item, i) => { ((Label) item).text = $"{i}th element"; }; // リストの各要素を操作する
listViewElement.itemsSource = Enumerable.Range(0, 5).ToList(); // itemsSourceの要素数がListViewの要素数になる。このケースでは中身は何でも良い
}
(おまけ1)縦幅を調整する
残念ながら、ListViewでは各リスト要素の height
は反映されないので、高さを調整するにはfixedItemHeight
で指定する必要があります。使うケースは多いです。
listViewElement.fixedItemHeight = 50;
(おまけ2)アセットからVisualElementをロードする
makeItem
から、アセット (UXML) からロードしたVisualElementを渡すこともできます。
(1) 以下のようにUIController.cs
のコードを編集します。
コードを表示
public class UIController : MonoBehaviour
{
[SerializeField] GameObject sampleListUI;
[SerializeField] VisualTreeAsset listElementAsset;
private UIDocument _uiDocument;
void Start()
{
// PrefabからUIを生成
var buttonObject = Instantiate(sampleListUI);
// UIDocumentの参照を保存
_uiDocument = buttonObject.GetComponent<UIDocument>();
// リスト内容を作成する
ListView listViewElement = _uiDocument.rootVisualElement.Q<ListView>("List1"); // 引数にVisualElementの名前を指定
listViewElement.makeItem = () => listElementAsset.Instantiate(); // リスト要素となるVisualElementを生成するファクトリを渡す
listViewElement.bindItem = (item, i) => { item.Q<Label>("Label1").text = $"{i}th element "; }; // リストの各要素を操作する
listViewElement.itemsSource = Enumerable.Range(0, 2).ToList(); // itemsSourceの要素数がListViewの要素数になる。このケースでは中身は何でも良い
listViewElement.fixedItemHeight = 120; // リスト要素の縦幅を指定する
}
}
(2) 先程作成したSimpleButton.uxml
をInspectorウィンドウから UIController
に渡します。
(3) 実行すると、UXMLからロードしたUIが要素として表示されています。
(おまけ3)実践的な使い方
ListViewの素のインタフェースはやや冗長で使いにくいので、ListViewの作成処理を関数化・クラス化などして使うのが良いでしょう。
また、少し工夫すると複数のVisualElementを要素として同じリスト上に並べることもできます。
その辺りの話を、この下の ListViewの使い勝手をよくする小技 の節で解説しています。
実践的なテクニック
ここでは実践的・応用的なテクニックを紹介していきます
親要素に子要素としてAddする
VisualElementに対して Add()
関数を用いることで、別のVisualElementを子要素として追加することができます。
parentElement.Add(chiledElement); // chiledElementを子要素として追加
実装例
(1) 以下のような空のVisualElementを含むUIを作成し、UIDocumentとしてSceneに設置します。
(2) UIDocumentに以下のスクリプトをアタッチして、要素を動的に追加します。
public class UIController : MonoBehaviour
{
private UIDocument uiDocument => GetComponent<UIDocument>();
void Start()
{
// リスト内容を作成する
VisualElement containerElement = uiDocument.rootVisualElement.Q<VisualElement>("Container1"); // 引数にVisualElementの名前を指定
var smallText = new Label("Hello");
containerElement.Add(smallText); // ラベルを子要素として追加
var largeText = new Label("Hello!");
largeText.style.fontSize = 50;
containerElement.Add(largeText); // 大きいラベルを子要素として追加
var button = new Button(() => { Debug.Log("Hello world!");});
button.text = "Say Hello";
containerElement.Add(button); // ボタンを子要素として追加
}
}
(3) 以下のように、Container1
の子要素としてラベルやボタンが配置されていることが分かります。こちらで説明した方法で自作のUXMLもVisualElmentとして生成・配置することができます。
ListViewの使い勝手をよくする小技
ListViewのmakeItem
, itemSource
, bindItem
を使ってセットするのは扱いづらい、端的に言えばそれぞれの要素が密結合かつ冗長です。
Add
を使った小技ですが、以下のような拡張メソッドを定義しておけば、listView.SetListContent(visualElements);
といった具合に、ListViewに直接リスト要素の(しかも任意のタイプな)VisualElementを渡せます。
public static class ListViewUtils {
public static void SetListContent(this ListView listView, IEnumerable<VisualElement> visualElements)
{
var elementList = visualElements.ToList();
listView.makeItem = () => new VisualElement(); // 空のVisualElementを噛ませる
listView.itemsSource = elementList;
listView.bindItem = (item, i) => { item.Add(elementList[i]); }; // 空のVisualElementにAddする
}
}
【拡張メソッドの詳細】
空のVisualElementをリストの要素として、そこに受け取ったVisualElementを子要素としてAdd
しています。
実装例
実装例は以下のようになります。
// (1) リスト要素のリストを生成する
var listElements = new VisualElement[]
{
new Label("hello"),
new Label("hello!"),
new Button() {text = "hello world"}
};
// (2) リストにセットする
ListView listViewElement = _uiDocument.rootVisualElement.Q<ListView>("List1"); // 中身をセットしたいListViewを取得
listViewElement.SetListContent(listElements); // リストの要素をセット
listViewElement.fixedItemHeight = 50; // リスト要素の縦幅をセット
上のコードを実行すると以下のような表示になります。Label
とButton
が同じリストに共存しています。
ドラッグ&ドロップで操作する方法
VisualElementをドラッグ&ドロップで移動させる方法を説明します。
1. Manipulator を用いる方法
まず、Scene上に以下のUIを持つUIDocumentが存在すると想定します。
同じオブジェクトに次のMonoBehaviourを追加します。
public class DraggableUIBehaviour : MonoBehaviour
{
VisualElement frameElement => GetComponent<UIDocument>().rootVisualElement.Q<VisualElement>("Frame");
void Start()
{
frameElement.AddManipulator(new DragManipulator());
}
}
DragManipulatorのソースコードはこちらです
public class DragManipulator : IManipulator
{
private VisualElement _targetElement;
private bool _isPointerDown;
public VisualElement target
{
get => _targetElement;
set
{
if (value == null) throw new ArgumentNullException();
else if (_targetElement == value) return;
if (_targetElement != null) RemoveEvents(_targetElement);
_targetElement = value;
AddEvents(_targetElement);
}
}
private void AddEvents(VisualElement targetElement)
{
targetElement.RegisterCallback<PointerMoveEvent>(OnPointerMove);
targetElement.RegisterCallback<PointerDownEvent>(OnPointerDown);
targetElement.RegisterCallback<PointerUpEvent>(OnPointerUp);
}
private void RemoveEvents(VisualElement targetElement)
{
targetElement.UnregisterCallback<PointerMoveEvent>(OnPointerMove);
targetElement.UnregisterCallback<PointerDownEvent>(OnPointerDown);
targetElement.UnregisterCallback<PointerUpEvent>(OnPointerUp);
}
private void OnPointerDown(PointerDownEvent ev)
{
_targetElement.CapturePointer(ev.pointerId); // ポインタをキャプチャしてUI範囲外のポインタイベントも取得するようにする
_isPointerDown = true;
}
private void OnPointerUp(PointerUpEvent ev)
{
_targetElement.ReleasePointer(ev.pointerId); // ポインタを開放
_isPointerDown = false;
}
private void OnPointerMove(PointerMoveEvent ev)
{
if (_isPointerDown)
target.transform.position += ev.deltaPosition;
}
}
上で作成したDragManipulator
は IManimpulator
を継承することで、任意のVisualElementにAddManipulator
を使ってアタッチすることができます。
この方法は手軽でおすすめですが、デメリットとしてDragManipulator
の内部でCapturePointer
を行う5ため、他のオブジェクトでマウスイベントを取得できなくなります。例えば、フレーム部分のみドラッグ&ドロップできるようにしたい、というようなケースだとこちらの方法は使えないので、その場合は次の方法を使用します。
2. 手動で入力イベントを処理する方法
複数のオブジェクトから入力イベントを処理する場合などは、MonoBehaviourのUpdate()
から入力イベントを直接処理します。
以下のような関数でマウス位置であるInput.mousePosition
が該当のVisualElement上にあるのかどうか判定します。VisualElementの座標系ではY軸方向が下方向を正としているので、座標を補正しています。
public static bool IsOnScreenPoint(this VisualElement visualElement, Vector2 screenPoint)
{
var visualElementPositionY = Screen.height - screenPoint.y; // Y座標は方向が逆になる
return visualElement.worldBound.Contains(new Vector2(screenPoint.x, visualElementPositionY));
}p
以下が実装例です。
実装例
DraggableFramedUIBehaviour.cs
public class DraggableFramedUIBehaviour : MonoBehaviour
{
private VisualElement _frameElement;
private VisualElement _innerContentElement;
private readonly DragState _dragState = new DragState();
void Start()
{
_frameElement = GetComponent<UIDocument>().rootVisualElement.Q<VisualElement>("Frame");
_innerContentElement = GetComponent<UIDocument>().rootVisualElement.Q<VisualElement>("InnerContent");
// モーダルの中身を適当に作成
var container = GetComponent<UIDocument>().rootVisualElement.Q<VisualElement>("Container1");
container.Add(new Label("Only framed area"));
container.Add(new Label("is draggable !"));
container.Add(new Label());
// クリックすると元の位置に戻るボタン
container.Add(new Button(() => { _frameElement.transform.position = Vector3.zero; }) {text = "Reset Position"!});
}
void Update()
{
// Frame上かつInnerContent上ではない場所でクリックされた場合
if (_frameElement.IsOnScreenPoint(Input.mousePosition)
&& !_innerContentElement.IsOnScreenPoint(Input.mousePosition)
&& Input.GetMouseButtonDown(0))
{
_dragState.StartDrag();
}
if (Input.GetMouseButtonUp(0))
{
_dragState.FinishDrag();
}
if (_dragState.state == DragState.State.Drag)
{
var diff = _dragState.UpdateDragPos(Input.mousePosition);
_frameElement.transform.position += new Vector3(diff.x, -diff.y); // Y座標は方向が逆になる
}
}
}
その他のクラス
/// <summary> ドラッグ状態の管理用のクラス </summary>
public class DragState
{
public enum State
{
Idle,
Drag,
}
public State state = State.Idle;
private Vector2? _previousFramePos;
public void StartDrag()
{
state = State.Drag;
_previousFramePos = null;
}
public void FinishDrag()
{
state = State.Idle;
_previousFramePos = null;
}
public Vector2 UpdateDragPos(Vector2 currentPos)
{
if (state == State.Idle) return Vector2.zero;
if (_previousFramePos != null)
{
var diff = currentPos - _previousFramePos;
_previousFramePos = currentPos;
return diff.Value;
}
else
{
_previousFramePos = currentPos;
return Vector2.zero;
}
}
}
public static class VisualElementUtil
{
public static bool IsOnScreenPoint(this VisualElement visualElement, Vector2 screenPoint)
{
var visualElementPositionY = Screen.height - screenPoint.y; // Y座標は方向が逆になる
return visualElement.worldBound.Contains(new Vector2(screenPoint.x, visualElementPositionY));
}
}
こちらの実装例では、枠の薄オレンジ色の部分をクリックした場合にのみ反応します。
-
(特にデザイン的に)複雑なUIの例は公式のサンプルゲームが参考になります。 ↩
-
このとき、
Assets/UI Toolkit
のフォルダと共に、Panel SettingsアセットがUnityにより自動で作成・アタッチされます。 ↩ -
A(不透明度)の値の指定漏れにご注意下さい。 ↩
-
CapturePointer
しないと、ポインタを素早く動かすとうまく追従できません。 (次のQiitaの記事が参考になります: https://qiita.com/ohbashunsuke/items/7692af357328998ac9ca) ↩