実践Flutter

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

順序変更可能リスト表示2・ReorderableListViewと順序永続化

はじめに

前回は順序変更可能リストのシンプルな例を単体で示しましたが、今回は少し実用的に、簡単なアプリのなかでの活用例を示していきます。

ここでは順序を変えた結果を永続化し、アプリを再起動しても順序を変更した結果を反映して再開するようにしています。

Hiveによる永続化やGetXによる状態管理を織り交ぜていますので、このあたりも確認してみてください。

またベースにするサンプルアプリは下記の2つです:

  • Hiveによる永続化を入れたTODOアプリ
  • 順序変更可能リストのサンプル

これらと重なる部分の解説は本記事では省きますので、不明な箇所がある場合は下記記事をご参照ください:


flutter.gakumon.jp
flutter.gakumon.jp






順序永続化付き順序可変TODOリスト

まずは今回のサンプルの動きのポイントをスクリーンショットで見ていきます。

スクリーンショット

基本的にはいつものTODOリストアプリをベースにします。下の「+」印のフローティングアクションボタンを押すと新規項目が追加できます。ここでは4つのタスクを入力した状態を示します:

f:id:linkedsort:20211117002727p:plain:w400

一番下の項目を上に引っ張ってみます。ブラウザなら右の二重線のところをドラッグ、Androidなどでは項目をロングタップの操作になります(操作が異なるので注意):

f:id:linkedsort:20211117002810p:plain:w400

一番上まで引っ張り上げてドロップします:

f:id:linkedsort:20211117002908p:plain:w400

アプリを再起動してもこのとおり。項目は永続化され、順番もそのまま保存されていますね:

f:id:linkedsort:20211117003039p:plain:w400

サンプルコード

では実装のサンプルを見ていきます。前提としてGetXとHiveを使いますのでpubspec.yamlへの依存性記述とpub getをしておいて下さい。もし不明でしたら下記をご参照ください:


flutter.gakumon.jp

まず「lib/todo_item.dart」にTodoItemの定義を記述します:

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;

  //リスト上の順序を保存
  @HiveField(2)
  int orderIndex = 0;

  TodoItem(this.title);
}

このファイルを作ったら「flutter packages pub run build_runner build」でタイプアダプタを作って下さい。

前回までと違うのは@HiveField(2)のリスト上の順序ですね。これはTodoリストの上から順番に0、1、2と振った番号です。

これを永続化し、またリストの初期化のときにこの番号でソートすることでリスト上の順番を保存します。これをしないとアプリを再起動するたびに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 {
  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);

      //(1) リストビュー上の項目を登録、その際、保存された順序にソート
      mainListItems
        ..addAll(todoBox.values)
        ..sort((a, b) => a.orderIndex.compareTo(b.orderIndex));
    }
  }
}

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());
    var list = controller.mainListItems;

    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(() => ReorderableListView.builder(
              itemCount: list.length,
              itemBuilder: (context, i) => makeItemCard(list[i]),
              onReorder: (int oldIndex, int newIndex) {
                if (oldIndex < newIndex) {
                  newIndex -= 1;
                }
                final TodoItem item = list.removeAt(oldIndex);
                list.insert(newIndex, item);

                //(2) 順序を更新して保存
                for (var i = 0; i < list.length; i++) {
                  list[i].orderIndex = i;
                  list[i].save();
                }
              })),
        ),
        floatingActionButton: FloatingActionButton(
          child: const Icon(Icons.add),
          onPressed: () => Get.to(() => const First()),
        ));
  }

  //インデックス追加した
  Widget makeItemCard(TodoItem item) {
    return Card(
      //(3) キーの設定
      key: Key(item.orderIndex.toString()),
      child: ListTile(
        leading: const Icon(Icons.wb_sunny),
        title: Text(item.title),
        onTap: () {
          Get.to(const First(), arguments: {"item": item});
        },
      ),
    );
  }
}

class First extends StatelessWidget {
  const First({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>();
    var list = controller.mainListItems;

    if (item == null) {
      //(4) 新規要素追加の時に一番後ろの番号をつける
      var newItem = TodoItem(txt)..orderIndex = list.length;
      var todoBox = Hive.box<TodoItem>(todoBoxName);

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

 

コードのポイント

コメントに番号を付したものについて解説していきます。今回は順序可変リストとその順序の永続化に関連するところのみになりますので、それ以外のポイントについては「はじめに」に挙げた記事をご参照ください:

(1) リストビューの保存データからの復帰
  //(1) リストビュー上の項目を登録、その際、保存された順序にソート
  mainListItems
    ..addAll(todoBox.values)
    ..sort((a, b) => a.orderIndex.compareTo(b.orderIndex));

上記カスケード記法(..)の1番目はボックスの値を全部追加、ということでこれは項目の永続化のところと同様です。

カスケードの2番目でその追加した全項目についてソートしています。リストに対してsortは比較関数(いわゆるcompareTo)を引数として、その比較の仕方に沿って要素をソートします。

比較関数(伝統的にcompareToという名前を使う言語が多い)は基本的に下記のようなものです:

  • a > bの場合、compareTo(a, b)が正の値
  • a == bの場合、compareTo(a, b)は0
  • a < bの場合、compareTo(a, b)は負の値

リストに対して各要素の属性値n(int型)でソートする場合、整数型のintにはcompareToが定義されているので、そのままcompareToを使って:

list.sort((a ,b)=> a.n.compareTo(b.n));

これでソートできます。整数型ではなく他の任意の型でも、その型(クラス)に対してcompareToが定義されていれば同じ式で書けるのでこれが基本形です。

ただ整数の場合は「compareTo(a, b) => b-a 」としても上記のcompareToの原則に照らして正しいですよね(というかこうできるから
compareToの原則があのように設計されているのですが)。なので下記のように書いても正しく動作します:

list.sort((a ,b)=> b.n - a.n);

有名な形なので覚えておくと良いです。compareToの記法の方がどんな型でもいける「原則的」なものなので綺麗なコードかなとは思います。

(2) 順序を保存
  //(2) 順序を更新して保存
  for (var i = 0; i < list.length; i++) {
    list[i].orderIndex = i;
    list[i].save();
  }

onReorderの中の処理で順序が入れ替わっている可能性があるので、順の入れ替え処理を行った後、リストの頭から番号を振り直しています。

そして各項目の番号が定まるたびに、永続化を実施しています。

(3) 順序を変更するリストの要素
  //(3) キーの設定
  key: Key(item.orderIndex.toString()),

前回同様ですが、順序を変更す対象となるウィジェット(ここではListTileではなくCardであることに注意)は、keyというパラメタが必須になります。

順序を永続化するため各要素に持っているようなケースでは、その順序がリストのなかで一意のはずなので、その順序の番号をKeyの引数にすればOKです。

(4) 新規要素の番号付け
  //(4) 新規要素追加の時に一番後ろの番号をつける
  var newItem = TodoItem(txt)..orderIndex = list.length;
  var todoBox = Hive.box<TodoItem>(todoBoxName);

  todoBox.add(newItem);
  list.add(newItem);

新規のTodo項目を追加するときは、リストの最後につけるため、リスト長と同じ数字を番号としています。(上記1行目のカスケードのところ)







おわりに

今回は順序変更可能リストの部分について永続化を加えたサンプルを示しました。

基本的には番号をつけて永続化していること、新規と順序変更のときは適切に番号を管理すること、ができていれば大丈夫です。

ソートのところは慣れていないと少し難しい説明に感じたかもしれません。ただし超定番なので理解していないと後で困ります。キーワードをひろってぐぐってみると、同じことを色々なところ、色々な言語で説明されている文章が見つかると思います。ここは理解できるまでしっかり読み込んでみて下さい(JavaでもC#でもだいたいの言語は伝統的に同じ設計になっていますのでどの言語の解説でも大丈夫です)。


f:id:linkedsort:20211117231814j:plain