バージョン2.0現在はモバイルアプリケーションを主なターゲットとした、マルチプラットフォームUIフレームワークです。
以下のような特徴があります。
主にGoogleが開発し、アメリカや中国を中心に導入が進んでいます。
近年は日本の大手IT企業でも採用事例があるなど、使用される機会が増えています。
2021年3月4日にFlutterのバージョン2がリリースされました。
このバージョンからWebアプリを生成する "Flutter for Web" が安定版に取り込まれ、iOS / Androidアプリケーションに加え、Webアプリケーションを生成できるようになりました。
また、現在はWindows / macOS / LinuxのデスクトップOS用のアプリケーションを生成する "Flutter Desktop" も開発が進んでおり、Ubuntu(Linuxディストリビューションの一つ)をメンテナンスしているCanonical社がUbuntu用アプリケーションの作成にFlutterを使用することを表明しています。
https://twitter.com/ubuntu/status/1367063203600031746
Flutterアプリケーションを記述するにはDart言語を使用します。
2021年3月現在、Dart代替言語は存在しないため、現在のところは他に選択肢がありません。
Dartは以下のような特徴を持ったプログラミング言語です。
void main() {
final String a = "hello world";
print(a);
}
以下の点でエンジニアとしてのキャリアを積む上でプラスになります。
他のプログラミング言語を学習したことがあり、手続き型オブジェクト指向プログラミングに理解があることを前提としています。
Dart Language Tutorにほぼすべて書かれています。長いので、とりあえず以下の項目を呼んで雰囲気だけでも掴んでください。
その他の項目も理解しておくと役に立ちます。読んでみてください。
※有志が作成したDartのNull Safety対応前の記事の日本語訳などもあります。英語が読めない場合に活用しても良いかもしれません。ただし内容が古いので最新かつ公式のドキュメントを読めるようになることを強くおすすめします。
Node.jsにおけるnpm Registory、JavaにおけるMaven Centralのように、Dartのパッケージ管理システムpubが使用する公開パッケージリポジトリがあります。
後述するpubspec.yamlファイルに記述したパッケージの依存関係解決には、デフォルトで上記のpub.devが使用されます。
パッケージソースを明示した場合は、上記以外のパッケージリポジトリや任意のGitリポジトリからパッケージを取得することが可能です。
言語ごとに、コミュニティや会社が定めたコーディングスタイルガイドがあることがあります。そうしたスタイルガイドに則ったコードを書くことで、他の人にも読みやすく理解しやすいコードになります。
Dartは公式がスタイルガイドを公開しているため、これがデファクトスタンダードとなっています。
また、こうしたスタイルガイドに則ったコードになっているか確認するために、Dartの静的解析ツールが使用できます。
予め厳し目のルールをプリセットにしたパッケージもあります。
今回は詳しく説明しません。事前にSDKのインストールからアプリが実行可能であることまでは確認済みであることを期待します。
OSごとに異なります。
基本的には以下のことをするのみです。
https://flutter.dev/docs/get-started/install/macos
上記ページから最新のSDKのアーカイブをダウンロードし、ダブルクリックするなどしてアーカイブを展開します。

展開されてできたフォルダを適当な場所に配置します。特にこだわりがなければ ~/sdk/flutter などに配置すると良いでしょう。
配置したら、CLIからコマンドを呼び出せるようにPathを通します。普段使いのシェルの設定ファイル末尾にPATHの設定を記述します。以下はbash / zshの例です。他のシェルを使用している場合は適切に設定するようにしてください。
PATH=$PATH:$HOME/sdk/flutter/bin
事前に以下のプログラムがインストールされ、使用可能になっていることを確認してください。
git コマンドが使用可能であれば大丈夫ですhttps://flutter.dev/docs/get-started/install/windows
上記ページから最新のSDKのアーカイブをダウンロードし、圧縮フォルダユーティリティを使用するなどしてアーカイブを展開します。
展開されてできたフォルダを適当な場所に配置します。特にこだわりがなければ C:\sdk\flutter などに配置すると良いでしょう。
配置したら、CLIからコマンドを呼び出せるようにPATHを通します。環境変数の設定ダイアログからPATHを設定してください。Windowsのバージョンによって設定の方法が異なるので、自身が使用しているWindowsに合わせて設定を変更してください。
参考リンク:環境変数の設定方法
PATHには (path to sdk)\flutter\bin を設定します。上記の例どおりであれば C:\sdk\flutter\bin になります。
以下以降すべてコマンドラインでの説明になります。ターミナルやPowerShellから実行してください。
flutter doctor コマンドを実行すると、Flutterを動作させるのに必要な準備が整っているか確認することができます。以下のように No issues found! と表示されれば何も問題ありません。
$ flutter doctor Doctor summary (to see all details, run flutter doctor -v): [✓] Flutter (Channel stable, 2.0.3, on macOS 11.2.3 20D91 darwin-x64, locale ja-JP) [✓] Android toolchain - develop for Android devices (Android SDK version 29.0.2) [✓] Xcode - develop for iOS and macOS [✓] Chrome - develop for the web [✓] Android Studio (version 4.1) [✓] IntelliJ IDEA Community Edition (version 2020.3.3) [✓] VS Code (version 1.54.2) [✓] Connected device (2 available) • No issues found!
問題があれば以下のように ! マークが表示され、解決方法が提案されます。提案された方法を一通り試してください。
$ flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 2.0.3, on macOS 11.2.3 20D91 darwin-x64, locale ja-JP)
[!] Android toolchain - develop for Android devices (Android SDK version 29.0.2)
! Some Android licenses not accepted. To resolve this, run: flutter doctor --android-licenses
[!] Xcode - develop for iOS and macOS
✗ CocoaPods installed but not working.
You appear to have CocoaPods installed but it is not working.
This can happen if the version of Ruby that CocoaPods was installed with is different from the one being used to invoke it.
This can usually be fixed by re-installing CocoaPods.
To re-install see https://guides.cocoapods.org/using/getting-started.html#installation for instructions.
[✓] Chrome - develop for the web
[✓] Android Studio (version 4.1)
[✓] IntelliJ IDEA Community Edition (version 2020.3.3)
[✓] VS Code (version 1.54.2)
[✓] Connected device (2 available)
! Doctor found issues in 2 categories.
また、Flutterのプロジェクトを作成して適当なターゲットのアプリケーションがビルド可能か確認してください。
$ flutter create launch_test Creating project launch_test... (中略) All done! In order to run your application, type: $ cd launch_test $ flutter run To enable null safety, type: $ cd launch_test $ dart migrate --apply-changes Your application code is in launch_test/lib/main.dart. $ cd launch_test $ flutter run
本章では最初に生成されるプロジェクトをもとに、Flutterアプリの基本的な構成を解説します。

Flutterに限らず、Dartのプロジェクトではソースコードをlib (プロジェクトの中核をなすコード群を保管)ないしbin (コマンドラインでの実行可能ファイルを生成するプロジェクトの場合、エントリーポイントを保管)に格納します。Flutterのプロジェクトでは、lib/main.dart がエントリーポイント(プログラムが実行される起点。main関数があるファイル)です。なお、ファイル名は自由に変更できます。
pubspec.yaml がプロジェクトのメタデータを司ります。pubspec.yaml自体はDartプロジェクトで一般的に使われるもので、Flutterに特有のものではありません。
NPMプロジェクトのpackage.jsonや、iOSプロジェクトの.xcodeprojファイル、Androidプロジェクトのbuild.gradle(.kts)などにあたるものです。
pubspec.lockは $ flutter pub get コマンドで依存ライブラリを解決した結果が記録されるものです。NPMプロジェクトのpackage.lockにあたるものです。
項目名 | 詳細 |
| プロジェクト名 |
| アプリの説明 |
| Dartパッケージのリポジトリ pub.dev に公開するかどうかの設定 Flutterプロジェクトは基本的に公開しないため none にしておく |
| バージョン名+バージョン番号 +以前はアプリのバージョン名として使用される |
| 依存しているパッケージやプラグイン |
| dependenciesと同じだが、他のプロジェクトから依存されたときには使用しないもの |
| Flutterプロジェクト特有の設定 |
各ビルド対象のプロジェクトテンプレートなどが格納されています。
Flutter 2.0時点ではandroid, ios, webの3つが生成されます。macos, windows, linuxは各プラットフォームの機能を有効にしないと生成されません。
アプリのアイコンなどの設定は、このディレクトリ内のアプリプロジェクトを変更することで行います。中身は単純なAndroid / iOSプロジェクトだったり、ただのindex.htmlやfaviconが置かれたディレクトリなので、詳しくは各ターゲットの設定方法を調べてください。
コマンドによって削除されたり更新されたりするファイルなので、基本的に編集することはありません。
生成されるAndroid / iOSアプリケーションが期待通りではないときに覗いてみるとよいかもしれません。
ボタンを押すと数字がカウントアップされるアプリケーションが生成されています。そのソースコードをベースにFlutterアプリケーションのソースコードの構造を説明します。
Flutterにおいては、「すべてがWidget」です。アプリケーションもレイアウトもアニメーションもWidgetで表現します。
void main() {
// runAppは引数のWidgetを根としたFlutterアプリケーションを起動します
runApp(MyApp());
}
// Widget自体が状態を持たない場合はStatelessWidgetを継承したWidgetクラスを作成する
class MyApp extends StatelessWidget {
// buildメソッドで、このWidgetが表現する子Widgetを返す
@override
Widget build(BuildContext context) {
// MaterialAppはMaterial Designに基づいた色調やタイポグラフィ設定、画面遷移機能、文字列表示における方向設定(ltrなど)などをまとめて提供する、標準提供されているユーティリティ。
// これがWidgetツリーの上位に存在しないと使用できない機能がたくさんある
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
// 画面遷移の起点として表示されるものをhomeとして設定
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
runAppに指定されたWidgetを根とした木構造になっている(Widgetのツリーが存在している)ことを理解しておくと、後の理解が深まります。

ReactやSwiftUIで言うところのComponent、JavaScript界隈で言われるところの仮想DOMです。
ReactやSwiftUIをご存じない方は、「画面上に表示される要素が、どのように構成されているか」という構成情報を表現するオブジェクトだと思ってください。
例えばText Widgetは引数に与えられた文字列を画面に表示します。
Center Widgetは、子Widgetを中央に配置します。
MaterialApp Widgetは、子Widgetからなるアプリケーションを表現します。
Flutterにおいては、画面に表示される具体的要素も、アニメーションも、アプリ自体もWidgetです。
Flutterも仕組みとしては仮想DOMの概念を用いたUIフレームワーク(ReactやVue.jsなど)と同様に、裏で画面に表示する実体となるオブジェクトを管理しています。が、今回の資料ではそうしたものは扱いません。
Widgetは状態を持つことができ、またその状態によって表示を変えることもできます。
// 状態を持つWidgetはStatefulWidgetとStateの2つのクラスで構成します
class MyHomePage extends StatefulWidget {
// Widgetのプロパティはコンストラクタ引数で渡すのが基本です
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
// Stateの状態はフィールドで保持することが多いです。
int _counter = 0;
void _incrementCounter() {
// setStateがbuildメソッドを再実行します(正確には再実行の予約をする)。
// 状態の更新はsetState引数の匿名関数内で行うのが一般的です
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
// タイトルバーや背景色などを表示する枠組みを提供するWidgetがScaffold。
// 「画面」に相当する役割をするWidgetでは使用することが多い
return Scaffold(
appBar: AppBar(
// State.widgetで関連するStatefulWidgetのインスタンスを参照できます
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
// 状態を参照したコードを書くことが可能です。
// setStateがbuildメソッドを再実行することで描画が更新されます
Text(
'$_counter',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
}
}
StatefulWidget & Stateを使用した方法の他にも状態管理の方法はあります。(後述)
たくさんの種類があり、ここですべてを紹介するのは不可能です。公式サイトにカタログや索引があるので、そちらを見てみてください。
また、週替りでWidgetの特徴や使い方を紹介する動画がYoutubeにアップロードされています。音声は英語ですが、日本語字幕も出せるので、こちらも参照してください。
TODOアプリを作ってみることで、基本的なFlutterアプリケーションの作り方を掴んでみてください。
以下の要件を満たすアプリを目指します。
完了したTODOを未完了に戻すであるとか、完了したTODOを削除するとかは考えないでおきます。後ほど自分で機能追加してみてください。
なお、出来上がりのサンプルは下記のリンクのとおりです。
以降のDartのソースコードはDart 2.12のNull安全に対応した際に使用可能な文法で書かれています。サンプルの通りに記述するには、Null安全を有効にする必要があります。pubspec.yamlのenvironment -> sdkの下限を2.12.0に設定します。
environment:
sdk: ">=2.12.0 <3.0.0"
lib/todo.dartを作成し、TODOを表現するクラスを作成します。
class Todo {
final String title;
final bool done;
Todo({required this.title, this.done = false});
}
まずはTODOを表示するリストを持った画面を作成しましょう。TODOは増えたり状態が変わったりするため、画面はStatefulWidgetとして作成します。
lib/todo_list.dartを作成し、TodoListPageという名前でStatefulWidget継承クラスを作成します。TODOリストはListで保持します。
import 'package:flutter/material.dart';
import 'package:nullsafe_todo/todo.dart';
class TodoListPage extends StatefulWidget {
@override
_TodoListPageState createState() => _TodoListPageState();
}
class _TodoListPageState extends State<TodoListPage> {
List<Todo> _todos = [];
@override
Widget build(BuildContext context) {
return Scaffold();
}
}
リストを表示するためにはListViewを使用します。ListViewにはコンストラクタがいくつかありますが、Listの内要素に合わせてWidgetを変更する場合はListView.builderコンストラクタを使用するのが便利です。
またListViewの中身にはListTile系のWidgetを使うとよくあるリストを作ることができます。TODOであれば完了/未完了を表現するチェックボックスがあるとよいので、今回はtrailingにCheckboxを配置します。
class _TodoListPageState extends State<TodoListPage> {
List<Todo> _todos = [];
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView.builder(
itemCount: _todos.length,
itemBuilder: (context, index) {
final todo = _todos[index];
return ListTile(
title: Text(todo.title),
trailing: Checkbox(
value: todo.done,
onChanged: (checked) {
setState(() {
_todos[index] = Todo(title: todo.title, done: checked ?? false);
});
},
),
);
},
),
);
}
}
起動時にこれを表示できるようにします。
lib/main.dartのMyHomePageを削除し、代わりにTodoListPageを表示するように変更します。
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'TODOアプリ',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: TodoListPage(),
);
}
}
ただ、この時点では画面は真っ白で、何も表示されません。_todosに予め適当なインスタンスを追加しておきましょう。
class _TodoListPageState extends State<TodoListPage> {
List<Todo> _todos = [
Todo(title: "ほげ"),
Todo(title: "ふが"),
Todo(title: "ぴよ"),
];
Hot ReloadまたはHot Restartで表示確認できます。リストのアイテムをクリックするとチェックボックスの表示が切り替わるのも確認しておきましょう。

TODOを追加する際、または編集する際に内容を入力するための画面を用意します。今回はそれをダイアログとして表示することにします。
入力されたテキストをWidgetの外部に取り出すためには、TextEditingControllerが必要です。また、TextEditingControllerは管理しているWidgetの破棄時に一緒に破棄する必要があるため、このWidgetはStatefulWidgetとして作成します(StatelessWidgetにはライフサイクルメソッドがないため、破棄されるタイミングを知ることができない)。
lib/edit_component.dartを作成して、StatefulWidget継承クラスのEditDialogを作成します。
import 'package:flutter/material.dart';
class EditDialog extends StatefulWidget {
@override
_EditDialogState createState() => _EditDialogState();
}
class _EditDialogState extends State<EditDialog> {
@override
Widget build(BuildContext context) {
return Container();
}
}
テキストの入力を受け付けるWidgetはTextFieldです。まずはこれをreturnすることにします。decorationにInputDecorationを指定することで、見た目のカスタマイズができます。
class _EditDialogState extends State<EditDialog> {
@override
Widget build(BuildContext context) {
return TextField(
decoration: InputDecoration(hintText: "TODO"),
);
}
}
これをダイアログとして表示できるようにします。AlertDialogが一般的なダイアログの形式を提供してくれるのでこれを使用します。
class _EditDialogState extends State<EditDialog> {
@override
Widget build(BuildContext context) {
return AlertDialog(
content: TextField(
decoration: InputDecoration(hintText: "TODO"),
),
);
}
}
これでダイアログとしての形はできましたが、いわゆるモーダルダイアログにはなりません。モーダル表示をするためにはshowDialog関数を使って表示する必要があります。純粋にshowDialogを使うといちいちbuilderにWidgetの生成関数を書かないといけないので、ダイアログクラスのstaticメソッドとして表示用のメソッドを用意すると便利です。今回はshowという名前で表示用メソッドを作成します。
class EditDialog extends StatefulWidget {
static Future<void> show(BuildContext context) {
return showDialog(
context: context,
builder: (context) => EditDialog(),
);
}
@override
_EditDialogState createState() => _EditDialogState();
}
表示の準備ができたので表示を試してみましょう。TodoListPageから表示できるようにします。FloatingActionButtonを追加して、画面右下にボタンを表示してみます。
class _TodoListPageState extends State<TodoListPage> {
List<Todo> _todos = [
Todo(title: "ほげ"),
Todo(title: "ふが"),
Todo(title: "ぴよ"),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView.builder(
// 中略
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () {
EditDialog.show(context);
},
),
);
}
}


入力値を使ってTODOを追加できるようにしましょう。編集を確定するボタンを用意して、ダイアログが閉じたときにTodoインスタンスを生成するようにしましょう。
表示用便利メソッドのshowメソッドはFutureを返すように変更します。また、showDialogの戻り値はNavigator.popの第2引数に渡すことで指定できます。
class EditDialog extends StatefulWidget {
static Future<Todo?> show(BuildContext context) {
return showDialog(
context: context,
builder: (context) => EditDialog(),
);
}
@override
_EditDialogState createState() => _EditDialogState();
}
class _EditDialogState extends State<EditDialog> {
@override
Widget build(BuildContext context) {
return AlertDialog(
content: TextField(
decoration: InputDecoration(hintText: "TODO"),
),
actions: [
ElevatedButton(
onPressed: () {
Navigator.pop(
context,
Todo(title: "後でここに入力値を入れます"),
);
},
child: Text("保存"),
),
],
);
}
}
TodoListPageでも、EditDialogの結果を受け取って状態として反映する処理を追加します。「保存」ボタン以外の手段でダイアログが閉じられた場合はnullが返るので、nullでなかったときのみ反映するようにします。
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () async {
final todo = await EditDialog.show(context);
if (todo != null) {
_setState(() {
_todo.add(todo);
});
}
},
),


このままだと固定された内容のTODOしか作成できないので、ユーザーの入力値を使えるようにします。_EditDialogStateにTextEditingControllerを保持させ、TextFieldに渡したり、ElevatedButtonが押されたときに値を取り出したりするのに使用できるようにします。また、ライフサイクルに合わせて破棄したりします。
class _EditDialogState extends State<EditDialog> {
late final TextEditingController _textEditingController;
@override
void initState() {
super.initState();
_textEditingController = TextEditingController();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
content: TextField(
controller: _textEditingController,
decoration: InputDecoration(hintText: "TODO"),
),
actions: [
ElevatedButton(
onPressed: () {
Navigator.pop(
context,
Todo(title: _textEditingController.text),
);
},
child: Text("保存"),
),
],
);
}
@override
void dispose() {
_textEditingController.dispose();
super.dispose();
}
}

EditDialogを編集にも使えるようにします。ダイアログを開く際に内容のデフォルト値を渡せるようにしましょう。
class EditDialog extends StatefulWidget {
static Future<Todo?> show(BuildContext context, [Todo? base]) {
return showDialog(
context: context,
builder: (context) => EditDialog(base: base),
);
}
EditDialog({this.base}):super();
final Todo? base;
@override
_EditDialogState createState() => _EditDialogState();
}
class _EditDialogState extends State<EditDialog> {
late final TextEditingController _textEditingController;
@override
void initState() {
super.initState();
_textEditingController = TextEditingController(text: widget.base?.title);
}
// 後略
既存のTODOを長押ししたときに編集ダイアログが出るようにします。_TodoListStateで生成しているListTileのonLongPressコールバックから、EditDialogを表示するようにします。
class _TodoListPageState extends State<TodoListPage> {
List<Todo> _todos = [
Todo(title: "ほげ"),
Todo(title: "ふが"),
Todo(title: "ぴよ"),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView.builder(
itemCount: _todos.length,
itemBuilder: (context, index) {
final todo = _todos[index];
return ListTile(
title: Text(todo.title),
trailing: Checkbox(
value: todo.done,
onChanged: (checked) {
setState(() {
_todos[index] = Todo(title: todo.title, done: checked ?? false);
});
},
),
onLongPress: () async {
final result = await EditDialog.show(context, todo);
if (result != null) {
setState(() {
_todos[index] = result;
});
}
},
);
},
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () async {
final result = await EditDialog.show(context);
if (result != null) {
setState(() {
_todos.add(result);
});
}
},
),
);
}
}


現在のままでは、アプリを終了すると作成したTODOやその状態は揮発してしまいます。揮発を防ぐには状態の保存(永続化)が必要です。
Flutterアプリケーションでデータの保存を行う方法はいくつもあります。今回は端末への保存が手軽に行えるshared_preferencesを使用します。
shared_preferencesは、プラットフォームが標準で提供するKVS(iOSならUserDefaults、AndroidならSharedPreferences、WebならWindow.localStorage)にデータを記録するAPIを提供してくれます。
pubspec.yamlのdependencies直下にパッケージ名と使用するバージョンの制約を記入します。
dependencies:
flutter:
sdk: flutter
shared_preferences: ^2.0.5
flutter pub getコマンドで記入した依存パッケージを取得します。使用しているIDEやエディタの同様のコマンドで取得しても構いません。
$ flutter pub get
これでshared_preferencesの機能が使用可能になります。
これをState系クラスで直接使用しても良いのですが、TODOの読み込みと保存方法を抽象化しておくと後の改造が楽になるので、それらを司るクラスを作成しておきます。特にshared_preferencesは基本的なデータ構造しか保存できないため、TODOのリストを保存しようと思ったらJSON文字列など何かしらの保管可能なデータ構造にシリアライズする必要がありますので、今回はJSON文字列との変換もこのクラスで行うこととします。
lib/repository.dartを作成し、TodoRepositoryクラスを以下の内容で作成します。
class TodoRepository {
Future<List<Todo>> loadAllTodo() async {
final pref = await SharedPreferences.getInstance();
final raw = pref.getString("todos");
if (raw != null) {
final decoded = json.decode(raw) as List;
return decoded
.cast<Map<String, dynamic>>()
.map(
(e) => Todo(
title: e["title"],
done: e["done"],
),
)
.toList();
}
return [];
}
Future<void> saveAllTodo(List<Todo> todos) async {
final pref = await SharedPreferences.getInstance();
final values = todos
.map((e) => {
"title": e.title,
"done": e.done,
})
.toList();
final raw = json.encode(values);
await pref.setString("todos", raw);
}
}
TodoListPageからTodoRepositoryを使用できるようにします。
class TodoListPage extends StatefulWidget {
final TodoRepository repository;
TodoListPage(this.repository);
@override
_TodoListPageState createState() => _TodoListPageState();
}
MyAppで、コンストラクタにTodoRepositoryにインスタンスを渡すようにします。
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'TODOアプリ',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: TodoListPage(TodoRepository()),
);
}
}
_TodoListPageStateの初期化時にTodoRepositoryからデータを読み出すようにします。
class _TodoListPageState extends State<TodoListPage> {
List<Todo> _todos = [];
@override
void initState() {
super.initState();
widget.repository.loadAllTodo().then((todos) => setState(() {
_todos = todos;
}));
}
// 後略
_TodoListPageStateの状態変更時にTodoRepositoryにも現状を保存するようにします。
class _TodoListPageState extends State<TodoListPage> {
List<Todo> _todos = [];
@override
void initState() {
// 省略
}
void _replaceTodo(int index, Todo newTodo) {
setState(() {
_todos[index] = newTodo;
});
widget.repository.saveAllTodo(_todos);
}
void _appendTodo(Todo newTodo) {
setState(() {
_todos.add(newTodo);
});
widget.repository.saveAllTodo(_todos);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView.builder(
itemCount: _todos.length,
itemBuilder: (context, index) {
final todo = _todos[index];
return ListTile(
title: Text(todo.title),
trailing: Checkbox(
value: todo.done,
onChanged: (checked) {
_replaceTodo(
index,
Todo(title: todo.title, done: checked ?? false),
);
},
),
onLongPress: () async {
final result = await EditDialog.show(context, todo);
if (result != null) {
_replaceTodo(index, result);
}
},
);
},
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () async {
final result = await EditDialog.show(context);
if (result != null) {
_appendTodo(result);
}
},
),
);
}
}
TODOを追加、変更してからアプリを再起動し、結果が保持されていることを確認しましょう。
「TODOアプリを作る」の章ではStatefulWidgetを使った状態管理の手法を紹介しました。
一般に以下のパターンが知られています。
名称 | 特徴 | メリット | デメリット |
StatefulWidgetパターン |
|
|
|
BLoCパターン (Business Logic of Component) |
|
|
|
ScopedModelパターン |
|
|
|
現在はScopedModelの派生形が使われることが多いようです。
FlutterにはStatefulWidget以外にもInheritedWidgetを使った状態の変更通知の手段があります。一般に、InheritedWidgetを使った手法の方がパフォーマンス的に有利だったり、状態を変更しないインスタンスをWidgetツリー上位から注入するなど依存性注入にも使用できて便利です。
使用するクラス | メリット | デメリット |
StatefulWidget + State |
|
|
InheritedWidget + Listenable |
|
|
が、InheritedWidgetをそのまま使用した状態変更通知は面倒なので、これを使いやすくしたサードパーティ製のパッケージがいくつか公開されています。
パッケージ名 | 特徴 |
| |
|
Flutter自体が比較的新しいフレームワークであること、宣言的UIフレームワークはビジネスロジックと表示のコードの分離が比較的簡単であることから、あまり手法を命名して開発者間で認識の統一を図ることが積極的には行われていないようです。
2021年初旬現在、一般的に使われているように思われるのは、provider + state_notifier + freezedもしくはriverpod + state_notifier + freezedのどちらかを使用した、ScopedModelパターン(の変形)です。
ただ、riverpodはまだメジャーリリースされていないため、実際の開発現場で採用する際はAPIの破壊的変更に注意する必要があります。
TodoListPageで管理しているTODOのリストと、そのリストに変更を加える操作はビジネスロジックとその状態に当たります。これをstate_notiferとfreezedで再構成し、providerを使って管理するように再構成してみましょう。
その過程で前章のTodoListPageを3つのクラスに分割します。
名称 | 役割 |
|
|
| TODO一覧画面の状態を表現する |
| TODO一覧画面が行うビジネスロジックを表現する。また、 |

注意しなければならないのは、TodoListState / TodoListControllerはTodoListPageよりもWidgetツリーの上位に注入しないといけない、ということ。これは、Widgetツリーは上から順に構築されるため、ツリーの下位のWidgetインスタンスを参照できないという制約によるものです。

まずは必要な依存関係を追加します。pubspec.yamlに以下のように依存関係を追加して、flutter pub getで依存関係を取得します。
dependencies:
flutter:
sdk: flutter
shared_preferences: ^2.0.5
# 以下4つを追加
provider: ^5.0.0
state_notifier: ^0.7.0
flutter_state_notifier: ^0.7.0
freezed_annotation: ^0.14.1
dev_dependencies:
flutter_test:
sdk: flutter
# 以下2つを追加
build_runner: ^1.12.2
freezed: ^0.14.1+1
lib/todo_list_state.dartを作成し、freezedによるビジネスロジックの状態表現クラスを作成します。
freezedはいわゆる値クラスの作成を自動コード生成によって行うツールです(Dart 2.12現在、DartにはJavaのrecordやKotlinのdata class、SwiftのEquatableプロトコル準拠structに相当する値クラスに使用できるデータ構造がないため、それと同じような挙動を実現するにはクラスに比較メソッドなどを自前で実装する必要がある)。
自動生成されるファイル名の指定のため、partの記述が必須です。ファイル名は、現在のファイル名の拡張子の手前に.freezedを付与したものになります(自動生成系ツールによって変わりますので、ツールのREADMEなどをよく読んでください)。
import 'package:freezed_annotation/freezed_annotation.dart';
part 'todo_list_state.freezed.dart';
@freezed
class TodoListState with _$TodoListState {
const factory TodoListState({
@Default(const []) List<Todo> todos,
@Default(true) bool loading,
}) = _TodoListState;
}
作成したらflutter pub run build_runner buildコマンドでコード生成を行います。
$ flutter pub run build_runner build [INFO] Generating build script... [INFO] Generating build script completed, took 392ms [INFO] Initializing inputs [INFO] Reading cached asset graph... [INFO] Reading cached asset graph completed, took 51ms [INFO] Checking for updates since last build... [INFO] Checking for updates since last build completed, took 433ms [INFO] Running build... [INFO] 1.5s elapsed, 0/1 actions completed. [INFO] Running build completed, took 1.8s [INFO] Caching finalized dependency graph... [INFO] Caching finalized dependency graph completed, took 31ms [INFO] Succeeded after 1.8s with 1 outputs (1 actions)
実行後、lib/todo_list_state.freezed.dartが生成されていることを確認してください。
次はlib/todo_list_controller.dartを作成し、ビジネスロジックのコアを持つTodoListControllerクラスを作成します。
class TodoListController extends StateNotifier<TodoListState> {
final TodoRepository _repository;
TodoListController({
required TodoRepository repository,
}) : _repository = repository,
super(TodoListState()) {
_repository.loadAllTodo().then((todos) {
state = state.copyWith(
todos: todos,
loading: false,
);
});
}
void replaceTodo(int index, Todo newTodo) {
final todos = state.todos;
todos[index] = newTodo;
state = state.copyWith(todos: todos);
_repository.saveAllTodo(todos);
}
void appendTodo(Todo newTodo) {
final todos = [...state.todos, newTodo];
state = state.copyWith(todos: todos);
_repository.saveAllTodo(state.todos);
}
}
TodoListPageより(Widgetツリーの)上位で、TodoListControllerを注入するようにします。画面Widgetと対になるクラスなので、画面Widgetクラスのstaticメソッドとして注入用のメソッドを用意します。
class TodoListPage extends StatefulWidget {
static Widget create() {
return StateNotifierProvider<TodoListController, TodoListState>(
create: (context) => TodoListController(repository: TodoRepository()),
child: TodoListPage(),
);
}
// 後略
lib/main.dartのMyAppでは今作成したメソッドを使ってTODOの一覧画面を作成するようにします。
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: TodoListPage.create(),
);
}
}
TodoListPageから状態管理のコードをなくし、StetelessWidgetに変更します。代わりに注入したTodoListStateとTodoListControllerを使用するようにします。
class TodoListPage extends StatelessWidget {
static Widget create() {
return StateNotifierProvider<TodoListController, TodoListState>(
create: (context) => TodoListController(repository: TodoRepository()),
child: TodoListPage(),
);
}
@visibleForTesting
TodoListPage() : super();
@override
Widget build(BuildContext context) {
final state = Provider.of<TodoListState>(context);
// 代わりにcontext.watch()でもよい。
// final state = context.watch<TodoListState>();
return Scaffold(
body: state.loading
? const Center(
child: CircularProgressIndicator(),
)
: ListView.builder(
itemCount: state.todos.length,
itemBuilder: (context, index) {
final todo = state.todos[index];
return ListTile(
title: Text(todo.title),
trailing: Checkbox(
value: todo.done,
onChanged: (checked) {
Provider.of<TodoListController>(context, listen: false)
.replaceTodo(
index,
Todo(title: todo.title, done: checked ?? false),
);
},
),
onLongPress: () async {
final result = await EditDialog.show(context, todo);
if (result != null) {
// Provider.of(context, listen: false)の代わりにcontext.read()でも良い
context
.read<TodoListController>()
.replaceTodo(index, result);
}
},
);
},
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () async {
final result = await EditDialog.show(context);
if (result != null) {
context.read<TodoListController>().appendTodo(result);
}
},
),
);
}
}
実行して、以前と変わりなく動作しているのを確認しましょう。
基本はproviderと全く同じです。TodoListStateとTodoListControllerをそのまま流用して、依存性注入の方法として、providerの代わりにriverpodを使用します。
riverpodはProviderと呼ばれる、依存対象を注入するものを指し示すインスタンスをグローバルスコープに保持します(poviderのProviderとは全くの別物なので注意)。実際に注入されるインスタンスはProviderScopeというWidgetが保持するため、ProviderScopeはProviderが使われるWidgetよりも上位に存在していなければなりません(通常は最上位に置きます)。
pubspec.yamlに依存関係を追加します。(providerとflutter_state_notifierの依存は、今節では使用しないので削除しても構いません)
dependencies:
flutter:
sdk: flutter
shared_preferences: ^2.0.5
provider: ^5.0.0
state_notifier: ^0.7.0
flutter_state_notifier: ^0.7.0
freezed_annotation: ^0.14.1
# 以下の2つを追加
riverpod: ^0.13.1
flutter_riverpod: ^0.13.1+1
TodoListControllerのProviderを作成します。lib/todo_list_controller.dartのグローバルスコープに以下のコードを追加します。StateNotifierProviderはflutter_state_notifierパッケージではなく、riverpodパッケージのものを使用するようにimportの際は気をつけてください。
final todoListControllerProvider = StateNotifierProvider(
(ref) => TodoListController(repository: TodoRepository()),
);
作成したtodoListControllerProviderをTodoListPageから使用できるようにします。TodoListPageがConsumerWidgetを継承するように変更し、提供されるwatch関数を使ってtodoListControllerProviderが提供するTodoListStateやTodoListControllerを使用します。
class TodoListPage extends ConsumerWidget {
@override
Widget build(BuildContext context, ScopedReader watch) {
final state = watch(todoListControllerProvider.state);
return Scaffold(
body: state.loading
? const Center(
child: CircularProgressIndicator(),
)
: ListView.builder(
itemCount: state.todos.length,
itemBuilder: (context, index) {
final todo = state.todos[index];
return ListTile(
title: Text(todo.title),
trailing: Checkbox(
value: todo.done,
onChanged: (checked) {
watch(todoListControllerProvider).replaceTodo(
index,
Todo(title: todo.title, done: checked ?? false),
);
},
),
onLongPress: () async {
final result = await EditDialog.show(context, todo);
if (result != null) {
watch(todoListControllerProvider)
.replaceTodo(index, result);
}
},
);
},
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () async {
final result = await EditDialog.show(context);
if (result != null) {
watch(todoListControllerProvider).appendTodo(result);
}
},
),
);
}
}
lib/main.dartのrunApp直下にProviderScopeを配置して、Providerに対応するインスタンスを保持できるようにします。
void main() {
runApp(ProviderScope(
child: MyApp(),
));
}
以前と変わりなく動作していることを確認してください。
前章までに、ローカル環境で動作するTODOアプリを作成しました。基本的なWidgetの使い方や状態管理方法を理解していただけたと思います。
今までの知識をベースに応用的なアプリケーションの作成も可能です。ご自身でWidgetを調査する、ロジックを考えるなどしてみてください。以下にレベルアップにちょうど良さそうな題材を用意しておきます。参考にしてください。
現在のアプリの構成のまま実装が可能と思われる範囲
現在のアプリから少々構成を変更する必要が出てくる範囲
現在のアプリからかなり構成を変更する必要が出てくる範囲