33
30

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Unityでも宣言的UI使いたくない??

Posted at

Unity でも宣言的 UI 使いたくない??

1 年前に Unity で 状態管理をするフレームワーク を作りました。
しかし、まだ辛いところがあったのです。

「宣言的に UI を書きたくない??」

宣言的 UI とは?

Wikipediaより

宣言的 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 に反映出来ないので以下のライブラリを利用します。

今回はカウンターを作りながら使い方を見ていきます。

Image from Gyazo

インストール方法

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[] {})
        }
    );

これで一旦動かして見ましょう。

スクリーンショット 2020-05-15 23.13.02.png

ヒエラルキー上で定義したような階層構造になっていることが分かると思います。
しかし、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.Texttext に表示したい文字を入れます。では、Veauty だったら?
答えは Attribute を使います。
上でも少し説明したように Attribute とは GameObject の Component に何かしらの値を反映させるためのもの です。
なので今回は UI.Texttext に表示したい文字列を反映させる 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 の更新をするために VeautyObjectSetState メソッドを使って 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 メソッドで初期化しています。
VeautyObjectSetState メソッドに更新するための操作を渡してあげると更新のための関数が生成されます。
これを利用して State を更新すると再描画がされるといった仕組みです。

最後に

これが Veauty のおおよその使い方になります!

しかし、まだまだ不備があったりと完全なものではないので興味のある人は是非コントリビュートをしていただけるとありがたいです :bow:
(ちなみにここで Widget と Attribute の作り方を丁寧に説明したのは、uGUI 用の Widget や Attribute をまとめたライブラリの Veauty-uGUI に協力してほしいからです...!)

また、Veautyでこんなの作ったよ!というのもの常に受け付けているのでぜひ触って見てください!!

それでは!!

33
30
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
33
30

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?