やりたいこと
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ファイルに分割している。
- StartPage.dart
- BaseViewContoller.dart
- BasePageView.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クラス
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クラスにしてしまったので、少々わかり難くなってしまっているが、必要な要素は今のところこれだけ。
- このクラスがChangeNotifierProviderの送り手となるので、ChangeNotifierをmixin(with)する。
- Stackで描画するWidgetを中で生成するので、WidgetのListを持つ。このとき、ViewControllerからWidgetの表示/非表示の切り替え(や後々その他制御)をするために、後述(※9)のSubWidgetItemクラスのListにしている。
- 画面遷移(Navigator.push/pop)なんかもやりたいので、contextを持つ。
- ここで(子クラスでoverrideして)初期画面を生成する。
- subWidgetsにaddするメソッドを用意している。説明は省くが、同じnameの物があれば上書き、なければ新規で登録(Listに追加)、のような処理としている。
- subWidgetsをfor/inで回してvisibleの値を変更するメソッド。ちなみに、subWidgetsをMapにしなかったのは、表示順をListのadd順にしたかったため。Mapにして、sort値を持たせても良いが面倒だったので・・・。
- List<SubWidgetItem>からnameを指定して値を取り出している。このままではダサいのでMapでインデックスを作成するか、上記(※6)の通り、そもそもSubWidgetItemのコンテナをMapにしてしまうか、要改善。
- 後述のBasePageViewで表示するために、subWidgetsのList(List<SubWidgetItem>)から、visible値を見ながら、中身のWidgetだけを取り出したList(ListList<Widget>)に変換している。
- Providerパターンでは、notifyListenersが呼ばれると画面が再描画されるので、一応Viewからも再描画を呼べるようにラップしておく。
- widgetに付随する、visible値などを持った構造体(クラス)。
次に、このページ固有の実装をする、PageViewControllerクラス
// (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を継承して実装する。
- BaseViewController側でも利用できるように、インスタンス引数にcontextを渡す。
- 初期画面を構成するメソッド。BaseViewControllerが持っているので、overrideして実装する。(ここで登場するBaseButtonは自作のボタンWidgetで、第1、2引数でサイズを指定し、第3引数がCallBackメソッドを渡している。)これをBaseViewControllerで実体化したsubWidgets(List<SubWidgetItem>)にaddSubWidgetsメソッドで追加することで、後述するBasePageViewの中で描画される。ここでは、50,100の座標に、160x60のサイズでボタンを表示しており、これをタップすると、onButtonPushメソッドが呼ばれるようになっている。
- ここでいろいろなWidgetをStackに重ねて画面を作成していく。
- 上述のボタン(wid1)をタップするとここが呼ばれる。ここでもaddSubWidgetsしてWidgetを追加しているが、redraw(=notifyListeners)を呼ぶことで画面が再描画され、そのタイミングで画面上にもう一つボタン(wid5)が表示される。(ここではダイアログのようなイメージ。ちなみにそのボタンをタップすると、modVisibleが呼ばれ、wid2の登録したWidgetが非表示になる。)
次に、これを利用する画面Widget(StatelessWidget)クラス
// 画面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で実装する。
- PageViewControllerの親クラス(BaseViewController)にcontextを持たせるためにインスタンス引数に渡す。
- 上で生成したPageViewControllerが、ChangeNotifierProviderの送り手となるので、ChangeNotifierProviderのcreateに渡す。
- 同様に、PageViewControllerの親クラス(BaseViewController)がStackで描画するためのWidgetのListを持っているので、次で述べるViewクラス(といってもただのStatelessWidget)のインスタンス引数に渡す。
次は、Viewクラス
これもまずBaseクラス。
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を返しているだけ。
- StackするWidgetはViewControllerで生成したいので、ViewControllerの持つListを参照できるようにプロパティに持つ。このあたり親子関係が気持ち悪いがいったん気にしないでおく。
- このContainerは単純に全てのページで共通のWidgetとして背景画像を読み込んでいるだけ。(そのためにBaseクラスにしたので。)
- ここでViewControllerのもつList<SubWidgetItem>のうち、isVisibleがtrueのものだけを描画している。
最後に、BasePageViewを継承した、StartPageViewクラス
// 画面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);
}
}
ここでは大したことはやっていない。
- コンストラクタで先述のBasePageViewにViewControllerを渡している。
- notifyListenersを検知するために、context.watchしている。(いわゆるProviderパターン。)ここは適切な値(state)を個別でwatchしないと効率が良くないという記事もあるので、要改善か・・・。
- 画面個別のWidgetはStackの上に重ねる予定で、そしてそれはViewControllerクラスの中で実装するので、ここでは何もせず、親クラスであるBasePageViewのbuildをそのまま返している。
以上の構成で、ViewControllerクラスでView(Widget)を生成したり、再描画を呼び出したり、Cocoa MVCぽく動作するようにしている。
やってみて
とりあえず、最低限アプリを開発するために必要なところは出来たので、まずはこれで進めてみる。とは言え、記事中でも触れたが、ChangeNotifier部分でパフォーマンスに疑念が残るので、もう少し理解を深めて改善に挑戦したい。