React DnD tutorial翻訳してもっと理解を!!

はじめに

React DnDは、Reactアプリでドラッグ&ドロップを楽に実装できるライブラリである。

React DnDのOverviewの翻訳を前回記述した。
そこで、Overviewを呼んだだけでは実装イメージがつかめなく、使い方がわからないということを指摘をした。

なので、今回は、公式Tutorialの肝となる部分を翻訳して実装助けになるものがかけたらと思う。Tutorialでは、チェスアプリをつくる。ナイトピースのみドラッグ&ドロップできるようにするものだ(Tutorialなので、実装するのは1ピースのみ)。

Adding the Drag and Drop Interactionというセクションに入るまでは、Reactの説明なのでその翻訳なし。それまでのソースにどうやってReact DnDを注入するのかがポイントなので、本セクション以降を例の通り自分がわかるように翻訳してみた。

ただし、セクションまでに作成されたソースはに記します。

翻訳

Adding the Drag and Drop Interaction

npm install -S react-dnd react-dnd-html5-backend

はじめにセットアップすることは、DragDropContextである。アプリでHTML5 backendを使うことを特定させるのに必要なのである。

Boardはアプリ内の最上位コンポーネントなので、これにDragDropContextを付与する。

Board.jsx

import React, { Component } from 'react';
import { DragDropContext } from 'react-dnd';
import HTML5Backend from 'react-dnd-html5-backend';

@DragDropContext(HTML5Backend) // HTML5Backendを使用することを引数で指定
export default class Board extends Component {
  /* ... */
}

次に、ドラッグできる固定のItemTypeを作成する。今回のアプリではKNIGHTというただ一つのItemTypeをもたせるだけだ。ConstantsモジュールとしてしようできるようにExportさせている。

Constants.jsx

export const ItemTypes = {
  KHNIGHT: 'knight'
};

これで準備は整った。Knightをドラッグさせよう!

HOCであるDragSourceは3つのパラメータを持つ。type,speccollectである。 typeは先程定義したものなので、ドラッグ対象仕様とcollect関数をコーディングする必要がある。Knightのドラッグ対象の仕様はあまりにも簡単である。

Knight.jsx

const knightSource = {
  beginDrag(props) {
    return {};
  }
};

というのは、何も記述することがないからである。全くもって、アプリ内でドラッグできるものはただ一つなのだから。複数のチェスピースを利用するなら、props{ pieceID: props.id }のような返り値を利用するのは良いアイデアであろうが、今回の場合空であっても十分なのである。

次に、collect関数をしあげよう。Knightに必要なpropsは何だろうか? ドラッグ対象ノードを特定する方法が必要なのはわかるだろう。 さらに、ドラッグ中にKnightの透明度がわずかにぼやけるようにするのもよいだろう。だから、ドラッグ状態にあるかかどうかを知ることも必要になる。

そこで、collect関数は以下のようになる。

Knight.jsx

function collect(connect, monitor) {
  return { 
    connectDragSource: connect.dragSource(),
    isDragging: monitor.isDragging() 
  }
}

それではKnightコンポーネント全体をみて、DragSourceが呼ばれていたり、render関数が変更されているのを確認しよう。

Knight.jsx

import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { ItemTypes } from './Constants';
import { DragSource } from 'react-dnd';

const knightSource = {
  beginDrag(props) {
    return {};
  }
};

function collect(connect, monitor) {
  return {
    connectDragSource: connect.dragSource(),
    isDragging: monitor.isDragging()
  }
}

@DragSource(ItemTypes.KNIGHT, knightSource, collect)
export default class Knight extends Component {
  static propTypes = {
    connectDragSource: PropTypes.func.isRequired,
    isDragging: PropTypes.bool.isRequired
  };

  render() {
    const { connectDragSource, isDragging } = this.props;
    return connectDragSource(
      <div style={{
        opacity: isDragging ? 0.5 : 1,
        fontSize: 25,
        fontWeight: 'bold',
        cursor: 'move'
      }}>
        ♘
      </div>
    );
  }
}

これでKnightはドラッグ対象となったが、まだドロップイベントに反応してくれるドロップ先は存在しない。なので、Squareをドロップ先にしよう。

ここで、Squareコンポーネントに位置情報を持たせないようにすることはできない。というのも、Squareコンポーネントは、自分の場所を知らずして、ドラッグされてるナイトをどこに移動すべきかにわかるはずがないからだ。しかしその一方で、Squareコンポーネント自体、アプリ内で変化することはないし、シンプルに使用されてるのだから、なぜ複雑にするのかと歯がゆさも依然として感じてしまう。このジレンマに直面したときは、smart and dumb componentsの考えを利用するときだ。

BoardSquareという新しいコンポーネントを紹介しよう。それは、前のSquareコンポーネントをその通りにrenderするが、位置情報も意識されて作られている。実際、BoardSquareコンポーネントは、Boardコンポーネント内のrenderSquareメソッドで先程行った幾分のロジックを包み込んでいる。

それではBoardSquareコンポーネントを見てみよう。

BoardSquare.jsx

import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Square from './Square';

export default class BoardSquare extends Component {
  static propTypes = {
    x: PropTypes.number.isRequired,
    y: PropTypes.number.isRequired
  };

  render() {
    const { x, y } = this.props;
    const black = (x + y) % 2 === 1;

    return (
      <Square black={black}>
        {this.props.children}
      </Square>
    );
  }
}

続いて、Boardコンポーネントも変更する

Board.jsx

renderSquare(i) {
  const x = i % 8;
  const y = Math.floor(i / 8);
  return (
    <div key={i}
         style={{ width: '12.5%', height: '12.5%' }}>
      <BoardSquare x={x}
                   y={y}>
        {this.renderPiece(x, y)}
      </BoardSquare>
    </div>
  );
}

renderPiece(x, y) {
  const [knightX, knightY] = this.props.knightPosition;
  if (x === knightX && y === knightY) {
    return <Knight />;
  }
}

BoardSquareDropTargetで包もう。dropイベントだけを処理するようにドロップ先の仕様を書こう。

BoardSquare.jsx

const squareTarget = {
  drop(props, monitor) {
    moveKnight(props.x, props.y);
  }
};

わかるだろうか? dropメソッドは、BoardSquarepropsを受け取るので、ドロップされたときにナイトをどこに移動させるかわかるのである。本番のアプリ開発では、monitor.getItem()も利用して、ドラッグ対象のbeginDragメソッドが返すdrag itemを受け取れるが、このアプリではドラッグ対象がひとつだけなので不要である。

collect関数では、ドロップ先のノードと結びつける関数を第1引数に与え、第2引数にもmonitorを渡して、現在カーソルがBoardSquare上にあるかどうかでハイライトさせるのに利用する。

BoardSquare.jsx

function collect(connect, monitor) {
  return {
    connectDropTarget: connect.dropTarget(),
    isOver: monitor.isOver()
  };
}

ドロップ先を結びつけ、カーソルが上に置かれたときにハイライトするようにrender関数を変更すると、BoardSquareは次のようになる。

BoardSquare.jsx

import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Square from './Square';
import { canMoveKnight, moveKnight } from './Game';
import { ItemTypes } from './Constants';
import { DropTarget } from 'react-dnd';

const squareTarget = {
  drop(props) {
    moveKnight(props.x, props.y);
  }
};

function collect(connect, monitor) {
  return {
    connectDropTarget: connect.dropTarget(),
    isOver: monitor.isOver()
  };
}

@DropTarget(ItemTypes.KNIGHT, squareTarget, collect)
export default class BoardSquare extends Component {
  static propTypes = {
    x: PropTypes.number.isRequired,
    y: PropTypes.number.isRequired,
    connectDropTarget: PropTypes.func.isRequired,
    isOver: PropTypes.bool.isRequired
  };

  render() {
    const { x, y, connectDropTarget, isOver } = this.props;
    const black = (x + y) % 2 === 1;

    return connectDropTarget(
      <div style={{
        position: 'relative',
        width: '100%',
        height: '100%'
      }}>
        <Square black={black}>
          {this.props.children}
        </Square>
        {isOver &&
          <div style={{
            position: 'absolute',
            top: 0,
            left: 0,
            height: '100%',
            width: '100%',
            zIndex: 1,
            opacity: 0.5,
            backgroundColor: 'yellow',
          }} />
        }
      </div>
    );
  }
}

これで順調なスタートである! このチュートリアルを完成させるためにはあともう一つ変更がある。移動可能な場所におかれたときにBoardSquareをハイライトさせ、またそのときにのみドロップを処理させたい。

ありがたいことに、React DnDを使えば楽勝である。ドロップ先を特定するオブジェクトにcanDropメソッドを定義するだけである。

BoardSquare.jsx

canDrop(props) {
  return canMoveKnight(props.x, props.y);
}

collect関数に、monitor.canDrop()も追加する。

BoardSquare.jsx

import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Square from './Square';
import { canMoveKnight, moveKnight } from './Game';
import { ItemTypes } from './Constants';
import { DropTarget } from 'react-dnd';

const squareTarget = {
  canDrop(props) {
    return canMoveKnight(props.x, props.y);
  },

  drop(props) {
    moveKnight(props.x, props.y);
  }
};

function collect(connect, monitor) {
  return {
    connectDropTarget: connect.dropTarget(),
    isOver: monitor.isOver(),
    canDrop: monitor.canDrop()
  };
}

@DropTarget(ItemTypes.KNIGHT, squareTarget, collect)
export default class BoardSquare extends Component {
  static propTypes = {
    x: PropTypes.number.isRequired,
    y: PropTypes.number.isRequired,
    connectDropTarget: PropTypes.func.isRequired,
    isOver: PropTypes.bool.isRequired,
    canDrop: PropTypes.bool.isRequired
  };

  renderOverlay(color) {
    return (
      <div style={{
        position: 'absolute',
        top: 0,
        left: 0,
        height: '100%',
        width: '100%',
        zIndex: 1,
        opacity: 0.5,
        backgroundColor: color,
      }} />
    );
  }

  render() {
    const { x, y, connectDropTarget, isOver, canDrop } = this.props;
    const black = (x + y) % 2 === 1;

    return connectDropTarget(
      <div style={{
        position: 'relative',
        width: '100%',
        height: '100%'
      }}>
        <Square black={black}>
          {this.props.children}
        </Square>
        {isOver && !canDrop && this.renderOverlay('red')}
        {!isOver && canDrop && this.renderOverlay('yellow')}
        {isOver && canDrop && this.renderOverlay('green')}
      </div>
    );
  }
}

Final Touches

このチュートリアルで、Reactコンポーネントを作成したり、コンポーネントのアプリのデータ層の仕様を決めたり、さらにドラッグ&ドロップ作用を追加できるようになった。我々がこれらを通じて狙っていることは、React DnDがReact哲学に非常にフィットしていることや、複雑なドラッグ&ドロップ作用の実装に入る前にアプリのアーキテクチャについてまず考えるようになることを示すことである。

最後のデモとして,ドラッグ中のプレビューカスタマイズを紹介しよう。ブラウザは、DOMノードのスクリーンショットをプレビューとして利用するのだが、別のものを表示したい場合どうすればよいのだろうか?

ラッキーなことに、React DnDを使えば簡単である。connect.dragPreview()Knightのcollect関数に追加するだけで十分である。

Knight.jsx

function collect(connect, monitor) {
  return {
    connectDragSource: connect.dragSource(),
    connectDragPreview: connect.dragPreview(),
    isDragging: monitor.isDragging()
  }
}

これでconnectDragSourceと同じように、render関数内でconnectDragPreviewが使えたり、またcomponentDidMount内であってもカスタムイメージを使えるようになる。

Knight.jsx

componentDidMount() {
  const img = new Image();
  img.src = 'data:image/png:base64,xxxxxxxxx';
  img.onload = () => this.props.connectDragPreview(img);
}

それでは、ドラッグ&ドロップを楽しみあれ〜

最後に

Overviewで紹介されたTypeやcollect関数、connector, monitorがどのように実装されていくのかが気づけたらいいなと思う。今後はチェスアプリにピースを追加するやり方や、他のExampleに挑戦しもっと実装方法について理解し説明できたらと思う。

最後まで読んでくれてありがとうございます!

React DnD注入までのソース全体

ディレクトリ構成は次の通り

.
├── dist
│   └── index.html
├── package.json
├── src
│   ├── App.js
│   ├── Board.jsx
│   ├── Game.js
│   ├── Knight.jsx
│   └── Square.jsx
└── webpack.config.js

各ファイルは下記の通り。flowを導入したので、 公式のソースとは若干異なる。

App.js

// @flow
import React from 'react';
import ReactDOM from 'react-dom';
import Board from './Board';
import { observe } from './Game';

const rootEl = document.getElementById('root');

observe(knightPosition =>
  ReactDOM.render(
    <Board knightPosition={knightPosition} />,
    rootEl
  )
);

Board.jsx

// @flow
import * as React from 'react';
import Square from './Square';
import Knight from './Knight';
import { canMoveKnight, moveKnight } from './Game';

type PropsType = {
  knightPosition: Array<number>,
};

export default class Board extends React.Component<PropsType> {
  handleSquareClick(toX: number, toY: number) {
    if (canMoveKnight(toX, toY)) {
      moveKnight(toX, toY);
    }
  }

  renderSquare(i: number): React.Node {
    const { knightPosition } = this.props;
    const x = i % 8;
    const y = Math.floor(i / 8);
    const black = (x + y) % 2 === 1;

    const [knightX, knightY] = knightPosition;
    const piece = (x === knightX && y === knightY) ? <Knight /> : null;

    return (
      <div
        key={i}
        style={{ width: '12.5%', height: '12.5%' }}
        role="presentation"
        onClick={() => this.handleSquareClick(x, y)}
      >
        <Square black={black}>
          {piece}
        </Square>
      </div>
    );
  }

  render(): React.Node {
    const squares = [];
    for (let i = 0; i < 64; i += 1) {
      squares.push(this.renderSquare(i));
    }

    return (
      <div
        style={{
          width: '100%',
          height: '100%',
          display: 'flex',
          flexWrap: 'wrap',
        }}
      >
        {squares}
      </div>
    );
  }
}

Game.jsx

// @flow
let knightPosition = [1, 7];
let observer: any = null;

function emitChange() {
  observer(knightPosition);
}

export function observe(o: (any) => any) {
  if (observer) {
    throw new Error('Multiple observers not implemented.');
  }

  observer = o;
  emitChange();
}

export function moveKnight(toX: number, toY: number) {
  knightPosition = [toX, toY];
  emitChange();
}

export function canMoveKnight(toX: number, toY: number): boolean {
  const [x, y] = knightPosition;
  const dx = toX - x;
  const dy = toY - y;

  return (Math.abs(dx) === 2 && Math.abs(dy) === 1)
         || (Math.abs(dx) === 1 && Math.abs(dy) === 2);
}

Knight.jsx

// @flow
import * as React from 'react';

export default class Knight extends React.Component {
  render(): React.Node {
    return <span>♘</span>;
  }
}

Square.jsx

// @flow
import * as React from 'react';

type PropsType = {
  black: boolean,
  children: ?React.Element<any>,
};

export default class Square extends React.Component<PropsType> {
  render(): React.Node {
    const { black, children } = this.props;
    const fill = black ? 'black' : 'white';
    const stroke = black ? 'white' : 'black';

    return (
      <div
        style={{
          backgroundColor: fill,
          color: stroke,
          width: '100%',
          height: '100%',
        }}
      >
        {children}
      </div>
    );
  }
}

Decorator機能をつかってるのでwebpackの設定も載せておきます。 webpack.config.js

const path = require('path');

const config = {
  mode: 'production',
  entry: './src/app.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'app.js',
  },
  devServer: {
    contentBase: './dist',
    port: 3000,
    inline: true,
  },
  devtool: 'source-map',
  module: {
    rules: [{
      test: /\.jsx?$/,
      exclude: path.resolve(__dirname, 'node_modules'),
      loader: 'babel-loader',
      query: {
        presets: ['react', 'env'],
        plugins: ['transform-class-properties', 'transform-decorators-legacy'],
      },
    },
    {
      test: /\.css$/,
      exclude: path.resolve(__dirname, 'node_modules'),
      loader: ['style-loader', 'css-loader?modules&localIdentName=[name]__[local]___[hash:base64:5]'],
    }],
  },
  resolve: {
    extensions: ['.js', '.jsx'],
  },
};

module.exports = config;

翻訳開始視点にもどる