【Flutter】並び替え中のリストのUIを変える

  • 2025年1月13日
  • 2025年1月14日
  • 小物

はじめに

Flutterでドラッグ&ドロップで入れ替え可能なリストを作成する場合、ReorderableListViewが非常に便利です。単に並び替えだけではなく、特定の項目を選択した際に動的な変化を与えることで、よりアトラクティブなUIを実現したくなりました。しかし、ReorderableListView内のitemBuilderをいじるだけでは実現できませんでした。そこで、リスト内のWidgetをStatefulにすることで解決できました。

この記事では、GlobalKeyを活用し、onReorderStartonReorderEndのイベント内でStatefulWidgetを操作し、ReorderableListViewでタップしたWidgetの動作を変化させる方法を解説します。

前提知識

ReorderableListViewの基本

ReorderableListViewは、アイテムをドラッグ&ドロップで並び替えるためのウィジェットです。onReorderonReorderStartonReorderEndなどのコールバックを使うことで、並び替え中のアイテムを識別し、状態を管理することができます。

詳しくは、以下を参照ください。

【Flutter】ReorderableListViewでリスト順序を自由に!

GlobalKeyを用いたStatefulWidgetの制御

GlobalKeyを使うことで、特定のStatefulWidgetインスタンスに直接アクセスし、その状態を操作できます。この方法により、onReorderStartでアイテムを選択し、onReorderEndで選択を解除するといったインタラクションが可能になります。

詳しくは、以下を参照ください。

【Flutter】StatefulWidgetのStateのメソッドを実行する

解説

GlobalKeyを用いた各リスト項目の識別

以下のコードでは、リスト項目ごとにGlobalKeyを設定し、ListTileWidgetにアクセスできるようにしています。

late final mapKeys = <int, GlobalKey<_ListTileWidgetState>>{
  for (final item in _items) item.id: GlobalKey()
};

このmapKeysListItemの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)を呼び出して選択状態を解除し、元の色やサイズに戻します。
  • 最後に_selectedItemnullにして、選択解除状態をクリアします。
  • この時点での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}'),
      ),
    );
  }
}
  • _isSelectedtrueの場合、色を青から緑、パディングを8から2、に設定します。
    高さを変更しようとしましたが、うまくいきませんでした(Listの制約?AnimatedContainerでもダメでした)。パディングを小さくして、大きくなったように見せています
  • selectedメソッドで選択状態を変更し、setStateで再描画を行います。

実装方法のポイント

  1. GlobalKeyを用いたインスタンスの参照
    StatefulWidgetの状態管理にはGlobalKeyが非常に有用です。特に、並び替え時に特定のウィジェットの状態を変化させたい場合は必須です。

  2. 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}'),
      ),
    );
  }
}