はじめに
Flutterでドラッグ&ドロップで入れ替え可能なリストを作成する場合、ReorderableListView
が非常に便利です。単に並び替えだけではなく、特定の項目を選択した際に動的な変化を与えることで、よりアトラクティブなUIを実現したくなりました。しかし、ReorderableListView
内のitemBuilder
をいじるだけでは実現できませんでした。そこで、リスト内のWidgetをStatefulにすることで解決できました。
この記事では、GlobalKey
を活用し、onReorderStart
とonReorderEnd
のイベント内でStatefulWidget
を操作し、ReorderableListViewでタップしたWidgetの動作を変化させる方法を解説します。
前提知識
ReorderableListViewの基本
ReorderableListView
は、アイテムをドラッグ&ドロップで並び替えるためのウィジェットです。onReorder
、onReorderStart
、onReorderEnd
などのコールバックを使うことで、並び替え中のアイテムを識別し、状態を管理することができます。
詳しくは、以下を参照ください。
GlobalKeyを用いたStatefulWidgetの制御
GlobalKey
を使うことで、特定のStatefulWidget
インスタンスに直接アクセスし、その状態を操作できます。この方法により、onReorderStart
でアイテムを選択し、onReorderEnd
で選択を解除するといったインタラクションが可能になります。
詳しくは、以下を参照ください。
解説
GlobalKey
を用いた各リスト項目の識別
以下のコードでは、リスト項目ごとにGlobalKey
を設定し、ListTileWidget
にアクセスできるようにしています。
late final mapKeys = <int, GlobalKey<_ListTileWidgetState>>{
for (final item in _items) item.id: GlobalKey()
};
このmapKeys
はListItem
のIDをキーとして持ち、対応するGlobalKey
を保持します。これにより、ListTileWidget
の状態に直接アクセスできるようになります。
並び替え開始時の操作(onReorderStart
)
onReorderStart
は並び替え開始時に呼び出され、対象のリスト項目を選択状態に変更します。
onReorderStart: (index) {
_selectedItem = _items[index];
final itemState = mapKeys[_selectedItem?.id]?.currentState;
if (itemState != null) {
setState(() {
itemState.selected(true); // 選択状態をtrueに設定
});
}
}
index
で現在選択しているアイテムを取得し、GlobalKey
経由で対応するState
オブジェクトにアクセスします。selected(true)
を呼び出して選択状態に切り替え、色やサイズを変更します。
並び替え終了時の操作(onReorderEnd
)
onReorderEnd
は並び替え終了時に呼び出され、選択状態を解除します。
onReorderEnd: (index) {
setState(() {
mapKeys[_selectedItem?.id]?.currentState?.selected(false); // 選択解除
});
_selectedItem = null;
}
selected(false)
を呼び出して選択状態を解除し、元の色やサイズに戻します。- 最後に
_selectedItem
をnull
にして、選択解除状態をクリアします。 - この時点でのindexはドロップした場所のため、ドラッグ中のWidgetを取得できるとは限りません。ドラッグ開始時に_selectedItemにドラッグしているStateを記録し、ドロップ時には記録した値を使うことが重要です
ListTileWidget
の状態管理
ListTileWidget
は状態を持つStatefulWidget
で、選択状態に応じてデザインが変化します。
class _ListTileWidgetState extends State<ListTileWidget> {
bool _isSelected = false;
static const _kBaseHeight = 64.0;
void selected(bool isSelected) {
if (_isSelected == isSelected) return;
setState(() {
_isSelected = isSelected;
});
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.all(_isSelected ? 2.0 : 8.0),
child: Container(
color: _isSelected ? Colors.green : Colors.blue,
height: _isSelected ? _kBaseHeight * 1.3 : _kBaseHeight,
width: double.infinity,
child: Text('title: ${widget.item.id}'),
),
);
}
}
_isSelected
がtrue
の場合、色を青から緑、パディングを8から2、に設定します。
高さを変更しようとしましたが、うまくいきませんでした(Listの制約?AnimatedContainerでもダメでした)。パディングを小さくして、大きくなったように見せていますselected
メソッドで選択状態を変更し、setState
で再描画を行います。
実装方法のポイント
-
GlobalKey
を用いたインスタンスの参照
StatefulWidget
の状態管理にはGlobalKey
が非常に有用です。特に、並び替え時に特定のウィジェットの状態を変化させたい場合は必須です。 -
UIの視覚的フィードバック
並び替え時の背景色やサイズを変更することで、ユーザーに明確な操作フィードバックを提供します。
まとめ
この記事では、ReorderableListView
を使ったリスト並び替え時の動作をカスタマイズする方法を解説しました。GlobalKey
を活用し、並び替え時にアイテムの見た目を動的に変化させることで、より視覚的にわかりやすいUIを構築できます。ぜひ、あなたのFlutterプロジェクトで試してみてください。
ソース(main.dartにコピペして動作確認用)
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: MyHomePage(),
);
}
}
class ListItem {
const ListItem(this.id);
final int id;
}
class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key}) : super(key: key);
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final List<ListItem> _items =
List.generate(5, (index) => ListItem(index)).toList();
late final mapKeys = <int, GlobalKey<_ListTileWidgetState>>{
for (final item in _items) item.id: GlobalKey()
};
ListItem? _selectedItem;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Flutter ReorderableListView'),
),
body: ReorderableListView.builder(
padding: const EdgeInsets.symmetric(
vertical: 16.0,
horizontal: 8,
),
onReorder: _onReorder,
onReorderStart: (index) {
_selectedItem = _items[index];
final itemState = mapKeys[_selectedItem?.id]?.currentState;
if (itemState != null) {
setState(() {
itemState.selected(true);
});
}
},
onReorderEnd: (index) {
setState(() {
mapKeys[_selectedItem?.id]?.currentState?.selected(false);
});
_selectedItem = null;
},
itemCount: _items.length,
itemBuilder: (BuildContext context, int index) {
final item = _items[index];
return ListTileWidget(
key: mapKeys[item.id],
item: item,
);
},
),
);
}
void _onReorder(int oldIndex, int newIndex) {
if (oldIndex < newIndex) {
newIndex -= 1;
}
setState(() {
final item = _items.removeAt(oldIndex);
_items.insert(newIndex, item);
});
}
}
class ListTileWidget extends StatefulWidget {
const ListTileWidget({
super.key,
required this.item,
});
final ListItem item;
@override
State<ListTileWidget> createState() => _ListTileWidgetState();
}
class _ListTileWidgetState extends State<ListTileWidget> {
bool _isSelected = false;
static const _kBaseHeight = 64.0;
void selected(bool isSelected) {
if (_isSelected == isSelected) {
return;
}
setState(() {
_isSelected = isSelected;
});
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.all(_isSelected ? 2.0 : 8.0),
child: Container(
color: _isSelected ? Colors.green : Colors.blue,
height: _isSelected ? _kBaseHeight * 1.3 : _kBaseHeight,
width: double.infinity,
child: Text('title: ${widget.item.id}'),
),
);
}
}