実践Flutter

Flutterでスマホを中心にアプリ制作していきます。

Flutterアプリ・Todoリストのサブタスク分割機能

はじめに

今回はFlutterによるTodoリストアプリの続きとして各タスクにサブタスクのリストを追加していきたいと思います。

実装の観点からいうと、HiveListによるリレーションを含めた永続化、そして画面遷移しない形でのリスト編集がポイントになります。

タスクもサブタスクも同じUIにして画面遷移させた形での編集だと、同じことの繰り返しになるのでサブタスクリストだけはその画面内で編集するようにしています。それによる実装の変化がいくつかありますので、そのあたりをよく見ていきましょう。




サブタスク分割機能つきTodoリスト

スクリーンショット

サブタスク分割機能の動きをスクリーンショットでみていきます。ホーム画面のタスクのリストは前回までと同様です。ここで下のフローティングアクションボタンで新規項目を追加します:

f:id:linkedsort:20211114134546p:plain:w400

タスク編集画面では、前回同様のタスク入力と、その下にサブタスク入力のフィールドを備えています:

f:id:linkedsort:20211114134612p:plain:w400

サブタスクのタイトルを入力し「サブタスク登録」を押すと、下のリストにサブタスクとして表示されていきます:

f:id:linkedsort:20211114134701p:plain:w400

サブタスクの項目を選択すると、再びこのタイトルを編集することができます。青い色はサブタスクが選択状態であることを表します:

f:id:linkedsort:20211114134732p:plain:w400

サブタスクの2番めの項目を改変してみました:

f:id:linkedsort:20211114134833p:plain:w400

タスク名、及びサブタスク名が空欄のまま登録ボタンを押すと、上部からスナックバーが出てきて入力を促されます:

f:id:linkedsort:20211114134903p:plain:w400

タスクを登録すると、前回と同様の画面です:

f:id:linkedsort:20211114134946p:plain:w400

サブタスクもアイコンをタップすると「実行済み」のグレーになります:

f:id:linkedsort:20211114135021p:plain:w400

サンプルコード

ではサンプルコードを見ていきます。前回までのTodoリストのコードをベースにしていますので重複する部分の解説は省きますが、サブタスクを違うUIで追加するので追加・変更箇所が少し多めです。

まずはTodo項目を表現するクラス。「lib/todo_item.dart」から見ていきます:

import 'package:hive/hive.dart';

part 'todo_item.g.dart';

@HiveType(typeId: 0)
class TodoItem extends HiveObject{
  //TODOの内容・表題
  @HiveField(0)
  String title;

  //実施されていれば真
  @HiveField(1)
  bool isDone = false;

  //属するサブタスクのリスト
  @HiveField(2)
  HiveList<TodoItem>? subTasks;

  //このアイテムがサブタスクであれば真
  @HiveField(3)
  bool isSub = false;

  //新規作成の項目であれば真。永続化しない属性値。
  bool isNew = false;

  //サブタスクが選択されていれば真。永続化しない属性値。
  bool isSelected = false;

  TodoItem(this.title);
}

サブタスクのリストとしてHiveListを入れています。またサブタスクかメインのタスクかを分けるためのフラグも用意しています。

永続化しない一時的ステータスとして、新規の項目なのか、およびサブタスクとして選択されている状態かどうかのフラグを追加しています。

次に「lib/main.dart」を見ていきます:

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'todo_item.dart';

const todoBoxName = 'todoBox';

void main() async {
  await Hive.initFlutter();
  Hive.registerAdapter(TodoItemAdapter());
  await Hive.openBox<TodoItem>(todoBoxName);

  runApp(const MyApp());
}

class Controller extends GetxController {
  //メインのリストビュー上のアイテム
  var mainListItems = <TodoItem>[].obs;

  //(1)サブタスクのリストビュー上のアイテム 
  var subListItems = <TodoItem>[].obs;

  //編集中のサブタスク
  var subItem = TodoItem("").obs
    ..value.isNew = true
    ..value.isSub = true;

  //このコントローラが初期化されていれば真
  static var isInitialized = false;

  Controller() {
    if (!isInitialized) {
      isInitialized = true;
      var todoBox = Hive.box<TodoItem>(todoBoxName);

      //(2) サブタスクではないタスクをメインリストに入れる
      for (TodoItem it in todoBox.values) {
        if (!it.isSub) mainListItems.add(it);
      }
    }
  }
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return GetMaterialApp(
        debugShowCheckedModeBanner: false,
        initialRoute: '/',
        getPages: [GetPage(name: '/', page: () => const Home())]);
  }
}

class Home extends StatelessWidget {
  const Home({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    var controller = Get.put<Controller>(Controller());

    return Scaffold(
        appBar: AppBar(title: const Text("タスクリスト"), actions: const [
          Padding(padding: EdgeInsets.only(right: 20), child: Icon(Icons.info))
        ]),
        body: Container(
            padding: const EdgeInsets.all(20),
            child: Obx(() => ListView.builder(
              itemCount: controller.mainListItems.length,
              itemBuilder: (context, i) =>
                  makeItemCard(controller.mainListItems[i]),
            ))),
        floatingActionButton: FloatingActionButton(
          child: const Icon(Icons.add),
          onPressed: () => Get.to(() => const TodoEdit()),
        ));
  }

  Widget makeItemCard(TodoItem item) {
    return Card(
      child: ListTile(
        leading: GestureDetector(
            child: Icon(item.isDone
                ? Icons.assignment_turned_in_outlined
                : Icons.assignment_rounded),
            onTap: () {
              item.isDone = !item.isDone; //トグル
              item.save();
              Get.find<Controller>().mainListItems.refresh();
            }),
        title: Text(item.title),
        tileColor: item.isDone ? Colors.grey : Colors.white,
        trailing: const Icon(Icons.more_vert),
        isThreeLine: false,
        dense: false,
        enabled: true,
        selected: false,
        onTap: () {
          Get.to(() => const TodoEdit(), arguments: {"item": item});
        },
        onLongPress: () {},
      ),
    );
  }
}

class TodoEdit extends StatelessWidget {
  const TodoEdit({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    //編集対象のタスク
    TodoItem? item = Get.arguments?["item"];
    item ??= TodoItem("")..isNew = true;
    //タスク編集フィールドの文字列
    var itemTitle = item.title;

    //(3) サブタスクのリストを作成する
    var controller = Get.find<Controller>();
    controller.subListItems.clear();
    if (item.subTasks != null) {
      //(4) サブタスクリスト表示を初期化
      controller.subListItems.addAll(item.subTasks!);
    }

    return Scaffold(
      appBar: AppBar(
        title: const Text("タスク"),
      ),
      body: Column(children: [
        const Padding(padding: EdgeInsets.only(top: 50)),
        TextField(
          controller: TextEditingController(text: itemTitle),
          maxLength: 50,
          decoration: const InputDecoration(
              prefixIcon: Icon(Icons.view_list_outlined),
              hintText: 'タスク名を入力してください'),
          onChanged: (String t) => itemTitle = t,
        ),
        ElevatedButton(
          onPressed: () {
            //(5) タスクの登録ボタン押下時の処理
            if (itemTitle.isEmpty) {
              Get.snackbar("タスク名を入力して下さい","it's necessary to be filled.", icon : const Icon(Icons.warning));
            } else {
              //編集のサブタスクを初期化
              controller.subItem.value.isSelected = false;
              controller.subItem.value = makeNewSubTaskItem();

              item!.title = itemTitle;
              makeItem(item);
              Get.back();
            }
          },
          child: const Text("タスク登録"),
        ),
        //(6) サブタスクのテキストフィールド
        Obx(()=>TextField(
          controller: TextEditingController(
              text: controller.subItem.value
                  .title),
          maxLength: 50,
          decoration: const InputDecoration(
            prefixIcon: Icon(Icons.view_list_outlined),
            hintText: 'サブタスク名を入力してください',
          ),
          onChanged: (String t) =>
          controller.subItem.value.title = t,
        )),
        ElevatedButton(
          onPressed: () {
            //(7) サブタスクの登録ボタン押下時の処理
            if (controller.subItem.value.title.isEmpty) {
              Get.snackbar("サブタスク名を入力して下さい","it's necessary to be filled.", icon : const Icon(Icons.warning));
            } else {
              makeSubTask(item!);
            }
          },
          child: const Text("サブタスク登録"),
        ),
        //(8) サブタスクのリスト
        Expanded(
            child: Obx(()=>ListView.builder(
              itemCount: controller.subListItems.length,
              itemBuilder: (context, i) =>
                  makeSubTaskCard(controller.subListItems[i]),
            ))),
      ]),
    );
  }

  void makeItem(TodoItem item) {
    var controller = Get.find<Controller>();

    if (item.isNew) {
      var todoBox = Hive.box<TodoItem>(todoBoxName);
      item.isNew = false;

      todoBox.add(item);
      controller.mainListItems.add(item);
    } else {
      item.save();
      controller.mainListItems.refresh();
    }
  }

  void makeSubTask(TodoItem parent) {
    var controller = Get.find<Controller>();
    var subItem = controller.subItem.value;

    if (subItem.isNew) {
      //(9) サブタスクの登録処理、サブタスクが新しい場合
      //もう新しくないので偽にする
      subItem.isNew = false;

      var todoBox = Hive.box<TodoItem>(todoBoxName);
      todoBox.add(subItem);
      parent.subTasks ??= HiveList<TodoItem>(todoBox);
      parent.subTasks!.add(subItem);
      subItem.save();
      controller.subListItems.add(subItem);
    } else {
      //(10) サブタスクが新しくない場合
      subItem.isSelected = false;
      subItem.save();
      controller.subListItems.refresh();
    }

    //(11) 新しいサブタスクの入れ物を用意
    controller.subItem.value = makeNewSubTaskItem();
  }

  Widget makeSubTaskCard(TodoItem item) {
    var controller = Get.find<Controller>();

    return Card(
      child: ListTile(
        leading: GestureDetector(
            child: Icon(item.isDone
                ? Icons.assignment_turned_in_outlined
                : Icons.assignment_rounded),
            onTap: () {
              item.isDone = !item.isDone; //トグル
              item.save();
              controller.subListItems.refresh();
            }),
        title: Text(item.title),
        tileColor: item.isSelected
            ? Colors.blueAccent
            : (item.isDone ? Colors.grey : Colors.white),
        trailing: const Icon(Icons.more_vert),
        isThreeLine: false,
        dense: false,
        enabled: true,
        selected: false,
        onTap: () {
          if (item.isSelected) {
            //(12) サブタスクがタップされた場合・すでに選択されていたとき
            //選択状態の解除
            item.isSelected = false;

            //新しいサブタスクを用意
            controller.subItem.value = makeNewSubTaskItem();
          } else {
            //(13) サブタスクがタップされた場合・選択されていなかったとき
            //全てのサブタスクをまず全て非選択状態にする
            for (var it in controller.subListItems) {
              it.isSelected = false;
            }

            //選んだ項目を選択状態にする
            controller.subItem.value = item..isSelected = true;
          }
          //(14) サブタスクのテキストフィールド、リストの再描画
          controller.subItem.refresh(); 
          controller.subListItems.refresh(); 
        },
        onLongPress: () {},
      ),
    );
  }

  //新しいサブタスクを作成
  TodoItem makeNewSubTaskItem(){
    return TodoItem("")
    ..isNew = true
    ..isSub = true;
  }
}

コードのポイント

以下、数字を付したコメントについて、ポイントを述べていきます。

(1) サブタスクのリストビュー上の項目
  //(1)サブタスクのリストビュー上のアイテム 
  var subListItems = <TodoItem>[].obs;

  //編集中のサブタスク
  var subItem = TodoItem("").obs
    ..value.isNew = true
    ..value.isSub = true;

コントローラーのなかにサブタスクのリストビュー上の項目と、編集中のサブタスクをもたせています。

双方監視項目ですので「obs」を付けて初期化をしています。

「..」はカスケード記法といって、同じインスタンスに連続でアクセスするときに上記コードのように「..」でアクセスできるというものです。

「obs」を付けているためインスタンスの値を設定するときは「value」を介していることに注意してください。

(2) メインのタスクリストの初期化
      //(2) サブタスクではないタスクをメインリストに入れる
      for (TodoItem it in todoBox.values) {
        if (!it.isSub) mainListItems.add(it);
      }

今回はサブタスクとメインのタスクがあり、両方とも同じボックスに入っています。そのうちメインのタスクのものを選別してリストに入れています。

(3) サブタスクのリスト作成
    //(3) サブタスクのリストを作成する
    var controller = Get.find<Controller>();
    controller.subListItems.clear();

タスクの編集画面に入ったとき、サブタスクのリストを設定します。上記コードは一旦コントローラのサブタスクのリストをクリアしています。

(4) タスクがもつサブタスクの表示初期化
    if (item.subTasks != null) {
      //(4) サブタスクリスト表示を初期化
      controller.subListItems.addAll(item.subTasks!);
    }

タスク項目(item)がもつサブタスクのリストを、表示用のリストに全て追加します。

ここでitemのsubTasksは「HiveList?型」でnullableになっています。直前のif文でnullチェックをしていますがaddAllはnullable型に適用できないと言われてしまいますので「!」を着けてエラーを回避します。

(5) 「タスク登録」ボタン押下処理
  onPressed: () {
   //(5) タスクの登録ボタン押下時の処理
   if (itemTitle.isEmpty) {
     Get.snackbar("タスク名を入力して下さい","it's necessary to be filled.", icon : const Icon(Icons.warning));
   } else {
     //編集のサブタスクを初期化
     controller.subItem.value.isSelected = false;
     controller.subItem.value = makeNewSubTaskItem();

     item!.title = itemTitle;
     makeItem(item);
     Get.back();
  }

タスク編集画面の上のボタン「タスク登録」を押下したときの処理を記述しています。

ここでタスク名が入力されていなかった場合、Get.snackbar()でスナックバーを表示しています。さりげなくやっていますがGetXではなく公式の方法だとごちゃごちゃした記法になって、しっかりハマりどころも用意されていますので、スナックバーを出すだけでもGetXを使うことをおすすめします

登録処理はif文のelseパートが本体になっています。ここではメインの画面に戻るので、まだ登録されていない編集中のサブタスクはクリアしてしまいます。
あとは前回と同様、タスク名として入力された文字列をタスクのタイトルに代入してGet.back()で戻っています。

(6) サブタスク編集用のTextField
  //(6) サブタスクのテキストフィールド
  Obx(()=>TextField(
    controller: TextEditingController(text: controller.subItem.value.title),
    maxLength: 50,
    decoration: const InputDecoration(
      prefixIcon: Icon(Icons.view_list_outlined),
      hintText: 'サブタスク名を入力してください',
    ),
    onChanged: (String t) => controller.subItem.value.title = t,
  )),

サブタスク名の編集用のTextFieldについて、TextField自体は特に前回までと変わりがありません。

ただObxで囲っているのが大きな違いになっています。これはサブタスクのリストからなにかを選択されたとき、その選択されたサブタスク名をTextFieldの内容に反映するためです。

そのためObxでラップし、文字列の初期設定をcontroller.subItem.value.titleにしています。コントローラ内のsubItemのタイトルの値、ということですね。

(7) サブタスクの登録ボタン押下処理
  //(7) サブタスクの登録ボタン押下時の処理
  if (controller.subItem.value.title.isEmpty) {
    Get.snackbar("サブタスク名を入力して下さい","it's necessary to be filled.", icon : const Icon(Icons.warning));
  } else {
    makeSubTask(item!);
  }

タスク登録と同様、サブタスク登録ボタン押下時にサブタスクのタイトルが入力されていない場合はスナックバーを表示します。

サブタスクの登録処理はmakeSubTaskメソッドにて行っています。

(8) サブタスクを表示するListView
  //(8) サブタスクのリスト          
  Expanded(
    child: Obx(()=>ListView.builder(
      itemCount: controller.subListItems.length,
      itemBuilder: (context, i) =>makeSubTaskCard(controller.subListItems[i]),
  ))),

サブタスクを表示するリストビューです。ポイントは2点。

まず全体をラップしているExpandedは、RowやColumnの子要素として使って、隙間を丁度埋めるスペースを作るものです。

ここではColumnが画面全体にあって、上からタスク登録のTextFieldとボタン、サブタスクのTextFieldとボタンがあって、その下全部の隙間を埋める形でListViewを配置しますので、ここでExpandedを使うときれいに画面いっぱいに埋まるわけです。

そしてListView自体はサブタスクの新規登録や実行済み選択、編集のための選択で様々に状態が変わりますのでその変化に応じて再描画するためObxでラップしています。

(9) サブタスクの登録処理1 新規登録の場合
  //(9) サブタスクの登録処理、サブタスクが新しい場合
  subItem.isNew = false;

  var todoBox = Hive.box<TodoItem>(todoBoxName);
  todoBox.add(subItem);

  parent.subTasks ??= HiveList<TodoItem>(todoBox);
  parent.subTasks!.add(subItem);
     
  subItem.save();
  controller.subListItems.add(subItem);

ここで登録するサブタスクはsubItemです。永続化用のボックスを取得してそこに入れます。

parentがこのサブタスクの所属する親のタスクのTodoItemになっていて、この親が既にリストを持っている場合はすでにparent.subTasksはHiveListを持っているわけですが、なければ新規に作っています。ここで「??」演算子が便利ですね。この代入でparent.subTasksはnullでないことが確定なのですが、型がHiveList?型なので、addするときに「!」を付けてnullの可能性がないことを示しています

あとはsubItemを保存して、サブタスク表示用のリストに追加して完了です。

(10) サブタスクの登録処理2 既存のサブタスクの場合
  //(10) サブタスクが新しくない場合
  subItem.isSelected = false;
  subItem.save();
  controller.subListItems.refresh();

既存のサブタスク登録処理は、タスクの登録処理とほぼ同じです。選択状態を解除しておきます。

(11) 新規のサブタスクの準備
  //(11) 新しいサブタスクの入れ物を用意
  controller.subItem.value = makeNewSubTaskItem();

サブタスクの登録が終わったら、新規のサブタスクの編集を受け入れるための準備として新しいサブタスクを用意して編集中のアイテムに入れています。

(12) サブタスクリスト中の項目がタップされた場合1
  //(12) サブタスクがタップされた場合・すでに選択されていたとき
  //選択状態の解除
  item.isSelected = false;

  //新しいサブタスクを用意
  controller.subItem.value = makeNewSubTaskItem();

サブタスクリスト中のタスクが選択された場合のうち、すでにそのタスクが選択中(青くなっている状態)の場合。

この場合は青い選択状態を解除して、新規のサブタスク編集の準備をしておけばOKです。

(13) サブタスクリスト中の項目がタップされた場合2
  //(13) サブタスクがタップされた場合・選択されていなかったとき
  //全てのサブタスクをまず全て非選択状態にする
  for (var it in controller.subListItems) {
    it.isSelected = false;
  }

  //選んだ項目を選択状態にする
  controller.subItem.value = item..isSelected = true;

こちらはサブタスクリスト中の項目をタップされたときのうち、その項目が選択されていなかった場合。

このときはまず全部のサブタスクリスト中のタスクの選択を解除します。選択中のものは一つだけなのですが、ピンポイントの参照がないので全部を解除する形で処理します。

そして選択された項目について、選択中の状態にします。

(14) サブタスクリストの項目タップに対応したリフレッシュ処理
  //(14) サブタスクのテキストフィールド、リストの再描画
  controller.subItem.refresh(); //これ→テキストフィールド書き換え用
  controller.subListItems.refresh(); //リスト書き換え用

この処理はサブタスクリストの項目をタップされたときの最後の処理ですが、ここでは

  • 選択によってサブタスクのタイトルをテキストフィールドに入れる処理
  • 選択によって項目を青く塗るなど、リストを更新する処理

の2つが発生する可能性があります。

なのでsubItemとsubListItemsの両方をリフレッシュしておきます。

とくにTextFieldを再描画するためにsubItemをリフレッシュしておくのを忘れては更新が反映されません。選択されただけでsubItemの中身(value)を更新していませんが、選択によってTextFieldが更新されるべき状態になるため、ここでリフレッシュをしておきます。






おわりに

今回はTodoリストアプリにサブタスクを入れる処理を追加しました。

HiveListによって複数のオブジェクトをデータに連ねる実践的なサンプルになっていると思います。といっても特別な処理はほぼありませんよね。自然な流れで永続化できているのではないでしょうか。

状態管理の関係がやや複雑になってきていますので、思ったようにウィジェットの自動更新が働いてくれない、と思われる場面もあるかもしれません。そういうときは:

  • Obxで更新したいウィジェットを囲っているか
  • 囲っているコントローラ変数を更新しているか

をよく確認してください。

更新してほしいポイントでObxで囲んでいる変数のvalueを更新しているかあるいはrefreshしているかをよく確認すれば解決できます。

f:id:linkedsort:20211115001434j:plain