かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

Unity でも DI 使ったりしたいし画面もいい感じに作りたい「Zenject & UIWidgets」 その 2

前回は Zenject 使って複数シーンを跨いで有効なシングルトンなオブジェクトを作ってみました。 アプリ全体で管理したい情報などは、こういうのを使って管理したりすると捗りそうです。

blog.okazuki.jp

UIWidgets を入れてみよう

ということで今は The Unity って感じの見た目をしているのでカウンターの値を表示する部分を UIWidgets でそれっぽくしてみたいと思います。UIWidgets を使うと画面遷移とかもその場でサクッと出来そうなので、今回のようなものではシーンをわける必要すらないのですが、3D 世界と 2D 世界を行き来するときはシーンでわけたほうがやりやすいケースとかもあるかもしれません。

導入

UIWidgets の GitHub のリポジトリーのリリースページからソースコードを zip で落とします。zip を解凍したフォルダーを Unity のプロジェクトフォルダーの Packages フォルダーにコピーします。 Material Icons を使うためにフォントも入れておきましょう。以下のサイトから ttf 形式をダウンロードします。

github.com

そして、コルーチンを await したかったので UniRx.Async も以下のページからダウンロードして入れました。

github.com

追加したら、Scripts フォルダーの下に作った Assembly Definition に Unity.UIWidgets と UniRx.Async を Assembly Definition References に追加します。

Zenject と UIWidgets を一緒に使うためのちょっとした下準備をしておきます。ZenjectStatefulWidget クラスを定義して以下のように変更します。

using Unity.UIWidgets.widgets;
using Zenject;

namespace UnityAppTest
{
    public class ZenjectStatefulWidget<TSelf, TState> : StatefulWidget
        where TSelf : StatefulWidget
        where TState : State
    {
        [Inject]
        public DiContainer Container { get; set; }
        public override State createState() => Container.Resolve<TState>();

        public static void InstallToContainer(DiContainer container)
        {
            container.Bind<TSelf>().AsTransient();
            container.Bind<TState>().AsTransient();
        }
    }
}

DI コンテナにステートとウィジェットの両方を追加したいので、登録漏れが少なくなるように登録用のメソッドも用意しておきました。

まず、最初の画面を作りましょう。先ほど作った ZenjectStatefulWidget を継承する形の Widget と State を定義します。

class HomeWidget : ZenjectStatefulWidget<HomeWidget, HomeState>
{
}

class HomeState : State<HomeWidget>
{
    [Inject]
    public Counter Counter { get; set; }
    public override Widget build(BuildContext context)
    {
        return new Theme(
            data: new ThemeData(),
            child: new Scaffold(
                appBar: new AppBar(
                    title: new Text(data: "Counter app"),
                    actions: new List<Widget>
                    {
                        new IconButton(
                            icon: new Icon(Icons.navigate_next),
                            onPressed: () => Navigator.of(context).pushNamed("/increment")
                        ),
                        new IconButton(
                            icon: new Icon(Icons.web),
                            onPressed: async () =>
                            {
                                await SceneManager.LoadSceneAsync("next", LoadSceneMode.Additive);
                                await SceneManager.UnloadSceneAsync("main");
                            }
                        )
                    }
                ),
                body: new Container(
                    padding: EdgeInsets.all(20),
                    child: new Text(data: $"This is a sample: {Counter.Value}")
                ),
                floatingActionButton: new FloatingActionButton(
                    tooltip: "Increment",
                    child: new Icon(Icons.add),
                    onPressed: () => {
                        setState(() => Counter.Increment());
                    }
                )
            )
        );
    }
}

State も Zenject の DiContainer からインスタンスを作る感じでやるので、Inject 出来ます。あとは適当に画面を作ってインクリメント用のボタンなんかも用意してます。 画面上部に /increment と別のシーンへ行くためのボタンも用意しました。

次は /increment で遷移する先の画面も作ります。

class IncrementWidget : ZenjectStatefulWidget<IncrementWidget, IncrementState>
{
}

class IncrementState : State<IncrementWidget>
{
    [Inject]
    public Counter Counter { get; set; }

    public override Widget build(BuildContext context)
    {
        return new Theme(
            data: new ThemeData(),
            child: new Scaffold(
                appBar: new AppBar(
                    title: new Text(data: "Increment"),
                    leading: new IconButton(icon: new Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context))
                ),
                body: new Container(
                    padding: EdgeInsets.all(20),
                    child: new Column(
                        children: new List<Widget>
                        {
                            new IconButton(icon: new Icon(Icons.add), tooltip: "Increment", onPressed: () => setState(() => Counter.Increment()))
                        }
                    )
                )
            )
        );
    }
}

画面上部のアプリバーに戻るボタンと、画面内にインクリメントするボタンがあるだけです。この画面からはカウンターの値が今いくつなのかは見ることは出来ません。まぁ見れてもいいんですが、前のページと同じになってしまうので、なんとなく出さないようにしました。本当に不便です。

ここまで出来たら UIWidgetsPanel を継承したクラスを作ります。このクラスを Panel のコンポーネントとして追加することで画面が描画されます。

public class CounterApp : UIWidgetsPanel
{
    [Inject]
    public DiContainer Container { get; set; }
    protected override void OnEnable()
    {
        Screen.fullScreen = false;
        FontManager.instance.addFont(Resources.Load<Font>(path: "fonts/MaterialIcons-Regular"), "Material Icons");
        base.OnEnable();
    }

    protected override Widget createWidget()
    {
        if (Container == null)
        {
            return new MaterialApp(home: new Scaffold());
        }

        return new MaterialApp(
            home: Container.Resolve<HomeWidget>(),
            routes: new Dictionary<string, WidgetBuilder>
            {
                ["/increment"] = ctx => Container.Resolve<IncrementWidget>(),
            }
        );
    }
}

フォントの読み込みと、/increment という名前が来たときに IncrementWidget が生成されるような定義が入っています。また、何度やっても Container プロパティがインジェクトされる前に createWidget が呼ばれて、Container がインジェクトされた後に、また createWidget が呼ばれるとか、あとは再生をしてないときは常時 Container が null みたいなので Container が null の時は真っ白な画面を出すだけにしました。

そして、main シーンにある Canvas を削除します。そして Panel を 1 つ作成して Image コンポーネントを削除 します。Image コンポーネントを削除したら、先ほどの CounterApp を Panel のコンポーネントに追加します。この状態でエディター上で実行してみると以下のようになりました。ちゃんと動いてる。

f:id:okazuki:20191219161604g:plain

実機で動かそう

実機向けのビルドにあたって少しだけコードをいじって base シーンが読み込まれたときに main シーンも追加で読み込むスクリプトだけ足します。まぁ、これは難しくないのでよしなに。

ビルド時には base, main, next の 3 つのシーンが入っていることを確認します。特に base が初期シーンとして読み込まれるように一番上に持っていきます。そして、今回は Android 向けにビルドしました。実機で動かしてみると…

f:id:okazuki:20191219170432g:plain

ちゃんと動いてる。やったね!

ソースコードは以下の github のリポジトリに上げておきました。

github.com