1. 설계 이념
React DnD gives you a set of powerful primitives, but it does not contain any readymade components. It's lower level than jQuery UI or interact.js and is focused on getting the drag and drop interaction right, leaving its visual aspects such as axis constraints or snapping to you. For example, React DnD doesn't plan to provide a Sortable component. Instead it makes it easy for you to build your own, with any rendering customizations that you need.
(출처: Non-Goals)
요약하자면, DnD 기능을 몇 가지 기초 인터페이스(잡을 수 있는 물건, 즉 '무'; 물건을 놓을 수 있는 컨테이너, 즉 '구멍')로 분해하고, DnD의 내부 상태를 이러한 인터페이스를 구현한 인스턴스에 노출합니다. 일반적인 비즈니스 시나리오에 대응하기 위해 수많은 Draggable Component를 제공하는 다른 라이브러리들과 달리, React DnD는 상대적으로 저수준(low-level) 관점에서 지원을 제공합니다. 이는 드래그 앤 드롭 능력에 대한 추상화 및 캡슐화로, 추상화를 통해 사용을 단순화하고 캡슐화를 통해 하위 계층의 차이를 은폐합니다.
2. 용어 및 개념
Backend
HTML5 DnD API는 호환성이 좋지 않고 모바일 환경에 적합하지 않습니다. 그래서 아예 DnD 관련 구체적인 DOM 이벤트를 분리하여 별도의 레이어로 만들었는데, 이것이 바로 Backend입니다.
Under the hood, all the backends do is translate the DOM events into the internal Redux actions that React DnD can process.
Item과 Type
Item은 요소/컴포넌트에 대한 추상적인 이해입니다. 드래그 앤 드롭의 대상은 DOM 요소나 React 컴포넌트가 아니라 특정 데이터 모델(Item)입니다.
An item is a plain JavaScript object describing what's being dragged.
이러한 추상화 역시 디커플링(Decoupling)을 위한 것입니다.
Describing the dragged data as a plain object helps you keep the components decoupled and unaware of each other.
Type과 Item의 관계는 Class와 Class Instance의 관계와 유사합니다. Type은 동일한 부류의 Item을 나타내는 유형 식별자로 사용됩니다.
A type is a string (or a symbol) uniquely identifying a whole class of items in your application.
Type은 무(drag source)와 구멍(drop target)의 매칭 근거가 되며, 기존 DnD 라이브러리의 group name과 같은 역할을 합니다.
Monitor
Monitor는 드래그 앤 드롭 상태의 집합입니다. 예를 들어 드래그 앤 드롭 작업이 진행 중인지, 진행 중이라면 무는 무엇이고 구멍은 무엇인지 등의 상태입니다.
React DnD exposes this state to your components via a few tiny wrappers over the internal state storage called the monitors.
예를 들어:
monitor.isDragging()
monitor.isOver()
monitor.canDrop()
monitor.getItem()
props 주입 방식을 통해 DnD 내부 상태를 노출하는데, 이는 Redux의 mapStateToProps와 유사합니다.
export default DragSource(Types.CARD, cardSource, (connector, monitor) => ({
// You can ask the monitor about the current drag state:
isDragging: monitor.isDragging()
}))(Card);
P.S. 사실 React DnD는 Redux를 기반으로 구현되었습니다. 아래 핵심 구현 섹션을 참조하세요.
Connector
Connector는 DOM 추상화(React)와 DnD Backend가 필요로 하는 구체적인 DOM 요소 사이의 연결을 구축하는 데 사용됩니다.
The connectors let you assign one of the predefined roles (a drag source, a drag preview, or a drop target) to the DOM nodes
사용법이 매우 흥미롭습니다.
render() {
const { highlighted, hovered, connectDropTarget } = this.props;
// 1. DnD Role에 대응하는 DOM 요소 선언
return connectDropTarget(
<div className={classSet({
'Cell': true,
'Cell--highlighted': highlighted,
'Cell--hovered': hovered
})}>
{this.props.children}
</div>
);
}
// 2. connector에서 connect 메서드를 가져와 props에 주입
export default DragSource(Types.CARD, cardSource, (connector, monitor) => ({
// Call this function inside render()
// to let React DnD handle the drag events:
connectDropTarget: connector.dropTarget()
}))(Card);
연결을 구축하는 부분인 connectDropTarget(<div/>)은 상당히 우아해 보입니다. 실제 역할은 대략 다음과 같을 것으로 추측됩니다.
render() {
const { connectToRole } = this.props;
return <div ref={(node) => connectToRole(node)}></div>
}
맞습니다.
Internally it works by attaching a callback ref to the React element you gave it.
Drag Source와 Drop Target
앞서 언급한 이 두 가지를 DnD Role이라고 부를 수 있으며, DnD에서 맡은 역할을 나타냅니다. drag source와 drop target 외에도 drag preview라는 것이 있는데, 이는 보통 다른 상태의 drag source로 간주할 수 있습니다.
DnD Role은 React DnD의 기본 추상화 단위입니다.
They really tie the types, the items, the side effects, and the collecting functions together with your components.
이는 해당 역할과 관련된 설명 및 동작의 집합으로, Type, DnD Event Handler(예: drop target은 보통 hover, drop 등의 이벤트를 처리해야 함) 등을 포함합니다.
3. 핵심 구현
./packages
├── dnd-core
├── react-dnd
├── react-dnd-html5-backend
└── react-dnd-test-backend
대응하는 논리 구조는 다음과 같습니다.
API (React 연결)
react-dnd: Context 정의, Provider, Container factory 등 상위 API 제공
-------
Core 추상화 (interface 정의)
dnd-core: Action, Reducer 정의, 상하위 계층 연결
-------
Backends (native 연결, DnD 기능 캡슐화 및 interface 구현)
react-dnd-xxx-backend: 구체적인 환경 연결, Dispatch Action을 통해 native DnD 상태를 상위로 전달
Redux 기반의 로직 분해로 볼 수 있습니다. 중간 계층인 Core가 DnD 상태를 보유하고, 하위 계층인 Backends가 약속된 interface 구현을 담당하여 Core의 데이터 소스 역할을 하며, 상위 API가 Core에서 상태를 가져와 비즈니스 계층으로 전달합니다.
4. 기본 사용법
1. DragDropContext 지정
App 루트 컴포넌트에 DragDropContext를 선언합니다. 예를 들어:
import { DragDropContext } from 'react-dnd';
import HTML5Backend from 'react-dnd-html5-backend';
class App extends Component {}
export default DragDropContext(HTML5Backend)(App);
2. DragSource 추가
DragSource 고차 컴포넌트(HOC)는 3개의 인자(type, spec, collect())를 받습니다. 예를 들어:
export const ItemTypes = {
KNIGHT: 'knight'
};
const knightSpec = {
beginDrag(props) {
// Item 구조 정의, monitor.getItem()으로 읽기
return {
pieceId: props.id
};
}
};
function collect(connector, monitor) {
return {
connectDragSource: connector.dragSource(),
isDragging: monitor.isDragging()
}
}
마지막으로 Component/Container와 연결합니다 (Redux의 connect()와 유사함).
export default DragSource(ItemTypes.KNIGHT, knightSpec, collect)(Knight);
컴포넌트는 주입된 DnD 상태를 가져와 대응하는 UI를 렌더링합니다. 예를 들어:
render() {
const { connectDragSource, isDragging } = this.props;
return connectDragSource(
<div style={{
opacity: isDragging ? 0.5 : 1,
cursor: 'move'
}} />
);
}
끌려가는 효과(드래그 대상이 반투명해짐)가 자연스럽게 구현되었습니다. 복잡한 DnD 처리 로직은 보이지 않으며, 이들은 모두 React DnD Backend에 캡슐화되어 비즈니스에 필요한 DnD 상태만 노출됩니다.
3. DropTarget 추가
마찬가지로 3개의 인자(type, spec, collect())가 필요합니다.
const dropSpec = {
canDrop(props) {
return canMoveKnight(props.x, props.y);
},
drop(props, monitor) {
const { id } = monitor.getItem();
moveKnight(id, props.x, props.y);
}
};
function collect(connector, monitor) {
return {
connectDropTarget: connector.dropTarget(),
isOver: monitor.isOver(),
canDrop: monitor.canDrop()
};
}
마지막으로 연결합니다.
export default DropTarget(ItemTypes.KNIGHT, dropSpec, collect)(BoardSquare);
컴포넌트는 주입된 DnD 상태를 가져와 대응하는 UI를 보여줍니다. 예를 들어:
render() {
const { connectDropTarget, isOver, canDrop } = this.props;
return connectDropTarget(
<div>
{isOver && !canDrop && this.renderOverlay('red')}
{!isOver && canDrop && this.renderOverlay('yellow')}
{isOver && canDrop && this.renderOverlay('green')}
</div>
);
}
드래그 작업의 유효성에 따라 구멍의 색상이 변하는 효과도 구현되었습니다. 이 역시 매우 자연스러워 보입니다.
4. DragPreview 커스텀
브라우저 DnD는 기본적으로 드래그되는 요소를 기반으로 drag preview(보통 반투명한 스크린샷 형태)를 생성합니다. 이를 커스텀하고 싶다면 DragSource 생성 방식과 유사하게 처리합니다.
function collect(connector, monitor) {
return {
connectDragSource: connector.dragSource(),
connectDragPreview: connector.dragPreview()
}
}
주입된 connectDragPreview()를 통해 DragPreview를 커스텀합니다. 인터페이스 시그니처는 connectDragSource()와 동일하게 dragPreview() => (elementOrNode, options?)입니다. 예를 들어 자주 쓰이는 드래그 핸들(handle) 효과는 다음과 같이 구현할 수 있습니다.
render() {
const { connectDragSource, connectDragPreview } = this.props;
return connectDragPreview(
<div
style={{
position: 'relative',
width: 100,
height: 100,
backgroundColor: '#eee'
}}
>
Card Content
{connectDragSource(
<div
style={{
position: 'absolute',
top: 0,
left: '100%'
}}
>
<HANDLE>
</div>
)}
</div>
);
}
또한 Image 객체를 DragPreview로 사용할 수도 있습니다 (IE 미지원).
componentDidMount() {
const img = new Image();
img.src = 'http://mysite.com/image.jpg';
img.onload = () => this.props.connectDragPreview(img);
}
5. 온라인 데모
Github 저장소: ayqy/example-react-dnd-nested
온라인 데모: https://ayqy.github.io/dnd/demo/react-dnd/index.html
참고 자료
-
React DnD: Redux 문서처럼 한 번 읽으면 멈출 수 없는 아름다운 문서입니다.
아직 댓글이 없습니다