×

UnityのUI Toolkitをランタイムで使うときのワークフローを考える

解決したい問題

UnityでランタイムのUIをUIToolkitで開発する上での良い感じのワークフローを考えたい。

背景

将来的にはUnityのUIはUIToolkitが推奨されるようになる予定らしいが、まだ機能が足りてないのか、ランタイムのUIには引き続きuGUIが推奨されている。 現時点でもシンプルなUIなら全然作れるようにはなっていて、公式ドキュメントも充実している。

ランタイムのUIをUIToolkitで作ってみた系の記事は既にネットに沢山あるが、いざアプリを作るぞとなったときにどういうワークフローにするかについての知見が多くない。 今回、色々と試行錯誤した結果ある程度良い感じになったので備忘録として書くことにした。

概要

色々試した結果以下になった。

  • 見た目はuxmlとussで管理する
  • カスタムコントールは小さなUIコンポーネントだけにする
  • UIのコンポーネントやその集まりに対してviewとview modelのクラスを作りRxを使ってバインドする
  • インラインのスタイルは極力使わない
  • 再利用したり動的に生成するUIは個別のuxmlにする

環境

  • Unity6
  • Rider

uxml vs C#クラス

以下のようなVisualElement継承クラスを作ることで、VisualElementをC#で記述することができる。

public class MyElement : VisualElement
{
    readonly Label label;
    readonly Button button;

    public MyElement()
    {
        label = new Label();
        button = new Button();
        Add(label);
        Add(button);
        AddClassToList("my-element");
        label.AddClassToList("my-element-label");
        button.AddClassToList("my-element-button");
    }
}

uxmlにシリアライズしたい場合はカスタムコントロールにすればよい。 型に[UxmlElement]をつけてpartialクラスにすればOK。(Unity2021の時点ではUxmlTraitsを使うフローになっていたが今はObsoleteになっているので注意)

pros

  • UIをコードで管理できる

cons

  • 編集を確認するためにC#のコンパイルを待つ必要があり、UIの調整がストレスフル

なので、uxmlファイルを使うことにして、C#側のクラスは以下のようにした。

public class MyElementView
{
    readonly Label label;
    readonly Button button;

    public MyElementView(VisualElement root)
    {
        label = root.Q<Label>("my-label");
        button = root.Q<Button>("my-button");
    }
}

pros

  • UIBuilderだけでUIの見た目が決定される
  • 編集体験が(C#よりも)良い

cons

  • C#側でクエリを引く必要がある(uxmlとC#で整合している必要がある)

viewとview model

概ね↓のようにUIに対してviewとview modelを用意する(modelはない)。

内製のUnity UI Frameworkの開発から導入・運用 - Cluster Tech Blogこんにちは、クラスターでUnityエンジニアをしているsenchaです! 本記事ではUnityの内製UI Framework「shiranui」の開発と、導入について紹介いたします。 clusterが抱えていたUnityでのUI開発の問題点 UI Framework 「shiranui」とは shiranuiでできること shiranuiの基本的な使い方(Sample画面の表示) shiranuiの開発 clusterのunityprojectへshiranuiを導入する shiranuiの運用 最後に clusterが抱えていたUnityでのUI開発の問題点 clusterのUnity UI…tech-blog.cluster.mu

ボタンやトグルなどを一から作るのは大変なので、ビルドインのものをラップしてBind関数を生やす。 バインディングはRxでやりたいので、Cysharp/R3を使う。

public class MyButtonState
{
    public readonly Action OnClick;
    public MyButtonState(Action onClick)
    {
        onClick = OnClick;
    }
}

[UxmlElement]
public partial class MyButton : VisualElement
{
    readonly Button button = new();

    [UxmlAttribute]
    public string Text
    {
        get => button.text;
        set => button.text = value;
    }

    public MyButton()
    {
        AddToClassList("my-button");
        Add(button);
    }

    public IDisposable Bind(MyButtonState state)
    {
        return Observable.FromEvent(h => button.clicked += h, 
            h => button.clicked -= h).Subscribe(_ => state.OnClick);
    }
}

MyButtonStateには必要に応じてMyButtonの状態、例えば、ラベルに表示するテキストや、フォーカス状態、インタラクト可能かなどを(必要ならReactivePropertyで)持たせて良い。 MyButtonStateの状態に応じて見た目を変更したい場合はBind関数でCompositeDisposableを使う。

   public IDisposable Bind(MyButtonState state)
    {
        return  new CompositeDisposable(
            Observable.FromEvent(h => button.clicked += h, 
                h => button.clicked -= h).Subscribe(_ => state.OnClick),
            state.IsFocused.Subscribe(x => EnableInClassList("my-button--focused", x)));
    }

同様のviewとview modelの分離をMyElementに対しても行う。

public class MyElementViewModel
{
    public readonly MyButtonState MyButtonState;
    public readonly MyToggleState MyToggleState;

    public MyElementViewModel(Foo foo)
    {
        MyButtonState = new(() => foo.DoSomething());
        MyToggleState = new(x => foo.ToggleSomething(x));
    }
}

public class MyElementView
{
    readonly MyButton myButton;
    readonly MyToggle toggle;

    public MyElementView(VisualElement root)
    {
        myButton = root.Q<MyButton>("my-button");
        myToggle = root.Q<MyToggle>("my-toggle");
    }

    public IDisposable Bind(MyViewModel model)
    {
        return new CompositeDisposable(
            myButton.Bind(model.MyButtonState),
            myToggle.Bind(model.MyToggleState));
    }
}

良いところ

  • uxmlとussとviewで見た目が完結する
  • 状態の管理とイベントハンドラをview modelに分離できる
  • MVPよりもコードが少ない(UnityでUIをMVPパターンで開発するのが好きじゃない)

style

  • Unityのデフォルトのussと共存させる(ビルトインのButtonなどのスタイルを一から定義するのが大変なので)
  • ussを使いインラインのスタイルは使わない
  • 要素ごとにclassを割り当てる
  • 共通のstyleや変数は :root に書く
  • 要素の状態ごとのstyleは.element-name--state-nameにする
  • ビルトインの要素(Buttonなど)を触るときは > でアクセスする

MyRuntimeThema.tss

@import url("/Assets/UI Toolkit/UnityThemes/UnityDefaultRuntimeTheme.tss");
@import url("MyStyle.uss");

MyStyle.uss

:root {
    -unity-font-definition: url("...省略...");
    --my-text-color: rgba(255, 255, 255, 0.5);
    --my-focused-color: rgba(0, 255, 255, 1);
    color: var(--my-text-color);
}

.my-button {
    flex-direction: row;
    justify-content: center;
}

.my-button--focused {
    background-color: var(--my-focused-color);
}

.my-button > Button {
    background-color: rgba(0, 0, 0, 0);
    color: var(--my-text-color);
}

動的に生成するUI

リストのようなUIがあって、子要素を動的に生成するようなケースを考える。 uGUIならPrefabをInstantiateするがUI Toolkitでどうするべきかが悩ましい。 同じVisualElementを動的に複製したい場合、カスタムコントロールとしてC#で全てを記述するかVisualTreeAssetのCloneTreeまたはInstantiate関数を使うのいずれかになる。

例えば、動的に複数のボタンを生成するようなUIがあるとする。この場合、view modelがMyButtonStateの配列を持っており、それに基づいてviewがMyButtonを動的に生成することになる。 MyButtonはカスタムコントロールだから以下のように書ける。

readonly VisualElement buttonList;

public IDisposable Bind(MyElementViewModel model)
{
    var disposable = new CompositeDisposable();
    foreach (var buttonState in model.Buttons)
    {
        var button = new MyButton();
        button.Bind(buttonState).AddTo(disposable);
        buttonList.Add(button);
    }

    return disposable;
}

MyButtonがより大きなviewでカスタムコントロールではない場合はというと、なんとかしてBindの中でVisualTreeAssetに触る必要がある。

普通にやるとviewのコンストラクタにVisualTreeAssetを渡すのだと思うが、viewがnestしまくってると面倒くさいので、自分のプロジェクトでは諦めてシングルトンを使うようになった。この辺良い案があれば教えてほしい。

readonly VisualElement fooList;

public IDisposable Bind(MyElementViewModel model)
{
    var disposable = new CompositeDisposable();
    foreach (var fooState in model.GetFoos())
    {
        var fooView = new FooView(VisualElementFactory.Instance.CreateFoo());
        fooView.Bind(fooState).AddTo(disposable);
        fooView.AddTo(fooList);
    }

    return disposable;
}

Factoryには以下のようにVisualTreeAssetを登録する

// シングルトンの実装は省略

readonly VisualTreeAsset foo;
readonly VisualTreeAsset bar;
public CreateFoo() => foo.Instantiate();
public CreateBar() => bar.Instantiate();

public static void Register(VisualTreeAsset foo, VisualTreeAsset bar)
{
    Instance = new VisualElementFactory(foo, bar);
}

落とし穴

VisualTreeAssetのCloneTreeやInstantiateで得られるVisualElementはTemplateContainerになっている。 下の画像のようにuxmlをネストさせたときに自動で生成されるものと同じ。

動的に生成したVisualElelementに対してドラッグや移動などの制御を行いたい場合にTemplateContainerが邪魔になることがある。

こういうときは、viewのコンストラクタでTemplateContainerに対してクエリを引いてTemplateContainer自体は捨ててしまうようにしてる(これもなんかいい方法あったら教えてほしい)。

例えば、以下のようなuxmlがある場合は

C#で#nodeをクエリで引いてくる。

public class NodeView
{
    readonly VisualElement root;
    public Rect WorldBound => root.worldBound;

    public NodeView(VisualElement template)
    {
        this.root = template.Q<VisualElement>("node")
    }

    public void AddTo(VisualElement parent) => parent.Add(root);
    public void RemoveFrom(VisualElement parent) => parent.Remove(root);
}

var nodeView = new NodeView(VisualElementFactory.CreateNode());
nodeView.AddTo(nodeParent);

所感

  • 見た目の調整がUIBuilder完結するのがうれしい
  • クエリを引くときにRiderが謎の技術で名前をサジェストしてくれるので、クエリ自体にはそんなに不便していない
  • どちらかというとスタイルの管理やC#とussの間の整合性の方が面倒くさい
  • 動的生成は面倒くさい
  • 凝ったUIが求められるゲームでの利用に耐えられるかは怪しい(表現力がussに縛られるのでシェーダーが使えるようにならないと厳しそう)
  • 逆にシステムUIっぽいものなら慣れたらこっちの方が楽なように思う(PrefabやMonoBehaviourを再利用するよりもussを使いまわす方が筋が良い)
  • 簡単なアニメーションならussで作れる
  • 顧客が本当に求めているものは、Unityのデフォルトのussをコピーしてきて、それを部分的に上書きすることでは・・・?

最後に

UI ToolkitでRectorという音に合わせて絵を作るアプリを作っている。

Happiness Machineopen.spotify.com

3月にそれを使ってVJするので遊びに来てください。

Referenced by