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
,spec
とcollect
である。
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 />; } }
BoardSquare
をDropTarget
で包もう。drop
イベントだけを処理するようにドロップ先の仕様を書こう。
BoardSquare.jsx
const squareTarget = { drop(props, monitor) { moveKnight(props.x, props.y); } };
わかるだろうか? drop
メソッドは、BoardSquare
のprops
を受け取るので、ドロップされたときにナイトをどこに移動させるかわかるのである。本番のアプリ開発では、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;