【Unity】UIBuilderを使用してEditor拡張Window作成

今風にUIBuilderを使用してEditorWindowを作成してみることにしました。

  • Unity2021.2.3f1

UIBuilderとは

Overview | UI Builder | 1.0.0-preview.18

公式リファレンスを見てもらうのが一番

Editor拡張など、スクリプトで構築していたUIをビジュアルオーサリングツールであるUIBuilderでUIをぽちぽち置いて画面を構築します

Unity2021.1 以降は標準に含まれているようで今後UIBuilderをうまく使いこなすのがスタンダードな時代になってくるのかもしれないですね


画面構成をXMLである .umxl というファイルに保存し、見た目の細かい指定は スタイルシート(USS) で構築します

が、EditorWindowでは見た目を気にするユーザーもいないので今回USSは使用しません。

目標

目標として下画面を作成を目指します

f:id:toshizabeth:20211125001510p:plain

以下を達成させます

  • 使用者が任意にパスを指定できるようにする。
  • そのパスは手入力もできるし、「ドラッグ・アンド・ドロップ」エリアにフォルダorファイルを突っ込むと自動でそのパスが入力される
    • ファイルを突っ込んだ場合はファイルが有るフォルダパスになる。
  • 実行ボタンを押したら反応できるようにする
  • 実行時の確認用のログを出せるようにする
    • 実行ボタン下にログがリストで並ぶ

項目で分けると

  1. パスを入力するエリア
  2. デバッグログを表示するリストビュー

の2点の実装。

そしてA、Bは他EditorWindowでも使いまわしたいので一つの独立したクラスとして作成する。

です


前準備

UI画面構成のUXMLファイルを適用なところに作成します

Create > UI ToolKit > Editor Window

から UXML, CSS. .cs 全てまとめて作成できます

しかし自分の環境ではバージョンのせいか、MacOSだからかUnityがエラー吐くという.. (使えねえ

f:id:toshizabeth:20211125002528p:plain

とにかく UXML を作成したらそのファイルをダブルクリックするとUIBuilder が自動的に立ち上がります


↓ 目指すべき完成

f:id:toshizabeth:20211125002407p:plain

赤丸内の構成が中央のViewportに表示されてます


HierarchyViewとSceneView みたいな感じ。


A. パスを入力するエリア

テキスト入力UIである TextField というものはUIBuilderの標準に用意されてあります。

しかし、毎回パスの手入力は面倒くさいので「ファイルorフォルダ」を ドラッグ・アンド・ドロップ してそのパスを自動設定する機能がほしい。

ですが、もちろん標準にその機能はありません


ここは自分で機能を作成します

f:id:toshizabeth:20211125094925p:plain


UIBuilderのパーツとして使用する方法は VisualElement クラスを継承し、 uxmlファイルで使用できるように UxmlFactory クラスも定義します

VisualElement.UxmlTraits を継承したクラスも定義します

(UIBuilderのインスペクタ上に独自のパラメータを定義するために使用します)

using UnityEditor
using UnityEngine.UIElements;

// ドラッグ・アンド・ドロップによる操作も受け付ける
public class PathFieldElement : VisualElement
{
    // このようにクラス無いクラスで定義するだけで良い
    public new class UxmlFactory : UxmlFactory<PathFieldElement, UxmlTraits> { }

    // 独自パラメータを定義するために使う
    public new class UxmlTraits : VisualElement.UxmlTraits
    {
        public override void Init(VisualElement ve, IUxmlAttributes bag, CreationContext cc)
        {
            base.Init(ve, bag, cc);
        }
    }
}

上記のクラスが基本形になります。ここに情報を肉付けしていきます


f:id:toshizabeth:20211128181232p:plain

まずはTextField。次にドラッグ・アンド・ドロップエリアを定義します


TextField

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

    public new class UxmlTraits : VisualElement.UxmlTraits
    {
        public override void Init(VisualElement ve, IUxmlAttributes bag, CreationContext cc)
        {
            base.Init(ve, bag, cc);
        }
    }

    private TextField _sourceFullPath; // TextField

    // コンストラクタ
    public PathFieldElement()
    {
         _sourceFullPath = new TextField("パス");

        // ↓表示調整なのであってもなくても
         _sourceFullPath.style.marginTop = 10;
         _sourceFullPath.style.marginBottom = 10;

         Add(_sourceFullPath);
    }
}

これで PathFieldElement を配置したときにテキストフィールドが描画されます。とても楽


ドラッグ・アンド・ドロップエリア

同じように作成していきますが処理が長いため別メソッドにしました

private VisualElement CreateDragAndDropArea()
{
    return new IMGUIContainer(() =>
    {
        var evt = Event.current;

        var dragAndDropArea = GUILayoutUtility.GetRect(0.0f, 40.0f, GUILayout.ExpandWidth(true));

        var boxStyle = new GUIStyle(EditorStyles.textField)
        {
            alignment = TextAnchor.MiddleCenter
        };

        GUI.Box(dragAndDropArea, "ドラッグ・アンド・ドロップ", boxStyle);

        int id = GUIUtility.GetControlID(FocusType.Passive);

        if (!dragAndDropArea.Contains(evt.mousePosition)) return;

        switch (evt.type)
        {
            // 更新または実行
            case EventType.DragUpdated:
                {
                    DragAndDrop.visualMode = DragAndDropVisualMode.Copy;
                    DragAndDrop.activeControlID = id;
                    break;
                }

            case EventType.DragPerform:

                DragAndDrop.AcceptDrag();

                // 最初のオブジェクトのみ判定する
                var obj = DragAndDrop.objectReferences.FirstOrDefault();
                if (obj != null)
                {
                    var assetPath = AssetDatabase.GetAssetPath(obj);

                    // ファイルの場合があるのでフォルダ名に変換する
                    if (!AssetDatabase.IsValidFolder(assetPath))
                    {
                        assetPath = System.IO.Path.GetDirectoryName(assetPath);
                    }

                    _sourceFullPath.value = assetPath;
                }

                DragAndDrop.activeControlID = 0;

                Event.current.Use();
                break;
        }
    });

これをTextFieldと同じくコンストラクタでAdd

    // コンストラクタ
    public PathFieldElement()
    {
         _sourceFullPath = new TextField("パス");

        // ↓表示調整なのであってもなくても
         _sourceFullPath.style.marginTop = 10;
         _sourceFullPath.style.marginBottom = 10;

         Add(_sourceFullPath);
        Add(CreateDragAndDropArea());
    }

これでOK


メソッド内の処理として、 IMGUIContainer を使用しています。

これは今までのEditor拡張で使用していた IMGUIをVisualElement として使えるように変換してくれるもので、これのおかげで今までの知識を活かしながらUIElementの実装ができます。

IMGUIContainer の初期化時のラムダの中で独自のEditor拡張UIを配置することでそれがそのまま描画されます。


つまりUIElementの機能にないものはIMGUIで作れば良い。ということです


これで UIBuilder のLibrary -> Project 内に 自分が定義した独自VisualElementが表示されます!

f:id:toshizabeth:20211128182829p:plain

あとはこれをUIBuilder上から配置します。


.uxml ファイル内を見てみると、UIBuiilderで配置した内容が反映されています。

このuxmlファイルをEditorWindowで使用します

        // 何らかのEditorWindow
    public class Hoge : EditorWindow
    {
        private const string UXMLPath = "{.uxmlファイルまでのパス}.uxml";

        [MenuItem("Utility/UI/Hoge")]
        public static void ShowExample()
        {
            var wnd = GetWindow<Hoge>();
            wnd.titleContent = new GUIContent("Hoge");
        }

        public void OnEnable()
        {
            // EditorWindowのRootにUXMLファイルの内容をぶら下げるイメージで
            var root = rootVisualElement;

            var visualTree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(UXMLPath);
            var uxml = visualTree.CloneTree();

            // RootにAddする
            root.Add(uxml);
                }
        }

これで UIBuilder で編集した内容を EditorWindow で表示できます。


UIBuilderでEditorWindowの見た目を編集出来るのはとても便利になったと思います(逆に今までコード上で定義してたのが疑問に思える)

少しテキストフォントを拡大したい。ボタンを大きくしたい。といったこともコードで調整していましたが今後はBuilder上で触るだけで良い。

今回のように機能を自作できることで部品として使い回せるのも利点ではないでしょうか。


今回の実装だけではパスをEditorWindow上から触れないため、EditorWindowでパスを取得する処理を書く必要があります

その実装は次回書きたいと思います