Перетяжка, Дом карьеры
Перетяжка, Дом карьеры
Перетяжка, Дом карьеры

Современный Drag and Drop в React с помощью dndkit: создаем перетаскиваемое меню

Как сделать Dran And Drop на примере меню. Как двигать кнопки в любом направлении и смещать их по рядам. Для этого используем React с dndKit.

124 открытий949 показов
Современный Drag and Drop в React с помощью dndkit: создаем перетаскиваемое меню

Привет!

Недавно передо мной встала задача реализовать меню с возможностью перетаскивания кнопок в разных направлениях и рядах. После исследования доступных библиотек я остановился на DnD Kit. Он предлагает отличную документацию, множество примеров и гибкие возможности кастомизации.

Основной функционал строится на двух хуках:

  • useDraggable — отвечает за перемещение элемента.
  • useDroppable — определяет область, куда можно вставить элемент.

Дополнительно можно настроить анимации и сортировку внутри рядов.

Современный Drag and Drop в React с помощью dndkit: создаем перетаскиваемое меню 1

UI и подготовка

Скачиваем сами либы

			yarn add @dnd-kit/core @dnd-kit/sortable
		

Настраиваем контекст

Оборачиваем не всё приложение, а только компонент, в котором будет происходить DnD. Добавляем сенсоры, чтобы обрабатывать взаимодействия мыши и тач-устройств.

			import {
  pointerWithin,
  useSensors,
} from "@dnd-kit/core";

const sensorSettings = {
  distance: 2,
};

export default function DndRoot() {
  const [activeDndItemId, setActiveDndItemId] = useState<null | number>(null);
  const sensors = useSensors(
    useSensor(MouseSensor, {
      activationConstraint: sensorSettings,
    }),
    useSensor(PointerSensor, {
      activationConstraint: sensorSettings,
    }),
  );
  
  const handleDragStart = ({active}: DragStartEvent) => {
    setActiveDndItemId(active.id as number);
  };
  
  const handleDragEnd = ({over}: DragOverEvent) => {
    setActiveDndItemId(null);
  };
  
  const handleDragOver = ({active, over}: DragOverEvent) => {
    // Обработаем позже
  } 
  
  return (
    <DndContext
      sensors={sensors}
      collisionDetection={pointerWithin}
      onDragStart={handleDragStart}
      onDragOver={handleDragOver}
      onDragEnd={handleDragEnd}>
       {/* Cюда будем добавлять элементы */}
    </DndContext> 
  );
} 

		

Создаем начальное состояние для кнопок. Поле order отвечает за порядок внутри ряда, а rowNumber — за номер ряда.

			interface IButton {
  id: number;
  text: string;
  color: string;
  order: number;
  rowNumber: number;
}

const [buttons, setButtons] = useState<IButton[]>(initialButtons);
		

Кнопка скрывается при перемещении (isDragging), а её положение изменяется через CSS.Transform и transition. useSortable будет следить за ref и событиями юзера, например, нажатие и удержание кнопки на этом блоке. Нужные оттуда пропсы прокинем в кнопку.

			export interface IDndBtnProps {
  btn: IButton;
  rowLength: number;
}

export const DndBtn = ({ btn, rowLength }: IDndBtnProps) => {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({
    id: btn.id,
  });
  const btnStyle = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0 : 1,
  };

  return (
    <Button
      style={{
        ...btnStyle,
        background: btn.color,
        width: `${100 / rowLength}%`,
      }}
      className={cn(styles.btn, styles.btnHover)}
      ref={setNodeRef}
      {...listeners}
      {...attributes}
    >
      {btn.text}
    </Button>
  );
};

		

Перейдем к компоненту ряда. Сортировку и ряды в массив будем добавлять снаружи в DndRoot, а тут мапим массив кнопок из пропса row: IButton[].

SortableContext выполняет очевидную роль сортировки по заданной стратегии, в данном случае — сортирует только горизонтально. Он сам будет временно менять местами блоки в ряду. Но наше свойство order мы поменяем только в handleDragEnd, когда юзер уже определится с выбором и отпустит нажатие.

			interface IDndRowProps {
  row: IButton[];
  rowId: string;
}

export const DndRow = ({ row, rowId }: IDndRowProps) => {
  const { setNodeRef } = useDroppable({
    id: rowId,
  });
  return (
    <SortableContext
      id={rowId}
      items={row}
      strategy={horizontalListSortingStrategy}
    >
      <div className={styles.row} ref={setNodeRef}>
        {row.map((btn) => (
          <DndBtn rowLength={row.length} key={btn.id} btn={btn} />
        ))}
      </div>
    </SortableContext>
  );
};

		

Теперь проходимся по всем нашим кнопкам и распределяем их по рядам, опираясь на свойство rowNumber. Также сортируем и в ряду по свойству order. Теперь у нас готов весь ui, и хуки могут обрабатывать dnd.

			export default function DndRoot() {
 
  const getButtons = () => {
    if (!buttons || !buttons.length) return null;
    let res: IButton[][] = [];
    buttons
      ?.sort((a, b) => a.order - b.order)
      .forEach((btn) => {
        if (!res[btn.rowNumber]) res[btn.rowNumber] = [];
        res[btn.rowNumber] = [...res[btn.rowNumber], btn];
      });
    res = res.filter((el) => el);
    return res.map((row, i) => <DndRow key={i} rowId={`row${i}`} row={row} />);
  };
  
  return (
    <DndContext
    ...
    >
       {getButtons()}
    </DndContext> 
  );
}

		

Логика DND

Переходим к нашим трем обработчикам из рута. Мы уже храним айдишник перетаскиваемого блока в activeDndItemId. Хук useSortable в кнопке сам отслеживает события юзера, так что в полученном параметре active.id у handleDragStart = ({active}: DragStartEvent) будет наш параметр.

В handleDragOver = ({active, over}: DragOverEvent) мы можем проверить, где сейчас находится передвигаемый блок (over), и какой из блоков активный (active). Как помните, в ряду мы использовалиси особый стринг id (`row${i}`), так что тут мы его обрабатывать не будем. Сравниваем, что id блока over отличается от id active. Тогда и сменим поле rowNumber у нашей кнопки.

В handleDragEnd = ({ over }: DragOverEvent) меняем order. Наши сортировочные хуки визуально меняют горизонтальное положение кнопок в ряду. Но когда мы отпустим блок, порядок не сменится. Поэтому в конце мы его фиксируем в стейте.

Но кто же пересчитает rowNumber и order, если мы добавим новые ряды, переместим все кнопки из одного ряда в другой или передвинем последнюю кнопку перед первой в том же ряду?

После каждого изменения ряда нам нужно пересчитывать их порядок в стейте. Чтобы не было таких расхождений:

			[
  { ... id: 1, rowNumber: 1 },
  { ... id: 2, rowNumber: 5 }
]

		

Так, после перетаскивания кнопок с первоночальных рядов в другие и исчезновения первых, rowNumber меняется с гепом. То же самое нужно сделать для order. Ради эксперимента пишем промт в chatGPT и генерим со 2 раза наши алгоритмы:

			export const updateButtonsRowOrder = (
 array: IButton[],
  id: number,
  newOrder: number
) => {
  const item = array.find((el) => el.id === id);
  if (!item) return array; // Если id не найден, возвращаем массив без изменений
  // Убираем элемент из массива и сортируем оставшиеся элементы по order
  const filteredArray = array
    .filter((el) => el.id !== id)
    .sort((a, b) => a.order - b.order);
  // Вставляем элемент с новым order в нужную позицию
  filteredArray.splice(newOrder - 1, 0, { ...item, order: newOrder });
  // Мы просто вставили в массив нужный элемент и тк массив отсортированный, заменим все order на index+1
  return filteredArray.map((el, index) => ({ ...el, order: index + 1 }));
};

export const updateButtonsRowNumber = (
 array: IButton[],
  id: number,
  newRowNumber: number
) => {
  const item = array.find((el) => el.id === id);
  if (!item) return array; // Если id не найден, возвращаем массив без изменений
  // Обновляем rowNumber указанного элемента
  item.rowNumber = newRowNumber;
  // Сортируем массив по rowNumber
  const sortedArray = array.sort((a, b) => a.rowNumber - b.rowNumber);
  // Пересчитываем rowNumber так, чтобы не было пропусков
  const uniqueRowNumbers = [
    ...new Set(sortedArray.map((el) => el.rowNumber)),
  ].sort((a, b) => a - b);
  const mapping = new Map(
    uniqueRowNumbers.map((num, index) => [num, index + 1])
  );
  // Находим по rowNumber в mapping и берем индекс+1 из mapping
  sortedArray.forEach((el) => {
    el.rowNumber = mapping.get(el.rowNumber) ?? el.rowNumber;
  });
  return sortedArray;
};

		

Теперь вставляем алгоритмы в обработчики:

			
  const handleDragEnd = ({ over }: DragOverEvent) => {
    if (!activeDndItemId) return;
    const overIndex = over?.data.current?.sortable.index || 0;
    setButtons((prev) => {
      const res = prev.map((btn) => {
        if (btn.id === activeDndItemId) {
          return {
            ...btn,
            order: overIndex + 1,
          };
        }
        return btn;
      });
      return updateButtonsRowOrder(res, activeDndItemId, overIndex + 1);
    });
    setActiveDndItemId(null);
  };

  const handleDragOver = ({ active, over }: DragOverEvent) => {
    const activeBtnId = active.id;
    const overBtnId = over?.id;
    const activeRowNumber = buttons?.find(
      (btn) => btn.id === activeBtnId
    )?.rowNumber;
    const overRowNumber = buttons?.find(
      (btn) => btn.id === overBtnId
    )?.rowNumber;

    if (
      !activeDndItemId ||
      !activeRowNumber ||
      !overRowNumber ||
      activeRowNumber === overRowNumber
    ) {
      return;
    }

    setButtons((prev) => {
      const res = prev.map((btn) => {
        if (btn.id === activeDndItemId) {
          return {
            ...btn,
            rowNumber: overRowNumber,
          };
        }
        return btn;
      });
      return updateButtonsRowNumber(res, activeDndItemId, overRowNumber);
    });
  };
		

Анимация при DND

В конце делаем красивую анимацию при переносе. Мы фактически подставим копию нашей кнопки в специальный рендер от библиотеки, и она ее отрендерит. Для сложных кейсов и плавных анимаций рекомендуется именно такой подход.

			
  const overlayItem = useMemo(() => {
    return buttons?.find((btn) => btn.id === activeDndItemId);
  }, [activeDndItemId, buttons]);

  <DndContext ...>
    {getButtons()}
    <DragOverlay dropAnimation={{...defaultDropAnimation}}>
      {overlayItem ? (
        <Button
          style={{
            zIndex: 999,
            background: overlayItem.color,
            width: `100%`,
          }}
          type="primary"
          className={rootStyles.menuBtn}>
          {getButtonText(overlayItem.text, MAX_BUTTONS_IN_ROW)}
        </Button>
      ) : null}
    </DragOverlay>
  </DndContext>

		

Что дальше?

Получилось довольно много кода, даже без учёта дополнительных сложностей (обрезки текста, превышающего размер кнопки, или ограничения количества кнопок в ряду).

Также можно добавить возможность вставки элементов над или под любым рядом с помощью нашего специального id row${i} или просто с помощью отдельной кнопки внизу (как показано в видео) для добавления нового ряда и блока в нем. Весь функционал, помимо базовой логики, можно улучшить, задействовав три обработчика в корневом компоненте.

А еще даю ссылку на sandbox с полным кодом из этой статьи.

Удачи в DND!

Следите за новыми постами
Следите за новыми постами по любимым темам
124 открытий949 показов