もふもふ技術部

IT技術系mofmofメディア

【Flutter 連載記事第5回】TODOアプリを作成する

本記事について

本記事はシリーズ連載記事の第5回になります。

今回は今まで学んできたことの総まとめという形で、簡単なTODOアプリを作ってみようと思います。

  1. flutterの環境構築
  2. widgetを使った画面表示や、画面遷移をさせる
  3. DBの設定やCRUDをする
  4. S3を使って画像アップロード・ダウンロードする
  5. TODOアプリを作成する

バージョン

  • OS: macOS Monterey 12.6
  • チップ: Apple silicon
  • Flutter SDK: flutter_macos_arm64_3.3.9-stable

TODOアプリを作る

機能としては

  • TODO一覧
  • TODO追加
  • TODO削除

という形でいきたいと思います。本来ならユーザごとにTODOを管理するためにログイン機能も必要になると思いますが、今までの総まとめという形ですので、今回はログイン機能はつけない形でいこうと思います。

最初に完成したものの動作イメージをお見せしようと思います。

flutter_todo_1

  • アプリを起動するとTODO一覧画面が開く
  • 一覧画面右下の「+」ボタンを押下するとTODO作成画面に遷移
  • TODO作成画面のフォームにTODOを入力してTODO作成
  • 一覧画面ではゴミ箱アイコンを押下すると対象のTODOを削除

上記の仕様になっています。実装したファイルとしてはlib/main.dart以外にTODO一覧画面としてlib/todo_list.dart、TODO作成画面としてlib/todo_create.dartを作成しました。

では実装完了したファイルをお見せして、内容について解説していこうと思います。

lib/main.dart

import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
import 'todo_list.dart';
import 'todo_create.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      routes: {
        '/': (context) => const TodoList(),
        '/todo_create': (context) => const TodoCreate(),
        '/todo_list': (context) => const TodoList(),
      },
    );
  }
}

lib/main.dartではFirebaseの初期化処理をして、routesにTODO一覧とTODO作成のページをそれぞれ設定しています。

次に、TODO作成画面のlib/todo_create.dartです。

lib/todo_create.dart

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

class TodoCreate extends StatefulWidget {
  const TodoCreate({super.key});

  @override
  State<TodoCreate> createState() => _TodoCreateState();
}

class _TodoCreateState extends State<TodoCreate> {
  String inputText = '';

  void createTodo(context) async {
    await FirebaseFirestore.instance
        .collection('todos')
        .add({'body': inputText});
    Navigator.of(context).pushReplacementNamed('/todo_list');
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('TODO作成')),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          TextFormField(
            decoration: const InputDecoration(
              border: OutlineInputBorder(),
              hintText: 'TODOを入力',
            ),
            onChanged: (String value) {
              setState(() {
                inputText = value;
              });
            },
          ),
          Container(
            margin: const EdgeInsets.all(15),
            child: ElevatedButton(
              onPressed: () => createTodo(context),
              child: const Text('TODOを登録'),
            ),
          ),
        ],
      ),
    );
  }
}

まずはこちらに注目してください。

class TodoCreate extends StatefulWidget {
  const TodoCreate({super.key});

  @override
  State<TodoCreate> createState() => _TodoCreateState();
}

class _TodoCreateState extends State<TodoCreate> {

今までと書き方が違っていることが分かると思います。今までは extends StatelessWidget となっていましたが、今回は extends StatefulWidget となっています。これはなぜかと言うと、今回はユーザがTODOとして入力した値を保持する必要があったからです。こういった、画面内で値(状態)を保持してそれを使って処理を行うような場合は StatefulWidget を使う必要があるのです。逆に、画面内で状態を保持する必要が無い場合、つまり静的な画面を表示させるだけで良い場合は StatelessWidget を使う、という形になります。

それに伴い、

@override
State<クラス名> createState() => _クラス名State();

class _クラス名State extends State<クラス名> {

という書き方をするようになっています。このあたりについてはこういう書き方をするんだな、という形で理解して頂けたら良いと思います。

String inputText = '';

void createTodo(context) async {
  await FirebaseFirestore.instance
      .collection('todos')
      .add({'body': inputText});
  Navigator.of(context).pushReplacementNamed('/todo_list');
}

ここでは「TODOを登録」ボタンを押下した際の処理を記述しています。Firestoreにアクセスして、入力された文字列をtodosコレクションのドキュメントとして登録して、登録が完了したらTODO一覧画面に遷移する流れになります。

入力された文字列を変数 inputText に入れる処理は画面表示側のWidgetのところで処理が記述されています。

children: <Widget>[
  TextFormField(
    decoration: const InputDecoration(
      border: OutlineInputBorder(),
      hintText: 'TODOを入力',
    ),
    onChanged: (String value) {
      setState(() {
        inputText = value;
      });
    },
  ),
  Container(
    margin: const EdgeInsets.all(15),
    child: ElevatedButton(
      onPressed: () => createTodo(context),
      child: const Text('TODOを登録'),
    ),
  ),
],

TextFormField Widgetの中で記述されている

onChanged: (String value) {
  setState(() {
    inputText = value;
  });
},

この onChangedsetState メソッドで変数 inputText にユーザの入力値を設定している形ですね。

Container(
  margin: const EdgeInsets.all(15),
  child: ElevatedButton(
    onPressed: () => createTodo(context),
    child: const Text('TODOを登録'),
  ),
),

この部分で「TODOを登録」のボタンを押下した際に、createTodoメソッドを呼ぶ処理を実装しています。

では、TODO一覧画面について説明します。 lib/todo_list.dart が下記になります。

lib/todo_list.dart

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

class TodoList extends StatelessWidget {
  const TodoList({super.key});

  void moveToCreatePage(context) {
    Navigator.of(context).pushReplacementNamed('/todo_create');
  }

  void deleteDoc(context, docId) async {
    await FirebaseFirestore.instance.collection('todos').doc(docId).delete();
    Navigator.of(context).pushReplacementNamed('/todo_list');
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('TODO一覧')),
      body: Center(
        child: FutureBuilder<QuerySnapshot>(
          future: FirebaseFirestore.instance.collection('todos').get(),
          builder: ((context, snapshot) {
            if (!snapshot.hasData) {
              return const Center(child: CircularProgressIndicator());
            }

            final List<DocumentSnapshot> todoSnapshot = snapshot.data!.docs;
            final List<Card> todoList = todoSnapshot.map((document) {
              return Card(
                child: ListTile(
                  title: Text(document['body']),
                  trailing: GestureDetector(
                    onTap: () {
                      deleteDoc(context, document.id);
                    },
                    child: const Icon(Icons.delete),
                  ),
                ),
              );
            }).toList();

            return Column(children: todoList);
          }),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          moveToCreatePage(context);
        },
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

TODO一覧画面ではTODO作成画面のように値の保持の必要がないので、今までと同じ extends StatelessWidget となっています。

void moveToCreatePage(context) {
  Navigator.of(context).pushReplacementNamed('/todo_create');
}

void deleteDoc(context, docId) async {
  await FirebaseFirestore.instance.collection('todos').doc(docId).delete();
  Navigator.of(context).pushReplacementNamed('/todo_list');
}

こちらではmoveToCreatePageメソッドでTODO作成画面への画面遷移の処理を、deleteDocメソッドでtodosコレクションのドキュメントIDを指定してドキュメント削除してTODO一覧への画面遷移を実装しています。

child: FutureBuilder<QuerySnapshot>(
  future: FirebaseFirestore.instance.collection('todos').get(),
  builder: ((context, snapshot) {
    if (!snapshot.hasData) {
      return const Center(child: CircularProgressIndicator());
    }

    final List<DocumentSnapshot> todoSnapshot = snapshot.data!.docs;
    final List<Card> todoList = todoSnapshot.map((document) {
      return Card(
        child: ListTile(
          title: Text(document['body']),
          trailing: GestureDetector(
            onTap: () {
              deleteDoc(context, document.id);
            },
            child: const Icon(Icons.delete),
          ),
        ),
      );
    }).toList();

    return Column(children: todoList);
  }),
),

ここは少し分かりにくいかもしれませんが、まずtodosコレクションのドキュメントを一覧で取得しています。そして取得したドキュメント一覧をmapメソッドを使って ListTile WidgetCard Widgetに入れたもののListに入れ替えてreturnしている流れです。 Card Widgetに入れているのはこうすることで外枠表示させているためで、TODOリストの本体は ListTile Widgetの方ですね。

child: ListTile(
  title: Text(document['body']),
  trailing: GestureDetector(
    onTap: () {
      deleteDoc(context, document.id);
    },
    child: const Icon(Icons.delete),
  ),
),

title がリストで表示させている文字列、 trailing が右側で表示させているゴミ箱アイコンになります。ゴミ箱をタップしたらdeleteDocメソッドが呼ばれるようになっていることが分かると思います。

floatingActionButton: FloatingActionButton(
  onPressed: () {
    moveToCreatePage(context);
  },
  tooltip: 'Increment',
  child: const Icon(Icons.add),
),

最後にこちらが右下の「+」ボタンのところになります。タップしたらmoveToCreatePageメソッドが呼ばれることが分かりますね。

最後に

簡単なものではありますが、これでTODOアプリを作成することが出来ました。今まで書いてきた記事の中でも、今回のTODOアプリでもそうでしたが、Flutterで実装をする上でハマってしまうポイントは型定義の部分にあるなというのは個人的に感じるところでした。Firestoreから取得したデータがどういう型になっていて、それをどういう形でWidgetに渡してやるかというところだったり、Widgetの引数として受け取れるものの型が決まっているのでそこに渡す変数の型を合わせるのが上手くいかずにハマってしまうということが多い印象でした。

ただ、そういった型定義の部分に慣れさえすれば、Widget自体は便利なオプションも沢山あってとても使いやすく、画面に配置していく作業も直感的で楽しく実装できるなと思いました。Widgetがある程度デザインの部分も担保してくれるのであまりデザイン力が無いとしてもそれなりに見れるものが出来そうなので、個人開発なんかもやりやすそうです。

今回は全5回の記事でFlutterの環境構築からTODOアプリの作成までやってきました。私自身もそうでしたが、Flutter初心者の方の学習の助けやモチベーションアップに繋がれば幸いです。