はじめに
Firestoreを利用したアプリをFlutterで開発中なのだが、ドラッグ&ドロップで表示順を変える機能の作り方に悩んでいる。ReorderableListView
というウィジェットを使えばいけそうなのだが、元々の表示順はFirestore側に保持しているため、どの様にコードを書けばやりたい事ができそうなのか、考えるのに結構時間がかかってしまった。
ベストプラクティスかどうか全く分からないけど、今回一応やりたい事はできたので、メモとしても残しておく。
実行環境
【PC】
MacBook Air (M1, 2020)
【各SWバージョン】
・macOS Big Sur 11.6.1
・Flutter 2.5.3 (dart 2.14.4)
・Xcode 13.1
・Cocoapods 1.11.2
・VScode(AplleSilicon) 1.62.2
【パッケージ】
・firebase_core: ^1.10.0
・cloud_firestore: ^3.1.0
FutureBuilderを使っている場合
まずは、Firestoreと連携させた普通のFutureBuilderを書いてみる。
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
runApp(App());
}
class App extends StatefulWidget {
@override
_AppPage createState() => _AppPage();
}
class _AppPage extends State<App> {
// ボタンクリックの挙動で使う変数
var insert_data;
int doc_num = 0;
late int add_number;
late String add_title;
// Firestoreからのデータを格納しておく変数
List<DocumentSnapshot> fire_documents = [];
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
// Stateの更新時に、Widgetが構築される
body: FutureBuilder<QuerySnapshot>(
future: FirebaseFirestore.instance
.collection('TestCollection')
.orderBy("seq")
.get(),
builder: (context, snapshot) {
if (snapshot.hasData) {
// List<DocumentSnapshot>`をsnapshotから取り出す。
if (snapshot.data != null) {
fire_documents = snapshot.data!.docs;
}
return ListView.builder(
shrinkWrap: true,
itemCount: fire_documents.length, //配列の長さの分だけ作成する。
itemBuilder: (context, index) {
return ListTile(
title: Text(fire_documents[index]["title"]),
);
},
);
} else if (snapshot.hasError) {
return Center(child: Text('snapshot Error1'));
} else {
return Center(child: Text('snapshot Error2'));
}
},
),
// 追加ボタン
floatingActionButton: Container(
margin: EdgeInsets.only(bottom: 10.0), // ボタンの配置
//width: 40.0, // ボタンのサイズ。形にも依るが先に記載されている大きさが優先っぽい。
//height: 40.0,
child: FloatingActionButton.extended(
backgroundColor: Colors.blue,
icon: Icon(Icons.add),
label: Text("追加"),
// テーマ追加ボタンクリック時の処理 ⇒ ダイアログ立ち上げる
onPressed: () => set_data(),
),
),
),
);
}
// ボタンクリック時のアクション内容
set_data() {
if (fire_documents.length == null) {
doc_num = 0;
} else {
doc_num = fire_documents.length;
}
add_number = (doc_num + 1);
add_title = 'title_' + add_number.toString();
insert_data = {
'seq': add_number,
'title': add_title,
};
FirebaseFirestore.instance.collection('TestCollection').add(insert_data);
setState(() {});
}
}
上記のコードでflutter run
を実行すると下図の様なアプリが起動するが、これは単純に連番の付いたタイトルを追加して、それを順番に表示する機能しかない。
これにドラッグ&ドロップで表示順番を変える機能を付けたいが、いきなり最終的なコードに行く前に、途中経過として、各行を上下に移動させた時のアクションをどの様に制御するのか書いておく。
上記のコードのListView.builder
を以下の様に ReorderableListView.builder
に書き換えてみる。
return ReorderableListView.builder(
shrinkWrap: true,
itemCount: fire_documents.length, //配列の長さの分だけ作成する。
itemBuilder: (context, index) {
return ListTile(
key: Key('$index'),
title: Text(fire_documents[index]["title"]),
);
},
onReorder: (int oldIndex, int newIndex) {
setState(() {
if (oldIndex < newIndex) {
newIndex -= 1;
print('下にずらした時に実行');
print('古いindex' + oldIndex.toString());
print('新しいIndex' + newIndex.toString());
}
if (oldIndex > newIndex) {
print('上にずらした時に実行される');
print('古いindex' + oldIndex.toString());
print('新しいIndex' + newIndex.toString());
}
});
},
);
上記コードに書き換えればドラッグ&ドロップ自体の動きは可能になるが、もちろんprint
で出力されるだけで表示順番は変わらない。
このprint
部分にFirestoreを更新する処理を入れられれば、やりたい事ができるはずなので、以下の様なコードを作成。(基本的には仕組みは一緒なので、一旦は下にずらした時のみの処理だけ)
return ReorderableListView.builder(
shrinkWrap: true,
itemCount: fire_documents.length, //配列の長さの分だけ作成する。
itemBuilder: (context, index) {
return ListTile(
key: Key('$index'),
title: Text(fire_documents[index]["title"]),
);
},
onReorder: (int oldIndex, int newIndex) async {
if (oldIndex < newIndex) {
print('下にずらした時に実行');
print('古いindex' + oldIndex.toString());
print('新しいIndex' + newIndex.toString());
// 動かしたことで先頭からの順番が変わるデータ(ドキュメント)のみ取得
await FirebaseFirestore.instance
.collection('TestCollection')
.where('seq', isGreaterThan: oldIndex)
.where('seq', isLessThanOrEqualTo: newIndex)
.get()
.then((value) {
for (int i = 0; i < value.docs.length; i++) {
if (i == 0) {
// 動かした行の処理
print(newIndex);
print(value.docs[i]["title"]);
print(value.docs[i].id);
FirebaseFirestore.instance
.collection('TestCollection')
.doc(value.docs[i].id)
.update({'seq': newIndex});
} else {
// 動かした行より下の処理
print(i + oldIndex);
print(value.docs[i]["title"]);
print(value.docs[i].id);
FirebaseFirestore.instance
.collection('TestCollection')
.doc(value.docs[i].id)
.update({'seq': i + oldIndex});
}
}
}).catchError((Object e) => print("エラーコード:$e"));
}
if (oldIndex > newIndex) {
print('上にずらした時に実行');
print('古いindex' + oldIndex.toString());
print('新しいIndex' + newIndex.toString());
}
setState(() {});
},
);
上記のコードでも、Firestoreが更新された後にsetStateが呼び出されるため、やりたかった事はできたのだが少し問題があった。
実際のエミューレータなどで動かしてみれば分かるのだが、setStateが呼び出されるまで多少のタイムラグがあるため、一瞬元の位置(ドラッグする前の位置)に戻ってから更新されるという挙動になってしまった。普通にバグっぽく見えるので、これは何とか解決したい。
っということで最終的に考えたのが以下のコード。
display_box.clear();
for (int i = 0; i < fire_documents.length; i++) {
display_box.add(fire_documents[i]["title"]);
}
return ReorderableListView.builder(
shrinkWrap: true,
itemCount: fire_documents.length, //配列の長さの分だけ作成する。
itemBuilder: (context, index) {
return ListTile(
key: Key('$index'),
title: Text(display_box[index]),
);
},
onReorder: (int oldIndex, int newIndex) async {
if (oldIndex < newIndex) {
// print('下にずらした時に実行');
// print('古いindex' + oldIndex.toString());
// print('新しいIndex' + newIndex.toString());
final String drug_row = display_box.removeAt(oldIndex);
display_box.insert(newIndex - 1, drug_row);
// 動かしたことで先頭からの順番が変わるデータ(ドキュメント)のみ取得
await FirebaseFirestore.instance
.collection('TestCollection')
.where('seq', isGreaterThan: oldIndex)
.where('seq', isLessThanOrEqualTo: newIndex)
.get()
.then((value) async {
for (int i = 0; i < value.docs.length; i++) {
if (i == 0) {
// 動かした行の処理
print(newIndex);
print(value.docs[i]["title"]);
print(value.docs[i].id);
await FirebaseFirestore.instance
.collection('TestCollection')
.doc(value.docs[i].id)
.update({'seq': newIndex});
} else {
// 動かした行より下の処理
print(i + oldIndex);
print(value.docs[i]["title"]);
print(value.docs[i].id);
await FirebaseFirestore.instance
.collection('TestCollection')
.doc(value.docs[i].id)
.update({'seq': i + oldIndex});
}
}
}).catchError((Object e) => print("エラーコード:$e"));
}
if (oldIndex > newIndex) {
print('上にずらした時に実行');
print('古いindex' + oldIndex.toString());
print('新しいIndex' + newIndex.toString());
}
},
);
上記のコードは、itemBuilder
よりも手前で表示用のリストを作成してしまい、onReorder
内でFirestoreの更新をかける前にそのリストを更新している。Firestore更新もすぐにかかっているが並び順が変わらないため、点滅する挙動にはならない。
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
runApp(App());
}
class App extends StatefulWidget {
@override
_AppPage createState() => _AppPage();
}
class _AppPage extends State<App> {
// ボタンクリックの挙動で使う変数
var insert_data;
int doc_num = 0;
late int add_number;
late String add_title;
List display_box = [];
// Firestoreからのデータを格納しておく変数
List<DocumentSnapshot> fire_documents = [];
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
// Stateの更新時に、Widgetが構築される
body: FutureBuilder<QuerySnapshot>(
future: FirebaseFirestore.instance
.collection('TestCollection')
.orderBy("seq")
.get(),
builder: (context, snapshot) {
if (snapshot.hasData) {
// List<DocumentSnapshot>`をsnapshotから取り出す。
if (snapshot.data != null) {
fire_documents = snapshot.data!.docs;
}
display_box.clear();
for (int i = 0; i < fire_documents.length; i++) {
display_box.add(fire_documents[i]["title"]);
}
return Container(
padding: EdgeInsets.only(top: 50.0),
child: ReorderableListView.builder(
shrinkWrap: true,
itemCount: fire_documents.length, //配列の長さの分だけ作成する。
itemBuilder: (context, index) {
return ListTile(
key: Key('$index'),
title: Text(display_box[index]),
);
},
onReorder: (int oldIndex, int newIndex) async {
// 下にずらした時に実行する処理
if (oldIndex < newIndex) {
//print('古いindex' + oldIndex.toString());
//print('新しいIndex' + newIndex.toString());
final String drug_row = display_box.removeAt(oldIndex);
display_box.insert(newIndex - 1, drug_row);
// 動かしたことで先頭からの順番が変わるデータ(ドキュメント)のみ取得
await FirebaseFirestore.instance
.collection('TestCollection')
.where('seq', isGreaterThan: oldIndex)
.where('seq', isLessThanOrEqualTo: newIndex)
.orderBy("seq")
.get()
.then((value) async {
for (int i = 0; i < value.docs.length; i++) {
if (i == 0) {
// 動かした行の処理
//print('動かした行:');
//print(newIndex);
//print(value.docs[i]["title"]);
//print(value.docs[i].id);
await FirebaseFirestore.instance
.collection('TestCollection')
.doc(value.docs[i].id)
.update({'seq': newIndex});
} else {
// 動かした行より下の処理
//print(i + oldIndex);
//print(value.docs[i]["title"]);
//print(value.docs[i].id);
await FirebaseFirestore.instance
.collection('TestCollection')
.doc(value.docs[i].id)
.update({'seq': i + oldIndex});
}
}
}).catchError((Object e) => print("エラーコード:$e"));
}
// 上にずらした時に実行する処理
if (oldIndex > newIndex) {
//print('古いindex' + oldIndex.toString());
//print('新しいIndex' + newIndex.toString());
final String drug_row = display_box.removeAt(oldIndex);
display_box.insert(newIndex, drug_row);
// 動かしたことで先頭からの順番が変わるデータ(ドキュメント)のみ取得
await FirebaseFirestore.instance
.collection('TestCollection')
.where('seq', isGreaterThan: newIndex)
.where('seq', isLessThanOrEqualTo: oldIndex + 1)
.orderBy("seq")
.get()
.then((value) async {
for (int i = 0; i < value.docs.length; i++) {
if (i == (value.docs.length - 1)) {
// 動かした行の処理
//print('動かした行:');
//print(newIndex + 1);
//print(value.docs[i]["title"]);
//print(value.docs[i].id);
await FirebaseFirestore.instance
.collection('TestCollection')
.doc(value.docs[i].id)
.update({'seq': newIndex + 1});
} else {
// 動かした行より下の処理
// print(newIndex + i + 2);
// print(value.docs[i]["title"]);
// print(value.docs[i].id);
await FirebaseFirestore.instance
.collection('TestCollection')
.doc(value.docs[i].id)
.update({'seq': newIndex + i + 2});
}
//setState(() {});
}
}).catchError((Object e) => print("エラーコード:$e"));
}
},
),
);
} else if (snapshot.hasError) {
return Center(child: Text('snapshot Error1'));
} else {
return Center(child: Text('snapshot Error2'));
}
},
),
// 追加ボタン
floatingActionButton: Container(
margin: EdgeInsets.only(bottom: 10.0), // ボタンの配置
//width: 40.0, // ボタンのサイズ。形にも依るが先に記載されている大きさが優先っぽい。
//height: 40.0,
child: FloatingActionButton.extended(
backgroundColor: Colors.blue,
icon: Icon(Icons.add),
label: Text("追加"),
// テーマ追加ボタンクリック時の処理 ⇒ ダイアログ立ち上げる
onPressed: () => set_data(),
),
),
),
);
}
// ボタンクリック時のアクション内容
set_data() {
if (fire_documents.length == null) {
doc_num = 0;
} else {
doc_num = fire_documents.length;
}
add_number = (doc_num + 1);
add_title = 'title_' + add_number.toString();
insert_data = {
'seq': add_number,
'title': add_title,
};
FirebaseFirestore.instance.collection('TestCollection').add(insert_data);
setState(() {});
}
}
StreamBuilderを使っている場合
FutureBuilderを使っている場合と同じ様にできると思ったが、実際それでは上手くいかなかった。
恐らくの原因は、リアルタイム更新のため、下や上に移動させた後の処理をfor文
で回すと、その分だけリアルタイム更新が走り、移動のさせ方によって一瞬同じ番号が競合することになるからだと推測。
一応パッと以下の解決策を考えてみた。
そもそものseq番号を奇数番号の連番で持ち、移動したものをその手前と後の間の偶数番号を付ける。
そして、その後に番号を変える必要があるものは、処理の順番に気を付けながら最終的な奇数番号を付与していく。(そうすれば数字的な競合も起こらず、表示順番も変わらずにその他のseq番号も動かせるはず!)
試しに以下のコードを作ってみたが、挙動的にはこれでやりたい事はできていそう。
※上のFutureBuilder
で作ったままのFirestoreのデータの入り方(追加ボタンクリック時の処理内容)だとダメなので、1回コレクションを削除してデータを入れ直している。
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
runApp(App());
}
class App extends StatefulWidget {
@override
_AppPage createState() => _AppPage();
}
class _AppPage extends State<App> {
// ボタンクリックの挙動で使う変数
var insert_data;
int doc_num = 0;
late int add_number;
late String add_title;
List display_box = [];
// Firestoreからのデータを格納しておく変数
List<DocumentSnapshot> fire_documents = [];
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
// リアルタイム更新 監視先は指定のコレクション全体
body: StreamBuilder<QuerySnapshot>(
stream: FirebaseFirestore.instance
.collection('TestCollection')
.orderBy("seq")
.snapshots(),
builder:
(BuildContext context, AsyncSnapshot<QuerySnapshot> snapshot) {
if (snapshot.hasError) {
return Text('Something went wrong');
}
if (snapshot.connectionState == ConnectionState.waiting) {
return Text("Loading");
}
// List<DocumentSnapshot>`をsnapshotから取り出す。
if (snapshot.data != null) {
fire_documents = snapshot.data!.docs;
}
display_box.clear();
for (int i = 0; i < fire_documents.length; i++) {
display_box.add(fire_documents[i]["title"]);
}
return Container(
padding: EdgeInsets.only(top: 50.0),
child: ReorderableListView.builder(
// padding: const EdgeInsets.all(8),
shrinkWrap: true,
itemCount: fire_documents.length, //配列の長さの分だけ作成する。
itemBuilder: (context, index) {
return ListTile(
key: Key('$index'),
title: Text(display_box[index]),
);
},
// ドラッグ & ドロップ時の処理
onReorder: (int oldIndex, int newIndex) async {
// 下にずらした時に実行する処理
if (oldIndex < newIndex) {
// print('古いindex' + (oldIndex * 2 + 1).toString());
// print('新しいIndex' + (newIndex * 2).toString());
final String drug_row = display_box.removeAt(oldIndex);
display_box.insert(newIndex - 1, drug_row);
// 動かしたことで先頭からの順番が変わるデータ(ドキュメント)のみ取得
await FirebaseFirestore.instance
.collection('TestCollection')
.where('seq', isGreaterThan: oldIndex * 2)
.where('seq', isLessThan: newIndex * 2)
.orderBy('seq')
.get()
.then((value) async {
for (int i = 0; i < value.docs.length; i++) {
if (i == 0) {
// 動かした行の処理
// print('動かした行:');
// print(newIndex * 2);
// print(value.docs[i]["title"]);
// print(value.docs[i].id);
await FirebaseFirestore.instance
.collection('TestCollection')
.doc(value.docs[i].id)
.update({'seq': newIndex * 2});
} else {
// 動かしたことで位置が上がるデータの処理
// print(2 * (i + oldIndex) - 1);
// print(value.docs[i]["title"]);
// print(value.docs[i].id);
await FirebaseFirestore.instance
.collection('TestCollection')
.doc(value.docs[i].id)
.update({'seq': (2 * (i + oldIndex) - 1)});
}
}
// 動かした行が偶数番号になっているため、-1 してあげる。
await FirebaseFirestore.instance
.collection('TestCollection')
.doc(value.docs[0].id)
.update({'seq': (newIndex * 2) - 1});
}).catchError((Object e) => print("エラーコード:$e"));
}
// 上にずらした時に実行する処理
if (oldIndex > newIndex) {
// print('古いindex' + (oldIndex * 2 + 1).toString());
// print('新しいIndex' + (newIndex * 2).toString());
final String drug_row = display_box.removeAt(oldIndex);
display_box.insert(newIndex, drug_row);
// 動かしたことで先頭からの順番が変わるデータ(ドキュメント)のみ取得
await FirebaseFirestore.instance
.collection('TestCollection')
.where('seq', isGreaterThan: newIndex * 2)
.where('seq', isLessThan: oldIndex * 2 + 2)
.orderBy('seq', descending: true)
.get()
.then((value) async {
// 動かした行を最初に処理する必要があるため、orderbyは降順で取得している。
for (int i = 0; i < value.docs.length; i++) {
if (i == 0) {
// 動かした行の処理
// print('動かした行:');
// print(value.docs[i]["title"]);
// print(newIndex * 2);
// print(value.docs[i].id);
await FirebaseFirestore.instance
.collection('TestCollection')
.doc(value.docs[i].id)
.update({'seq': newIndex * 2});
} else {
// 動かしたことで位置が下がるデータの処理
// print(value.docs[i]["title"]);
// print(3 + 2 * (oldIndex - i));
// print(value.docs[i].id);
await FirebaseFirestore.instance
.collection('TestCollection')
.doc(value.docs[i].id)
.update({'seq': (3 + 2 * (oldIndex - i))});
}
}
// 動かした行が偶数番号になっているため、+1 してあげる。
await FirebaseFirestore.instance
.collection('TestCollection')
.doc(value.docs[0].id)
.update({'seq': (newIndex * 2) + 1});
}).catchError((Object e) => print("エラーコード:$e"));
}
},
),
);
}),
// 追加ボタン
floatingActionButton: Container(
margin: EdgeInsets.only(bottom: 10.0), // ボタンの配置
//width: 40.0, // ボタンのサイズ。形にも依るが先に記載されている大きさが優先っぽい。
//height: 40.0,
child: FloatingActionButton.extended(
backgroundColor: Colors.blue,
icon: Icon(Icons.add),
label: Text("追加"),
// テーマ追加ボタンクリック時の処理 ⇒ ダイアログ立ち上げる
onPressed: () => set_data(),
),
),
),
);
}
// ボタンクリック時のアクション内容
set_data() {
if (fire_documents.length == null) {
doc_num = 0;
} else {
doc_num = fire_documents.length;
}
add_number = (doc_num * 2 + 1);
add_title = 'title_' + add_number.toString();
insert_data = {
'seq': add_number,
'title': add_title,
};
FirebaseFirestore.instance.collection('TestCollection').add(insert_data);
}
}