【Flutter】GridViewでドラッグ&ドロップ

目次

ListViewにはReordableListViewがあるが、GridViewにはそういうものがない。 非公式のライブラリを使うか、自前で実装する必要があるので、今回は自前でやる。

登場人物は以下の通り。

Draggable<T> のプロパティ解説

基本プロパティ

プロパティ説明
child必須。ドラッグされていない通常時に表示されるウィジェット
feedback必須。ドラッグ中に指にくっついて動くウィジェット
dataDragTargetに渡すデータ。アイテムのIDなど、一意の値を設定

見た目のカスタマイズ

プロパティ説明
childWhenDraggingドラッグ開始後、childがあった元の場所に表示されるウィジェット
feedbackOffsetドラッグ中のfeedbackを指の位置からずらす量
dragAnchorStrategyドラッグ開始時にfeedbackが指のどの位置に現れるかを決定

イベントコールバック

プロパティ説明
onDragStartedドラッグが開始された瞬間に呼ばれる
onDragUpdateドラッグ中に指が動くたびに呼ばれる
onDragEndドラッグの終了時に、成功・キャンセルの両方で呼ばれる。引数の DraggableDetails.data がtrueのときは成功、falseのときは失敗と判定できる
onDragCompletedDragTargetに正常にドロップされ、ドラッグが成功した時に呼ばれる。プログラム的にいうと、onDragEnd で details.wasAccepted=true のとき
onDraggableCanceledDragTarget以外の場所で指を離し、ドラッグがキャンセルされた時に呼ばれる。プログラム的にいうと、onDragEnd で details.wasAccepted=false のとき

高度な設定

プロパティ説明
axisドラッグ方向を垂直(Axis.vertical)または水平(Axis.horizontal)に制限
maxSimultaneousDrags同時にいくつの指でドラッグできるかを設定(通常は1)
ignoringFeedbackSemantics,
ignoringFeedbackPointer
ドラッグ中のfeedbackが、システムのユーザー補助機能やタップイベントにどう影響するかを設定
rootOverlayfeedbackをアプリの最上位レイヤーに描画するかどうか。ダイアログなどを超えてドラッグする場合にtrueにする
hitTestBehaviorchildがタップを検知する範囲を設定
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の範囲外に移動した時に呼ばれる
onMoveDraggableがDragTargetの範囲内で動いている間、継続的に呼ばれる

高度な設定

プロパティ説明
hitTestBehaviorDragTargetがタップ(ドロップ)を検知する範囲。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 にすると…
addAutomaticKeepAliveswantKeepAlive => 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)),
        ],
      ),
    );
  }
}