Александр Ланский

Создаём drag-and-drop элементы на React

В этой статье рассмотрим создание drag-and-drop элементов на React с помощью библиотеки react-beautiful-dnd от Atlassian.

51646

В этой статье рассмотрим создание 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 и дополнительные звуки, то это сделает её более приятной.

Ресурсы

51646