あいつの日誌β

人生はお酒があれば何とかなります。 drunkard.tokyo

react-dnd の使い方

あらすじ

react-dnd を使いました。いつものように、きっと思い出せなくなる自分の為にTutorial を書きました。

つくるもの

  • 9マスの BOX を左から右へ向かってコマを進める。
  • コマは最大2マス進めるが、後ろには戻れない。
  • コマを選択する(isDragging)と、コマの右隣かもう一つ隣のマスが光る
  • コマを移動可能なマスへ移動させる(drop)と、コマの移動が完了する。
  • 最終的に一番右のマスへ移動すれば終了となる

完成したものはこちら: https://okamuuu.github.io/tutorial-react-dnd/

ソースコード: GitHub - okamuuu/tutorial-react-dnd

大まかな流れ

src/components の配下に Stateless Component である Board, Box, Piece を配備します。

次に src/Game にそれらを配備して盤上のマスの上にあるコマを移動させる処理を用意します。この時、move, canMove といった関数を用意します。state 管理には react-easy-state を使用します。理由は後述します。

この後 Board, Box, Piece を Draggable で Droppable な Component に変換する為に react-dnd で wrap して src/Game.js に再配備します。

この wrap されたコンポーネントが Drag and Drop が行われる際にイベントを発火しているのですが、それぞれのイベントがコンポーネント毎に発火しています。Game に state を持たせてしまうと、このイベントが発火するポイントまで state を伝播させるのが大変です。とはいえ redux を導入するほどの規模ではないので react-eazy-state を使っています。

準備

create-react-app tutorial-react-dnd && cd $_
mkdir src/components
touch src/components/{Board.js,Box.js,Piece.js}
touch src/Game.js
yarn add styled-components react-dnd react-dnd-html5-backend react-easy-state lodash --save

Stateless な components を用意する

とりあえず src/components/{Board.js,Box.js,Piece.js} を用意します。

create src/components/Board.js

import styled from "styled-components";

const baseColor = "#666";

export default styled.div`
  margin: 0 auto;
  width: 450px;
  height: 50px;
  color: #fff;
  border-bottom: 1px solid ${baseColor};
  border-left: 1px solid ${baseColor};
  display: grid;
  grid-template: repeat(1, 1fr) / repeat(9, 1fr)
`

create src/components/Box.js

import styled from "styled-components";

const borderColor = "#666";

export default styled.div`
  background-color: #eee;
  border-top: 1px solid ${borderColor};
  border-right: 1px solid ${borderColor};
  font-family: Helvetica;
  font-weight: bold;
  font-size: 1em;
  display: flex;
  justify-content: center;
  align-items: center;
`

create src/components/Piece.js

import styled from "styled-components";

export default styled.div`
  cursor: pointer;
  padding: 20px;
  background-color: #222;
`

edit src/App.js

import React, { Component } from 'react';
import Board from './components/Board';
import Box from './components/Box';
import Piece from './components/Piece';

class App extends Component {
  render() {
    return (
      <div style={{padding: "15px"}}>
        <Board>
          <Box><Piece /></Box>
          <Box />
          <Box />
          <Box />
          <Box />
          <Box />
          <Box />
          <Box />
          <Box />
        </Board>
      </div>
    );  
  }
}

export default App;

動作確認。このような画面になっていると思います。

f:id:okamuuu:20180523231819p:plain

Business Logic を追加

Drag And Drop を実装する前に、ひとまずクリックした箇所にコマを移動させるようにします。

create src/Game.js

import React, { Component } from 'react';
import { store, view } from 'react-easy-state';
import _ from 'lodash';

import Board from './components/Board';
import Box from './components/Box';
import Piece from './components/Piece';

const state = store({ position: 0 });

function move(position) {
  if (canMove(position)) {
    state.position = position;
  }
}

function canMove(position) {
  return position > state.position && position - state.position <= 2;
}

class Game extends Component {

  render() {
    const boxes = _.times(9, n => {
      const piece = state.position === n ? <Piece /> : null;
      return (<Box onClick={() => move(n)} key={n} position={n}>{piece}</Box>)
    });

    return (
      <Board>
        {boxes}
      </Board>
    );
  }
}

export default view(Game);

edit src/App.js

import React, { Component } from 'react';
import Game from './Game';

class App extends Component {
  render() {
    return (
      <div style={{padding: "15px"}}>
        <Game />
      </div>
    );  
  }
}

export default App;

クリックするとコマが移動します。右側にしか移動できないので右端まで進んだらリロードしてください。

コンポーネントをドラッグする

というわけでやっと本題です。大まかに説明すると以下のような作業が発生します。

  • Board に DragDropContext を適用する
  • Piece を Droaggable な component にする
  • Box に Draggable な component を drop したらイベントを発火させるようにする

意外と記述する内容が多いので、まずは コマをドラッグする だけをやってみます。

diff --git a/src/Game.js b/src/Game.js
index 97efcd5..42726ef 100644
--- a/src/Game.js
+++ b/src/Game.js
@@ -6,6 +6,9 @@ import Board from './components/Board';
 import Box from './components/Box';
 import Piece from './components/Piece';
 
+import { DragDropContext, DragSource } from 'react-dnd';
+import HTML5Backend from 'react-dnd-html5-backend';
+
 const state = store({ position: 0 });
 
 function move(position) {
@@ -18,18 +21,44 @@ function canMove(position) {
   return position > state.position && position - state.position <= 2;
 }
 
+const Types = {
+  PIECE: 'piece'
+};
+
+// Board
+const DndBoard = DragDropContext(HTML5Backend)(Board);
+
+// Board
+
+// Piece
+const dragSource = {
+  beginDrag(props) {
+    return {}
+  },
+  endDrag(props) {
+    return {}
+  }
+};
+
+const ConnectedSource = props => {
+  const {connectDragSource} = props;
+  return (<Piece {...props} innerRef={instance=>connectDragSource(instance)}></Piece>)
+}
+
+const DndPiece = DragSource(Types.PIECE, dragSource, connect => ({
+  connectDragSource: connect.dragSource(),
+}))(ConnectedSource);
+
+
 class Game extends Component {
 
   render() {
     const boxes = _.times(9, n => {
-      const piece = state.position === n ? <Piece /> : null;
+      const piece = state.position === n ? <DndPiece /> : null;
       return (<Box onClick={() => move(n)} key={n} position={n}>{piece}</Box>)
     });
 
     return (
-      <Board>
+      <DndBoard>
         {boxes}
-      </Board>
+      </DndBoard>
     );
   }
 }

ひとまず Drag だけできるようになっていると思います。ドラッグ開始と終了のイベントは拾えますが、Drop したときのイベントはまだ拾えません。

さて、以下の箇所を補足しておきます。これはエラーを回避する為にこのような書き方をしています。ポイントは connectDragSource 関数を実行する場所を変えている事と children を含めた Props を展開して渡しているところです。

const ConnectedSource = props => {
  const {connectDragSource} = props;
  return (<Piece {...props} innerRef={instance=>connectDragSource(instance)}></Piece>)
}

これを return connectDragSource(<Piece />) と書くと次のようなエラーが出ます。

Error: Only native element nodes can now be passed to React DnD connectors.You can either wrap styled.div into a <div>, or turn it into a drag source or a drop target itself.

https://github.com/react-dnd/react-dnd/issues/1021

ドラッグしたコンポーネントをドロップする

少し駆け足になりますが src/Game.js を以下のように修正します。

diff --git a/src/Game.js b/src/Game.js
index aea4133..475b8d7 100644
--- a/src/Game.js
+++ b/src/Game.js
@@ -6,7 +6,7 @@ import Board from './components/Board';
 import Box from './components/Box';
 import Piece from './components/Piece';
 
-import { DragDropContext, DragSource } from 'react-dnd';
+import { DragDropContext, DragSource, DropTarget } from 'react-dnd';
 import HTML5Backend from 'react-dnd-html5-backend';
 
 const state = store({ position: 0 });
@@ -28,7 +28,51 @@ const Types = {
 // Board
 const DndBoard = DragDropContext(HTML5Backend)(Board);
 
-// Board
+// Box
+const dropTarget = {
+  drop(props, monitor) {
+    const { position } = props;
+    state.position = position;
+    return {};
+  },
+  canDrop(props, monitor) {
+    return canMove(props.position);
+  }
+};
+
+const ConnectedTarget = props => {
+  const {canDrop, children, connectDropTarget} = props;
+  const renderOverlay = (color) => {
+    return (
+      <div style={{
+        position: 'absolute',
+        top: 0,
+        left: 0,
+        height: '100%',
+        width: '100%',
+        zIndex: 1,
+        opacity: 0.5,
+        backgroundColor: color,
+      }} />
+    );
+  }
+  return (
+    <Box {...props} innerRef={instance=>connectDropTarget(instance)}>
+      {children}
+      { !children && canDrop &&
+        <div style={{position: 'relative', width: '100%', height: '100%'}}>
+          {renderOverlay('yellow')}
+        </div>
+      }
+    </Box>
+  )
+}
+
+const DndBox = DropTarget(Types.PIECE, dropTarget, (connect, monitor) => {
+  return {
+    connectDropTarget: connect.dropTarget(),
+    canDrop: monitor.canDrop(),
+}})(ConnectedTarget);
 
 // Piece
 const dragSource = {
@@ -56,7 +100,7 @@ class Game extends Component {
   render() {
     const boxes = _.times(9, n => {
       const piece = state.position === n ? <DndPiece /> : null;
-      return (<Box onClick={() => move(n)} key={n} position={n}>{piece}</Box>)
+      return (<DndBox onClick={() => move(n)} key={n} position={n}>{piece}</DndBox>)
     });
 
     return (

以上です。もう少し良い書き方だったり変数名があったりすると思いますが、そのへんは各自で工夫してください。

まとめ

react-dnd はイベントがあちこちで発火して、戻り値のハンドリングが意外と大変でしたが react-dnd 関連の処理を一つの component(この場合は stateful になって良い)にまとめてしまって state の処理を react-easy-state に任せるとそこまでごちゃごちゃしなくて良いと思いました。おしまい。