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 上去。
完成後就可以做到跟 Trello
一樣的卡片拖拉效果!
👩🏫 課務小幫手:
✨ 想掌握 React 觀念和原理嗎?
我們有開設 💠 React 全攻略入門班 課程唷 ❤️️