実践Flutter

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

Flutterアプリ・Todoリストの項目編集

はじめに

本記事ではFlutterによるシンプルなTodoリストの実装を解説していきたいと思います。

スマホアプリの登竜門中の登竜門がTodoリストかと思います。これがちゃんと作れれば、ほかのアプリも結構な割合作れる基礎ができてきたという感じですよね。

ここでは今まで取り扱ってきたGetXによるページ遷移、状態管理、Hiveによる永続化を総動員していきますので、それぞれの要素の基礎が怪しい場合は本記事と合わせて以前の記事もご参照いただければ理解が進みやすいかと思います。

一旦一番大事なところに集中するため、Todoが実行済みの項目をチェックしていく部分は次回に譲り、今回はTodoリストの編集の部分にフォーカスしていきます。





TODOアプリ

まず最初に、今回実装するアプリの動きをざっとみていきます。ホーム画面は下記のようになっており、下にフローティングアクションボタンがあります。

f:id:linkedsort:20211112232845p:plain:w400

フローティングアクションボタンをタップすると、Todo編集画面に遷移します。

f:id:linkedsort:20211112232953p:plain:w400

テキストフィールドにTodo項目を入力し、決定をタップします。

f:id:linkedsort:20211112233035p:plain:w400


決定をタップすると、ホームの画面のリストビューに編集の結果が反映されます。

f:id:linkedsort:20211112233104p:plain:w400


また、既存のアイテムをタップすると、その既存アイテムを編集する画面に遷移します。

f:id:linkedsort:20211112233236p:plain:w400


既存アイテムの編集画面では、元に入力したTodo項目が予め記載されています。

f:id:linkedsort:20211112233304p:plain:w400


これを再編集して、決定ボタンを押します。

f:id:linkedsort:20211112233432p:plain:w400

ホーム画面に戻ると、既存項目を編集した結果が反映されています。

f:id:linkedsort:20211112233616p:plain:w400

ではこの実装を次にみていきます。ここではGetXとHiveのパッケージを使用します。これらパッケージの導入は下記の記事をご参照ください。


flutter.gakumon.jp

Todo項目のクラス

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);
}

isDoneはその項目が完了しているかどうかですが、次回の記事で扱います。今回はtitleの編集のみにフォーカスします。

その他はいつものHiveObjectの実装ということで特に変わった点はないと思います。

このファイルを作って、タイプアダプタを生成しておきます。不明なところがありましたら、前回までのHiveの記事を参照してください。

Todoアプリの本体

次に「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 {
  //(1) Hiveの初期化
  await Hive.initFlutter();
  Hive.registerAdapter(TodoItemAdapter());
  await Hive.openBox<TodoItem>(todoBoxName);

  runApp(const MyApp());
}

//(2) GetXのコントローラーを作成
class Controller extends GetxController {
  //ホーム画面のリストビュー上のTodo項目
  var mainListItems = <TodoItem>[].obs;

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

  Controller() {
    //(3) 初期化されていなければ下記初期化を実行する
    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) {
    //(4) GetXコントローラの登録&取得
    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),
            //(5) controllerに変化があった場合に再構築するウィジェットをObxで囲む
            child: Obx(() => ListView.builder(
                  itemCount: controller.mainListItems.length,
                  itemBuilder: (context, i) =>
                      makeItemCard(controller.mainListItems[i]),
                ))),
        floatingActionButton: FloatingActionButton(
          child: const Icon(Icons.add),
          //(6) Home画面のフローティングアクションボタンで新規アイテム編集
          onPressed: () => Get.to(() => const TodoEdit()),
        ));
  }

  Widget makeItemCard(TodoItem item) {
    return Card(
      child: ListTile(
        leading: const Icon(Icons.wb_sunny),
        title: Text(item.title),
        trailing: const Icon(Icons.more_vert),
        isThreeLine: false,
        dense: false,
        enabled: true,
        selected: false,
        onTap: () {
          //(7) メインリストの項目がタップされた場合は編集モードとして遷移
          Get.to(()=>const TodoEdit(), arguments: {"item": item});
        },
        onLongPress: () {},
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    //(8) 編集画面の初期文字列設定
    TodoItem? item = Get.arguments?["item"];
    String itemTitle = (item == null) ? "" : item.title;

    return Scaffold(
      appBar: AppBar(
        title: Text("TODO編集"),
      ),
      body: Column(children: [
        const Padding(padding: EdgeInsets.only(top: 50)),
        //(9) Todo編集のためのテキストフィールド
        TextField(
          controller: TextEditingController(text: itemTitle),
          maxLength: 50,
          decoration:
              const InputDecoration(prefixIcon: Icon(Icons.view_list_outlined)),
          onChanged: (String t) => itemTitle = t,
        ),
        ElevatedButton(
          onPressed: () {
            //(10) 決定ボタンがおされたら、現在の入力状態を反映したTodo項目作成 or 更新
            makeItem(itemTitle, item);
            Get.back();
          },
          child: const Text("決定"),
        ),
      ]),
    );
  }

  void makeItem(String txt, TodoItem? item) {
    //(11) GetXのコントローラを取得
    var controller = Get.find<Controller>();

    if (item == null) {
      //(12) 新規項目のためTodoItemを作成してBoxに格納(永続化)、コントローラに入れる
      var newItem = TodoItem(txt);
      var todoBox = Hive.box<TodoItem>(todoBoxName);

      todoBox.add(newItem);
      controller.mainListItems.add(newItem);
    } else {
      //(13) itemのtitleに編集結果を反映してsaveで永続化。reflesh呼び出し。
      item.title = txt;
      item.save();

      controller.mainListItems.refresh();
    }
  }
}

コードのポイント

(1) Hiveの初期化
  //(1) Hiveの初期化
  await Hive.initFlutter();
  Hive.registerAdapter(TodoItemAdapter());
  await Hive.openBox<TodoItem>(todoBoxName);

Hiveに関連する初期化をしていきます。TodoItemに関するアダプタの登録、TodoItem用のボックスのオープンを行います。アダプタの登録が先、オープンが後という順序に注意してください。

(2) GetXのコントローラ定義
//(2) GetXのコントローラーを作成
class Controller extends GetxController {
  //ホーム画面のリストビュー上のTodo項目
  var mainListItems = <TodoItem>[].obs;

GetxControllerを継承してコントローラを担当するクラスを作ります。この点が不明という場合はGetXによる状態管理の記事をご参照ください。アプリを横断して状態を管理・制御する必要のあるデータをコントローラに備えるようにします。

ここで「mainListItems」はホームの画面のTodoリストに表示しているTodo項目のリストです。Todo項目の編集結果を反映するため「obs」をつけて監視対象にしています。

このリストが変化すると、ホーム画面のリストビューが再描画されるように実装していきます。

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

  Controller() {
    //(3) 初期化されていなければ下記初期化を実行する
    if (!isInitialized) {
      isInitialized= true;
      var todoBox = Hive.box<TodoItem>(todoBoxName);
      //今回はすべてのアイテムをメインのリストに表示するのですべて追加
      mainListItems.addAll(todoBox.values);
    }
  }

Controllerクラスのコンストラクタの初期化で、最初にすべてのTodo項目をmainListItemsに追加しています。

そして同じ処理を何度も行わないように、クラス変数にisInitializedというフラグを設け、これがfalseのときだけ、すべての項目を追加する処理を行うようにしています。


これは何度もControllerのコンストラクタが呼ばれたとき、無駄に追加処理を繰り返さないようにと条件を設けたものです。

Controllerのコンストラクタが呼び出されるのはHomeウィジェットでコントローラを登録するときのコンストラクタ呼び出しの箇所のみです。

今回のアプリ構成では厳密に言うとHomeウィジェットは一度も破壊されずに残るので、実際にはこの条件分岐は必要ありません。ただアプリによっては何度も再構築されるウィジェット上でGet.putする処理があり、コンストラクタが呼び出される可能性がある場合、ここの処理のように無駄に処理が重複しないようにすると有用な場面もあると思います。

ただここまで頑張るなら唯一のControllerのインスタンスを自分で持っておいて使い回す方がよいかもしれませんね。

(4) GetXコントローラの登録
    //(4) GetXコントローラの登録&取得
    var controller = Get.put<Controller>(Controller());

HomeウィジェットでのGetXコントローラの登録&取得。ここでコンストラクタを呼び出す必要があるので、重複処理を止める条件分岐をControllerの初期化処理に追加していました。


putは2回目以降に呼ばれる場合でもシングルトンパターンですので同じControllerのインスタンスを返します。

ただしコンストラクタは引数の時点で呼び出していますので、初期化処理は2回目以降も無駄に動いてしまします。そして2回目以降に作ったControllerのインスタンスはこの場で捨てられます。シングルトンですからね。

(5) メインのTodo項目のリスト表示
  //(5) controllerに変化があった場合に再構築するウィジェットをObxで囲む
  child: Obx(() => ListView.builder(
    itemCount: controller.mainListItems.length,
    itemBuilder: (context, i) =>
        makeItemCard(controller.mainListItems[i]),
  ))),

controller.mainListItemsが更新された場合、このListViewが再描画されるようにしておきます。そのためObx( ()=> )でListViewをラップしています。

前のGetXの例ではTextウィジェットをObxで囲んでいましたが、ListViewのように複雑なウィジェットでも理屈は同じです。変化するものを含むウィジェットをObxで囲めばOKです。

(6) フローティングアクションボタン押下時の処理
  //(6) Home画面のフローティングアクションボタンで新規アイテム編集
 onPressed: () => Get.to(() => const TodoEdit()),

フローティングアクションボタンが押された場合、TodoEditの画面に遷移します。Get.toに与える引数がコールバック関数形式になっている理由は、GetXによるページ遷移の記事を参照してください。単純にこの形でページ遷移を記述する、という風に覚えておいてもOKです。

(7) リスト上の項目がタップされた場合の処理
   onTap: () {
     //(7) メインリストの項目がタップされた場合は編集モードとして遷移        
     Get.to(()=>const TodoEdit(), arguments: {"item": item});
   },

既存項目がタップされた場合の処理を記述しています。

フローティングアクションボタンから編集画面への遷移では、新規項目作成のために、なにもパラメタを渡していませんでしたが、ここでは選択された既存の項目(item)をパラメタとして渡しています

(8) 編集画面の初期設定
    //(8) 編集画面の初期文字列設定
    TodoItem? item = Get.arguments?["item"];
    String itemTitle = (item == null) ? "" : item.title;

Get.argumentsで、Getの遷移時にargumentsパラメタによって渡されたオブジェクトを受け取ることができます。

ここではHomeのフローティングアクションボタンによる遷移ではパラメタなし(null)、既存のTodo項目が選択された場合はMapがパラメタとして渡されてきます。

Get.argumentsがMapの場合はキー「item」に対応する値を取り出し、nullであればnullが代入されるのが最初の行です。Get.argumentsがnullかも知れないので「?」を間に挟んでいます。これ便利です。

2行目で編集用TextFieldの初期文字列itemTitleに、itemがnullであれば空文字列、nullでなければitemのタイトルを代入しています。

(9) 編集用のTextField
  //(9) Todo編集のためのテキストフィールド
  TextField(
    controller: TextEditingController(text: itemTitle),
    maxLength: 50,
    decoration:
        const InputDecoration(prefixIcon: Icon(Icons.view_list_outlined)),
    onChanged: (String t) => itemTitle = t,
  ),

Todo項目を編集するTextFieldの設定です。

onChangedでTextFieldに入力があるたびに、その内容をitemTitleに代入しています。

(10) Todo編集画面の決定ボタンのアクション
   onPressed: () {
      //(10) 決定ボタンがおされたら、入力状態を反映したTodo項目作成 or 更新
     makeItem(itemTitle, item);
     Get.back();
   },

決定ボタンが押された場合、makeItemというTodoItemを作成するメソッドを呼び出したのち、Get.back()で戻ります。

(11) makeItemメソッドにて、コントローラ取得
  void makeItem(String txt, TodoItem? item) {
    //(11) GetXのコントローラを取得
    var controller = Get.find<Controller>();

Todo項目を作成するmakeItemメソッドにて、GetXコントローラを取得。

Get.findでは型指定は必要ですが、インスタンスを与える必要はありません。

(12) 新規項目作成処理
  //(12) TodoItemを作成してBoxに格納(永続化)、コントローラに入れる
      var newItem = TodoItem(txt);
      var todoBox = Hive.box<TodoItem>(todoBoxName);

      todoBox.add(newItem);
      controller.mainListItems.add(newItem);

新規項目を作成する処理は、

  • 新しいTodoItemのインスタンスを作成、タイトルの値を設定
  • TodoItemのボックスを取得してそこに新規項目を追加(永続化)
  • コントローラ内のmainListItemsに新規項目を追加

となります。

最後のコントローラ内のmainListItemsに新しい要素を追加したことにより、(5)のObxで囲まれたListViewが再描画されます。

(13) 既存項目の編集処理
      //(13) itemのtitleに編集結果を反映してsaveで永続化。reflesh呼び出し。
      item.title = txt;
      item.save();
      controller.mainListItems.refresh();

既存の項目の場合はタイトルを更新した後、save()を呼び出して永続化。
itemはHiveObjectですのでお手軽に再セーブできます。

既存の場合、コントローラのmainListItemsに追加は発生しないのですが、項目のタイトルが変わったことで再描画が必要になります。

そこで値は変えないものの再描画を強制的に起こすため、最後のrefresh()を呼び出しています。これがないとHome画面に戻ったときにリストが更新されず、編集前の見た目のままになってしまいます。






おわりに

今回はTodoリストアプリの編集の部分についてサンプルを解説しました。

いままでのGetX、Hive、TextFieldなどで出てきた項目の集大成になっています。

部分的にはそれぞれの要素が理解できたつもりでも、いざ組み合わせるとわからなくなる、というのはありがちです。

今回のひとまとまりになったものを眺めながら、各要素の怪しい部分があれば詳細を見直してみて下さい。単品では見えてこなかった部分が、見えてくるかもしれません。

f:id:linkedsort:20211113142556j:plain