はじめに
タイトルが変わりました
TODOアプリを作る中で空想が膨らみタスク管理アプリのTrelloみたいなものを作る事にしました。
前回までのあらすじ
- Tailwindを導入できた
- ComponentにPropsを渡せた
- Componentの中でPropsを使用できた
- Componentをループを使って表示できた
- React Iconsを導入できた
- useStateを使って状態管理ができた
- setStateを使って状態の更新ができた
- 状態に対するイミュータブル(不変)な操作の必要性が分かった
- 子側で親のイベントをトリガーできた
状態に応じてフィルターしたTodoリストを並べる
状態に応じてTodoリストにフィルターをかけ各タスクリストを状態に対応して横並びにしました。
上記の修正についてポイントを絞って記載します。
状態を配列で定義しこれをループさせて各状態に応じたカラムを作成します
// Status型に含まれませんが"All"という全ての状態のTodoを表示するカラムも作る事にします
const [statuses, setStatuses] = useState([
"All",
"Incomplete",
"Progress",
"Done",
]);
// useStateを使わないとDOM描画時にstatuses.lengthが取得できなかったです。
// (原因わかったら追記します)
各カラムとTodoItemを描画する部分
return (
<>
{/* colsはstatusesに設定した状態の数に合わせる */}
<div className={`grid grid-cols-${statuses.length}`}>
{statuses.map((status, i) => {
// filterを使用してTodoListの状態に応じてフィルタリングする
const filteredTodoList = todoItemList.filter(
// statusが"All"の場合はフィルタリングしない
(item) => status === "All" || item.status === status
);
return (
<div key={i} className="mx-2 px-4 py-2 rounded-lg bg-gray-200">
{/* statusに対応したタグを設置 */}
<span
className="inline-flex items-center py-1.5 px-3 mb-1 rounded-full text-xs font-medium bg-gray-500 text-white">
{status}
</span>
{/* filterしたTodoListをmapで回してTodoを描画*/}
{filteredTodoList.map((todo, j) => (
<div key={j}>
<TodoItem {...todo} />
</div>
))}
{/* statusがAllの時だけtTodoFormを設置 */}
{status === "All" && <TodoForm addTodoOnclick={addTodoOnClick} />}
</div>
);
})}
</div>
</>
今更ですがtsxの関数内ではDOMをそのまま変数に定義できる事に驚きました。
// 状態に応じたIconのElementを定義しています。
switch (props.todo.status) {
case "Done":
...
statusValues.bgColor = "bg-emerald-500";
// ここでElementをそのまま変数に入れてる
statusValues.iconDom = (
<FaCheckCircle className="w-6 h-6 text-white fill-current" />
);
break;
case "Progress":
...
statusValues.bgColor = "bg-blue-600";
// ここでElementをそのまま変数に入れてる
statusValues.iconDom = (
<TbProgress className="w-6 h-6 text-white fill-current" />
);
break;
case "Incomplete":
...
statusValues.bgColor = "bg-gray-600";
// ここでElementをそのまま変数に入れてる
statusValues.iconDom = (
<RiZzzFill className="w-6 h-6 text-white fill-current" />
);
break;
}
コンポーネントの中でこんな感じで色に関するstyleとセットでiconを描画しています
<div className={`w-12 ${statusValues.bgColor}`}>
{statusValues.iconDom}
</div>
ドラッグ&ドロップで並べ替える
ドラック&ドロップでカラム内のTodoコンポーネントの並べ替えを作成したいと思います。
下記サンプルではカラム内だけでなくカラムを横断した並び替えを実装していますが、ここではまずはカラム内の並び替えのみを実装します。
下記サイトでライブラリを比較を参考にドラック中の描画がいい感じだったdnd kitというライブラリを使用したいと思います。
公式サイトにサンプルコードもありましたので参考にしながらアプリに仕込みました。
上記ではアプリとして不足部分がありますが、ここまでの手順をdnd kitの仕組みと合わせポイントを絞ってまとめます。
DnD導入
npm i @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
Dnd kitを仕込む
DnD kitを組み込んだコード部分のみ記載します。
return (
<>
<div className={`grid grid-cols-${statuses.length}`}>
{statuses.map((status, i) => {
const filteredTodoList = todoItemList.filter(
(item) => status === "All" || item.status === status
);
return (
<div key={i} className="mx-2 px-4 py-2 rounded-lg bg-gray-200">
<span className="inline-flex items-center py-1.5 px-3 mb-1 rounded-full text-xs font-medium bg-gray-500 text-white">
{status}
</span>
{status !== "All" ? (
<>
// ここから
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}>
{filteredTodoList.map((todo, j) => (
<SortableContext
key={todo.id}
items={todoItemList}
strategy={verticalListSortingStrategy}>
<TodoItem todo={todo} isSortable />
</SortableContext>
))}
</DndContext>
// ここまで
</>
) : (
filteredTodoList.map((todo, j) => (
<TodoItem key={todo.id} todo={todo} />
))
)}
{status === "All" && <TodoForm addTodoOnclick={addTodoOnClick} />}
</div>
);
})}
</div>
</>
);
以下のサイトではカラム間のドラック&ドロップが解説付きで実装されているため、DnD kitを知りたい方をすぐにこれらの記事を開いて下さい。
senser
- ドラッグの開始、移動、終了をどのような操作で実行するか設定する
ポインター操作とキーボード操作を設定しています。 - KeyboardSensorにcoordinateGetter:sortableKeyboardCoordinatesを設定する事で矢印キー押下時に隣のアイテムと入れ替えられるみたいです
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
collisionDetection設定:衝突検出のアルゴリズム
もっとも近い要素を取得するclosestCenterを設定しました。
Handler
onDragEndにドロップしたの際の処理を設定します。
ドラック&ドロップによる入れ替わりに必要な処理は自作する必要があります。
ドラックさせるデータをどんな構造で保持しているかは開発者によって異なるため、入れ替わりの処理もそれらに対応させなければいけないのだと思います。
// 現状のデータ構造はシンプルな配列です
data:Todo[] = [
{
id: 1,
title: "タイトル",
content: "TODO内容はここに記載します。",
status: "Done",
},
{
id: 2,
title: "タイトル2",
content: "TODO内容の二番目",
status: "Progress",
},
{
id: 3,
title: "タイトル3",
content: "TODO内容の3番目",
status: "Incomplete",
},
]
ドラック&ドロップの動きをデータに紐づけるため
- SortableContextのitems propsにバインドしたいデータ配列を設定
- keyに配列内の各データに一意の値を設定
<SortableContext
/* keyの値を設定するためTodoにidを持たせました
idは後に記述するHandelrで使用します */
key={todo.id}
// itemsは入れ替わりが発生するデータ配列を設定します
items={todoItemList}
strategy={rectSortingStrategy}>
...
</SortableContext>
またTodoItem側でも渡されたidをuseSortableに設定しています。
keyとしてもidを渡しているのですがkeyとuseSortableのidは一致させておく必要がありました。
その辺の処理内容をきちんと調べてないです。
<div
// ↓この辺はよく分からない
ref={setNodeRef}
{...attributes}
{...listeners}
// ソートが可能か否かisSortableというpropsに渡して対応したstyleを設定してます。
style={props.isSortable ? style : { cursor: "default" }}>
...
</div>
上記の設定でドラック操作対象のTodoのidやドロップした場所にあるTodoのidを取得する事ができます。
上記のidをHandler内で利用し入れ替わりを実装します。
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over) {
return;
}
// 別のTodo上にドロップした場合(over.idとactive.idが異なる)
if (active.id !== over.id) {
const oldIndex = todoItemList.findIndex((v) => v.id === active.id);
const newIndex = todoItemList.findIndex((v) => v.id === over.id);
// arrayMoveを使用して配列内の順番を入れ替える
setTodoList(arrayMove(todoItemList, oldIndex, newIndex));
}
};
HandlerはDndContextに以下のようにpropsで渡します。
現在はonDragEnd propsとして設定しドロップしたタイミングで発火させます
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}>
...
</DndContext>
ちょっと見にくいですがConsoleにactive.idとover.idを出力するとこのようになります。
データが入れ替わった後、その情報が即座にDOMに反映されるのはuseStateを使用してデータの状態を管理し、ドロップ後のhandlerでsetStateを使用しているためです。
またアニメーションやスタイルはDnd kitが用意したstyleをTodoItem側に設定しています。
// ドラック&ドロップに伴うスタイルを設定
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
// 先ほども記載したこの部分
<div
ref={setNodeRef}
{...attributes}
{...listeners}
// ソートが可能か否かisSortableというpropsに渡して対応したstyleを設定してます。
style={props.isSortable ? style : { cursor: "default" }}>
まだカラム間のドラック&ドロップを実装していませんが今回はここで一旦区切ります。
まとめ
私にとってはライブラリが充実している事より、ライブラリの記事が充実している方が大切だと感じました。
みなさん分かりやすい記事をありがとう…
- filterをかけて条件に一致したデータでComponentを表示できた
- Dnd kitを導入できた
- Dnd kitのDndContextとソート機能に関するSortableContextでカラム内ソートを実装できた