0
2

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.

[Flutter] (Cocoa) MVCぽく作ってみる

Last updated at Posted at 2020-08-08

やりたいこと

iOSアプリ開発からFlutter開発に移行するにあたって、できるだけCocoa MVCぽく作りたい。ViewControllerクラスを作成して、Viewを制御(addSubViewやvisibleの切り替え)したり、自由にModelクラスを生成したりしたい。

やったこと

  • 画面を構成するルートWidgetはStatelessWidgetとする。
  • buildメソッドの中でViewController(自作)を生成する。
  • state管理は、Providerパターン(ChangeNotifierProvider)を使用する。
  • ルートのWidgetはStackを使用する。
  • Stackで表示するListをViewControllerクラスが保持する。
  • こうすることで、Viewの生成や制御をViewControllerで行うようにする。
  • ListはWidgetをそのまま配列にするのではなく、visible(表示/非表示フラグ)などその他の制御をするために構造体の配列とする。

ソースコード

今回ViewControllerと、View(画面を表すWidget)をそれぞれBaseクラスに分けたので、以下のような構成で3ファイルに分割している。

  1. StartPage.dart
  2. BaseViewContoller.dart
  3. BasePageView.dart

最初に、StartPage.dart の全文

StartPage.dart
import 'package:sample/page/base/BaseViewController.dart';
import 'package:sample/page/base/BasePageView.dart';
import 'package:sample/widget/BaseButton.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

// (1)画面Widgetクラス
class StartPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final viewController = PageViewController(context);
    return ChangeNotifierProvider(
      create: (context) => viewController,
      child: StartPageView(viewController),
    );
  }
}

// (2)画面Widgetクラス ビュー
class StartPageView extends BasePageView {
  BaseViewController viewController;
  StartPageView(this.viewController) : super(viewController);

  @override
  Widget build(BuildContext context) {
    final pageViewController = context.watch<PageViewController>();
    return super.build(context);
  }
}

// (3)画面ViewController 処理実装
class PageViewController extends BaseViewController {
  BuildContext context;
  PageViewController(this.context) : super(context) {
  }

  @override
  void initialWidget() {
    addSubWidgets(name: "wid1", widget: Positioned(top: 50, left: 100, child: BaseButton(160, 60, onButtonPush)));
  }
  void onButtonPush() {
    addSubWidgets(name: "wid2", widget: Positioned(top: 120, left: 100, child: BaseButton(160, 60, someMethod)));
    redraw();
  }
}

各画面ファイルは上記のような構成となり、基本的に、画面ViewController(PageViewControllerクラス)にViewの作成も含めて処理を実装していく。
各Baseクラスと合わせて、解説は以下で記述する。

まずは、ViewContollerクラス

BaseViewContoller.dart
import 'package:flutter/material.dart';

// [※1]
class BaseViewController with ChangeNotifier {
  BuildContext context;
  // [※2]
  List<SubWidgetItem> subWidgets;
  // [※3]
  BaseViewController(this.context) {
    subWidgets = List<SubWidgetItem>();
    initialWidget();
  }

  // [※4]
  void initialWidget() {
  }

  // [*5]
  void addSubWidgets({@required String name, @required Widget widget, bool isVisible}) {
    // すでにリストにあるかどうかを探す
    SubWidgetItem createWidget = null;
    for (final swi in subWidgets) {
      if (swi.name == name) {
        createWidget = swi;
        break;
      }
    }

    // なければ新規で作成
    if (createWidget == null) {
      createWidget = SubWidgetItem();
      subWidgets.add(createWidget);
      createWidget.name = name;
      // isVisible省略時はtrue
      if (isVisible == null) {
        createWidget.isVisible = true;
      }
    }

    // 値をセット
    createWidget.widget = widget;
    if (isVisible != null) createWidget.isVisible = isVisible;
  }

  // [*6]
  void modVisible({@required String name, @required bool isVisible}) {
    for (final swi in subWidgets) {
      if (swi.name == name) {
        swi.isVisible = isVisible;
        return;
      }
    }
  }

 // [※7]
  SubWidgetItem getWidgetItemByName(String name) {
    for (final swi in subWidgets) {
      if (swi.name == name) {
        return swi;
      }
    }
  }

  // [*8]
  List<Widget> visibleSubWidget() {
    List<Widget> wlist = List<Widget>();
    subWidgets.forEach((subWidgetItem) {
      if (subWidgetItem.isVisible) {
        wlist.add(subWidgetItem.widget);
      }
    });
    return wlist;
  }

  // [※9]
  void redraw() {
    notifyListeners();
  }
}

// [*10]
class SubWidgetItem {
  Widget widget;
  bool isVisible;
  String name;
}

共通化するためにBaseクラスにしてしまったので、少々わかり難くなってしまっているが、必要な要素は今のところこれだけ。

  1. このクラスがChangeNotifierProviderの送り手となるので、ChangeNotifierをmixin(with)する。
  2. Stackで描画するWidgetを中で生成するので、WidgetのListを持つ。このとき、ViewControllerからWidgetの表示/非表示の切り替え(や後々その他制御)をするために、後述(※9)のSubWidgetItemクラスのListにしている。
  3. 画面遷移(Navigator.push/pop)なんかもやりたいので、contextを持つ。
  4. ここで(子クラスでoverrideして)初期画面を生成する。
  5. subWidgetsにaddするメソッドを用意している。説明は省くが、同じnameの物があれば上書き、なければ新規で登録(Listに追加)、のような処理としている。
  6. subWidgetsをfor/inで回してvisibleの値を変更するメソッド。ちなみに、subWidgetsをMapにしなかったのは、表示順をListのadd順にしたかったため。Mapにして、sort値を持たせても良いが面倒だったので・・・。
  7. List<SubWidgetItem>からnameを指定して値を取り出している。このままではダサいのでMapでインデックスを作成するか、上記(※6)の通り、そもそもSubWidgetItemのコンテナをMapにしてしまうか、要改善。
  8. 後述のBasePageViewで表示するために、subWidgetsのList(List<SubWidgetItem>)から、visible値を見ながら、中身のWidgetだけを取り出したList(ListList<Widget>)に変換している。
  9. Providerパターンでは、notifyListenersが呼ばれると画面が再描画されるので、一応Viewからも再描画を呼べるようにラップしておく。
  10. widgetに付随する、visible値などを持った構造体(クラス)。

次に、このページ固有の実装をする、PageViewControllerクラス

StartPage.dart
// (3)画面ViewController 処理実装
class PageViewController extends BaseViewController {
  //[※1]
  BuildContext context;
  PageViewController(this.context) : super(context) {
  }

  //[※2]
  @override
  void initialWidget() {
    addSubWidgets(name: "wid1", widget: Positioned(top: 50, left: 100, child: BaseButton(160, 60, onButtonPush)));

    // [*3]
    addSubWidgets(name: "wid2", widget: その他Widget);
    addSubWidgets(name: "wid3", widget: その他Widget);
    addSubWidgets(name: "wid4", widget: その他Widget);
  }

  //[※4]
  void onButtonPush() {
    addSubWidgets(name: "wid5", widget: Positioned(top: 120, left: 100, child: BaseButton(160, 60, () {
      modVisible(name: "wid2", isVisible: false);
      redraw(); // wid2が非表示になる
    })));
    redraw(); // wid5が表示される
  }
}

このクラスでViewを作成したり、今回未登場だがModelクラスを操作したりして、アプリを開発していく。先述のBaseViewControllerを継承して実装する。

  1. BaseViewController側でも利用できるように、インスタンス引数にcontextを渡す。
  2. 初期画面を構成するメソッド。BaseViewControllerが持っているので、overrideして実装する。(ここで登場するBaseButtonは自作のボタンWidgetで、第1、2引数でサイズを指定し、第3引数がCallBackメソッドを渡している。)これをBaseViewControllerで実体化したsubWidgets(List<SubWidgetItem>)にaddSubWidgetsメソッドで追加することで、後述するBasePageViewの中で描画される。ここでは、50,100の座標に、160x60のサイズでボタンを表示しており、これをタップすると、onButtonPushメソッドが呼ばれるようになっている。
  3. ここでいろいろなWidgetをStackに重ねて画面を作成していく。
  4. 上述のボタン(wid1)をタップするとここが呼ばれる。ここでもaddSubWidgetsしてWidgetを追加しているが、redraw(=notifyListeners)を呼ぶことで画面が再描画され、そのタイミングで画面上にもう一つボタン(wid5)が表示される。(ここではダイアログのようなイメージ。ちなみにそのボタンをタップすると、modVisibleが呼ばれ、wid2の登録したWidgetが非表示になる。)

次に、これを利用する画面Widget(StatelessWidget)クラス

StartPage.dart
// 画面Widgetクラス
class StartPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // [※1]
    final viewController = PageViewController(context);
    return ChangeNotifierProvider(
      create: (context) => viewController, // [※2]
      child: StartPageView(viewController), // [※3]
    );
  }
}

これが、この画面のルートとなるWidgetクラスとなる。StatelessWidgetで実装する。

  1. PageViewControllerの親クラス(BaseViewController)にcontextを持たせるためにインスタンス引数に渡す。
  2. 上で生成したPageViewControllerが、ChangeNotifierProviderの送り手となるので、ChangeNotifierProviderのcreateに渡す。
  3. 同様に、PageViewControllerの親クラス(BaseViewController)がStackで描画するためのWidgetのListを持っているので、次で述べるViewクラス(といってもただのStatelessWidget)のインスタンス引数に渡す。

次は、Viewクラス

これもまずBaseクラス。

BasePageView.dart
import 'package:sample/page/base/BaseViewController.dart';
import 'package:flutter/material.dart';

class BasePageView extends StatelessWidget {
  BaseViewController viewController;
  // [※1]
  BasePageView(this.viewController);
  @override
  Widget build(BuildContext context) {
    return Stack(
        children: <Widget>[
              // [※2]
              Container(
                width: MediaQuery.of(context).size.width,
                height: MediaQuery.of(context).size.height,
                // 背景画像セット
                decoration: BoxDecoration(
                  image: DecorationImage(
                      image: AssetImage("assets/widget/back.png"),
                      fit: BoxFit.fill),
                ),
              )
            ] +
            viewController.visibleSubWidget()); // [※3]                 
  }
}

ここでは、Stack Widgetを返しているだけ。

  1. StackするWidgetはViewControllerで生成したいので、ViewControllerの持つListを参照できるようにプロパティに持つ。このあたり親子関係が気持ち悪いがいったん気にしないでおく。
  2. このContainerは単純に全てのページで共通のWidgetとして背景画像を読み込んでいるだけ。(そのためにBaseクラスにしたので。)
  3. ここでViewControllerのもつList<SubWidgetItem>のうち、isVisibleがtrueのものだけを描画している。

最後に、BasePageViewを継承した、StartPageViewクラス

StartPage.dart
// 画面Widgetクラス ビュー実装
class StartPageView extends BasePageView {
  //[※1]
  BaseViewController viewController;
  StartPageView(this.viewController) : super(viewController);

  @override
  Widget build(BuildContext context) {
    //[※2]
    final pageViewController = context.watch<PageViewController>();
    //[※3]
    return super.build(context);
  }
}

ここでは大したことはやっていない。

  1. コンストラクタで先述のBasePageViewにViewControllerを渡している。
  2. notifyListenersを検知するために、context.watchしている。(いわゆるProviderパターン。)ここは適切な値(state)を個別でwatchしないと効率が良くないという記事もあるので、要改善か・・・。
  3. 画面個別のWidgetはStackの上に重ねる予定で、そしてそれはViewControllerクラスの中で実装するので、ここでは何もせず、親クラスであるBasePageViewのbuildをそのまま返している。

以上の構成で、ViewControllerクラスでView(Widget)を生成したり、再描画を呼び出したり、Cocoa MVCぽく動作するようにしている。

やってみて

とりあえず、最低限アプリを開発するために必要なところは出来たので、まずはこれで進めてみる。とは言え、記事中でも触れたが、ChangeNotifier部分でパフォーマンスに疑念が残るので、もう少し理解を深めて改善に挑戦したい。

0
2
0

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
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?