UI Toolkitでステージエディタを作ってみた
この記事はAkatsuki Advent Calendar 19日目の記事です。
昨日はcllightzさんの「マツダ車のAndroid Auto機能でナビ画面にUnityを映したかったが数々の壁に阻まれた件」でした。
車のナビにUnity画面を出すという試み、とても面白そうでした。続報が気になります!
UI Toolkit とは
Unityが標準で提供している、UI作成のための機能/リソース/ツールです。
以前はエディタ拡張だけで使えるもの(UIElements)でしたが、2021LTS以降はランタイムでも使えます。
通常のエディタ拡張では構造/見た目/挙動を全てC#で記述しますが、UI Toolkitでは以下のように分割して定義できるので、可読性が上がったり修正が容易になりそうな気がします。Web開発経験のある方には直感的かもしれません。
- UXML: UI要素の構造を定義するマークアップ
- USS: UI要素の見た目を定義するスタイルシート
- C#: UI要素の挙動を実装する
また、uGUIのようにGUI操作でUXMLとUSSを自動生成できるツール(UI Builder)も用意されています。
UI Toolkitの説明やメリットについては、先日のアドカレで同期のyuyuが書いた「今からでも遅くない!最新のUnityエディタ拡張を知ろう 〜 UIElementsのすすめ 〜」がとても分かりやすかったので、是非ご一読ください!
この記事は何?
この記事では、 実際にUI Toolkitを使って実用的なツール(EditorWindow)を作ってみた例を紹介 します。
UI Toolkitを学ぶにあたって、公式ドキュメントや各種記事はもちろん参考になったのですが、実用的なツール規模の作成例がまだまだ少ない気がします。
特に全体の構成/設計をどうすべきなのか、といった情報がなかなかありません。
ということで、自分は現状こんな感じでやってます、という構成/設計と実際のツール作成例について記事にしました。
自分自身まだまだUI Toolkitのベストプラクティス的なものを探っている段階なので、他の作成例も参考にしつつ自分の中でブラッシュアップできたらと思っています(逆にそういう方にとってこの記事が参考のひとつになれば幸いです)。
ちなみに今回はランタイムでの使用やUI Builderについては全く触れません。
今回作るもの
こんな感じのステージエディタを作ります。
大まかな要件
- ScriptableObjectでステージデータ群を管理し、その値をステージエディタで表示/変更する
- ステージデータ群のIDリストを左側に表示し、選択したデータの内容を右側に表示する
- 右側上部にマス種類のボタン群を表示し、1つ選択できるようにする
- 右側下部にステージのマスを表示する
- マスをクリックした場合、右側上部で選択しているマス種類に置き換える(消しゴム選択時は空マスになる)
成果物
実際に作ったもの(この記事で解説する内容+α)はこちらにあげました。
記事を読むよりも実際に手元で触りながらコードを読みたい、という方は是非上記からclone等して試してみてください!
実装方針
分割の方針
可読性や再利用性などの理由で、以下のように分割することを目指します。
-
左側のIDリストビュー
右側上部のツールビュー
右側下部の編集ビュー
で分割する - 各ビューそれぞれ
構造(UXML)
見た目(USS)
挙動(C#)
で分割する
全体構成の方針
- 各ビューの領域を定義するためにUIの大枠を生成する
- 各ビューのUIをそれぞれ生成する
- 各ビューを大枠にはめ込む
という構成にします。
ステージエディタ作成
EditorWindowを作る
Projectビューで右クリックし、 Create > UI Toolkit > Editor Window
を選択。
ウィンドウが出てくるので、任意のファイル名を入力します。
こんな感じでUXML/USS/C#のファイルが自動生成されます。
作ったEditorWindowは生成時に自動で開かれますが、閉じてしまった場合はメニューバーから開くことができます。(場所はC#ファイル内に指定してあります)
[MenuItem("Window/UI Toolkit/GridStageEditorWindow")]
UIの大枠を作る
<UXML xmlns="UnityEngine.UIElements">
<Style src="GridStageEditorWindow.uss" />
<VisualElement name="container">
<VisualElement name="list-view-container" />
<VisualElement name="right-container">
<VisualElement name="tool-view-container" />
<VisualElement name="edit-view-container" />
</VisualElement>
</VisualElement>
</UXML>
全体はUXMLタグで囲みます。xmlns属性でデフォルト名前空間を指定します。
Styleタグのsrc属性でUSSファイルへの相対パスを指定することで、スタイルを適用できます。
C#側で指定することもできますが、今回はUXML内で指定しました。
VisualElementタグはデフォルトのUI要素です。
name属性で識別子を指定し、USSやC#から参照できるようにしておきます。
* {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
#container {
flex-direction: row;
}
#list-view-container {
width: 60px;
border-right-width: 1px;
border-right-color: #888;
}
#right-container {
flex-direction: column;
}
#tool-view-container {
height: 60px;
}
#edit-view-container {
background-color: #555;
}
#hoge
はUXML内の name="hoge"
の要素に適用されます。
各ビューの領域(container)の大きさや、枠色&背景色を指定しておきます。
using UnityEditor;
using UnityEngine;
namespace GridStageEditor
{
// 大元のWindow
public class GridStageEditorWindow : EditorWindow
{
[MenuItem("Window/GridStageEditorWindow")]
public static void Open()
{
var window = GetWindow<GridStageEditorWindow>();
window.titleContent = new GUIContent("GridStageEditorWindow");
}
public void CreateGUI()
{
var rootElement = rootVisualElement;
// UXMLを元にUI要素を作成
var windowTree = UIToolkitUtil.GetVisualTree("GridStageEditorWindow");
var windowElement = windowTree.Instantiate();
rootElement.Add(windowElement);
}
}
}
using UnityEditor;
using UnityEngine.UIElements;
namespace GridStageEditor
{
// 便利クラス
public static class UIToolkitUtil
{
private const string DIRECTORY_PATH = "Assets/GridStageEditor/Scripts/Editor/";
public static VisualTreeAsset GetVisualTree(string path)
{
var fullPath = DIRECTORY_PATH + path + ".uxml";
return AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(fullPath);
}
}
}
描画処理は EditorWindow
を継承しているクラスの CreateGUI()
メソッド内に記述します。
rootVisualElement
がWindowのroot要素です。
UXMLアセットをVisualTreeAsset型として取得し、 Instantiate()
でUI生成します。
rootElement.Add(windowElement);
することで、rootElementの子として生成したUIを配置できます。
ここまでで各ビューの領域分割ができました。
リストビューを作る
ステージデータ作成
まずは表示させるステージデータを作成します。
using System;
using System.Collections.Generic;
using UnityEngine;
namespace GridStageEditor
{
// ステージデータの管理クラス
[CreateAssetMenu]
public class StageDataHolder : ScriptableObject
{
public List<StageData> stageDataList;
}
[Serializable]
public class StageData
{
public int id;
public Vector2Int size;
public List<StageUnitType> unitTypes;
}
}
Projectビューで右クリックして Create > StageDataHolder
するとScriptableObjectが作成できるので、適当にデータを入れておきます。
リストビュー実装
<UXML xmlns="UnityEngine.UIElements">
<Style src="ListView.uss" />
<ListView />
</UXML>
ListViewタグでリストビューを作成できます。
Label {
border-bottom-width: 1px;
border-bottom-color: #555;
-unity-text-align: middle-center;
}
Label
で指定した内容は、UXML内のLabel(テキスト要素)に適用されます。
ここでは要素に区切り線を表示することと、テキストを中央揃えすることだけ指定します。
このあとC#でListView内にLabelを生成する処理を書きます。
using System;
using System.Linq;
using UnityEngine.UIElements;
namespace GridStageEditor
{
// ステージデータ群のIDリストビュー
public class ListView : VisualElement, IStageDataChangedNotifier
{
public Action<StageData> NotifyStageDataChanged { private get; set; }
public ListView()
{
var baseTree = UIToolkitUtil.GetVisualTree("ListView/ListView");
var baseElement = baseTree.Instantiate();
Add(baseElement);
Initialize();
}
private void Initialize()
{
var stageDataHolder = UIToolkitUtil.GetAssetByType<StageDataHolder>();
var dataList = stageDataHolder.stageDataList;
var listView = this.Q<UnityEngine.UIElements.ListView>();
listView.makeItem = () => new Label();
listView.bindItem = (element, index) => ((Label) element).text = dataList[index].id.ToString();
listView.itemsSource = dataList;
listView.selectionType = SelectionType.Single;
listView.onSelectionChange += selection => NotifyStageDataChanged?.Invoke((StageData) selection.First());
}
}
}
using System;
namespace GridStageEditor
{
// ステージデータの変更を通知する
public interface IStageDataChangedNotifier
{
public Action<StageData> NotifyStageDataChanged { set; }
}
}
// 指定の型のアセットを取得する便利メソッド
public static T GetAssetByType<T>(string[] searchInFolders = null) where T : UnityEngine.Object
{
var guids = AssetDatabase.FindAssets($"t:{typeof(T)}", searchInFolders);
if (guids == null || !guids.Any())
{
return null;
}
var guid = guids.First();
var path = AssetDatabase.GUIDToAssetPath(guid);
var component = AssetDatabase.LoadAssetAtPath<T>(path);
return component;
}
コンストラクタでListViewのUXMLを取得して、自分の子としてUI生成します。
生成した子要素は this.Q<UnityEngine.UIElements.ListView>()
で取得できます。
取得したListViewは以下のように挙動を指定します。
// リスト要素の生成処理: Label(テキスト要素)を生成
listView.makeItem = () => new Label();
// リスト要素の初期化処理: データのIDをLabelのtextに指定
listView.bindItem = (element, index) => ((Label) element).text = dataList[index].id.ToString();
// リストのデータ: リストの元データを指定
listView.itemsSource = dataList;
// 選択タイプ: 複数選択を許可しないように指定
listView.selectionType = SelectionType.Single;
// リスト要素の選択時処理: selectionに選択された要素リストが入っているので、最初の要素の選択通知を行う
listView.onSelectionChange += selection => NotifyStageDataChanged?.Invoke((StageData) selection.First());
ツールビューを作る
マス種別のアイコン素材データ作成
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace GridStageEditor
{
// マス種別のアイコン素材データの管理クラス
[CreateAssetMenu]
public class StageUnitDataHolder : ScriptableObject
{
public List<StageUnitData> stageUnitDataList;
public Sprite GetSprite(StageUnitType type)
{
return stageUnitDataList.FirstOrDefault(data => data.type == type)?.sprite;
}
}
[Serializable]
public class StageUnitData
{
public StageUnitType type;
public Sprite sprite;
}
public enum StageUnitType
{
Empty = 0,
Block = 1,
Button = 2,
Player = 3
}
}
Projectビューで右クリックして Create > StageUnitDataHolder
するとScriptableObjectが作成できるので、データを入れておきます。
ツールアイコンの実装
次に、ツールアイコン単体を実装します。
<UXML xmlns="UnityEngine.UIElements">
<Style src="ToolItem.uss" />
<Button>
<Image />
</Button>
</UXML>
Button {
margin: 0;
padding: 0;
}
Image {
padding: 6px;
}
.selected {
border-color: #fc0;
border-width: 4px;
}
.hoge
はUXML内の class="hoge"
の要素に適用されます。
nameと似ていますが、classは複数要素に適用する用途で使うものと思われます。
.selected
では、ツールアイコン選択時の選択枠の見た目を指定します。
using System;
using UnityEngine.UIElements;
namespace GridStageEditor
{
// ツールアイコン
public class ToolItem : VisualElement
{
public ToolItem(StageUnitData data, Action<StageUnitType> onClick)
{
var baseTree = UIToolkitUtil.GetVisualTree("ToolView/ToolItem");
var baseElement = baseTree.Instantiate();
Add(baseElement);
Initialize(data, onClick);
}
private void Initialize(StageUnitData data, Action<StageUnitType> onClick)
{
var image = this.Q<Image>();
image.sprite = data.sprite;
var button = this.Q<Button>();
button.clicked += () => onClick?.Invoke(data.type);
}
public void SetSelected(bool isSelected)
{
const string selectedClassName = "selected";
this.Q<Button>().EnableInClassList(selectedClassName, isSelected);
}
}
}
VisualElementに対して EnableInClassList(className, isEnabled)
を実行することで、
指定したクラス属性(C#のクラスではなくUXMLのクラス属性)の有効/無効を切り替えます。
ここでは選択したボタンのselectedクラス属性を有効にし、それ以外のボタンのselectedクラス属性を無効にすることで、選択したボタンに選択枠を表示するようにしています。
ツールビューの実装
<UXML xmlns="UnityEngine.UIElements">
<Style src="ToolView.uss" />
<VisualElement name="tool-item-container" />
</UXML>
#tool-item-container {
flex-direction: row;
}
ToolItem {
width: 40px;
height: 40px;
margin: auto 4px;
}
flex-direction: row;
でツールアイコンを横並びにします。
また、ツールアイコンの大きさやマージンを指定しておきます。
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;
namespace GridStageEditor
{
// 操作するマス種別を選択するツールビュー
public class ToolView : VisualElement
{
private readonly Dictionary<StageUnitType, ToolItem> itemButtons = new();
public static StageUnitType CurrentStageUnitTool { get; private set; }
public ToolView()
{
var baseTree = UIToolkitUtil.GetVisualTree("ToolView/ToolView");
var baseElement = baseTree.Instantiate();
Add(baseElement);
Initialize();
}
private void Initialize()
{
var stageUnitDataHolder = UIToolkitUtil.GetAssetByType<StageUnitDataHolder>();
var dataList = stageUnitDataHolder.stageUnitDataList.ToList();
var eraserSprite = AssetDatabase.LoadAssetAtPath<Sprite>("Assets/GridStageEditor/Images/eraser.png");
dataList.Insert(0, new StageUnitData { type = StageUnitType.Empty, sprite = eraserSprite });
var itemContainer = this.Q<VisualElement>("tool-item-container");
itemButtons.Clear();
foreach (var data in dataList)
{
var itemButton = new ToolItem(data, OnClickToolItem);
itemContainer.Add(itemButton);
itemButtons.Add(data.type, itemButton);
}
}
private void OnClickToolItem(StageUnitType unitType)
{
foreach (var (type, itemButton) in itemButtons)
{
itemButton.SetSelected(type == unitType);
}
CurrentStageUnitTool = unitType;
}
}
}
var eraserSprite = AssetDatabase.LoadAssetAtPath<Sprite>("Assets/GridStageEditor/Images/eraser.png");
dataList.Insert(0, new StageUnitData { type = StageUnitType.Empty, sprite = eraserSprite });
ScriptableObjectで指定したマス種別に加えて、空マス用のアイコン画像もdataListに入れておきます。
foreach (var data in dataList)
{
var itemButton = new ToolItem(data, OnClickToolItem);
itemContainer.Add(itemButton);
itemButtons.Add(data.type, itemButton);
}
dataList分、ToolItemを生成してitemContainerの子に配置します。
private void OnClickToolItem(StageUnitType unitType)
{
foreach (var (type, itemButton) in itemButtons)
{
itemButton.SetSelected(type == unitType);
}
CurrentStageUnitTool = unitType;
}
アイコンをクリックした際は SetSelected()
を呼び出し、選択されたアイコンボタンに選択枠を表示します。
また、現在選択中のツールを CurrentStageUnitTool
に入れて外部から参照できるようにしておきます。
編集ビューを作る
<UXML xmlns="UnityEngine.UIElements">
<Style src="EditView.uss" />
<VisualElement name="edit-view-content">
<VisualElement name="grid-container">
<VisualElement name="grid-content" />
</VisualElement>
</VisualElement>
</UXML>
#grid-container {
padding: 40px;
}
#grid-content {
margin: auto;
flex-direction: row;
flex-wrap: wrap;
}
#grid-content
は
flex-direction: row;
flex-wrap: wrap;
でグリッド上に子要素を配置するように指定します。
子要素はC#で生成して配置します。
using System;
using UnityEngine;
using UnityEngine.UIElements;
namespace GridStageEditor
{
// ステージデータの中身を表示/編集する編集ビュー
public class EditView : VisualElement, IStageDataApplier, IStageDataChangedNotifier
{
private StageData currentStageData;
public Action<StageData> NotifyStageDataChanged { private get; set; }
public EditView()
{
var baseTree = UIToolkitUtil.GetVisualTree("EditView/EditView");
var baseElement = baseTree.Instantiate();
Add(baseElement);
}
public void ApplyStageData(StageData stageData)
{
currentStageData = stageData;
CreateGrid(stageData);
}
private void CreateGrid(StageData stageData)
{
var size = stageData.size;
var gridContainer = this.Q<VisualElement>("grid-container");
var containerAreaSize = gridContainer.contentRect.size;
var buttonPx = Mathf.Min(containerAreaSize.x / size.x, containerAreaSize.y / size.y);
var gridContent = this.Q<VisualElement>("grid-content");
var contentAreaSize = (Vector2) size * buttonPx;
gridContent.style.width = contentAreaSize.x;
gridContent.style.height = contentAreaSize.y;
gridContent.Clear();
var stageUnitDataHolder = UIToolkitUtil.GetAssetByType<StageUnitDataHolder>();
for (var y = size.y - 1; y >= 0; y--)
{
for (var x = 0; x < size.x; x++)
{
var index = y * size.x + x;
var stageUnitSprite = stageUnitDataHolder.GetSprite(stageData.unitTypes[index]);
var button = CreateGridButton(index, (int) buttonPx, stageUnitSprite);
gridContent.Add(button);
}
}
}
private Button CreateGridButton(int index, int px, Sprite sprite)
{
var button = new Button
{
style =
{
width = px,
height = px,
backgroundImage = new StyleBackground(sprite)
}
};
button.clicked += () =>
{
currentStageData.unitTypes[index] = ToolView.CurrentStageUnitTool;
NotifyStageDataChanged?.Invoke(currentStageData);
};
return button;
}
}
}
namespace GridStageEditor
{
// ステージデータを反映させる
public interface IStageDataApplier
{
public void ApplyStageData(StageData stageData);
}
}
CreateGrid(StageData stageData)
メソッドでStageDataの内容をグリッド表示します。
var containerAreaSize = gridContainer.contentRect.size;
var buttonPx = Mathf.Min(containerAreaSize.x / size.x, containerAreaSize.y / size.y);
でgridContainerに収まるマスの大きさを計算し、
var contentAreaSize = (Vector2) size * buttonPx;
gridContent.style.width = contentAreaSize.x;
gridContent.style.height = contentAreaSize.y;
でマス群がぴったり入る大きさにgridContentの大きさを調整しています。
あとはgridContentの子要素としてマス群を生成/配置します。
CreateGridButton()
メソッド内の
var button = new Button
{
style =
{
width = px,
height = px,
backgroundImage = new StyleBackground(sprite)
}
};
ではボタンを生成する際に、
- 計算した大きさにする
- 対象のツールアイコンを背景画像に表示する
ようにしています。
また、ボタンがクリックされた際の処理を以下のように記述しています。
button.clicked += () =>
{
currentStageData.unitTypes[index] = ToolView.CurrentStageUnitTool;
NotifyStageDataChanged?.Invoke(currentStageData);
};
現在選択中のマス種別を ToolView.CurrentStageUnitTool
で取得し、対象のステージデータの対象のマス情報を更新するようにしています。
また、 NotifyStageDataChanged
を呼んで変更を通知するようにしています。
各ビューを大枠にはめ込む
各ビューの実装ができたので、大元のWindowに用意した大枠にそれぞれはめ込みます。
public void CreateGUI()
{
~中略~
// 各containerにViewを生成
var listView = new ListView();
var toolView = new ToolView();
var editView = new EditView();
rootElement.Q<VisualElement>("list-view-container").Add(listView);
rootElement.Q<VisualElement>("tool-view-container").Add(toolView);
rootElement.Q<VisualElement>("edit-view-container").Add(editView);
}
GridStageEditorWindow.cs
の CreateGUI()
メソッド内で、各ビューのC#インスタンスを生成し、各ビューのcontainer(はめ込む大枠)を取得してその子要素に各ビューを配置します。
各ビューでの変更を検知/反映させる
最後に、各ビューでステージデータ等が変更された場合に、検知/反映させる仕組みを実装します。
public void CreateGUI()
{
// ~中略~
// ステージデータの変更を検知/反映
var stageDataChangedNotifiers = new List<IStageDataChangedNotifier> { listView, editView };
var stageDataAppliers = new List<IStageDataApplier> { editView };
InitViewsDependedOnStageData(stageDataChangedNotifiers, stageDataAppliers);
}
private void InitViewsDependedOnStageData(List<IStageDataChangedNotifier> notifiers, List<IStageDataApplier> appliers)
{
foreach (var notifier in notifiers)
{
notifier.NotifyStageDataChanged = OnStageDataChanged;
}
void OnStageDataChanged(StageData stageData)
{
// ステージデータの変更を保存
var stageDataHolder = UIToolkitUtil.GetAssetByType<StageDataHolder>();
EditorUtility.SetDirty(stageDataHolder);
AssetDatabase.SaveAssets();
foreach (var applier in appliers)
{
applier.ApplyStageData(stageData);
}
}
}
IStageDataChangedNotifier
を実装したステージデータの変更を通知するクラスと、 IStageDataApplier
を実装したステージデータの変更を反映するクラスをそれぞれリストとして用意しておきます。
リストビューは表示するStageDataの変更、EditViewは表示しているStageDataの内容変更が行えるので、この2つのビューに IStageDataChangedNotifier
が実装されています。
また、EditViewは表示するべきStageDataが変わると表示更新する必要があるので、 IStageDataApplier
も実装されています。
IStageDataChangedNotifier
を実装したビューには以下のように、ステージデータが変更された時に呼び出すアクションを指定しておきます。
notifier.NotifyStageDataChanged = OnStageDataChanged;
IStageDataApplier
を実装したビューには以下のように、ステージデータが変更されたらステージデータを反映するようにしておきます。
applier.ApplyStageData(stageData);
振り返り
今回良かった点としては、各ビューで分割して作成することができた点 だと感じています。
例えば別のEditorWindowでもIDリストビューを使いたい、といった場合にモジュールとしてそのまま使い回すことができそうです。
逆に今回モヤっとした点は、UXMLでUI構造を定義するメリットがイマイチ納得できていない点 です。
リスト表示やfor文を使う表示、ボタン挙動などはUXMLで定義できないにもかかわらず、UnityのEditorWindowでは使用頻度が高いため、結局C#で色々書いたり動的に生成したりすることが多くなりそうです。
そうなった場合に、構造だけUXMLに抜き出すよりも、C#で一緒に書いてしまった方が手っ取り早いと感じてしまいました。
ただし、リスト表示などをしないシンプルなUIを作る場合に関しては、C#で書くよりもUXMLの方がシンプルで階層構造も分かりやすく書けるため、大きなメリットになりそうです。
最後に
最初にも触れましたが、UI Toolkitについてはまだまだ手探り状態なので、徐々に知見を貯めていきたい所存です。
今回はエディタ拡張についてでしたが、ランタイムもどんどん使い勝手が良くなっているはずなので追っていきたいです(将来的にはuGUIに代わる唯一のUI作成機能を目指している[1] そうですが、そんな日が来るのでしょうか...)。
明日の記事はnaoya kohdaさんの "ぼくのかんがえたさいきょうのば~ちゃるびしょ~じょはいしんしすてむ" についてです。
社内ミーティングでもVの姿で参加したりしているkohdaさんの "さいきょうしすてむ" 、楽しみにしております!
最後に、アカツキゲームスでは一緒に働くエンジニアを募集しています。
カジュアル面談からでもぜひ!
Discussion