実践Flutter

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

Flutterアプリ・Todoリストの項目タップ

はじめに

本記事では、TODOリストの続きとして各項目のアイコンの部分をタップしたら「実行済み」になるという部分を実装していきます。

ここではアイコン画像にユーザのアクションを捉える機能を追加しますが、基本的にはテキストでもなんでも同じ理屈でアクション捕捉機能を追加することができます。

その方法だけをみるなら一番下の方の「コードのポイント」をピンポイントに参照いただければOKです。全体の流れの中でどう織り込んでいくかを示すために、TODOリストのコードをひとまとめに載せておきます。





TODO項目のアイコンタップで実施済み

動きのイメージをスクリーンショットでみていきます。下記のようなTODO項目が並んでいるとします:

f:id:linkedsort:20211114093352p:plain:w400

このとき、項目の左のアイコンではない部分をタップした場合は(水色の円でタップ位置を示しています):

f:id:linkedsort:20211114093854p:plain:w400


その項目の編集画面に遷移します。この挙動は前回の通りです:

f:id:linkedsort:20211114094022p:plain:w400


そしてTODO項目の左のアイコンをタップした場合は(水色の円がタップ位置):

f:id:linkedsort:20211114093941p:plain:w400


タップされた項目をグレーにして「実行済み」であることを示します:

f:id:linkedsort:20211114094044p:plain:w400


これを繰り返して、TODOリストのチェック機能が完成です。実行済みにした項目のアイコンをもう一度タップすると「未実行」のもとの表示状態に戻ります:

f:id:linkedsort:20211114094108p:plain:w400

サンプルコード

以下にサンプルコードを示していきます。番号を付したコメントについて、コードの後に解説を加えていきます。その他の部分について不明な点がありましたら前回の記事などをご参照ください。前回と同様、使用パッケージはHiveおよびGetXですのでそれぞれが導入されていることを前提とします。

まずはTODO項目のデータです。「lib/todo_item.dart」ファイルに保存することを想定しています:

import 'package:hive/hive.dart';

part 'todo_item.g.dart';

@HiveType(typeId: 0)
class TodoItem extends HiveObject{
  @HiveField(0)
  String title;

  @HiveField(1)
  bool isDone = false;

  TodoItem(this.title);
}

これは前回と同様の内容です。ここで「flutter packages run build_runner build」でタイプアダプタを生成しておきます。

次に「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;

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

  Controller() {
    if (!isInitialized) {
      isInitialized = true;
      var todoBox = Hive.box<TodoItem>(todoBoxName);
      //とりあえず全部のアイテムをメイン表示するので全部入れる
      mainListItems.addAll(todoBox.values);
    }
  }
}

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("TODOホーム"), 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(
        //(1) Icon部分にジェスチャー検出をラップさせる
        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),
        //(2) isDoneの値によってカラーを制御
        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"];
    String itemTitle = (item == null) ? "" : item.title;

    return Scaffold(
      appBar: AppBar(
        title: const Text("TODO編集"),
      ),
      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)),
          onChanged: (String t) => itemTitle = t,
        ),
        ElevatedButton(
          onPressed: () {
            makeItem(itemTitle, item);
            Get.back();
          },
          child: const Text("決定"),
        ),
      ]),
    );
  }

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

    if (item == null) {
      var newItem = TodoItem(txt);
      var todoBox = Hive.box<TodoItem>(todoBoxName);

      todoBox.add(newItem);
      controller.mainListItems.add(newItem);
    } else {
      item.title = txt;
      item.save();
      controller.mainListItems.refresh();
    }
  }
}

コードのポイント

コメント中に数字を付した部分について解説していきます。

(1) アイコン部分にタップ検出を付加
        //(1) Icon部分にジェスチャー検出をラップさせる
        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(); //リストをリフレッシュ
            }),

このleadingはListTileウィジェットのパラメタで、左端にアイコンや画像を表示させるものです。そこにIconウィジェットを指定していたわけですが、これをGestureDetectorというウィジェットでラップして、Iconはそのchildのパラメタに指定しています。

GestureDetectorウィジェットはジェスチャーを検出する機能を付加するためのウィジェットで、見た目は変わらずonTapなどの検出機能を追加することができます。

ここではタップされたときにitemのisDoneの真偽を反転させること、セーブすること、そしてリストを再描画させるためにコントローラのリストのリフレッシュを実行しています。

またGestureDetectorのchildに与えているIconも、isDoneの真偽、つまり実行済みか未実行かでIconの種類を使い分けています。

(2) 未実行・実行済でTODOアイテムの色を変更
        //(2) isDoneの値によってカラーを制御
        tileColor: item.isDone ? Colors.grey : Colors.white,

tileColorはListTileのカラーを示すパラメタです。ここではisDoneが真であればグレー、そうでなければ白にしています。

これによってアイコンがクリックされてisDoneの真偽が反転したときに、TODOアイテムの色が変化します。





おわりに

今回は任意のウィジェットにタップなどのジェスチャー検出機能を付加するGestureDetectorの使い方のサンプルを示しました。

この使い方はシンプルで特に難しいところはないかと思います。

あとはタップによるデータ更新とHiveの永続化やGetXによる状態管理との組み合わせの部分について、見直しておいて下さい。


f:id:linkedsort:20211114190307j:plain