用 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
來測試一下運作是否正確,將 id
為 1
的卡更新狀態為 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 />
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 上去。