Unity UI ToolKit を使ってみた

こんにちは。UI フレームワークの狂人、亀山です。世は大宣言的 UI フレームワーク時代ですが、プラットフォームごとに乱立しまくっていて正解が見つけづらい状況でもあります。Unity のランタイム UI は長らく uGUI で構築されてきましたが、デフォルトの見た目は古めかしいし、エンジニア的にはシーン上であれこれ設定するのはメンテも大変という問題がありました。そこで最近登場した新しい Unity の UI システムである UI ToolKit を試した話と、諦めた話をします。

UI ToolKit

HTML + CSS 風なマークアップ言語で UI を構築するフレームワークと、Unity Editor 上でビジュアルで構築するためのビルダーなどのツール群です。

イベント

クリックなどイベントの紐付けは UIDocument.rootVisualElement.Q で要素を取得して行うことができます。XAML 的なバインディングを期待していたので面倒な印象です。

public class MainView : MonoBehaviour, IMainView
{
    public Action OnClickCloseButton { get; set; }
    public Action OnClickFolderButton { get; set; }
    public Action OnClickSettingButton { get; set; }

    void Start()
    {
        var doc = GetComponent<UIDocument>();

        var closeButton = doc.rootVisualElement.Q("button-close").Q<Button>();
        closeButton.clicked += () => OnClickCloseButton?.Invoke();

        var folderButton = doc.rootVisualElement.Q("button-folder").Q<Button>();
        folderButton.clicked += () => OnClickFolderButton?.Invoke();

        var settingButton = doc.rootVisualElement.Q("button-setting").Q<Button>();
        settingButton.clicked += () => OnClickSettingButton?.Invoke();
    }
}

uGUI の場合

ほとんど同じでした。IMainView として抽象化していてインターフェースは変わっていません。

public class MainView : MonoBehaviour, IMainView
{
    public Action OnClickCloseButton { get; set; }
    public Action OnClickFolderButton { get; set; }
    public Action OnClickSettingButton { get; set; }

    [SerializeField]
    Button closeButton;
    [SerializeField]
    Button settingButton;
    [SerializeField]
    Button localContentsButton;
    [SerializeField]
    Button remoteContentsButton;

    void Start()
    {            
        closeButton.onClick.AddListener(() => OnClickClose?.Invoke());
        settingButton.onClick.AddListener(() => OnClickSetting?.Invoke());
        localContentsButton.onClick.AddListener(() => OnClickLocalContents?.Invoke());
        remoteContentsButton.onClick.AddListener(() => OnClickRemoteContents?.Invoke());
    }
}

ボタンの見た目のカスタマイズ

アイコンを表示するボタンの View を作ります。

MainButton.uxml

<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements" editor-extension-mode="False">
    <Style src="project://database/Assets/UI%20Toolkit/Styles.uss?fileID=7433441132597879392&amp;guid=144b0bb50c9c844f29cc25d216f5d1fe&amp;type=3#Styles" />
    <ui:Button text="&#10;" display-tooltip-when-elided="true" name="Button" class="main-button">
        <ui:VisualElement name="icon" class="icon" />
    </ui:Button>
</ui:UXML>

CSS 的な USS を書きます。ボタンごとにアイコンを変えるため、それぞれクラス名を付けて USS で background-image を指定しています。

Styles.uss

.main-button {
    background-image: none;
    padding-left: 0;
    padding-right: 0;
    padding-top: 0;
    padding-bottom: 0;
    width: 44px;
    height: 44px;
    margin-left: 0;
    margin-right: 0;
    margin-top: 0;
    margin-bottom: 0;
    justify-content: center;
    align-items: center;
    background-color: rgba(255, 255, 255, 0.08);
    border-top-left-radius: 8px;
    border-bottom-left-radius: 8px;
    border-top-right-radius: 8px;
    border-bottom-right-radius: 8px;
    border-left-width: 0;
    border-right-width: 0;
    border-top-width: 0;
    border-bottom-width: 0;
}

.main-button:hover {
    opacity: 0.5;
}

.main-button .icon {
    width: 20px;
    height: 20px;
    position: relative;
}

.main-button-setting .icon {
    background-image: url('project://database/Assets/Textures/iconmonstr-gear-6-240.png?fileID=21300000&guid=95571b7a470d84b68bff18d2bb9f9028&type=3#iconmonstr-gear-6-240');
}

.main-button-close .icon {
    background-image: url('project://database/Assets/Textures/close-button.png?fileID=21300000&guid=11027724a34854471ad7a51982213d42&type=3#close-button');
}

.main-button-folder .icon {
    background-image: url('project://database/Assets/Textures/iconmonstr-folder-16-240.png?fileID=21300000&guid=51a0f695b1efc4494be1635272875e53&type=3#iconmonstr-folder-16-240');
}

スクロールバーの見た目のカスタマイズ

UI Toolkit Debugger でカスタマイズしたいビューのクラス名を探して USS を書きます。 組み込みのスクロールバーは次のコードですこしマシになりました。

/* スクロールバーの背景色を消す */
#ListView .unity-base-slider__tracker {
    background-color: rgba(0, 0, 0, 0);   
    border-left-width: 0;
    border-right-width: 0;
}

/* スクロールバー上下のボタンを消す */
#ListView .unity-scroller__low-button,
#ListView .unity-scroller__high-button {
    width: 0;
    height: 0;
    border-left-width: 0;
    border-right-width: 0;
    border-top-width: 0;
    border-bottom-width: 0;
}

#ListView .unity-scroller .unity-base-slider__dragger {
    border-top-left-radius: 9px;
    border-top-right-radius: 9px;
    border-bottom-left-radius: 9px;
    border-bottom-right-radius: 9px;
}

Before

スクロールバーが古めかしいです。Windows 98?

After

ちょっとマシになった。

ListView の実装

動的に View を生成するための ListView の makeItem 等のメソッドを実装します。iOS の UITableViewDataSource みたいな感じです。

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using ActiveText.Domain;
using UnityEngine;
using UnityEngine.Assertions;
using UnityEngine.UIElements;

public class ContentListView : MonoBehaviour
{
    UIDocument document;

    [SerializeField]
    VisualTreeAsset cellTemplate;

    ListView listView;

    public Action OnOpen { get; set; }
    public Action OnClickClose { get; set; }

    void Start()
    {
        document = GetComponent<UIDocument>();
        Assert.IsNotNull(document);

        listView = document.rootVisualElement.Q<ListView>();
        Assert.IsNotNull(listView);

        var closeButton = document.rootVisualElement.Q<Button>("button-close");
        Assert.IsNotNull(closeButton);
        closeButton.clicked += () => OnClickClose?.Invoke();

        listView.makeItem = () =>
        {
            var elem = cellTemplate.Instantiate();
            var cell = new ContentListCell(elem);
            elem.userData = cell;
            return elem;
        };

        listView.bindItem = (item, index) =>
        {
            var items = listView.itemsSource as List<FileListItem>;
            (item.userData as ContentListCell).Configure(items[index]);
        };

        listView.onSelectionChange += (IEnumerable<object> selectedItems) =>
        {
            FileListItem item = (FileListItem)selectedItems.First();
            item.onClick?.Invoke();
        };

        listView.selectionType = SelectionType.Single;
        listView.fixedItemHeight = 50;
    }

    public void SetItems(List<FileListItem> items)
    {
        var label = document.rootVisualElement.Q<Label>();
        label.text = items.Count.ToString();
        listView.itemsSource = items;
        listView.Rebuild();
    }

    public void Show()
    {
        document.rootVisualElement.SetDisplay(true);
    }

    public void Hide()
    {
        document.rootVisualElement.SetDisplay(false);
    }
}

良かったところ

  • HTML, CSS に慣れているとあまり違和感なく書くことができる
  • hover などが使えるのでスクリプトを書かずに簡単にいい感じの UI の挙動を作り込むことができる
  • コードで書けるので Git 的に嬉しい
  • CanvasScaler などを気にしないでいい感じにレンダリングしてくれる
  • フォントも特に TextMeshPro などのことを気にしないで使い始められた

良くなかったところ

  • 新しい仕組みの割にバインディングが面倒
  • View 再利用のためのコンポーネント化のワークフローがいまいちイケてない
  • Q でいちいち文字列のクエリを書かないといけないのでコンパイル時に検査されず安全じゃない
  • デフォルトの UI がダサい
  • デフォルトの UI をカスタマイズするには内部で使われている USS のセレクタを頑張って調べる必要があって難しい
  • CSS じゃないので USS の独自の書き方が分かりづらい。ドキュメントも探しづらく、画像 URL の指定など手探りで見つけた
  • インターネット上にランタイムで UI ToolKit を使っている情報が少ない
  • ビルダーでスタイルを調整するのがわかりづらく、結局コードで USS を書いた

まとめ

uGUI で構築していた UI を UI ToolKit でリプレイスすることを検討しました。その結果、問題なくリプレイスはできました。しかしデフォルトの UI はしょぼいのでカスタマイズが必要になりますが、UI ToolKit 独自のノウハウがかなり必要でネット上で答えも見つけづらい状態なので、自分から能動的に仕組みを掘り下げていける人でないとメンテも難しいです。そのため、チームでメンテしていくアプリへの導入はハードルが高いと考え導入を見送りました。

この記事が誰かの UI ToolKit 導入の礎となり、人々が新たな地雷をどんどん踏み抜いて平和な大地が築かれることを望みます。いい頃合いになったらまた見に来ます。

その後

TextMeshPro で苦しんでる、誰か助けてくれ。