FlutterをMVVMで基本的なGUIを作っていく:前編続き

前回は参考サイトを元にMVVMの土台を作りました。

中身は擬似的なログイン機能をつけて ルーティングを作ってサンプルのカウンターができる画面に遷移するというものです。

MVVMに関してはこういう作り方するんだって慣れるしかないというのがコードを書いててよくわかりました。

KotlinでAndroidアプリを作ってたときやSwiftでiOSアプリを作ってたときもそうなんですが アプリ作りって結構独特な書き方をするのでもうそれを頭に叩き込むのが一番上達する近道なので とりあえず先人が気付きあげた書き方を真似しまくって覚えたほうが効率がよさそうです。

さて今回はMVVMの形を維持しつつ自分なりのレイアウトをくんでみようと思います。

ログイン機能はつけておきたいので、一旦そのままにしましょう。

カウンターの方も一旦残しておいて新たなViewを作るといった感じになります。

復習も兼ねてログインしたら最初に作った自由にウィジェットを移動できるものを組み合わせて見ようと思います。

まずは新たな空Viewを作ってそこに遷移できるようにします。

新たなViewとViewModelを作る

とりあえず真っ白なページを表示するためにBlankなViewとViewModelを作りましょう。

名前はなんでもいいですが、私は自由に動かせるwidgetをウィンドウと見立てて 配置するためにlib/uiディレクトリ下に「layoutedits」というディレクトリを作成し そこにファイルを格納していきます。

まずはlayoutedit_view_model.dartの中身です。

import 'package:flutter/material.dart';

class LayouteditViewModel extends ChangeNotifier {
  void changeWidgetLayout() {
    notifyListeners();
  }
}

本当に何も持たないし通知だけするクラスです。

次にView側のlayoutedit_page.dartも作ります。

import 'package:flutter/material.dart';
import 'package:helloworld/ui/layoutedit/layoutedit_view_model.dart';
import 'package:provider/provider.dart';

class LayouteditPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MultiProvider(
        providers: [
          ChangeNotifierProvider(create: (_) => LayouteditViewModel()),
        ],
        child: Scaffold(
          appBar: AppBar(
            title: Text("Layout Edit"),
          ),
          body: LayouteditPageBody(),
        ));
  }
}

class LayouteditPageBody extends StatefulWidget {
  @override
  _LayouteditPageBodyState createState() => _LayouteditPageBodyState();
}

class _LayouteditPageBodyState extends State<LayouteditPageBody> {
  @override
  void initState() {
    super.initState();

    var viewModel = Provider.of<LayouteditViewModel>(context, listen: false);
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Text("Layout Edit Text"),
    );
  }
}

最低限の構成です。

とりあえずbodyに中身がわかりやすいようにテキストだけつけていますが、 実際はウィジェットを描写する部分です。

これでログインしたときにHomeではなくLayouteditに飛ぶようにしましょう。

まずはmain.dartのルーティングを増やします。

      routes: <String, WidgetBuilder>{
        "/": (BuildContext context) => LoginPage(),
        "/home": (BuildContext context) => HomePage(),
        "/layoutedit": (BuildContext context) => LayouteditPage(),
      },

homeの下に同じようにlayouteditルーティングを追加しました。

次にログインした際に飛ばすルートを書き換えましょう。

    viewModel.loginSuccessAction.stream.listen((_) {
      Navigator.of(context).pushReplacementNamed("/layoutedit");
    });

__LoginPageBodyStateクラスの中の部分ですね。

homeなのをlayouteditに変更しただけです。

これで起動してログインボタンを押して先程作った画面に飛んだら成功です。

……私の方は問題なく動きました。

次に進みましょう。

ウィジェットごとの値をどうやって確保するかを考える

理想的には自由にウィジェットを追加してレイアウトしていくっていう感じで それぞれのウィンドウが値を保持しておくのが理想ですが、 MVVMはview自体に値を持たないということで配列的な感じで1:1で紐づく状態配列が必要になりそうです。

ウィンドウは自由に追加削除ができるため動的配列じゃないとダメだということもわかりますね。

その段階に行く前にまずは作った自由配置ウィジェットをMVVM方式に作り変えてみましょう。

とりあえずイジるのは以下のコードです。

  Widget build(BuildContext context) {
    return Center(
      child: Text("Layout Edit Text"),
    );
  }

現状基本ウィジェットを元にセンタリングしてテキストを載っけてるだけですね。

基本ウィジェットを親としてここの子に複数のウィジェットを動的に付け加えていくイメージになります。

今のところ動的にウィジェットを追加する方法がわからないので次回か次々回あたりにします。

とにかくまずは自由配置ウィジェットの素となる矩形を置いてみましょう。

  Widget build(BuildContext context) {
    return Stack(
      children: <Widget>[
        Positioned(
          child: Draggable(
            feedback: Container(
              width: 200,
              height: 100,
              color: Colors.blue[100],
            ),
            child: Container(
              width: 200,
              height: 100,
              color: Colors.blue,
            ),
            childWhenDragging: Container(),
          ),
        ),
      ],
    );
  }

横幅や高さなんかも本来はViewModel側で管理して引っ張ってくるのがいいのですが 段階を踏んでやりたいため一旦リテラルで書いてます。

これでドラッグだけできる矩形を置くことが出来ました。

次は状態を保存したいのでViewModel側に変数を準備してみます。

class LayouteditViewModel extends ChangeNotifier {
  // 矩形のポジション変数
  Offset _pos = Offset(50, 50);
  // 値を取得するためのget関数
  Offset get pos => _pos;

  void changeWidgetLayout() {
    notifyListeners();
  }
}

とりあえずOffset型の_posというローカル変数を用意して初期値を設定しています。 直接変数を読み出すのはよろしくないので、作った変数を読み出すためのgetを作成しました。

view側はこのget関数を用いて値を取得したいと思います。

その前に現状Stateに直でウィジェットを書いてしまっているのでウィジェットを取得するクラスに切り分けます。

class _absoluteWindow extends StatelessWidget {
  // String _getButtonText(LoginViewModel vm) =>
  //     vm.isLogging ? "Wait..." : "Login";

  @override
  Widget build(BuildContext context) {
    return Consumer<LayouteditViewModel>(builder: (context, viewModel, _) {
      return Stack(
        children: <Widget>[
          Positioned(
            left: viewModel.pos.dx,
            top: viewModel.pos.dy,
            child: Draggable(
              feedback: Container(
                width: 200,
                height: 100,
                color: Colors.blue[100],
              ),
              child: Container(
                width: 200,
                height: 100,
                color: Colors.blue,
              ),
              childWhenDragging: Container(),
            ),
          ),
        ],
      );
    });
  }
}

_absoluteWindowにウィジェットを切り分けました。

ついでにここでViewModel側の関数を呼び出してTopとLeftに値を設定しています。

実際は配列にしてやらないといけないのですが、 一旦ViewModelから値を取るという方法を覚えるためにやってます。

元々ウィジェットを直書きしていた部分はこうなりました。

class _LayouteditPageBodyState extends State<LayouteditPageBody> {
  @override
  void initState() {
    super.initState();

    var viewModel = Provider.of<LayouteditViewModel>(context, listen: false);
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      child: _absoluteWindow(),
    );
  }
}

returnしてるところのContainerは今の所正直何を使ったらいいのかよくわかんなかったので 一旦基本であるContainerを使いました。

これで実行したら矩形の左上の位置がズレています。

後はDraggableCancelでoffsetをViewModelに渡して 更新したらViewModel側から更新したぞって通知して View側の値を書き換えればいけそうですね。

まずは_absoluteWindowクラスに新たな関数を作ります。

DraggableCancelがOffsetを渡すのでそれを受け取ってViewModelに渡す感じにしてみましょう。

  void _setPos(BuildContext context, Offset vp) {
    Provider.of<LayouteditViewModel>(context, listen: false).setWindowPosition(vp);
  }

とりあえず他の例にならってcontextとvpを受けとってProvider.ofを呼び出して そこからViewModelの関数を叩いて引数にoffsetを渡します。

次にDraggableCancelで作った関数を呼び出します。

  onDraggableCanceled: (view, offset) {
    _setPos(context, offset);
  },

そしてViewModelの方で飛んできた値を変数に代入して更新通知を送るようにします。

  void setWindowPosition(Offset vp) {
    _pos = vp;
    notifyListeners();
  }

あとは起動してドラッグしたときにオフセットが飛んでいって保存されて view側が更新されれば完璧です!

実際に実行してみるとちょっと挙動がおかしくなりました。

ちゃんと位置の更新はできているにはできているのですが、 何故かY座標が少し+されて更新されてしまいます。

debugprintで渡す前と渡した後の数値を見てみると どちらも同じ数値だったのでどこかで何かがズレてしまっているような気がします。

飛んでくるときにはまだ正しい数値なので更新時におかしくなっていそうです。

どうやら親側のAppBarの分の高さがズレてしまっている感じがしました。

MaterialデザインのAppbarの高さは56なのでその分を代入前にひいてやればいいのですが なんかスマートではないですよね。

ディスプレイに対してのグローバル座標っていうやり方も見つけましたが今は使いどきじゃない気がします。

AppBarを残したままなんとかきれいにやる方法を模索してみます。

……グローバルポジションなんかを取る方法を試してみたのですがいいやり方が思いつかないので とりあえず代入時にAppBarの高さ分を減らす手法で一旦逃げようと思います。

AppBarが存在しているかどうか、その高さを取得できるのかどうかを調べてみたところ

……AppBarのデフォルトの高さは取得することが出来ました。

AppBarが存在するかどうかはちょっとよくわからなかったです。

子から親に辿れるはずなんですが、MVVMを採用しているので

とりあえずAppBarの高さを取得してその分引いた数の位置にOffsetを設定することにしました。

  void setWindowPosition(Offset vp) {
    _pos = Offset(vp.dx, vp.dy - AppBar().preferredSize.height);
    notifyListeners();
  }

これでいい感じに配置できるようになったと思います。

親から子に渡していくと複雑になる

AppBarを設定している親のWidgetからBodyに指定してる子Widgetに値を伝播させていってもいいのですが そういったことを簡単にしてくれるやり方を見つけました。

http://aimana-it.com/widget-of-the-week-36-inheritedwidget/

InheritedWidgetという仕組みです。

これを使えば子に値を渡さなくても参照できるようになるらしいのですが、 孫でもいけるのかがちょっと心配になっています。

ただ、調べているとProviderを使うようになってから使わなくなったとあるので 親ウィジェットから直接ViewModelに値を渡せるやり方を覚えたらいけそうですね。

そして調べているとProviderはInheriteWidgetのラッパーだということでした。

ということはProviderを使っとけばいいって感じですかね……

とりあえずAppBarの定義をViewModel側で行って変数として保存し ゲッターを用意して呼び出せるようにしてみます。

  // AppBarの情報を保持
  AppBar _appBar = AppBar(title: Text("Layout Edit"));
  AppBar get appBar => _appBar;

次にこれをView側で呼び出してみましょう。

class LayouteditPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MultiProvider(
        providers: [
          ChangeNotifierProvider(create: (_) => LayouteditViewModel()),
        ],
        child: Scaffold(
          appBar: LayouteditViewModel().appBar,
          body: LayouteditPageBody(),
        ));
  }
}

実行してみると変化は無いですが問題なく動いているので切り分けができました。

ViewModeでAppBarを定義できたということはわざわざ値を下に渡さなくてもよくなりました。

ViewModel側だけでAppBarが存在しているのかがチェックできるようにもなりましたね。

シャワーを浴びてきたら案外詰まってたことがすぐ解決したりするので、 煮詰まったら一度離れてリフレッシュするのが良いことが身にしみました。

試行錯誤しながら記事を書いているせいかちょっと乱雑になってしまいました。

ブログテーマを刷新するタイミングで記事整理をしようかなと思っているのでとりあえずなぐり書きです。

ついでにAppBarを参照していた部分を作った変数に置き換えました。

  void setWindowPosition(Offset vp) {
    _pos = Offset(vp.dx, vp.dy - appBar.preferredSize.height);
    notifyListeners();
  }

ただこのままだとappBarが定義されなかったときnullになってしまうので その時はnullチェックとかをしたほうがいいですね。

一旦はAppBarの存在が確定しているのでnullチェックはまた今度にしましょう。

今回は長くなったのでここまでです。

次回はViewModelが持つ値を表示するステータスウィジェットを作ってみましょう。

仕組みとしては今回作ったViewModelを参照して値を読み出していいくだけなので そんなに難しくはないかなと思います。

少しずつではありますが確実に前に進んでいるので頑張りましょう。