用 React Drag & Drop 來實作 Trello 拖拉牆

React DnD 使用 HOC 的方式來實作複雜的 Drag and Drop 介面,可以在 Drag Drop Component 之間輕鬆的溝通傳遞資料。

此文章不會詳細列出各項程式碼,如果有興趣可以 clone source code 下來 checkout 研究研究。


1. 先刻出靜態 React Component,並且將每張卡片的資料用 state 管理

class Card extends Component {
  render() {
    const {
      name
    } = this.props

    return (
      <div className='card'>
        { name }
      </div>
    )
  }
}

class CardWall extends Component {
  render() {
    const {
      children,
      status,
    } = this.props

    return (
      <div className='card-wall'>
        <div className='card-wall-wrapper'>
          <p>{ status }</p>
          <div className='card-wall-content'>
            { children }
          </div>
        </div>
      </div>
    )
  }
}

class App extends Component {
  constructor(props) {
    super(props)
    this.state = {
      cards: [
        { id: '1', name: 'issue 1111', status: 'todo' },
        { id: '2', name: 'issue 104', status: 'todo' },
        { id: '3', name: 'issue 9527', status: 'todo' },
        { id: '4', name: 'issue 5278', status: 'todo' },
        { id: '5', name: 'issue 591', status: 'develop' },
        { id: '6', name: 'issue 666', status: 'develop' },
        { id: '7', name: 'issue 9453', status: 'develop' },
        { id: '8', name: 'issue 8591', status: 'deploy' },
        { id: '9', name: 'issue 9999', status: 'deploy' },
      ]
    }
  }

  ... // 省略

  render() {
    const cards = this.groupOfCards()

    return (
      <div className="App">
        <div className="board">
          {
            ['todo', 'develop', 'test', 'deploy'].map(status => (
              <CardWall key={status} status={status}>
                {
                  (cards[status] || []).map(card => (
                    <Card
                      key={card.id}
                      id={card.id}
                      name={card.name}
                      status={card.status}
                    />
                  ))
                }
              </CardWall>
            ))
          }
        </div>
      </div>
    );
  }
}

以上主要是把 <CardWall /> 以迴圈的方式把 <Card /> map 出來,this.groupOfCards() 會把 state.cards 的資料以 status 來做 group 分類,會得到:

{
  todo: [...],
  develop: [...],
  test: [...],
  deploy: [...],
}

完成後長這樣:


2. 準備好更新卡片狀態的 function

class App extends Component {
  constructor(props) {

    ... // 省略

    window.test_update = this.updateCardStatus.bind(this)
  }

  updateCardStatus(cardId, targetStatus) {
    const { cards } = this.state
    const targetIndex = cards.findIndex(c => (cardId === c.id))
    cards[targetIndex].status = targetStatus // 更新 card status

    const targetCard = cards.splice(targetIndex, 1)[0] // 刪除原始陣列位置的 card
    cards.push(targetCard) // 將目標 card 放入陣列最後一筆

    this.setState({ cards })
  }

  ... // 省略
}

然後就可以使用 window.test_update 來測試一下運作是否正確,將 id1 的卡更新狀態為 test


3. <Card /> 套用 React DragSource

依照 官方文件 DragSource<Card /> 改寫成 HOC 套用的狀態。

import React from 'react'
import PropTypes from 'prop-types'
import { DragSource } from 'react-dnd'

const dragSource = {
  beginDrag(props) {
    // 會將所有 <Card /> 的 props 帶到 onDrop Component 
    return {
      ...props,
    }
  }
}

// collect function 回傳的 object
// 將會由 HOC 的方式以 props 帶入至 <Card /> 裡面
function dragCollect(connect, monitor) {
  return {
    connectDragSource: connect.dragSource(),
    connectDragPreview: connect.dragPreview(),
    isDragging: monitor.isDragging()
  }
}

class Card extends React.Component {
  render() {
    const {
      name,
      isDragging, // Injected by React DnD
      connectDragPreview, // Injected by React DnD
      connectDragSource, // Injected by React DnD
    } = this.props


    return connectDragSource(
      <div className='card'>
        { name }
      </div>
    )
  }
}

// export component 的時候用 DragSource 以 HOC 的方式
// 將 Card 帶入,DragSource 第一個參數為 item type (string),
// 會用來判斷 DropTarget 的 item type 是否為一樣,
// 一樣才會觸發 Drag & Drop。
export default DragSource('CONNECT_CARD', dragSource, dragCollect)(Card)

4. <CardWall /> 套用 React DropTarget

依照 官方文件 DropTarget<Card /> 改寫成 HOC 套用的狀態。

import React from 'react'
import PropTypes from 'prop-types'
import { findDOMNode } from 'react-dom'
import { DropTarget } from 'react-dnd'

const dropTarget = {
  ..., // code 省略

  drop(props, monitor, component) {
    ... // code 省略

    // 此處已經可以得到 props(CardWall) 與 item (Card props)
    const item = monitor.getItem()    
    console.log('dropCard:', item) // card props
    console.log('dropWall', props) // card wall props
    const { id } = item
    const { updateCardStatus, status: targetStatus } = props

    // 更新 Card status
    updateCardStatus(id, targetStatus)

    return { moved: true }
  },
}

// collect function 回傳的 object
// 將會由 HOC 的方式以 props 帶入至 <CardWall /> 裡面
const dropCollect = (connect, monitor) => ({
  connectDropTarget: connect.dropTarget(),
  isOver: monitor.isOver(),
  isOverCurrent: monitor.isOver({ shallow: true }),
  canDrop: monitor.canDrop(),
  itemType: monitor.getItemType(),
})

class CardWall extends React.Component {
  render() {
    const {
      children,
      status,
      isOver, // Injected by React DnD
      canDrop, // Injected by React DnD
      connectDropTarget, // Injected by React DnD
    } = this.props

    return connectDropTarget(
      <div className='card-wall'>
        <div className='card-wall-wrapper'>
          <p>{ status }</p>
          <div className='card-wall-content'>
            { children }
          </div>
        </div>
      </div>
    )
  }
}

// DropTarget 第一個參數為 item type(string),
// 必須要跟 DragSource 的 item type 設為一樣才可以觸發 Drag & Drop
export default DropTarget('CONNECT_CARD', dropTarget, dropCollect)(CardWall)

5. 最後再把 DragDropContext 套用在 <App />

官方文件 DragDropContext

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

class App extends Component {
    /* ... */
}

export default DragDropContext(HTML5Backend)(App)

這樣就完成簡易的拖拉功能


6. 處理 <Card /> onDrag 效果

class Card extends React.Component {
  render() {
    const {
      name,
      isDragging, // Injected by React DnD
      connectDragPreview, // Injected by React DnD
      connectDragSource, // Injected by React DnD
    } = this.props


    return connectDragSource(
      <div
        className='card'
        style={{
          // React DnD 提供的 isDragging 用來達到 onDrag 效果
          opacity: isDragging ? 0.6 : 1,
          cursor: isDragging ? 'grabbing' : 'pointer',
        }}
      >
        { name }
      </div>
    )
  }
}

7. 處理 <CardWall /> onDrop 效果

要達到 onDrop 時,卡片插入的效果,必須先準備用來 render 的空白 <Card />,並且只能在不同的狀態牆上觸發。

加入 empty props 到 <Card />

  class Card extends React.Component {
  render() {
    const {
      name,
      empty,
      isDragging, // Injected by React DnD
      connectDragPreview, // Injected by React DnD
      connectDragSource, // Injected by React DnD
    } = this.props


    return connectDragSource(
      <div
        // 加入 empty props, 來讓卡片有 empty class name
        className={`card ${ empty ? 'empty-card' : '' }`}
        style={{
          opacity: isDragging ? 0.6 : 1,
          cursor: isDragging ? 'grabbing' : 'pointer',
        }}
      >
        <span>{ name }</span>
      </div>
    )
  }
}

css:

.card.empty-card { background-color: #c9c9c9; }
.card.empty-card > * { display: none; }

<CardWall />

import Card from '../Card'

const dropTarget = {
  canDrop(props, monitor) {
    // You can disallow drop based on props or item
    const item = monitor.getItem()
    const { status: wallStatus } = props // 卡片牆
    const { status: cardStatus } = item // 卡片

    // 卡片跟卡片牆為不同時,才會回傳 canDrop: true
    return wallStatus !== cardStatus
  },

  ... // 省略
}

class CardWall extends React.Component {

  ... // 省略

  render() {
    const {
      children,
      status,
      isOver, // Injected by React DnD
      canDrop, // Injected by React DnD
      connectDropTarget, // Injected by React DnD
    } = this.props

    return connectDropTarget(
      <div className='card-wall'>
        <div className='card-wall-wrapper'>
          <p>{ status }</p>
          <div className='card-wall-content'>
            { children }

            // 符合 isOver 和 canDrop 狀態才可以插入 empty <Card />
            { isOver && canDrop && <Card empty /> }

          </div>
        </div>
      </div>
    )
  }
}

完成後就有插入卡片的感覺了


8. 處理 <Card /> onDrag 旋轉效果

要達到卡片 onDrag 時的 transform: rotate 效果,但是 React DnD 預設是使用 HTML5 backend 來替 onDrag 物件做 snapshot, 所以並不能直接操作 onDrag 中的物件,而且還會有這種畫面被切斷的 bug:

不過 React DnD 提供了另一種方式 DragLayer 同樣可以達到這個效果,而且還不會有畫面被切斷的問題。

<Card /> 中加入以下程式碼,讓 onDrag snapshopt 為一個空的圖片:

import { getEmptyImage } from 'react-dnd-html5-backend'

class Card extends React.Component {
  componentDidMount() {
    const { connectDragPreview } = this.props
    if (connectDragPreview) {
      // Use empty image as a drag preview so browsers don't draw it
      // and we can draw whatever we want on the custom drag layer instead.
      connectDragPreview(getEmptyImage(), {
        // IE fallback: specify that we'd rather screenshot the node
        // when it already knows it's being dragged so we can hide it with CSS.
        captureDraggingState: true,
      })
    }
  }

  ... // 省略
}

依照官方文件實作出一個 <CardLayer /> ,該 component 為一個 position: fixed; 滿寬滿長的畫布, 並且會追蹤你的滑鼠座標來動態改變裡面的替代 component 的座標。

import React from 'react'
import { DragLayer } from 'react-dnd'

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

const snapToGrid = (x, y) => {
  ... // 省略
}

const getItemStyles = (props) => {
  const { initialOffset, currentOffset } = props
  if (!initialOffset || !currentOffset) {
    return {
      display: 'none',
    }
  }

  let { x, y } = currentOffset

  if (props.snapToGrid) {
    x -= initialOffset.x
    y -= initialOffset.y
    [x, y] = snapToGrid(x, y)
    x += initialOffset.x
    y += initialOffset.y
  }

  // 加入想要的 transform rotate 效果
  const transform = `translate(${x}px, ${y}px) rotate(5deg)`
  return {
    transform,
    WebkitTransform: transform,
  }
}

const LayerCollect = monitor => ({
  ... // 省略
})

const CardLayer = (props) => {
  const { item, itemType, isDragging } = props
  if (!isDragging) {
    return null
  }

  return (
    <div className='card-layer' style={layerStyles}>
      // 你的替代 onDrag component,
      // style 將會帶入動態的座標位置(使用 transform)
      <div className='card' style={getItemStyles(props)}>{ item.name }</div>
    </div>
  )
}

export default DragLayer(LayerCollect)(CardLayer)

<CardLayer /> 放到 <App /> 裡面:


import CardLayer from './CardLayer'

class App extends Component {

  ... // 省略

  render() {
    const cards = this.groupOfCards()

    return (
      <div className="App">
        <div className="board">
          <CardLayer />

          ... // 省略

        </div>
      </div>
    );
  }
}

export default DragDropContext(HTML5Backend)(App)

將原本的 html5 onDrag snapshot 藏起來,改用可視範圍內的畫布來動態追蹤滑鼠的座標並將替代 component mount 上去。

完成後就可以做到跟 Trello 一樣的卡片拖拉效果!