Material-UI で並べ替え可能なリストを作る

Material-UI では、Material Design Guidelines で定められている[ドラッグ＆ドロップで並べ替えられるリスト](https://material.io/design/components/lists.html)が提供されていません。ドラッグ＆ドロップの機能を提供するライブラリと組み合わせることで、これを実現できます。


# ドラッグ＆ドロップ

React でドラッグ＆ドロップを実現するライブラリは数多ありますが、今回は以下の4つを比較・検討しました。箇条書きはあくまで個人的な感想です。

### react-dnd

[react-dnd/react-dnd: Drag and Drop for React](https://github.com/react-dnd/react-dnd)

- 一番有名どころ。オリジナルの作者は、React や Redux の Dan Abramov (gaearon) 氏。
- 拡張性が高いので、その気になればいろんなことができそう。一方で、アニメーションなど自力で実装しなければならない部分が多い。
- 記述量は多め。

### react-beautiful-dnd

[atlassian/react-beautiful-dnd: Beautiful and accessible drag and drop for lists with React](https://github.com/atlassian/react-beautiful-dnd)

- Atlassian 製。
- ソートのアニメーションが優れている。記述量は多め。
- “Not for everyone” だそうだ。たとえば、今回必要な「軸の固定 (axis locking) 」は、何度か機能提案されているが、UX を損なう (意訳) としてすべて却下されている。
- ということで、“Not for me” だった。

### react-sortable-hoc
[clauderic/react-sortable-hoc: A set of higher-order components to turn any list into an animated, touch-friendly, sortable list ✌️](https://github.com/clauderic/react-sortable-hoc)

- 記述量が少なくて済むので、実装が楽。
- 現在非推奨な `findDOMNode` が使われていてレガシーな感じ。
- Material-UI で DragHandle をやろうとするとちょっとハマった (後述)。

### react-smooth-dnd

[kutlugsahin/react-smooth-dnd: react wrapper components for smooth-dnd](https://github.com/kutlugsahin/react-smooth-dnd)

- Star の数では劣るが、特に問題はなさそう。
- 記述量は少なくて済む。ハマりポイントもなく、一番素直に実装できた。
- タッチデバイスだと難ありらしい ([Issue が上がっている](https://github.com/kutlugsahin/react-smooth-dnd/issues/27))。


# 実装例

react-sortable-hoc で実装してみた例と、react-smooth-dnd で実装してみた例を記載します。(手元で実装したものと CodeSandbox に上げたもので、使用したパッケージのバージョンが異なりますが、動作に問題はありません。)

## react-sortable-hoc

[![Edit Material-UI Sortable List with react-sortable-hoc](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/l7v0qz869z)

### 使用したパッケージ
- React v16.8.5
- Material-UI v3.9.2
- react-sortable-hoc v1.8.3
- array-move v2.0.0

```jsx
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc';
import arrayMove from 'array-move';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import ListItemText from '@material-ui/core/ListItemText';
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction';
import DragHandleIcon from '@material-ui/icons/DragHandle';

const DragHandle = SortableHandle(() => (
  <ListItemIcon>
    <DragHandleIcon />
  </ListItemIcon>
));

const SortableItem = SortableElement(({ text }) => (
  <ListItem ContainerComponent="div">
    <ListItemText primary={text} />
    <ListItemSecondaryAction>
      <DragHandle />
    </ListItemSecondaryAction>
  </ListItem>
));

const SortableListContainer = SortableContainer(({ items }) => (
  <List component="div">
    {items.map(({ id, text }, index) => (
      <SortableItem key={id} index={index} text={text} />
    ))}
  </List>
));

const SortableList = () => {
  const [items, setItems] = useState([
    { id: '1', text: 'Item 1' },
    { id: '2', text: 'Item 2' },
    { id: '3', text: 'Item 3' },
    { id: '4', text: 'Item 4' }
  ]);

  const onSortEnd = ({ oldIndex, newIndex }) => {
    setItems(items => arrayMove(items, oldIndex, newIndex));
  };

  return (
    <SortableListContainer
      items={items}
      onSortEnd={onSortEnd}
      useDragHandle={true}
      lockAxis="y"
    />
  );
};

ReactDOM.render(<SortableList />, document.getElementById('root'));
```

ハマった点ですが、ドラッグ中の `li` 要素が `document.body` にクローンされるため、リストのマーカーが表示されてしまって、表示崩れを起こしてしまいました。これを回避するにはいくつかの方法があります。

- `body` に `list-style-type: none;` を適用する。
- `ListItemSecondaryAction` を `SortableHandle` に含めてしまう。
- `ListItem` に `ContainerComponent="div"` を指定する。

実装例では3番目の方法を採用しています。`ListItem` だけ変更すると、実際の DOM で `ul` の直下に `div` がきて気持ち悪いので、`List` にも `component="div"` を指定しています。

## react-smooth-dnd

[![Edit Material-UI Sortable List with react-smooth-dnd](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/74wxnz38m6)

### 使用したパッケージ
- React v16.8.5
- Material-UI v3.9.2
- react-smooth-dnd v0.8.2
- array-move v2.0.0

```jsx
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
import { Container, Draggable } from 'react-smooth-dnd';
import arrayMove from 'array-move';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import ListItemText from '@material-ui/core/ListItemText';
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction';
import DragHandleIcon from '@material-ui/icons/DragHandle';

const SortableList = () => {
  const [items, setItems] = useState([
    { id: '1', text: 'Item 1' },
    { id: '2', text: 'Item 2' },
    { id: '3', text: 'Item 3' },
    { id: '4', text: 'Item 4' }
  ]);

  const onDrop = ({ removedIndex, addedIndex }) => {
    setItems(items => arrayMove(items, removedIndex, addedIndex))
  };

  return (
    <List>
      <Container dragHandleSelector=".drag-handle" lockAxis="y" onDrop={onDrop}>
        {items.map(({ id, text }) => (
          <Draggable key={id}>
            <ListItem>
              <ListItemText primary={text} />
              <ListItemSecondaryAction>
                <ListItemIcon className="drag-handle">
                  <DragHandleIcon />
                </ListItemIcon>
              </ListItemSecondaryAction>
            </ListItem>
          </Draggable>
        ))}
      </Container>
    </List>
  );
};

ReactDOM.render(<SortableList />, document.getElementById('root'));
```

先述の通り、現バージョンではタッチデバイスで難ありらしいですが、PC 向けにはこれで問題ないように思います。


# 余談

いずれ React Hooks を使って `useDragAndDrop` みたいに書けるようになるかもしれないですね。実装が楽になって、見通しがよくなるといいですね。