はじめに
こんにちは!
モバイルエンジニアのやまたつと申します!
今回は TODOアプリ📝 を作成してみました!
この記事が誰かの役に立てば幸いです。
※明らかに間違っていたり、非推奨の実装などありましたら、アドバイスいただけると嬉しいです🙇♂️
作成したTODOアプリ📝 の GIF
GitHub
以下にソースコードを置いています。
YamaTatsu10969/todo_app_yamatatsu
ブランチは、 textField
と textFormField
を使ったものがあります。
本記事で紹介するのは textField
のプロジェクトになります!
参考になりましたら、スターを付けてもらえるとめちゃくちゃ嬉しいです😆
機能
最低限の機能だけを実装しています。
- タスクの追加
- タスクの削除
- タスクの編集
- タスクの読み込み
- 簡単な validate (Nameが空の時だけ)
環境
- Dart: 2.7.0
- Flutter: 1.12.13+hotfix.8 • channel stable
- provider: 4.0.4
構成
ファイル構成
State管理
Provider
+ ChangeNotifier
で管理しています。
以下のドキュメントが参考になります。
参考:Simple app state management - Flutter
私はいくつかの State管理を試しましたが、 今までの私の経験からは Provider
+ ChangeNotifier
が一番しっくりきました。(BLoC は学習コストが高いと思いました)
以下の mono さんの記事にもある通り、オススメの管理方法です。
BLoC と 「provider + ChangeNotifier」 やその他のパターンの優劣はケースバイケースであり一概には言えないが、「provider + ChangeNotifier」が「StatefulWidgetにベタ書き」の次のステップとしてのスタンダードであり、かつ適切に扱えばそのパターンで大抵の要件はスムーズにこなせる(という前提で、好みや要件に応じて他のパターンの採用・併用もありという考え)。
参考:FlutterのBLoC(Business Logic Component)のライフサイクルを正確に管理して提供するbloc_providerパッケージの解説
State管理フローチャートで、管理方法を決めるのもオススメです。
Flutter状態管理フローチャートを書いた( ´・‿・`)https://t.co/YS9wqexLtQ 周りを勉強する際に見通しが良くなるはず( ´・‿・`) pic.twitter.com/zCczrCEZJ6
— mono 🎯 @自宅 💙 (@_mono) September 8, 2019
各ファイルの説明
initialRoute
と route
で遷移を管理しています。
TaskViewModel (ChangeNotifierProvider)
で管理しているプロパティは全ての画面で使いたいので、最上階層に配置しています。
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:todo_app_yamatatsu/screen/add_task_screen/add_task_screen.dart';
import 'package:todo_app_yamatatsu/screen/task_screen/task_screen.dart';
import 'package:todo_app_yamatatsu/view_model/task_view_model.dart';
void main() {
print('Welcome Yamatatsu Todo App !!!');
runApp(
ChangeNotifierProvider(
create: (context) => TaskViewModel(),
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Yamatatsu TODO',
theme: ThemeData(
primarySwatch: Colors.lightGreen,
),
initialRoute: TaskScreen.id,
routes: {
TaskScreen.id: (context) => TaskScreen(),
AddTaskScreen.id: (context) => AddTaskScreen(),
},
);
}
}
単純なモデルです。
class Task {
String name;
String memo;
bool isDone;
final DateTime createdAt;
DateTime updatedAt;
Task(
{this.name,
this.memo,
this.isDone = false,
this.createdAt,
this.updatedAt});
}
ChangeNotifier
を継承したクラスです。
このクラスで表示するタスクや、追加するタスク、編集するタスクを管理しています。
状態変更をUIに反映させたい時には、必ず notifyListeners();
を呼びましょう。
タスクの編集時に初期値を入れたかったので、TextEditingController
を使っています。
初期値を入れない場合は、 TextField → onChanged で変更を管理するだけで大丈夫そうです。
textFormField には initialValue というプロパティがあるのでそれに入れると初期値を設定できます(textFieldにも欲しい。。。)
textFormField の実装も GitHub で行っているので、見たい方は参考にしてください(todo_app_yamatatsu at textFormField)
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:todo_app_yamatatsu/model/task.dart';
class TaskViewModel extends ChangeNotifier {
String get editingName => nameController.text;
String get editingMemo => memoController.text;
TextEditingController nameController = TextEditingController();
TextEditingController memoController = TextEditingController();
String _strValidateName = '';
String get strValidateName => _strValidateName;
bool _validateName = false;
bool get validateName => _validateName;
List<Task> _tasks = [];
UnmodifiableListView<Task> get tasks {
return UnmodifiableListView(_tasks);
}
bool validateTaskName() {
if (editingName.isEmpty) {
_strValidateName = 'Please input something.';
notifyListeners();
return false;
} else {
_strValidateName = '';
_validateName = false;
return true;
}
}
void setValidateName(bool value) {
_validateName = value;
}
void updateValidateName() {
if (validateName) {
validateTaskName();
notifyListeners();
}
}
void addTask() {
final newTask = Task(
name: nameController.text,
memo: memoController.text,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
_tasks.add(newTask);
clear();
}
void updateTask(Task updateTask) {
var updateIndex = _tasks.indexWhere((task) {
return task.createdAt == updateTask.createdAt;
});
updateTask.name = nameController.text;
updateTask.memo = memoController.text;
updateTask.updatedAt = DateTime.now();
_tasks[updateIndex] = updateTask;
clear();
}
void deleteTask(int index) {
_tasks.removeAt(index);
notifyListeners();
}
void toggleDone(int index, bool isDone) {
var task = _tasks[index];
task.isDone = isDone;
_tasks[index] = task;
notifyListeners();
}
void clear() {
nameController.clear();
memoController.clear();
_validateName = false;
notifyListeners();
}
}
AppBar の右に、次の画面に遷移するボタンを配置しています。
actions
は右に配置されて、leading
は左に配置されます。
ListView はファイルを別にして切り出しています。
import 'package:flutter/material.dart';
import 'package:todo_app_yamatatsu/screen/add_task_screen/add_task_screen.dart';
import 'package:todo_app_yamatatsu/screen/task_screen/task_list_view.dart';
class TaskScreen extends StatelessWidget {
static String id = 'task_screen';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Task List'),
actions: [
GestureDetector(
onTap: () {
Navigator.pushNamed(context, AddTaskScreen.id);
},
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Icon(Icons.add),
),
),
],
),
body: Container(
child: TaskListView(),
),
);
}
}
Consumer<TaskViewModel>(builder: (context, taskViewModel, _) {
を使って、
stateの変更を受け取っています。
taskViewModel
から値を受け取ったり、メソッドを使用したりしています。
taskViewModel.tasks
が空の場合は、EmptyView を配置しているようにしています。
Flutter でとても便利だなと思うことの一つは、エラーの場合や空の場合に、通常とは異なる View(今回は EmptyView) を配置する実装がとても簡単なことです。
(Swift で tableView を実装していた時に、 DZNEmptyDataSet
と言うライブラリを良く使っていました)
今回は Dismissible
を使用しており、item を横にスライドさせた時にアニメーションが走ります。
今回は、タスクを DONE にしたり、削除したりできるようにしています。
以下に使い方が載っています。
参考: Dismissible (Flutter Widget of the Week) - YouTube
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/painting.dart';
import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart';
import 'package:todo_app_yamatatsu/screen/add_task_screen/add_task_screen.dart';
import 'package:todo_app_yamatatsu/screen/task_screen/task_item.dart';
import 'package:todo_app_yamatatsu/view_model/task_view_model.dart';
class TaskListView extends StatelessWidget {
const TaskListView({
Key key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Consumer<TaskViewModel>(builder: (context, taskViewModel, _) {
if (taskViewModel.tasks.isEmpty) {
return _emptyView();
}
return ListView.separated(
itemBuilder: (context, index) {
var task = taskViewModel.tasks[index];
return Dismissible(
key: UniqueKey(),
onDismissed: (direction) {
if (direction == DismissDirection.endToStart) {
taskViewModel.deleteTask(index);
} else {
taskViewModel.toggleDone(index, true);
}
},
background: _buildDismissibleBackgroundContainer(false),
secondaryBackground: _buildDismissibleBackgroundContainer(true),
child: TaskItem(
task: task,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) {
var task = taskViewModel.tasks[index];
taskViewModel.nameController.text = task.name;
taskViewModel.memoController.text = task.memo;
return AddTaskScreen(editTask: task);
}),
);
},
toggleDone: (value) {
taskViewModel.toggleDone(index, value);
},
),
);
},
separatorBuilder: (_, __) => Divider(),
itemCount: taskViewModel.tasks.length);
});
}
Container _buildDismissibleBackgroundContainer(bool isSecond) {
return Container(
color: isSecond ? Colors.red : Colors.green,
alignment: isSecond ? Alignment.centerRight : Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.all(8),
child: Text(
isSecond ? 'Delete' : 'Done',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white),
),
),
);
}
Widget _emptyView() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text("You don't have a task!!!"),
SizedBox(height: 16),
Text(
'Complete!!!',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 32,
),
),
],
),
);
}
}
item ではプロパティを持たせて、コンストラクタで受け取るようにしていて、DIを意識しています。
final Function(bool) toggleDone;
を Function で渡すことで、
CheckBox
の onChanged
の値を受け取ることができます。
final Function(bool) toggleDone
は、 final Function toggleDone
とも書けるのですが、これはお勧めしません。
型が dynamic になってしまい、予期せぬエラーにつながります。
これを防ぐには、 analysis_options.yaml
を配置して、警告を出すようにすると良いです。
こちらに詳しく載っているので Flutter を勉強していく方はぜひ一読すると良いと思います。
Dart/Flutter の静的解析強化のススメ - Flutter 🇯🇵 - Medium
Tips ですが、final でプロパティを記述し終わった後に、option + enter を押すと、
コンストラクタが自動生成されます(Android Studioだけかな)
import 'package:flutter/material.dart';
import 'package:todo_app_yamatatsu/model/task.dart';
class TaskItem extends StatelessWidget {
final Task task;
final VoidCallback onTap;
final Function(bool) toggleDone;
const TaskItem(
{Key key,
@required this.onTap,
@required this.task,
@required this.toggleDone})
: super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(8.0).copyWith(left: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
task.name,
style: Theme.of(context)
.textTheme
.headline
.copyWith(fontWeight: FontWeight.bold),
),
(task.memo.isEmpty)
? Container()
: Column(
children: <Widget>[
SizedBox(height: 4),
Text(
task.memo,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.body1,
),
],
),
],
),
),
Checkbox(
value: task.isDone,
onChanged: (value) {
print(value);
toggleDone(value);
},
activeColor: Colors.lightGreen,
)
],
),
),
);
}
}
WillPopScope
を使って back ボタンをハンドリングしています。return false
にすると戻れないようにすることができます。
Consumer
で viewModel のメソッドやプロパティを参照しています。
1ファイルが大きくなっているのでファイルを分けてもよかったかなと思います。
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:todo_app_yamatatsu/model/task.dart';
import 'package:todo_app_yamatatsu/view_model/task_view_model.dart';
class AddTaskScreen extends StatelessWidget {
static String id = 'add_task_screen';
final Task editTask;
AddTaskScreen({Key key, this.editTask}) : super(key: key);
@override
Widget build(BuildContext context) {
return Consumer<TaskViewModel>(
builder: (context, viewModel, _) {
return WillPopScope(
onWillPop: () async {
viewModel.clear();
return true;
},
child: Scaffold(
appBar: AppBar(
title: Text(_isEdit() ? 'Save Task' : 'Add Task'),
),
body: ListView(
children: <Widget>[
_buildInputField(
context,
title: 'Name',
textEditingController: viewModel.nameController,
errorText:
viewModel.validateName ? viewModel.strValidateName : null,
didChanged: (_) {
viewModel.updateValidateName();
},
),
_buildInputField(
context,
title: 'Memo',
textEditingController: viewModel.memoController,
errorText: null,
),
_buildAddButton(context),
],
),
),
);
},
);
}
bool _isEdit() {
return editTask != null;
}
void tapAddButton(BuildContext context) {
final viewModel = Provider.of<TaskViewModel>(context, listen: false);
viewModel.setValidateName(true);
if (viewModel.validateTaskName()) {
_isEdit() ? viewModel.updateTask(editTask) : viewModel.addTask();
Navigator.of(context).pop();
}
}
Widget _buildInputField(BuildContext context,
{String title,
TextEditingController textEditingController,
String errorText,
Function(String) didChanged}) {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
title,
style: Theme.of(context).textTheme.subtitle,
),
TextField(
controller: textEditingController,
decoration: InputDecoration(errorText: errorText),
onChanged: (value) {
didChanged(value);
},
),
],
),
);
}
Widget _buildAddButton(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(20),
child: RaisedButton(
onPressed: () => tapAddButton(context),
color: Theme.of(context).primaryColor,
padding: const EdgeInsets.all(20),
child: Center(
child: Text(
_isEdit() ? 'Save' : 'Add',
style:
Theme.of(context).textTheme.title.copyWith(color: Colors.white),
),
),
),
);
}
}
まとめ
以上が TODOアプリの説明になります!
このアプリを作る中でも勉強になるところがたくさんありました。
TODO アプリは言語の学習に最適だと言われる理由がわかった気がします。
Swift を学習していた時も TODOアプリを作って、わからないところを質問して、理解を深めていきました。
その後に自分のアプリを作成して、エンジニアとして成長できたと思っています!
この TODOアプリが誰かの役に立つととても嬉しいです。
なにかありましたら Twitter にてご連絡ください 。
Twitter: やまたつ🐥
読んでいただき、ありがとうございました (^^)
おまけ: Flutter の学習に良いコンテンツ
私が実際に読んで良かったと思っているコンテンツです!
Flutterの効率良い学び方 - Flutter 🇯🇵 - Medium
FlutterとFirebaseで開発した 英語の瞬間翻訳トレーニングアプリ Lala の技術 - Qiita
Flutter製アプリ「筋トレ ビフォーアフター」を元にFlutterと個人開発を学ぶ - Qiita
Udemy: The Complete 2020 Flutter Development Bootcamp with Dart | Udemy
以下は Dartの言語についてですので、一通り学習が済んだら読んでみると面白いかもしれません。
Effective Dartまとめ - Qiita