React DnDでかんばんボードを作ってみる

9 min読了の目安(約8800字TECH技術記事

※本記事はSansan Advent Calendar 2020の3日目になります。

はじめに

自分はSansanが展開しているtoC向けの名刺管理サービス Eight を開発しています。
先日EightのWebページでReact DnDを利用した並び替えを実装していたので、そのときの経験を踏まえてかんばんボードを作成してみました。
できあがりのサンプルはこちら、ソースコードはこちらになります。参考としたのは本家のSortable Simpleです。

本記事では以下の流れで簡単な解説をしていきます。

  1. React DnDの概要
  2. 環境構築
  3. Sample作成
  4. まとめ

React Dndの概要

React Dndは2014年から開発されているReact用のドラッグ&ドロップを実現するライブラリです。
Reduxの作者であるDan Abramovさんによるものとしても知られています。
READMEでも言及されているとおり、リリース当時から少し難しめの思想となっていたようです。
とはいえhooksや型の提供など、メンテナンスがされており、安心して使えるライブラリかな、とも感じます。

登場人物

React DnDの主要な登場人物は以下のとおりです。

Monitors

  • ドラッグ&ドロップでの状態保管をする
  • collectという関数を経由して、コンポーネントに状態を伝える
  • 内部的な状態管理はReduxを使っている

DragSource (useDrag)

  • ドラッグの振る舞いを定義する
  • このときドラッグ対象をプレーンなオブジェクトして定義する
    • 最低限必要なのはtype
    • これは次のDropTargetでも指定する

DropTarget (useDrop)

  • ドロップの振る舞いを定義する
  • どのDragSourceを受け入れられるかをtypeで指定できる

Connectors

  • DragSourceやDropTargetをどこのDOMと結びつけるかを指定する

特徴

React DnDは内部でHTML5のAPI setDragImageを利用しています。
これにより、ドラッグ中の掴んでいる要素を半透明画像としてマウスポインタに追従してくれます。
ほかのライブラリなどではこの処理を自前で実装していたりするので、ドラッグ中の位置計算、レンダリングが都度動いてしまう場合もあり、パフォーマンス面などでメリットになります。
また外部からのファイルドロップといったネイティブとの連携もしやすくなっています。

環境構築

まずcreate-react-appを使ってReactアプリの雛形を準備します。
今回のサンプルではTypeScriptで記述していくため、以下のようにコマンドを実行します。

npx create-react-app react-dnd-sample --template typescript
cd react-dnd-sample
yarn install
yarn add react-dnd react-dnd-html5-backend
yarn start

本記事での各ライブラリのバージョンは以下のとおりです。

  • react, react-dom: 17.0.1
  • react-dnd, react-dnd-html5-backend: 11.1.3
  • typescript: 4.1.2
  • react-scripts: 4.0.1

これでreact-dndを利用するための準備が整いました。
今回はPCでの利用を想定しているため、タッチデバイス用ではなくhtml5-backendを利用します。
スマホでの利用を考える場合は、backendを切り替えることで実現できます。

Sample作成

最終的な構成は以下のとおりです。(CSSは割愛)

src/
 ├ components/
 │  ├ Draggable.tsx
 │  ├ Group.tsx
 │  ├ Header.tsx
 │  └ Item.tsx
 ├ hooks/
 │  └ useGroupedItems.ts
 ├ App.tsx
 ├ data.ts
 └ index.tsx

今回のポイントは主に以下になります。

  • 複数のグループをひとつのデータ配列で表現している
  • ひとつのDragSourceに対して、複数のDropTargetを準備している

プロバイダーの準備

App.tsx がカンバン全体になっているので、そこにreact-dndのプロバイダーを設置します。

import React from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';

const App = () => {
  return (
    <div className='app'>
      <DndProvider backend={HTML5Backend}>
      {/* ここにグループボードや付箋アイテムを配置していく */}
      </DndProvider>
    </div>
  );
}

export default App;

付箋アイテムコンポーネント

Item.tsx として、付箋アイテムの表示だけを担当するコンポーネントを準備します。

import React from 'react';
import { NoteIcon } from '@primer/octicons-react';

import './Item.css';
import type { Contents } from '../data';

const Item: React.FC<{
  id: number;
  contents: Contents
}> = ({ contents }) => (
  <div  className='item'>
    <NoteIcon className='icon'/>
    <div className='contents'>
      <p className='title'>{contents.title}</p>
      <p className='memo'>{contents.memo}</p>
    </div>
  </div>
);

export default Item;

ドラッグ&ドロップ処理

ここからがreact-dndのメイン部分です。
hooksを利用して振る舞いやMonitorとの連携を書いていきます。今回はDraggable.tsxにreact-dndを利用した処理を準備します。

import React, { useRef } from 'react';
import { useDrag, useDrop } from 'react-dnd';

import './Item.css';
import { Item, ItemWithIndex, ItemTypes, MoveHandler } from '../data';

const Draggable: React.FC<{
  item: Item, index: number, onMove: MoveHandler
}> = ({
  item, index, onMove, children
}) => {
  const ref = useRef<HTMLDivElement>(null);

  const [, drop] = useDrop({
    // acceptに指定したtypeだけがコールバックへの対象となる (本サンプルでは ['todo', 'doing', 'done'])
    accept: ItemTypes,
    // マウスドラッグをしたときにhoverした部分でのコールバックを定義
    hover(dragItem: ItemWithIndex, monitor) {
      if (!ref.current) return;
      const dragIndex = dragItem.index;
      const hoverIndex = index;
      if (dragIndex === hoverIndex) return;

      if (item.group === dragItem.group) {
        // グループ内での並び替えの場合は入れ替え方向とhover位置に応じて入れ替えるかを確定
        const hoverRect = ref.current.getBoundingClientRect();
        const hoverMiddleY = (hoverRect.bottom - hoverRect.top) / 2;
        const mousePosition = monitor.getClientOffset();
        if (!mousePosition) return;
        const hoverClientY = mousePosition.y - hoverRect.top;
        if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY * 0.5) return;
        if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY * 1.5) return;
      }

      // 内部のデータも変更しつつ、onMoveでstate変更を依頼する
      onMove(dragIndex, hoverIndex, item.group);
      dragItem.index = hoverIndex;
      dragItem.group = item.group;
    }
  });

  // collectでmonitorから取得したデータのみが戻り値として利用できる (collectに指定することで型補完も適用される)
  const [{ isDragging, canDrag }, drag] = useDrag({
    item: { ...item, index },
    isDragging: monitor => monitor.getItem().id === item.id,
    collect: monitor => ({
      isDragging: monitor.isDragging(),
      canDrag: monitor.canDrag(),
    })
  })

  // refをconnectorと呼ばれる関数(drag,drop)に渡すことで、対象refと↑のuseDrag,useDropでの処理を結びつける
  drag(drop(ref));

  return (
    <div
      ref={ref}
      style={{
        opacity: isDragging ? 0.4 : 1,
        cursor: canDrag ? 'move' : 'default',
      }}
    >
      {children}
    </div>
  );
};

export default Draggable;

上記2つのコンポーネントを組み合わせて並び替え可能な付箋を準備するのですが、それを組み込むのはグループセクションを作るGroup.tsxです。
ここではグループにもドロップ可能なエリアを準備します。これにより、付箋がない部分にもドロップ可能となります。
グループ内での移動は先ほどのDraggable.tsxにまかせていますので、こちらではグループ間で最下部に移動する場合のみを考慮しています。

import React from 'react';
import { useDrop } from 'react-dnd';

import './Group.css';
import Item from './Item';
import Draggable from './Draggable';
import { Item as _Item, ItemWithIndex, GroupType, ItemTypes, TitleMap, MoveHandler } from '../data';

const Group: React.FC<{
  items: _Item[];
  groupType: GroupType;
  firstIndex: number;
  onMove: MoveHandler;
}> = ({ items, groupType, firstIndex, onMove }) => {
  const [, ref] = useDrop({
    accept: ItemTypes,
    hover(dragItem: ItemWithIndex) {
      const dragIndex = dragItem.index;
      if (dragItem.group === groupType) return;
      const targetIndex = dragIndex < firstIndex ?
        // forward
        firstIndex + items.length - 1 :
        // backward
        firstIndex + items.length;
      onMove(dragIndex, targetIndex, groupType);
      dragItem.index = targetIndex;
      dragItem.group = groupType;
    }
  });

  return (
    <div className={['group', groupType].join(' ')}>
      <h2><span className='count'>{items.length }</span>{TitleMap[groupType]}</h2>
      <ul className='list' ref={ref}>
        {items.map((item, i) => {
          return (
            <li key={item.id} className='item-wrapper'>
              {/* 先ほどの2つのコンポーネントを利用 */}
              <Draggable item={item} index={firstIndex + i} onMove={onMove}>
                <Item id={item.id} contents={item.contents} />
              </Draggable>
            </li>
          );
        })}
      </ul>
    </div>
  );
};

export default Group;

stateの変更処理

各コンポーネントからのonMoveコールバックから受け取ったデータをもとに、かんばんボード全体としてのデータを更新します。

const App = () => {
  // 元の配列データから {todo: Item[], doing: Item[], done: Item[]} データを準備するhooks
  const [groupedItems, items, setItems] = useGroupedItems(initial);
  const moveItem: MoveHandler = useCallback((dragIndex, targetIndex, group) => {
    // dragIndexとtargetIndexからswap処理
    const item = items[dragIndex];
    if (!item) return;
    setItems(prevState => {
      const newItems = prevState.filter((_, idx) => idx !== dragIndex);
      newItems.splice(targetIndex, 0, { ...item, group });
      return newItems;
    })
  }, [items, setItems]);

  return (
    <div className='app'>
      <div className='horizontal'>
        <DndProvider backend={HTML5Backend}>
          {GroupTypes.map(group => {
            const items = groupedItems[group];
            const firstIndex = index;
            if (items === undefined) return null;
            index = index + items.length;

            return (
              <section key={group} className='group-section'>
                <Group
                  items={items}
                  groupType={group}
                  firstIndex={firstIndex}
                  onMove={moveItem}
                />
              </section>
            )
          })}
        </DndProvider>
      </div>
    </div>
  );
}

export default App;

これでサンプルの準備は完了です。そのほかにデモではヘッダーを準備したりしていますが、ここでは割愛します。

まとめ

今回はReact Dndを使ったかんばんボードを作ってみました。
中身を理解するとカスタムなどもしやすくなり、今後の助けになりそうです。
ただアニメーションをつけたい場合など、このライブラリで実施するのは少しめんどくさそうな印象でした。
用途が決まっている場合はほかのライブラリ(react-beautiful-dnd)などの利用を検討するのも良さそうです。