【Flutter】GridViewでドラッグ&ドロップ
目次
ListViewにはReordableListViewがあるが、GridViewにはそういうものがない。 非公式のライブラリを使うか、自前で実装する必要があるので、今回は自前でやる。
登場人物は以下の通り。
- GridView:二次元的にアイテムを並べる
- Draggable :ドラッグされるもの
- DragTarget :ドロップされる場所
Draggable<T> のプロパティ解説
基本プロパティ
| プロパティ | 説明 |
|---|---|
| child | 必須。ドラッグされていない通常時に表示されるウィジェット |
| feedback | 必須。ドラッグ中に指にくっついて動くウィジェット |
| data | DragTargetに渡すデータ。アイテムのIDなど、一意の値を設定 |
見た目のカスタマイズ
| プロパティ | 説明 |
|---|---|
| childWhenDragging | ドラッグ開始後、childがあった元の場所に表示されるウィジェット |
| feedbackOffset | ドラッグ中のfeedbackを指の位置からずらす量 |
| dragAnchorStrategy | ドラッグ開始時にfeedbackが指のどの位置に現れるかを決定 |
イベントコールバック
| プロパティ | 説明 |
|---|---|
| onDragStarted | ドラッグが開始された瞬間に呼ばれる |
| onDragUpdate | ドラッグ中に指が動くたびに呼ばれる |
| onDragEnd | ドラッグの終了時に、成功・キャンセルの両方で呼ばれる。引数の DraggableDetails.data がtrueのときは成功、falseのときは失敗と判定できる |
| onDragCompleted | DragTargetに正常にドロップされ、ドラッグが成功した時に呼ばれる。プログラム的にいうと、onDragEnd で details.wasAccepted=true のとき |
| onDraggableCanceled | DragTarget以外の場所で指を離し、ドラッグがキャンセルされた時に呼ばれる。プログラム的にいうと、onDragEnd で details.wasAccepted=false のとき |
高度な設定
| プロパティ | 説明 |
|---|---|
| axis | ドラッグ方向を垂直(Axis.vertical)または水平(Axis.horizontal)に制限 |
| maxSimultaneousDrags | 同時にいくつの指でドラッグできるかを設定(通常は1) |
| ignoringFeedbackSemantics, ignoringFeedbackPointer | ドラッグ中のfeedbackが、システムのユーザー補助機能やタップイベントにどう影響するかを設定 |
| rootOverlay | feedbackをアプリの最上位レイヤーに描画するかどうか。ダイアログなどを超えてドラッグする場合にtrueにする |
| hitTestBehavior | childがタップを検知する範囲を設定 |
| allowedButtonsFilter | マウス操作時、どのボタン(左、右、中央)でドラッグを開始できるかをフィルタリングする |
DragTarget<T> のプロパティ解説
基本プロパティ
| プロパティ | 説明 |
|---|---|
| builder | 必須。DragTarget自身の見た目を構築する関数で、引数を3つとる。ドラッグ中の状況に応じて動的にUIを変更できる |
| 引数の型 | 説明 |
|---|---|
| BuildContext context | 通常のビルドコンテキスト |
| List<T?> candidateData | 現在このDragTargetの上をホバーしているDraggableのdataのリスト |
| List<dynamic> rejectedData | ホバーしているが、onWillAcceptWithDetailsで拒否されたDraggableのdataのリスト |
イベントコールバック
| プロパティ | 説明 |
|---|---|
| onWillAcceptWithDetails (旧 onWillAccept) | Draggableが上に乗ってきた瞬間に呼ばれる。trueを返すと受け入れ可能(candidateDataに入る)、falseを返すと拒否(rejectedDataに入る)する。ここで、ドラッグしているアイテムがドロップできるかどうかの判定ロジックを書く。 |
| onAcceptWithDetails (旧 onAccept) | Draggableが上でドロップされ、且つ、onWillAcceptWithDetails がtrueを返した場合に呼ばれる。 |
| onLeave | 一度ホバーしたDraggableが、DragTargetの範囲外に移動した時に呼ばれる |
| onMove | DraggableがDragTargetの範囲内で動いている間、継続的に呼ばれる |
高度な設定
| プロパティ | 説明 |
|---|---|
| hitTestBehavior | DragTargetがタップ(ドロップ)を検知する範囲。HitTestBehavior.translucentにすると、ウィジェットが透明な部分でもドロップを検知できるようになる |
サンプルコード
import 'package:flutter/material.dart';
class GalleryModel {
const GalleryModel({required this.id, required this.title});
final String id;
final String title;
}
class DragAndDrop extends StatefulWidget {
const DragAndDrop({super.key});
@override
State<DragAndDrop> createState() => _DragAndDropState();
}
class _DragAndDropState extends State<DragAndDrop> {
/// グリッド表示するアイテムリスト
List<GalleryModel> items = [];
/// ドラッグ開始時のアイテムを保持
List<GalleryModel> backupItems = [];
/// ドラッグ中のアイテムのID
String? draggingId;
int _indexOf(String id) => items.indexWhere((final e) => e.id == id);
@override
void initState() {
super.initState();
// サンプルデータを流し込む
items = List.generate(50, (final i) {
final id = i + 1;
return GalleryModel(id: id.toString(), title: 'タイトル($id)');
});
backupItems = List.of(items); // バックアップにもコピー
}
/// ドラッグ中に並べ替えを実行
void reorder(String draggedId, String targetId) {
final from = _indexOf(draggedId);
final to = _indexOf(targetId);
if (from == -1 || to == -1) {
return; // 不正値
}
if (from == to) {
return; // 同じ場所へのドロップ
}
setState(() {
final moved = items.removeAt(from);
items.insert(to, moved);
});
}
/// ドロップ、キャンセル、ドラッグの完了と終了した際に実行
void endDrag({bool cancelReorder = false}) {
setState(() {
if (cancelReorder) {
items = List.of(backupItems); // バックアップから復元、ドラッグ前に戻す
} else {
backupItems = List.of(items); // バックアップを最新リストで更新
}
draggingId = null;
});
}
Widget buildGridItem(final BuildContext context, final int index) {
final item = items[index];
final childTile = GalleryTile(item: item);
return DragTarget(
onWillAcceptWithDetails: (final details) {
reorder(details.data.toString(), item.id);
return true;
},
onAcceptWithDetails: (final DragTargetDetails details) {
endDrag(cancelReorder: false);
},
builder: (context, candidateData, rejectedData) {
final isHovered = candidateData.isNotEmpty;
final isHidden = isHovered || draggingId == item.id;
return LongPressDraggable(
data: item.id,
delay: const Duration(milliseconds: 70),
onDragStarted: () {
setState(() {
draggingId = item.id;
backupItems = List.of(items);
});
},
onDraggableCanceled: (_, __) {
debugPrint('onDraggableCanceled');
},
onDragCompleted: () {
debugPrint('onDragCompleted');
},
onDragEnd: (final DraggableDetails details) {
endDrag(cancelReorder: !details.wasAccepted);
},
feedback: Material(
// overlay 上に出すため Material で包む
elevation: 8,
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: 120,
height: 120 / 0.8,
child: GalleryTile(item: item),
),
),
childWhenDragging: Opacity(
opacity: isHidden ? 0 : 1,
child: childTile,
),
child: Opacity(opacity: isHidden ? 0 : 1, child: childTile),
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('GridのD&D'),
foregroundColor: Colors.white,
backgroundColor: Colors.blueGrey,
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
child: GridView.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 125,
crossAxisSpacing: 5,
mainAxisSpacing: 10,
childAspectRatio: 0.8,
),
itemCount: items.length,
itemBuilder: buildGridItem,
),
),
);
}
}
class GalleryTile extends StatelessWidget {
const GalleryTile({super.key, required this.item});
final GalleryModel item;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final w = constraints.maxWidth;
final h = constraints.maxHeight;
final imgSrc = 'https://picsum.photos/id/${item.id}/2000/1500';
return Container(
width: w.roundToDouble(),
height: h.roundToDouble(),
padding: EdgeInsets.symmetric(vertical: 10, horizontal: 5),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 96,
height: 96,
child: Image.network(imgSrc, fit: BoxFit.cover),
),
Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis),
],
),
);
},
);
}
}
補足:GridView VS SliverGrid
GridView は内部で SliverGrid を組み立てている。SliverGrid では addAutomaticKeepAlives、addRepaintBoundaries、addSemanticIndexesを指定できるところが大きな違いで、これら3つのフラグが全てtrueであり且つgridDelegateが同じ場合、同等の構成になることが多い。
以下2つコードは、書き方は違うが内部的にもほぼ同じ構成になる。
GridView.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 125,
mainAxisSpacing: 10,
crossAxisSpacing: 5,
childAspectRatio: 0.8,
),
itemCount: items.length,
itemBuilder: (c, i) => buildTile(i),
);
CustomScrollView(
slivers: [
SliverGrid(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 125,
mainAxisSpacing: 10,
crossAxisSpacing: 5,
childAspectRatio: 0.8,
),
delegate: SliverChildBuilderDelegate(
(c, i) => buildTile(i),
childCount: items.length,
addAutomaticKeepAlives: true,
addRepaintBoundaries: true,
addSemanticIndexes: true,
),
),
],
);
フラグの説明
| フラグ名 | true の効果(デフォルト) | false にすると… |
|---|---|---|
| addAutomaticKeepAlives | wantKeepAlive => true を設定する子の状態を、画面外に出ても保持できる | 画面外に出た子は即破棄(メモリ節約できる) |
| addRepaintBoundaries | 各子を分離するので、親や隣の変更で再描画されない(ただしレイヤーは増える) | レイヤーを減らせる(GPUメモリを節約できる) 再描画の際は多少の負荷がかかる |
| addSemanticIndexes | スクリーンリーダー向けに「n個中のi番目」を付与 | アクセシビリティの自動番号付けが無くなる |
サンプルコード
class _DragAndDropState extends State<DragAndDrop> {
// ... 省略
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
const SliverAppBar(
pinned: true,
title: Text('GridのD&D'),
foregroundColor: Colors.white,
backgroundColor: Colors.blueGrey,
),
const SliverToBoxAdapter(child: SizedBox(height: 12)),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 125,
crossAxisSpacing: 5,
mainAxisSpacing: 10,
childAspectRatio: 0.8,
),
delegate: SliverChildBuilderDelegate(
buildItem,
childCount: items.length,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
addSemanticIndexes: false,
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 30)),
],
),
);
}
}