Reactで作る同期する並び替えリスト:ドラッグ&ドロップの実装ガイド
https://scrapbox.io/files/6789bb900bf80fff2d17819a.gif
# はじめに
Webアプリケーションでよく見かける機能の1つが、ドラッグ&ドロップによる並び替え可能なリストです。さらに、2つのリストを同期させて、片方を並び替えると自動的にもう片方も同じように並び替わる、という要件も多いのではないでしょうか。
この記事では、外部ライブラリに頼らず、React + HTML5のドラッグ&ドロップAPIを使用して、同期する並び替えリストを実装する方法を解説します。
# 完成イメージ
実装するのは以下のような機能を持つリストです:
- ドラッグ&ドロップで項目を並び替え可能
- 2つのリストが完全同期
- ドラッグ中の視覚的フィードバック
- ドロップ位置のインジケーター表示
- スムーズなアニメーション
# 実装手順
## 1. 基本構造とステート管理
まず、コンポーネントの基本構造とステート管理を実装します。
`tsx
import React, { useState } from 'react';
const SynchronizedLists = () => {
// リストアイテムの状態管理
{ id: '1', content: 'アイテム 1' },
{ id: '2', content: 'アイテム 2' },
{ id: '3', content: 'アイテム 3' },
{ id: '4', content: 'アイテム 4' },
{ id: '5', content: 'アイテム 5' },
]);
// ドラッグ操作に関する状態
return (
<div className="flex justify-center items-start gap-8 p-8">
{/* リスト実装はここに */}
</div>
);
};
`
ポイント:
- 単一の items 状態で両方のリストを管理することで同期を実現
- ドラッグ操作中の状態を3つの状態で管理
- draggedItem: 現在ドラッグ中のアイテム
- draggedOverItem: ドラッグオーバー中のアイテム
- dragPosition: ドロップ位置(上部/下部)
## 2. ドラッグ&ドロップイベントハンドラーの実装
HTML5のドラッグ&ドロップAPIを使用するためのイベントハンドラーを実装します。
`tsx
// ドラッグ開始時
const handleDragStart = (e, item) => {
setDraggedItem(item);
e.target.style.opacity = '0.5';
};
// ドラッグ終了時
const handleDragEnd = (e) => {
e.target.style.opacity = '1';
setDraggedItem(null);
setDraggedOverItem(null);
setDragPosition(null);
};
// ドラッグオーバー時(ドロップ位置の判定)
const handleDragOver = (e) => {
e.preventDefault();
if (e.target.getAttribute('data-item-id')) {
const boundingRect = e.target.getBoundingClientRect();
const mouseY = e.clientY;
const threshold = boundingRect.top + boundingRect.height / 2;
setDragPosition(mouseY < threshold ? 'top' : 'bottom');
}
};
// 要素にドラッグが入ってきた時
const handleDragEnter = (e, item) => {
e.preventDefault();
if (item.id !== draggedItem?.id) {
setDraggedOverItem(item);
}
};
`
ポイント:
- e.preventDefault() でブラウザのデフォルト動作を防止
- ドラッグ中のアイテムの透明度を変更して視覚的フィードバックを提供
- マウス位置に基づいてドロップ位置を判定
## 3. ドロップ処理の実装
最も重要な部分であるドロップ時の並び替え処理を実装します。
`tsx
const handleDrop = (e, targetItem) => {
e.preventDefault();
if (!draggedItem || !dragPosition) return;
setItems(prevItems => {
const draggedIndex = newItems.findIndex(item => item.id === draggedItem.id);
const targetIndex = newItems.findIndex(item => item.id === targetItem.id);
// ドラッグ中のアイテムを一旦削除
newItems.splice(draggedIndex, 1);
// ドロップ位置に応じて挿入位置を調整
const adjustedTargetIndex =
dragPosition === 'bottom' ?
(targetIndex > draggedIndex ? targetIndex : targetIndex + 1) :
(targetIndex > draggedIndex ? targetIndex - 1 : targetIndex);
// 新しい位置に挿入
newItems.splice(adjustedTargetIndex, 0, draggedItem);
return newItems;
});
// 状態をリセット
setDraggedItem(null);
setDraggedOverItem(null);
setDragPosition(null);
};
`
ポイント:
- インデックスの計算時に元の位置と新しい位置の関係を考慮
- 配列操作は必ずイミュータブルに行う
- ドロップ後は全ての状態をリセット
## 4. 視覚的フィードバックの実装
ドロップ位置のインジケーターとアニメーションを実装します。
`tsx
const SortableList = ({ className }) => (
<div className={bg-gray-100 p-4 rounded-lg min-h-64 w-64 ${className}}>
{items.map((item, index) => (
<div key={item.id} className="relative">
{/* ドロップ位置インジケーター(上部) */}
{draggedOverItem?.id === item.id && dragPosition === 'top' && (
<div className="absolute -top-1 left-0 right-0 h-0.5 bg-blue-500 z-10">
<div className="absolute -left-2 -top-1 w-2 h-2 rounded-full bg-blue-500" />
<div className="absolute -right-2 -top-1 w-2 h-2 rounded-full bg-blue-500" />
</div>
)}
{/* ドラッグ可能なアイテム */}
<div
data-item-id={item.id}
draggable
onDragStart={(e) => handleDragStart(e, item)}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragEnter={(e) => handleDragEnter(e, item)}
onDrop={(e) => handleDrop(e, item)}
className={`
p-4 mb-2 bg-white rounded-md shadow cursor-move
transition-all duration-200
hover:scale-1.02 hover:shadow-md ${draggedItem?.id === item.id ? 'opacity-50' : 'opacity-100'}
select-none
`}
{item.content}
</div>
{/* ドロップ位置インジケーター(下部) */}
{draggedOverItem?.id === item.id && dragPosition === 'bottom' && (
<div className="absolute -bottom-1 left-0 right-0 h-0.5 bg-blue-500 z-10">
<div className="absolute -left-2 -top-1 w-2 h-2 rounded-full bg-blue-500" />
<div className="absolute -right-2 -top-1 w-2 h-2 rounded-full bg-blue-500" />
</div>
)}
</div>
))}
</div>
);
`
ポイント:
- CSSトランジションを使用してスムーズなアニメーションを実現
- 条件付きレンダリングでインジケーターを表示
- Tailwind CSSでスタイリングを簡潔に記述
# 完全なコード
すべてを組み合わせた完全なコードは以下のようになります:
`tsx
// 上記のすべてのコードを統合したもの
`
# カスタマイズのポイント
1. **スタイリングの変更**
- Tailwind CSSのクラスを変更することで簡単にデザインを調整可能
- インジケーターの色や形状も自由にカスタマイズ可能
2. **アニメーションの調整**
- transition-all の duration-200 を変更することでアニメーション速度を調整可能
- より複雑なアニメーションを追加することも可能
3. **機能の拡張**
- リスト間でのドラッグ&ドロップを実装
- 並び替え履歴の管理(Undo/Redo機能)
- ドラッグ中のプレビュー表示のカスタマイズ
# まとめ
この実装方法の利点:
- 外部ライブラリに依存しないため、バンドルサイズを抑えられる
- カスタマイズの自由度が高い
- パフォーマンスを最適化しやすい
- TypeScriptとの相性が良い
注意点:
- モバイルデバイスでの対応が必要な場合は、タッチイベントの処理を追加する必要がある
- 大量のデータを扱う場合は、仮想化(virtualization)の実装を検討する
- ブラウザ互換性に注意が必要
以上の実装を基本として、プロジェクトの要件に応じてカスタマイズしていくことで、使いやすい並び替えリストを実現できます。
# 参考リンク