FlutterのWidget管理の実装上で生じる問題とは
LocalKeyを理解するためには、LocalKeyを指定していない場合についてさらにもう一歩踏み込んで考えてみると、実はわかりやすくなります。そこで最初にFlutterでLocalKeyを意識する必要ないケースの実装を改めて説明します。続いて、LocalKeyが必要なケースの実装について考えるという手順で説明します。
どちらのケースでも図2のような簡単なリストの表示順を変更するコード例を用いて説明します。
LocalKeyを意識する必要がない実装例
これまでの連載の中でも簡単なリスト表示をする例はいくつか紹介してきましたが、改めてListViewを使った場合の実装例をリスト1に示します。
class _ListViewPage extends State<ListViewPage>{
// (1) noとtitleだけをプロパティに持つTodoItemクラスのデータリスト
List<TodoItem> _items = [];
bool _sort = false;
@override
void initState() {
super.initState();
// (2) サンプルとなる実データを作成
_items.add(TodoItem(0,"タスク - 1"));
// : (省略)
_items.add(TodoItem(5,"タスク - 6"));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('タスク一覧'),
actions : [
IconButton(
onPressed: () {
setState(() {
// (3) _itemsの順番を入れ替える
_sort =! _sort;
_items = List.from(_items.reversed);
});
},
icon: _sort? const Icon(Icons.arrow_circle_up) : const Icon(Icons.arrow_circle_down))
]
),
body: ListView.builder(
// (4) リスト表示部分の実装
itemCount: _items.length,
itemBuilder: (context,index){
return Container(
// : (省略)
margin: const EdgeInsets.all(5),
height: 50,
child: Center(
child: Text(_items[index].title),
),
);
},
),
);
}
}
(1)は単純なnoとtitleだけを持つTodoItemクラスというオブジェクトを持つリストを用意します。そして、サンプルとして表示するためのデータを(2)で作成します。
実際にはこのデータを(4)で各Containerウィジェットとして表示します。それらのデータの順番を逆転させるための実装が(3)です。
このようにLocalKeyの存在はここではまったく現れません。つまり、データと表示ウィジェットの関係、そしてそれらの表示位置などが画面作成時に固定されている場合には通常、LocalKeyは必要ありません。
少々わかりにくく、歯切れの悪い表現ですが、StatelessWidgetで構成される表示の場合にはLocalKeyは必要ありませんと言えば少々分かりやすくなると思います。つまり、反対に言えば、StatefulWidgetでは問題になるケースがあります。
LocalKeyがないと問題となるケース
続いて、LocalKeyがないと問題となる実装コードをこれから紹介します。ここで紹介するコードでは、ソート機能が正しく動かない問題が生じるようになっています。
ただし、これらのコードは意図的に問題が分かりやすく生じるように再現したコードのため、少々、不自然な実装と感じるところがありますがその点はご了承ください。
まず、先ほどのListViewを使ったコードを問題が生じやすいように書き換えたコードがリスト2です。ただし、「生じやすい」と記述しているのは、このコード自体で問題が生じるわけではないためです。
class _LocalKeyPage extends State<LocalKeyPage>{
// (1) Widgetのリスト
List<Widget> _items = [];
bool _sort = false;
@override
void initState() {
super.initState();
// (2) 表示する部品を作成
_items.add(createTodoItemWidget(0,"タスク - 1"));
// : (省略)
_items.add(createTodoItemWidget(5,"タスク - 6"));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('ローカルキーサンプル'),
actions : [
IconButton(
onPressed: (){
// _itemsの順番を入れ替える
setState(() {
_sort =! _sort;
_items = List.from(_items.reversed);
});
},
icon: _sort? const Icon(Icons.arrow_circle_up) : const Icon(Icons.arrow_circle_down))
]
),
body: Column(
children: _items,
),
);
}
Widget createTodoItemWidget(int no,String title){
// (3) Statefulのウィジェットを作成
return TodoItemWidget(no, title);
}
}
先ほどのコードと違うのは、TodoItemという単純なデータ構造クラスのリストだったのに対して、今回のコードでは(1)のようにWidgetのリストになっています。そして、(2)のinitStateでそれぞれのリスト表示の各アイテムのためのWidgetを作成してしまいます。通常、画面を構成する部品はbuildメソッド内で構築しますが、ここが意図的に問題を生じやすくしているところです。
また、作成するウィジェットは(3)で作成します。問題となる直接の原因はこのTodoItemWidgetの実装にあり、そのコードはリスト3のようになります。
class _TodoItemWidget extends State<TodoItemWidget>{
late int no;
late String title;
@override
void initState() {
super.initState();
// (1) 引数をstateで管理するためのプロパティとして更新
setState(() {
no = widget.no;
title = widget.title;
});
}
@override
Widget build(BuildContext context) {
var child = Container(
child: Center(
// (2) stateで管理しているtitleを利用
child: Text(title),
),
);
return child;
}
}
(1)では、TodoItemWidgetのコンストラクタで渡されたパラメータをState管理する変数として設定しています。このような実装も通常は行わないですが、ここでは意図的に問題が生じるようにしています。そして、(2)ではそのtitleプロパティの値を使ってテキスト表示します。
このコードを実行しても、ソートがされずに表示されているアイテムの順番はかわりません。しかし、前述したコードと違いどこに問題があるのかわからない方も多いはずです。
LocalKeyを使ってただしく動くコードに変更する
問題あるコードをLocalKeyを使ってただしく動くように変更するには、リスト4のようにStatefulWidgetのコンストラクタにキーを追加すると正しく動作するようになります。
// : (省略)
class _LocalKeyPage extends State<LocalKeyPage>{
Widget createTodoItemWidget(int no,String title){
return TodoItemWidget(no, title,
// キーを追加
key: ValueKey(no)
);
}
}
// : (省略)
Flutterではできるだけ必要のない画面更新はしないように処理の効率化をしています。しかし、今回の実装ではその効率化のための実装が問題を生じさせています。
Flutterでは、同じ画面定義、つまり同じWidgetクラスであれば表示済みのものをできるだけ再利用しようとします。つまり、インスタンスが異なっていても同じWidgetであれば再利用可能であるかもしれないという判断をします。
そこで、StatefulWidgetの場合、この再利用できるかどうかの識別が正しくできないためにこのような問題が生じています。ただし、正しくできないという意味は不具合があるわけではなく、処理の最適化や複雑な問題が生じないためにもプログラマ側に画面更新する範囲の指定を委ねたという意味になります。
また、例ではValueKeyを使ってキーを指定しましたが、LocalKeyには表1のようなキーがあります。
| 入力種類 | 説明 |
|---|---|
| ValueKey | ユニークとなる数値や文字列などの値を指定してキーを作成する |
| ObjectKey | オブジェクトを利用して一意なキーを作成する |
| UniqueKey | ランダムな一意のキーを作成する |
自分の実装に合わせて、一意なキーになるように指定すればいずれのクラスであっても問題ありません。
実際にLocalKeyが必要になるシーン例
ここまでの説明では、問題が生じるコードをあえて書き、そして多少強引にLocalKeyが必要として説明しました。
これは内部の理解とその原因がよりわかりやすくするためのことですが、実は図3のようなケースでも問題が生じます。そして、同じ実装でもFlutter内部では行っている意味は全く異なります。
ソートのような全体の並び順の変更には、データ側で対応することのほうがより一般的と思いますが、利用者がアイテムの順番をドラッグ&ドロップで入れ替えたい場合にはデータ側の操作ではなくUI側、つまり。部品の再配置だけになってしまうはずです。
そして、最初に示したListViewを使ったコードをそのまま再利用し、ドラッグ&ドロップで順番を入れ替えることを可能にしたコード例がリスト5になります。
class _DraggableListViewPage extends State<DraggableListViewPage>{
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
// : (省略)
),
// (1) 順番の入れ替え可能なListView
body: ReorderableListView.builder(
itemCount: _items.length,
itemBuilder: (context,index){
return Container(
// (2) キーを必要とする
key: UniqueKey(),
);
}, onReorder: (int oldIndex, int newIndex){
// (3) 順番を入れ替える処理
setState(() {
final TodoItem item = _items.removeAt(oldIndex);
_items.insert(newIndex,item);
});
},
),
);
}
}
Flutterではドラッグ&ドロップで順番の入れ替えが可能なListViewの実装がすでに存在し、(1)のようにListViewであった部分をReorderableListViewに書き換えるだけで済みます。
ただし、(2)のように作成する子ウィジェットにはkeyが必ず必要になります。ここまでのLocalKeyの説明について理解していれば、keyが必須になった理由も分かると思います。また、順番の入れ替えを確定して実際に画面部品の再配置をする処理が(3)になります。
