В этой статье рассмотрим создание drag-and-drop элементов на React с помощью библиотеки react-beautiful-dnd от Atlassian. Статья рассчитана на людей, знакомых с React.
Вы научитесь создавать drag-and-drop элементы на React и сможете создать игру, наподобие такой:
Основные концепции
DragDropContext: место (поле), где фактически осуществляется drag-and-drop. Этот компонент вызывает onDragEnd
после того, как перетаскиваемый объект был отпущен. Также можно определить onDragStart
и onDragUpdate
для вызова после начала перетаскивания и по какому-нибудь событию во время перетаскивания соответственно.
Droppable: компонент, откуда и куда перетаскивается элемент. Этому компоненту нужны несколько свойств, которые будут описаны далее.
Draggable: элемент, который будет перемещаться. Как и droppable, ему нужны некоторые свойства, чтобы сделать компонент перемещаемым.
Создание игры
Определим начальные настройки, которые нужны для создания игры:
const initialData = {
column: {
id: 'column-1',
numberIds: ['four', 'one', 'five', 'three', 'two'],
},
numbers: {
'five': { id: 'five', content: '5' },
'four': { id: 'four', content: '4' },
'one': { id: 'one', content: '1' },
'three': { id: 'three', content: '3' },
'two': { id: 'two', content: '2' },
}
};
export default initialData;
Теперь можно перейти к созданию первого компонента. Он будет содержать только один метод render
. Собственно, вот он:
class NumbersGame extends React.Component<any, INumbersGameState>{
public constructor(props: any) {
super(props);
this.onDragEnd = this.onDragEnd.bind(this);
this.state = {...initialData};
}
public onDragEnd(result: any) {
// Элемент отпущен!
}
public render() {
const numbers = this.state.column.numberIds.map((numberId: string) => this.state.numbers[numberId]);
return (
<NumbersGameContext onDragEnd={this.onDragEnd}>
<VerticalColumn column={this.state.column} items={numbers} />
</NumbersGameContext>
)
}
}
NumbersGameContext
: это просто обёртка для DragDropContext
.
VerticalColumn
: это столбец, содержащий перетаскиваемые элементы.
export default (props: IVerticalColumnProps) =>
<DroppableWrapper droppableId={props.column.id} className="source">
<DraggableListItems items={props.items} />
</DroppableWrapper>
Droppable
DroppableWrapper
— это компонент, который реализует droppable-контейнер. У него есть необходимые свойства, которые определяются так:
export default (props: any) =>
<Droppable droppableId={props.droppableId}>
{(provided: any) => (
<div className={props.className}
ref={provided.innerRef}
{...provided.droppableProps}
{...provided.droppablePlaceholder}>
{props.children}
</div>
)}
</Droppable>
Здесь нужен droppableId
, который должен быть уникальным в рамках DragDropContext
. Он также ожидает функцию в качестве дочернего элемента и использует паттерн render props, который позволяет избежать внешнего вмешательства в DOM.
Первый аргумент этой функции — provided
. У этого аргумента есть droppableProps
и они важны для определения компонента как droppable. Здесь можно применить некоторые или все из возможных свойств с помощью spread-синтаксиса. Для более подробной информации можно посмотреть документацию.
Второе свойство — это innerRef
, функция, которая передаёт необходимый DOM-элемент библиотеке.
Последнее свойство — placeholder
. Этот элемент при необходимости используется для увеличения пространства в droppable-области во время перетаскивания. Placeholder
нужно сделать дочерним элементом droppable-компонента. На этом этапе настройка компонента закончена.
Draggable
Теперь можно перейти к написанию DraggableListItems
. Этот компонент создаёт NumberBox
(перетаскиваемые объекты).
export default (props: IDraggableListItems) =>
<div> {props.items.map(toNumberBox)} </div>
function toNumberBox(item: INumberItemProps, position: number) {
return <NumberBox key={item.id}
className="box"
itemPosition={position}
value={item.id}
content={item.content} />
}
NumberBox
определяет обёртку для draggable:
export default (props: IDraggableItem) => {
const className = `dnd-number size-${props.value}`;
return (
<DraggableItemWrapper draggableId={props.value}
index={props.itemPosition}
className={className}>
<div>{props.content}</div>
</DraggableItemWrapper>
)
}
DraggableItemWrapper
реализует draggable, потому что как и в случае droppable у него есть нужные свойства.
export default (props: any) =>
<Draggable draggableId={props.draggableId} index={props.index}>
{(provided: any) => (
<div className={props.className}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}>
{props.children}
</div>
)}
</Draggable>
В этом коде реализуется уже упомянутый выше паттерн render props. В данном случае у draggable есть два свойства: draggableId
и index
. DraggableId
должен быть уникален в рамках DragDropContext
. Он также принимает функцию как дочерний элемент. Первый аргумент функции — provided
(как и в droppable). Второй аргумент — dragHandleProps
. Это свойство определяет компонент как draggable.
Теперь вы можете использовать DraggableItemWrapper
и забыть про низкоуровневые свойства.
onDragEnd
Вот что уже реализовано:
NumberGameContext
: реализацияDragDropContext
.DroppableWrapper
: реализация droppable.DraggableItemWrapper
: реализация draggable.
А вот те компоненты, которые являются частью игры:
VerticalColumn
: компонент, который инкапсулирует droppable-столбцы и draggable-элементы.DraggableListItems
: компонент, инкапсулирующий все NumberBox-элементы.NumberBox
: собственно, переносимый элемент.
Но пока onDragEnd
остаётся пустым. Исправим это.
Первым делом пройдёмся по аргументам метода:
result: {
destination: {
droppableId: "column-1"
index: 2
}
draggableId: "four"
reason: "DROP"
source: {
droppableId: "column-1"
index: 0
}
type: "DEFAULT"
}
Аргумент содержит данные об элементе, который перетаскивают (из какого столбца и позиции) и о месте, куда перетаскивают элемент (в какой столбец и позицию), а также какое действие сейчас происходит: «drag» или «drop».
Первым делом сохраним destination
, draggableId
и source
в переменные:
const { destination, source, draggableId } = result;
Теперь создадим новый список отсортированных элементов:
const column = this.state.column;
const numberIds = Array.from(column.numberIds);
numberIds.splice(source.index, 1);
numberIds.splice(destination.index, 0, draggableId);
const numbers = numberIds.map((numberId: string) =>
parseInt(this.state.numbers[numberId].content, 10));
И обновим состояние:
const newColumn = {
...column,
numberIds
};
this.setState({
...this.state,
column: newColumn
});
И изюминка игры: как только пользователь перетаскивает элемент или же выигрывает, то воспроизводится соответствующая мелодия из папки assets
:
public playSound(numbers: number[]) {
const sound = isSortedAsc(numbers) ? ClapsSound : MoveSound;
new Audio(sound).play();
}
Вот так выглядит весь метод:
public onDragEnd(result: any) {
const { destination, source, draggableId } = result;
if (!destination) { return }
const column = this.state.column;
const numberIds = Array.from(column.numberIds);
numberIds.splice(source.index, 1);
numberIds.splice(destination.index, 0, draggableId);
const numbers = numberIds.map((numberId: string) =>
parseInt(this.state.numbers[numberId].content, 10));
this.playSound(numbers);
this.updateState(column, numberIds);
}
Заключение
Тех компонентов, которые были описаны выше, хватит для создание группы элементов с возможностью их перетаскивания в рамках одного столбца. Если в игру добавить CSS и дополнительные звуки, то это сделает её более приятной.
Ресурсы
Перевод статьи «Beautiful drag and drop with React — The beginner’s guide»