Edited at

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

こんにちは、ミツバです。

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

react-dnd

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

mitubaEX/react-dnd-example


動作イメージ


テスト環境

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


  • 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を用いてサクッと作成します。

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を変更する

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を作成する

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);

いろいろ概念が登場していますが、CustomDragLayerDragItemをrenderしています。

そして最後にDragDropContextに分岐を書いてモバイルの場合とそうでない場合のBackendを切り替えています。

このBackendは、今回HTML5とTouchの2種類ありデバイスによって、Backendを変更することでそのデバイスに対応します。


DragItem, CustomDragLayerを作成する

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

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を導入し、ドラッグの軌跡をそいつに描画してもらいます。


動作イメージ

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

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

componentDidMount() {

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


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

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

それを紹介します。

DragItemを以下のように変更します。

今回はdropをhoverに変更し、そこに処理を書いていきました。

他にもいろいろAPIが提供されているので調べてみてください。


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として渡します。


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


動作イメージ


まとめ

react-dndについて調べることがあったので、少し記事にしてみました。

コードペタペタ貼っただけなので、各自雰囲気で理解してください。

ドラッグ・アンド・ドロップ周りでこれがいいよとか他にあったら教えてください。


参考

react-dnd これだけ抑えておけばおk

React DnDでスマホでもドラッグアンドドロップ