React-DnDでドラッグ&ドロップを行う（モバイル対応も）

こんにちは、ミツバです。
今回はReact-DnDを利用して、ドラッグ&ドロップを用いたリストの並び替えを実現したいと思います。

[react-dnd](https://github.com/react-dnd/react-dnd)

ソースコードは以下に置いています。

[mitubaEX/react-dnd-example](https://github.com/mitubaEX/react-dnd-example)

## 動作イメージ

![drag](https://github.com/mitubaEX/react-dnd-example/raw/master/demo/drag.gif)

## テスト環境

テストを行った際の各ライブラリのバージョンを明記しておきます。

- react: ^15.6.2
- react-dnd: ^2.6.0
- react-dnd-html5-backend: ^2.6.0
- react-dnd-touch-backend: ^0.4.0
- react-dom: ^15.6.2
- react-scripts: 2.1.5

## setup

create-react-appを用いてサクッと作成します。

```sh
npx create-react-app react-dnd-example
cd react-dnd-example

# install
npm install --save react-dnd@2.6.0
npm install --save react-dnd-html5-backend@2.6.0
npm install --save react-dnd-touch-backend@0.4.0
```

package.jsonの各バージョンは適宜変更してください。

## App.jsを変更する

```js
import React, { Component } from 'react';
import SortableList from './SortableList.js';

class App extends Component {
  render() {
    return (
      <div className="App">
        <SortableList />
      </div>
    );
  }
}

export default App;
```

## SortableListを作成する

```js
import React, { Component } from 'react';
import { DragDropContext } from 'react-dnd';
import HTML5Backend from 'react-dnd-html5-backend';
import TouchBackend from 'react-dnd-touch-backend';
import DragItem from './DragItem';
import CustomDragLayer from './CustomDragLayer';

class SortableList extends Component {
  constructor(props) {
    super(props);
    this.state = {
      items: [
        {id: 1, name: "aaa"},
        {id: 2, name: "bbb"},
        {id: 3, name: "ccc"}
      ],
    }
  }

  onDrop = (toId, fromId) => {
    const items = this.state.items.slice();
    const toIndex = items.findIndex(i => i.id === toId);
    const fromIndex = items.findIndex(i => i.id === fromId);
    const toItem = items[toIndex];
    const fromItem = items[fromIndex];
    items[toIndex] = fromItem;
    items[fromIndex] = toItem;
    this.setState({items})
  }

  render () {
    return (
      <div>
        {(isAndroid() || isIOS()) && <CustomDragLayer/>}
        {
          this.state.items.map(item => {
            return (
              <DragItem
                key={item.id}
                id={item.id}
                onDrop={this.onDrop.bind(this)}
                name={item.name}
              />
            )
        })
  }
</div>
    )
}
}

function isAndroid() {
  return !!window.navigator.userAgent.match(/Android/);
}

function isIOS() {
  return !!window.navigator.userAgent.match(/iPhone|iPad|iPod/);
}

export default DragDropContext((isAndroid() || isIOS()) ? TouchBackend : HTML5Backend)(SortableList);
```

いろいろ概念が登場していますが、`CustomDragLayer`と`DragItem`をrenderしています。
そして最後に`DragDropContext`に分岐を書いてモバイルの場合とそうでない場合の`Backend`を切り替えています。
この`Backend`は、今回HTML5とTouchの2種類ありデバイスによって、`Backend`を変更することでそのデバイスに対応します。

## DragItem, CustomDragLayerを作成する

DragItemは以下のような感じです。

```js
import React, { Component } from 'react';
import { DragSource, DropTarget } from 'react-dnd'

// ドラッグされるSourceの動作を定義する
const dragSource = DragSource("item", {
  beginDrag(props) {
    return props;
  }
}, (connect, monitor) => {
  return {
    connectDragSource: connect.dragSource(),
    connectDragPreview: connect.dragPreview(),
    isDragging: monitor.isDragging()
  };
})

// ドロップされるTargetの動作を定義する
const dropTarget = DropTarget("item", {
  drop(dropProps, monitor) {
    const dragProps = monitor.getItem();　
    if (dropProps.id !== dragProps.id) {
      dragProps.onDrop(dragProps.id, dropProps.id);
    }
  }
}, connect => {
  return {
    connectDropTarget: connect.dropTarget()
  };
})

class DragItem extends Component {
  constructor(props) {
    super(props)
  }

  getItemStyles() {
    const { isDragging } = this.props

    return {
      opacity: isDragging ? 0.4 : 1,
    }
  }

  render() {
    return this.props.connectDragSource(
      this.props.connectDropTarget(
        <div style={this.getItemStyles()}>
          {this.props.name}
        </div>
      )
    )
  }
}

export default dragSource(dropTarget(DragItem));
```

ドラッグされるSourceとドロップされるTargetの動作を定義し、最後にComponentを包んでやる流れです。
今回はドラッグされるものもドロップされるものも同じなので、同じComponent(DragItem)を包んでいます。

これでだいたいオッケーなのですが、モバイルだとドラッグした時の軌跡が表示されません。
そのためにDragLayerを導入し、ドラッグの軌跡をそいつに描画してもらいます。

### 動作イメージ

![no_layer](https://github.com/mitubaEX/react-dnd-example/raw/master/demo/non_layer.gif)

DragLayerを利用して、包んだComponentであるCustomDragLayerは以下のような感じ。

```js
import * as React from 'react'
import { DragLayer } from 'react-dnd'

const layerStyles = {
  position: 'fixed',
  pointerEvents: 'none',
  top: 0,
  left: 0,
  width: '100%',
  height: '100%',
}

function collect(monitor) {
  return {
    item: monitor.getItem(),
    itemType: monitor.getItemType(),
    initialOffset: monitor.getInitialSourceClientOffset(),
    currentOffset: monitor.getSourceClientOffset(),
    isDragging: monitor.isDragging()
  }
}

class CustomDragLayer extends React.Component {
  getItemStyles(currentOffset) {
    if (!currentOffset) {
      return {
        display: 'none'
      }
    }

    // move position
    const x = currentOffset.x
    const y = currentOffset.y
    const transform = `translate(${x}px, ${y}px) scale(1.05)`

    return {
      WebkitTransform: transform,
      transform: transform,
    }
  }

  render() {
    const { item, itemType, isDragging, currentOffset } = this.props

    if (!isDragging) {
      return null
    }

    // render
    if (itemType === 'item') {
      return (
        <div style={layerStyles}>
          <div style={this.getItemStyles(currentOffset)}>
            {item.name}
          </div>
        </div>
      )
    }
    return null;
  }
}

export default DragLayer(collect)(CustomDragLayer)
```

これでモバイルでもドラッグの軌跡が表示されたと思います。

## 細かい対応

### DragLayerをwebの方にも適応する

今回、DragLayerをモバイルのドラッグ軌跡の描画に用いましたが、ドラッグしたときのアニメーションを個別で決めたいときにも利用できます。
しかし、デフォルトだとwebのドラッグの軌跡は表示されているので、軌跡が二重に表示されます。
なので、それを防ぐために以下のissueのようにドラッグされているときは、軌跡を描画しないようにします。

[connectDragPreview() component is also displayed as regular component](https://github.com/react-dnd/react-dnd/issues/780)

```js
componentDidMount() {
  // Preview表示する時のみ、元のComponentの描画を消す
  if (this.props.connectDragPreview) {
    this.props.connectDragPreview(getEmptyImage(), {
      captureDraggingState: true,
    });
  }
}
```

### hoverを利用して入れ替え処理を行う

DropでItemを変えるよりもより直感的に入れ替える方法として、hoverというものがあります。
それを紹介します。

`DragItem`を以下のように変更します。
今回はdropをhoverに変更し、そこに処理を書いていきました。
他にもいろいろAPIが提供されているので調べてみてください。

```js

import React, { Component } from 'react';
import {
  DragSource,
  DropTarget,
} from 'react-dnd'

const dragSource = DragSource("item", {
  // 変更部分
  beginDrag(props) {
    return {
      id: props.id,
      name: props.name,
      originalIndex: props.findItem(props.id)
    };
  },

  endDrag(props: CardProps, monitor: DragSourceMonitor) {
    const { id, originalIndex } = monitor.getItem()
    const didDrop = monitor.didDrop()

    if (!didDrop) {
      props.onDrop(id, originalIndex)
    }
  },
}, (connect, monitor) => {
  return {
    connectDragSource: connect.dragSource(),
    connectDragPreview: connect.dragPreview(),
    isDragging: monitor.isDragging()
  };
})

const dropTarget = DropTarget("item", {
  // 変更部分
  canDrop() {
    return false
  },

  hover(props: CardProps, monitor: DropTargetMonitor) {
    const draggedId = monitor.getItem().id
    const overId = props.id

    if (draggedId !== overId) {
      const overIndex = props.findItem(overId)
      props.onDrop(draggedId, overIndex)
    }
  },

}, connect => {
  return {
    connectDropTarget: connect.dropTarget()
  };
})

class DragItem extends Component {
  constructor(props) {
    super(props)
  }

  getItemStyles() {
    const { isDragging } = this.props

    return {
      opacity: isDragging ? 0 : 1,
    }
  }

  render() {
    return this.props.connectDragSource(
      this.props.connectDropTarget(
        <div style={this.getItemStyles()}>
          {this.props.name}
        </div>
      )
    )
  }
}

export default dragSource(dropTarget(DragItem));
```

そしてfindItemは以下のような定義です。
これをDragItemのpropsとして渡します。

```js

findItem = (index) => {
  return this.state.items.filter(c => c.id === index)[0].id
}
```

### 動作イメージ

![hover](https://github.com/mitubaEX/react-dnd-example/raw/master/demo/hover.gif)

## まとめ

react-dndについて調べることがあったので、少し記事にしてみました。
コードペタペタ貼っただけなので、各自雰囲気で理解してください。
ドラッグ・アンド・ドロップ周りでこれがいいよとか他にあったら教えてください。

## 参考

[react-dnd これだけ抑えておけばおｋ](https://qiita.com/mizchi/items/9a426af4fe919cd9d9d5)
[React DnDでスマホでもドラッグアンドドロップ](https://qiita.com/hiyamamoto/items/d83cf4ccfcfa886a0ba8)