Unity でも宣言的 UI 使いたくない??
1 年前に Unity で 状態管理をするフレームワーク を作りました。
しかし、まだ辛いところがあったのです。
「宣言的に UI を書きたくない??」
宣言的 UI とは?
宣言的 UI は宣言型プログラミングを用いて構成された GUI、それを実現する手法である。GUI の生成・更新を変更前状態に基づいた更新命令によってコーディングするのではなく、あるべき状態を宣言してコーディングする。状態を分離することで UI の状態をより予測しやすいものにできる。テンプレートエンジンは静的テンプレートと動的変数の関係を宣言しているとみなせるため、更新された状態とテンプレートからテンプレートエンジンによって UI 生成をおこなって UI を更新する形は宣言的 UI といえる。そういった意味でも宣言的 UI 自体は古くから存在する GUI 実装手法の 1 つである。
簡単に言うと「この状態のときはこの UI にする」というのを設定することです。
HTML でいうと、「HPが10のときには下のようなHTMLにする」
<div>
<span>HP:</span>
<span>10</span>
</div>
見たいな感じですね
しかし、UI って動的な値を反映させてあげないといけないので愚直に宣言的 UI を実装しようとすると UI の構成要素を全部新しく作らないといけません。
これってかなり重たい処理で、Unity で言うと UI に反映させたい値(HP とか)を更新するたびに uGUI の構成要素を Instantiate
するということです(逆に使わなくなった UI は、Destory
するということ)
作ったもの
そこで今回作ったライブラリは、効率よくUIの変更を更新できるようになっています!
仕組み
参考にしたのは、React や Vue などの Web フロントエンドのフレームワークで使われている 仮想 DOM の概念です。
仮想 DOM
仮想 DOM の概念はシンプルなもので表示に使っている Object(Web なら DOM、Unity なら GameObject)を仮想的なもの(木構造になっているデータ構造)で表して、更新の際はその木構造の差分を計算して必要な変更だけをしてあげる。
というものです。
機能
この Veauty 自体の機能は主に2つで
- 仮想 Object の Tree を作成
- 差分の計算
があります。
ここで気になった人もいると思いますが、必要な変更だけをしてあげる が入ってないんですね。
この部分は来る UIElement に向けて 無駄に 抽象化をしていてこのライブラリを使って実装するようにしています。(詳細は次の章で)
使い方
先程も言ったとおりこのライブラリだけだと GameObject に反映出来ないので以下のライブラリを利用します。
今回はカウンターを作りながら使い方を見ていきます。
インストール方法
Unity Package Manager を利用しているのでプロジェクトルート以下にある Packages/manifest.json
に
{
"dependencies" : {
...
"com.uzimaru.veauty": "https://github.com/uzimaru0000/Veauty.git",
"com.uzimaru.veauty-gameobject": "https://github.com/uzimaru0000/Veauty-GameObject.git",
...
}
}
と追記してエディタに行くとインストールされます.
ボイラープレート
若干のボイラープレート的な物を書かないといけないのでそれにコードを追加していく形で解説していきます。
// UIRoot.cs
using UnityEngine;
using UI = UnityEngine;
using Veauty;
using Veauty.VTree;
using Veauty.GameObject;
public class UIRoot : MonoBehaviour
{
private VeautyObject veauty;
void Start()
{
this.veauty = new VeautyObject(gameObject, Render, true);
}
void Render()
=> new Node("GameObject", IAttribute[] {}, IVTree[] {});
}
急にいろいろなクラスが出てきていますが順を追って説明していきます。
ここで作成された UIRoot
クラスは Canvas に Attach してください。
UI を作成
察しのいい人は分かると思うのですが、Render
メソッドに UI の定義を書いていきます。
GameObject の作成
早速、ただの GameObject は以下のように宣言します。
// UIRoot.cs
IVTree Render() =>
new Node("GameObject", IAttribute[] {}, IVTree[] {});
Node
クラスが何も component がついていない GameObject を生成する要素です。
第1引数の文字列は、GameObject の名前を示していてここの文字列が違うと前回とは違う要素だと判断して再描画されます。
第2引数の IAttribute
の配列は、この GameObject の Component に何かしらの値を反映させるためのものです。(transform.position
の変更とか)
最後の引数の IVTree
の配列は、この GameObject の子要素の配列になります。
Component のついた GameObject の作成
このままでは何もすることが出来ないので GameObject に Component をつけていきます。
HorizontalLayoutGroup
のついた GameObject を作成してみましょう。
// UIRoot.cs
IVTree Render() =>
new Node<UI.HorizontalLayoutGroup>(
"HorizontalLayoutGroup",
new IAttribute[] {},
new IVTree[] {}
);
Node
クラスの Generics に Attach したい Component の型をつけてあげるだけです。簡単ですね!
Button を作成する
カウンターの値を加算・減算するための Button を作成しましょう。
前のコードと同じように Button
クラスのついたNodeを作成しましょう!
// UIRoot.cs
IVTree Render() =>
new Node<UI.HorizontalLayoutGroup>(
"HorizontalLayoutGroup",
new IAttribute[] {},
new IVTree[]
{
new Node<UI.Button>("Button", IAttribute[] {}, new IVTree[] {})
}
);
これで一旦動かして見ましょう。
ヒエラルキー上で定義したような階層構造になっていることが分かると思います。
しかし、uGUI のButton
クラスは押すために Graphic
クラスをtargetGraphic
に設定しないといけないため現状では動きません。。。
そんな少し複雑になっている UI を作成するために使うのが Widget
クラスです。
// ButtonWidget.cs
public class ButtonWidget : Widget
{
private IAttribute[] attrs;
private IVTree[] kids;
public ButtonWidget(IAttribute[] attrs, IVTree[] kids)
{
this.attrs = attrs;
this.kids = kids;
}
public override GameObject Init(GameObject go)
{
var image = go.AddComponent<UI.Image>();
var btn = go.GetComponent<UI.Button>();
btn.targetGraphic = image;
return go;
}
public override IVTree Render() =>
new Node<UI.Button>(
"Button",
this.attrs,
this.kids
);
public override void Destroy(GameObject go) { }
public override IVTree[] GetKids() => this.kids;
}
少し長いですが、こんな感じのコードです。
順を追って説明していきます。
Widget
今回のメインの Widget
クラスを継承します。このクラスは抽象クラスになっているので以下のメソッドをオーバーライドしなければいけません。
GameObject Init(GameObject go)
IVTree Render()
void Destory(GameObject go)
IVTree[] GetKids()
Init
メソッド
このメソッドは、実体化した GameObject の初期設定をするためのメソッドです。
今回でいうと、Image
クラスを Attach してButton
クラスの targetGraphic
に設定しています。(Button
クラスは Node
クラスの Generics で設定済み)
Render
メソッド
widget 内での UI の宣言です。
今回は、Node
クラスにButton
クラスをつけてコンストラクタで受け取った Attributes と子要素を渡しています。
Destory
メソッド
この Widget が削除されるときに実行されるメソッドです(実はまだ未実装)
GetKids
メソッド
子要素を返します。
早速ここで作成した、Button
を使ってボタンを作成してみましょう!
IVTree Render() =>
new Node<UI.HorizontalLayoutGroup>(
"HorizontalLayoutGroup",
new IAttribute[] {},
new IVTree[]
{
new Button(IAttribute[] {}, new IVTree[] {})
}
);
これでボタンが生成されたと思います!
テキストに文字を指定する
ボタンは出来ましたが、中に入る Text
が出来ていません。
とりあえず Text
を出す Widget を作成します。
using Veauty.VTree;
using UnityEngine;
using Veauty;
using UI = UnityEngine.UI;
public class Text : Widget
{
private IAttribute[] attrs;
public Text(IAttribute[] attrs)
{
this.attrs = attrs;
}
public override IVTree[] GetKids() => new IVTree[0];
public override GameObject Init(GameObject go)
{
var textComponent = go.GetComponent<UI.Text>();
textComponent.font = Resources.GetBuiltinResource<Font>("Arial.ttf");
textComponent.alignment = TextAnchor.MiddleCenter;
textComponent.color = Color.black;
return go;
}
public override IVTree Render() => new Node<UI.Text>("Text", attrs, GetKids());
public override void Destroy(GameObject go) { }
}
Widget の中身は Button と同じようなものなので省略します。
さて、ここで文字を指定するにはどうしたら良いでしょう?
普通の Unity だったら UI.Text
の text
に表示したい文字を入れます。では、Veauty だったら?
答えは Attribute を使います。
上でも少し説明したように Attribute
とは GameObject の Component に何かしらの値を反映させるためのもの です。
なので今回は UI.Text
の text
に表示したい文字列を反映させる Attribute を作成しましょう。
ValueAttribute
Text だと Widget の方とかぶってしまうので Value
という名前にします。
// Value.cs
using Veauty;
using UI = UnityEngine.UI;
public class Value : IAttribute
{
private string value;
public Value(string value)
{
this.value = value;
}
public string GetKey() => "Value";
public void Apply(GameObject obj)
{
var textComponent = obj.GetComponent<UI.Text>();
if (textComponent)
{
textComponent.text = this.value;
}
}
public bool Equals(IAttribute attr)
{
if (attr is Value other)
{
return this.value == other.value;
}
return false;
}
}
IAttribute
インターフェースで実装するメソッドは 3 つです。
string GetKey()
この Attribute を識別するためのものです。
void Apply(GameObject obj)
渡ってきた Object に対してこの Attribute がしたい操作を反映させます。
bool Equals(IAttribute attr)
渡ってきた IAttribute
を見てこの Attribute と等しいかを判定します。
実際に使って見ましょう。
// UIRoot.cs
IVTree Render() =>
new Node<UI.HorizontalLayoutGroup>(
"HorizontalLayoutGroup",
new IAttribute[] {},
new IVTree[]
{
new Button(IAttribute[] {}, new IVTree[]
{
new Text(new IAttribute[] { new Value("↑") })
}),
new Text(new IAttribute[] { new Value("0") }),
new Button(IAttribute[] {}, new IVTree[]
{
new Text(new IAttribute[] { new Value("↓") })
}),
}
);
だんだん形が見えて来ましたね。
OnClick を実装する
Button
に対する OnClick もAttribute
として実装します。
コードは以下のようになります。
// OnClick.cs
using UnityEngine;
using UI = UnityEngine.UI;
using Events = UnityEngine.Events;
using Veauty;
public class OnClick : IAttribute
{
private Events.UnityAction action;
public OnClick(Events.UnityAction action)
{
this.action = action;
}
public string GetKey() => "OnClick";
public void Apply(GameObject obj)
{
var button = obj.GetComponent<UI.Button>();
if (button)
{
button.onClick.RemoveAllListeners();
button.onClick.AddListener(this.action);
}
}
public bool Equals(IAttribute attr)
{
if (attr is OnClick other)
{
return this.action == other.action;
}
return false;
}
}
これを使うとこんな感じですね
// UIRoot.cs
IVTree Render() =>
new Node<UI.HorizontalLayoutGroup>(
"HorizontalLayoutGroup",
new IAttribute[] {},
new IVTree[]
{
new Button(new IAttribute[] { new OnClick(() => Debug.Log("↑")) }, new IVTree[]
{
new Text(new IAttribute[] { new Value("↑") })
}),
new Text(new IAttribute[] { new Value("0") }),
new Button(new IAttribute[] { new OnClick(() => Debug.Log("↓")) }, new IVTree[]
{
new Text(new IAttribute[] { new Value("↓") })
}),
}
);
これでボタンを押すと console に Log が出ると思います。
State を更新する
最後に State を更新してみましょう!
Veauty では State の更新をするために VeautyObject
の SetState
メソッドを使って State 更新用の関数を生成します。
コードで見るとこんな感じです。今回は、counter
という int
型の値を State とします。
// UIRoot.cs
using UnityEngine;
using UI = UnityEngine.UI;
using Veauty;
using Veauty.VTree;
using Veauty.GameObject;
public class Sample : MonoBehaviour
{
private VeautyObject veauty;
private int counter = 0;
private System.Action<int> setCounter;
void Start()
{
this.veauty = new VeautyObject(gameObject, Render, true);
this.setCounter = this.veauty.SetState<int>(n => this.counter = n);
}
IVTree Render() =>
new Node<UI.HorizontalLayoutGroup>(
"HorizontalLayoutGroup",
new IAttribute[] { },
new IVTree[]
{
new ButtonWidget(new IAttribute[] {new OnClick(() => this.setCounter(this.counter + 1))}, new IVTree[]
{
new Text(new IAttribute[] {new Value("↑")})
}),
new Node("Display", new IAttribute[0], new IVTree[]
{
new Text(new IAttribute[] {new Value($"{this.counter}")}),
}),
new ButtonWidget(new IAttribute[] {new OnClick(() => this.setCounter(this.counter - 1))}, new IVTree[]
{
new Text(new IAttribute[] {new Value("↓")})
}),
}
);
}
ここで State を OnClick 内で直接更新しないで this.setCounter
を経由して居ることを確認してください。
これは、VeautyObject
に State が変わったこと(再描画をしてほしいこと)を通知するためにこのような更新の仕方をしています。
普通にcounter
を更新をしてしまうと State が変わったことを検知できないため再描画がされません。。。
肝心の this.setCounter
ですが、Start メソッドで初期化しています。
VeautyObject
のSetState
メソッドに更新するための操作を渡してあげると更新のための関数が生成されます。
これを利用して State
を更新すると再描画がされるといった仕組みです。
最後に
これが Veauty
のおおよその使い方になります!
しかし、まだまだ不備があったりと完全なものではないので興味のある人は是非コントリビュートをしていただけるとありがたいです
(ちなみにここで Widget と Attribute の作り方を丁寧に説明したのは、uGUI 用の Widget や Attribute をまとめたライブラリの Veauty-uGUI に協力してほしいからです...!)
また、Veautyでこんなの作ったよ!というのもの常に受け付けているのでぜひ触って見てください!!
それでは!!