Flutterを使ったタスク管理アプリUIの作り方を解説

Flutterを使ったアプリ開発について勉強してきたら、それらの知識をつかってなにか作ってみたいという人は多いと思います。
そこで今回は、学んだ知識を活用してTodoアプリを作り方を紹介したいと思います。

Flutterアプリ開発の基礎

Flutterアプリをまだ作ったことがない人やイチから学びたいという人は、先に以下の記事を順番に読んで学ぶことをおすすすめします。

今回つくるものはTodoリスト

今回つくるアプリはTodoリストアプリです。Todoリストアプリには「買い物」や「今日しなければいけないこと」などのタスクを追加・一覧・達成状況の管理などが出来るものです。

基本機能

今回のTodoリストアプリの作り方では、一般的なTodoアプリにあるような次のような機能の実装方法を紹介していきます。UIクラスやロジックの解説などもあるので、理解しながら実装を進めていきましょう。

  • タスク一覧
  • 新規タスク追加
  • タスク達成の記録

これらを実装していくと、最終的には写真のようなアプリになります。

Todoアプリの作成

ここから、上記の機能を実装したTodoアプリを作成していきます。データモデルの作成、タスクの一覧、追加という順に解説していきます。細かく解説を入れているので、なるべく理解しながら進めていくとアプリ開発の基本が分かるようになっていくと思います。

基本のレイアウト

まずは、Scaffoldを使って一覧画面のレイアウトを作成していきます。タスクを追加するためのボタンをfloatingActionButtonに配置し、body部分には後ほどタスク一覧を配置します。

タスクの追加処理などは後半で実装するため、現段階ではタスク追加のボタンは特にアクションでは特に何もしないようにします。TodoListViewは、タスクのデータを保有するのでStatefulWidgetを利用します。また、タスクについてはとりあえず文字列で管理します。

ここで使用するListViewの使いかたについては、下記のブログで紹介しているので参考にしてください。

    class MyApp extends StatelessWidget {
      const MyApp({Key? key}) : super(key: key);
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
            title: 'Flutter Demo',
            theme: ThemeData(
              primarySwatch: Colors.blue,
            ),
            home: TodoListView());
      }
    }
    
    class TodoListView extends StatefulWidget {
      const TodoListView({Key? key}) : super(key: key);
    
      @override
      State<StatefulWidget> createState() => TodoListState();
    }
    
    class TodoListState extends State<TodoListView> {
      @override
      Widget build(BuildContext context) {
        var listItems = [
          const Text("机を片付ける"),
          const Text("コーヒーを淹れる")
        ]
    
        return Scaffold(
          appBar: AppBar(
            title: const Text("タスクリスト"),
          ),
          body: ListView(listItems),
          floatingActionButton: FloatingActionButton(
            onPressed: showAddingTaskPage,
            tooltip: 'Add Task',
            child: const Icon(Icons.add),
          ),
        );
      }
    
      showAddingTaskPage() {
        // floatingActionButtonをタップするとこの関数がよばれる
        // タスク追加画面は後ほど実装
      }
    }

各タスクのレイアウト

このままでは、タスク一覧が見にくいので各タスクのレイアウトを作成していきます。TaskListCardには、タスクを引数で渡せるようにします。

    import 'package:flutter/material.dart';
    
    class TaskListCard extends StatelessWidget {
      const TaskListCard(this._task, {Key? key}) : super(key: key);
    
      final String _task;
    
      @override
      Widget build(BuildContext context) {
        var title = ListTile(title: Text(_task));
        return Card(child: title);
      }
    }

では、今つくったタスクカードをTodoListViewに反映してみたいと思います。

    class TodoListState extends State<TodoListView> {
      @override
      Widget build(BuildContext context) {
        var listItems = [
          TaskListCard("机を片付ける"),
          TaskListCard("コーヒーを淹れる")
        ]
    
        // ...
      }
    }
上記のようTextだった部分TaskListCardをに置き換えることで、タスク一覧の各タスクへデザインを反映することが出来ます。
また、下記のようにmap関数をりようすることでタスクのリストからTaskListCardを生成することができます。
    List<String> _tasks = [
      "机を片付ける",
      "コーヒーを淹れる"
    ]
    
    class TodoListState extends State<TodoListView> {
      @override
      Widget build(BuildContext context) {
        var listItems = _tasks.map((task) => TaskListCard(task)).toList();
    
        // ...
      }
    }

Todoタスクのデータ構造とモデル

次はは、モデルでタスクを定義していきます。今は、タスクがすべて文字列で保存されているのですが、このままだと詳細や達成状況を管理しにくい状態です。そこで、タスクに含めるデータをモデルとして定義していきます。モデルはMVCやMVVMなどの様々なアーキテクチャでもよく使われており、データを構造化して扱いやすくするために利用するものです。今回は、タイトル、内容、達成フラグの3つを1つのタスクモデルとして扱います。

    class Task {
      int id;
      String title;
      String content;
      bool isDone;
    
      Task(this.id, this.title, this.content, this.isDone);
    
      changeState() {
        isDone = !isDone;
      }
    }

モデルが定義できたら、文字列だったタスクをモデルに置き換えていきます。以下のように修正していくことでタスクをモデルとして扱うことが出来ます。

    List<Task> _tasks = [
      Task(0, "タイトル", "内容", false);
    ]

TodoListCardでは、Stringだった場所をモデルに修正していきます。これで、タスクの詳細や達成状況などを扱いやすくできます。

    class TaskListCard extends StatelessWidget {
    
      // ...
    
      final Task _task; // 修正
      
      @override
      Widget build(BuildContext context) {
        // 完了済みなら打ち消し線を引く
        var textStyle = _task.isDone
            ? const TextStyle(decoration: TextDecoration.lineThrough)
            : null;
    
        var title = Text(_task.title, style: textStyle); // 修正
        var content = Text(_task.content, style: textStyle); // 追加
        var checkIcon = _task.isDone ? const Icon(Icons.done) : null; // 追加
    
        return Card(
            child: ListTile(title: title, subtitle: content, trailing: checkIcon)); // 修正
      }
    }

新規タスクの追加

次にタスクを作成する画面を作っていきます。まずは、Scaffoldを利用してボタンを押すとタスクが追加される簡単な画面を作っていきます。一覧画面と同様にStatelessWidgetを使ってCreateTaskViewを作成します。

    class CreateTaskView extends StatelessWidget {
      // アンダースコアがあるものはprivate変数
      final TextEditingController _titleController = TextEditingController();
      final TextEditingController _contentController = TextEditingController();
    
      CreateTaskView({Key? key}) : super(key: key);
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
            appBar: AppBar(
              title: const Text("タスク作成"),
            ),
            body: ListView(
              children: [
                TextField(
                  controller: _titleController,
                  decoration: const InputDecoration(
                      border: OutlineInputBorder(), labelText: "タイトル"),
                ),
                TextField(
                  controller: _contentController,
                  decoration: const InputDecoration(
                      border: OutlineInputBorder(), labelText: "詳細"),
                ),
                Center(
                    child: ElevatedButton(
                        style: ElevatedButton.styleFrom(
                            textStyle: const TextStyle(fontSize: 20)),
                        child: const Text("作成"),
                        onPressed: () {
                          var title = _titleController.text;
                          var content = _contentController.text;
    
                          var task = Task(0, title, content, false);
                          Navigator.of(context).pop(task);
                        }))
              ],
            ));
      }
}

次にトップページから新規タスク作成画面への遷移を実装します。画面の遷移にはNavigatorクラスを利用します。このNavigatorクラスでは、遷移先の画面から戻ってくる際に引数を渡すことで遷移元に移動してからそれ受け取ることが出来ます。今回は、作った新規タスクを受け取る形にします。

遷移元の画面でも受け取り処理を実装する時、バックボタンで戻ってくることも踏まえて受け取ったタスクがnullかどうかチェックします。

    class TodoListState extends State<TodoListView> {
      // ...
    
      @override
      Widget build(BuildContext context) {
    
        // ...
    
      }
    
      // 新規作成したタスクを一覧へ追加
      addNewTask(Task newTask) {
        // 
        newTask.id = _tasks.length + 1;
        setState(() => _tasks.add(newTask));
      }
    
      // 新規作成画面への遷移
      showAddingTaskPage() {
        Navigator.of(context)
            .push(MaterialPageRoute(builder: (context) => CreateTaskView()))
            .then((newTask) {
              if (newTask != null) {
                addNewTask(newTask);
              }
            });
      }   
}

タスクの完了処理を追加

最後に、タスクを終えたときに完了にするための機能を実装します。ここではタスクを完了させるために、一覧画面で終了させたいタスクをタップをして完了表示します。
完了は、タスクにチェックマークを表示してタイトルと詳細に打ち消し線を表示することで分かるようにします。
    class TodoListState extends State<TaskSample> {
      List<Task> _tasks = [];
    
      @override
      Widget build(BuildContext context) {
        // タップした時にタスクを終了にする
        var listItems = _tasks
            .map((task) => InkWell(
                onTap: (() => chagneState(task.id)), child: TaskListCard(task)))
            .toList();
    
            // ...
      }
      
      // ...
    
      // 同一IDのタスクを見つけてステータス変更
      chagneState(int id) {
        for (var task in _tasks) {
          if (task.id == id) task.changeState();
        }
    
        setState(() => _tasks = _tasks);
      }
    }