Руководство по Drag And Drop в React

Интерфейс перетаскивания стал неотъемлемой частью большинства современных приложений. Он обеспечивает богатство пользовательского интерфейса, не затрагивая UX.

Есть много вариантов использования перетаскивания пользовательского интерфейса. Наиболее распространенные из них:

  • Использование перетаскивания в браузере для загрузки файлов. Такие продукты, как Gmail, WordPress, Invision и т. д., имеют это в качестве одной из своих основных функций.
  • Перемещение элементов между несколькими списками. Trello, Asana и многие другие продукты для повышения производительности имеют эту функцию.
  • Перестановка изображений или ресурсов. Эта функция есть в большинстве видеоредакторов, а также в таких продуктах, как Invision, для перемещения элементов дизайна между разделами.

Сегодня мы увидим некоторые из этих вариантов использования перетаскивания, создав простой проект в React.

Наше простое приложение будет иметь следующие функции:

  • Загрузка файлов изображений, перетаскивая файлы в браузере
  • Показать предварительный просмотр этих изображений в виде сетки
  • Измените порядок этих изображений, перетащив их

Давайте начнем с начальной загрузки приложения React, используя create-react-app, например:

npx create-react-app logrocket-drag-and-drop
cd logrocket-drag-and-drop
yarn start

Загрузка файлов с помощью перетаскивания

Мы не собираемся изобретать велосипед, создавая всю логику и компоненты самостоятельно. Вместо этого мы будем использовать самые стандартные и известные библиотеки в нашем проекте.

Для функции загрузки с помощью drag and drop мы будем использовать одну из самых известных библиотек в React под названием react-dropzone. Он имеет более 6 тысяч звезд на Github и поддерживает React Hooks. Вы можете прочитать документацию здесь . Это очень мощная библиотека, которая помогает создавать собственные компоненты в React.

Сначала установим его:

yarn add react-dropzone

После того, как мы установим это, давайте создадим новый файл с именем Dropzone.js. Этот компонент отвечает за преобразование простой области содержимого в область перетаскивания, куда вы можете перетаскивать свои файлы.

Как react-dropzone работает:

  • react-dropzoneскрывает ввод файла и показывает красивую пользовательскую область dropzone
  • Когда мы удаляем файлы, react-dropzone используются onDragсобытия HTML и захватываются файлы из события в зависимости от того, были ли файлы удалены в области зоны сброса.
  • Если мы нажмем на область, react-dropzone библиотека инициирует диалог выбора файла через скрытый ввод с использованием React ref и позволит нам выбирать файлы и загружать их.

Давайте создадим наш компонент с именем Dropzone:

/* 
  filename: Dropzone.js 
*/

import React from "react";
// Import the useDropzone hooks from react-dropzone
import { useDropzone } from "react-dropzone";

const Dropzone = ({ onDrop, accept }) => {
  // Initializing useDropzone hooks with options
  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop,
    accept
  });

  /* 
    useDropzone hooks exposes two functions called getRootProps and getInputProps
    and also exposes isDragActive boolean
  */

  return (
    <div {...getRootProps()}>
      <input className="dropzone-input" {...getInputProps()} />
      <div className="text-center">
        {isDragActive ? (
          <p className="dropzone-content">Release to drop the files here</p>
        ) : (
          <p className="dropzone-content">
            Drag 'n' drop some files here, or click to select files
          </p>
        )}
      </div>
    </div>
  );
};

export default Dropzone;

Давайте подробнее рассмотрим этот код.

useDropzone предоставляет нам несколько методов и переменных для создания пользовательской области dropzone. Для нашего проекта нас в основном интересуют три разных свойства:

  • getRootProps— это props, который будет установлен на основе родительского элемента области дропзоны. Таким образом, этот элемент определяет ширину и высоту области дропзоны.
  • getInputProps— это props, переданный элементу ввода. И это необходимо для того, чтобы мы могли поддерживать события щелчка вместе с событиями перетаскивания для получения файлов.
  • Все параметры, связанные с файлами, которые мы передаем, useDropzone будут установлены для этого элемента ввода. Например, если вы хотите поддерживать только отдельные файлы, вы можете передать multiple: false. Это автоматически потребует, dropzone чтобы только один файл был принят
  • isDragActive будет установлено, если файлы будут перетаскиваться над областью дропзоны. Будет очень полезно сделать стиль на основе этой переменной

Вот пример того, как установить имена стилей/классов на основе значения isDragActive:

const getClassName = (className, isActive) => {
  if (!isActive) return className;
  return `${className} ${className}-active`;
};

...
<div className={getClassName("dropzone", isDragActive)} {...getRootProps()}>
...

В нашем примере мы использовали только два props. Библиотека поддерживает множество props для настройки dropzone области в соответствии с вашими потребностями.

Мы использовали accept props, чтобы разрешать только файлы изображений. Наши App.jsдолжны выглядеть так:

/*
filename: App.js 
*/

import React, { useCallback } from "react";
// Import the dropzone component
import Dropzone from "./Dropzone";

import "./App.css";

function App() {
  // onDrop function  
  const onDrop = useCallback(acceptedFiles => {
    // this callback will be called after files get dropped, we will get the acceptedFiles. If you want, you can even access the rejected files too
    console.log(acceptedFiles);
  }, []);

  // We pass onDrop function and accept prop to the component. It will be used as initial params for useDropzone hook
  return (
    <main className="App">
      <h1 className="text-center">Drag and Drop Example</h1>
      <Dropzone onDrop={onDrop} accept={"image/*"} />
    </main>
  );
}

export default App;

Пользовательский интерфейс с консолью после удаления изображений

Мы добавили dropzoneкомпонент на главную страницу. Теперь, если вы перетащите файлы, он будет утешать удаленные файлы изображений.

  • acceptedFiles представляет собой массив Fileзначений. Вы можете прочитать файл или отправить файл на сервер и загрузить. Какой бы процесс вы ни хотели сделать, вы можете сделать это там
  • Даже когда вы нажмете область и загрузите, onDropбудет вызван тот же обратный вызов
  • acceptprops принимает MIME-типы. Вы можете проверить документ для всех поддерживаемых типов mime. Он поддерживает все стандартные типы пантомимы, а также шаблоны сопоставления. Если вы хотите разрешить только pdf, то accept={'application/pdf'}. Если вам нужен как тип изображения, так и pdf, то он поддерживаетaccept={'application/pdf, image/*'}
  • onDropфункция заключена в useCallback. На данный момент мы не проводили никаких сложных вычислений и не отправляли файлы на сервер. Мы просто утешаем acceptedFiles. Но позже мы прочитаем файлы и установим состояние для отображения изображений в браузере. Рекомендуется для useCallbackдорогих функций и во избежание ненужных повторных рендеров. В нашем примере это совершенно необязательно

Давайте прочитаем файлы изображений и добавим их в состояние в App.js:

/*
filename: App.js
*/
import React, { useCallback, useState } from "react";
// cuid is a simple library to generate unique IDs
import cuid from "cuid";

function App() {
  // Create a state called images using useState hooks and pass the initial value as empty array
  const [images, setImages] = useState([]);

  const onDrop = useCallback(acceptedFiles => {
    // Loop through accepted files
    acceptedFiles.map(file => {
      // Initialize FileReader browser API
      const reader = new FileReader();
      // onload callback gets called after the reader reads the file data
      reader.onload = function(e) {
        // add the image into the state. Since FileReader reading process is asynchronous, its better to get the latest snapshot state (i.e., prevState) and update it. 
        setImages(prevState => [
          ...prevState,
          { id: cuid(), src: e.target.result }
        ]);
      };
      // Read the file as Data URL (since we accept only images)
      reader.readAsDataURL(file);
      return file;
    });
  }, []);

  ...
}

Структура данных нашего images состояния:

const images = [
  {
    id: 'abcd123',
    src: 'data:image/png;dkjds...',
  },
  {
    id: 'zxy123456',
    src: 'data:image/png;sldklskd...',
  }
]

Давайте покажем предварительный просмотр изображений в виде сетки. Для этого мы собираемся создать еще один компонент с именем ImageList.

import React from "react";

// Rendering individual images
const Image = ({ image }) => {
  return (
    <div className="file-item">
      <img alt={`img - ${image.id}`} src={image.src} className="file-img" />
    </div>
  );
};

// ImageList Component
const ImageList = ({ images }) => {

  // render each image by calling Image component
  const renderImage = (image, index) => {
    return (
      <Image
        image={image}
        key={`${image.id}-image`}
      />
    );
  };

  // Return the list of files
  return <section className="file-list">{images.map(renderImage)}</section>;
};

export default ImageList;

Теперь мы можем добавить этот компонент ImageList в App.js и показать предварительный просмотр изображений.

function App() {
  ...

  // Pass the images state to the ImageList component and the component will render the images
  return (
    <main className="App">
      <h1 className="text-center">Drag and Drop Example</h1>
      <Dropzone onDrop={onDrop} accept={"image/*"} />
      <ImageList images={images} />
    </main>
  );
}

Мы успешно выполнили половину нашей заявки. Мы сможем загружать файлы с помощью перетаскивания, а также сможем просматривать изображения в предварительном просмотре.

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

Существует три различных пакета React, которые очень популярны для перетаскивания:

  1. react-beautiful-dnd, 30 тысяч звезд на Github (при поддержке Atlasssian)
  2. react-dnd, 19 тысяч звезд на Github
  3. react-grid-layout, 18 тысяч звезд на Github

Все они одинаково популярны среди разработчиков React, а также имеют активных участников, но у каждой библиотеки есть свои плюсы и минусы.

Мы составили список с указанием плюсов и минусов каждой библиотеки:

React beautiful DND

Плюсы

  • Он очень хорошо работает для одномерного макета (например, списков), и если для перетаскивания требуется либо горизонтальное, либо вертикальное перемещение.
  • Например, макет, похожий на Trello, список дел и т. д. будут работать из коробки с react-beautiful-dnd
  • API простой, любой может легко во всем разобраться. Опыт разработчиков действительно хорош и приятен с добавлением сложности в кодовую базу.

Минусы

  • react-beautiful-dnd не работает для сеток, потому что вы перемещаете элементы во всех направлениях и react-beautiful-dnd не сможете вычислить позиции для осей x и y одновременно. Таким образом, при перетаскивании элементов в сетке ваш контент будет смещаться случайным образом, пока вы не перетащите элемент.

React grid layout

Плюсы

  • Это работает для сеток. Сама сетка покрывает все, поэтому технически она работает и для одномерных движений.
  • Он хорошо работает для сложных макетов сетки, которые требуют перетаскивания.
  • Например, информационные панели с полной настройкой и изменением размера (т. е. средство просмотра, продукты для визуализации данных и т. д.).
  • Это стоит сложности для крупномасштабных приложений

Минусы

  • У него очень уродливый API — много вычислений приходится делать самостоятельно
  • Вся структура макета должна быть определена в пользовательском интерфейсе через их компонентный API, и это создает дополнительный уровень сложности при создании динамических элементов на лету.

React DND

Плюсы

  • Он работает почти для всех случаев использования (сетка, одномерные списки и т. д.).
  • Он имеет очень мощный API для любой настройки с помощью перетаскивания.

Минусы

  • API легко запустить для небольших примеров. Становится очень сложно добиться чего-то, если вашему приложению нужно что-то нестандартное. Кривая обучения выше и сложнее, чем у react-beautiful-dnd
  • Нам нужно сделать много хаков для поддержки как веб-устройств, так и сенсорных устройств.

Для нашего варианта использования я выбираю react-dnd. Я бы выбрал react-beautiful-dnd, если бы наш макет включал только список элементов. Но в нашем примере у нас есть сетка изображений. Итак, следующий самый простой API для перетаскивания — это react-dnd.

Перетаскивание списков с помощью React

Прежде чем мы углубимся в код перетаскивания, нам нужно сначала понять, как react-dnd работает.

React DND может сделать любой элемент перетаскиваемым, а также сделать любой элемент перетаскиваемым. Чтобы добиться этого, у react dnd есть несколько допущений:

  • Он должен иметь ссылки на все выпадающие предметы.
  • Он должен иметь ссылки на все перетаскиваемые элементы.
  • Все элементы, которые можно перетаскивать и удалять, должны быть заключены в react-dndконтекстный провайдер . Этот провайдер используется для инициализации, а также для управления внутренним состоянием.

Нам не нужно слишком беспокоиться о том, как он управляет состоянием. У него есть хороший и простой API для отображения этих состояний, мы можем вычислять и обновлять наши локальные состояния, используя его.

Давайте начнем с кода. Установите пакет:

yarn add react-dnd

Во-первых, мы поместим наш компонент ImageList внутри поставщика контекста DND, например:

/* 
  filename: App.js 
*/

import { DndProvider } from "react-dnd";
import HTML5Backend from "react-dnd-html5-backend";

function App() {
  ...
  return (
    <main className="App">
      ...
      <DndProvider backend={HTML5Backend}>
        <ImageList images={images} onUpdate={onUpdate} />
      </DndProvider>
    </main>
  );
}

Это просто, мы просто импортируем DNDProvider и инициализируем его с помощью внутренних props.

backend – Как я упоминал ранее, это переменная, которая помогает выбрать, какой API использовать для перетаскивания.

Он поддерживает:

  • API перетаскивания HTML5 (поддерживается только в Интернете, а не на сенсорных устройствах)
  • Touch drag and drop API (поддерживается на сенсорных устройствах)

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

Теперь нам нужно добавить элементы как перетаскиваемые и сбрасываемые. В нашем приложении перетаскиваемые и выпадающие элементы одинаковы. Мы перетащим Image компонент и поместим его на другой Image компонент. Так что это немного облегчает нашу работу.

Давайте реализуем это, например:

import React, { useRef } from "react";
// import useDrag and useDrop hooks from react-dnd
import { useDrag, useDrop } from "react-dnd";

const type = "Image"; // Need to pass which type element can be draggable, its a simple string or Symbol. This is like an Unique ID so that the library know what type of element is dragged or dropped on.

const Image = ({ image, index }) => {
  const ref = useRef(null); // Initialize the reference

  // useDrop hook is responsible for handling whether any item gets hovered or dropped on the element
  const [, drop] = useDrop({
    // Accept will make sure only these element type can be droppable on this element
    accept: type,
    hover(item) {
      ...
    }
  });

  // useDrag will be responsible for making an element draggable. It also expose, isDragging method to add any styles while dragging
  const [{ isDragging }, drag] = useDrag({
    // item denotes the element type, unique identifier (id) and the index (position)
    item: { type, id: image.id, index },
    // collect method is like an event listener, it monitors whether the element is dragged and expose that information
    collect: monitor => ({
      isDragging: monitor.isDragging()
    })
  });

  /* 
    Initialize drag and drop into the element using its reference.
    Here we initialize both drag and drop on the same element (i.e., Image component)
  */
  drag(drop(ref));

  // Add the reference to the element
  return (
    <div
      ref={ref}
      style={{ opacity: isDragging ? 0 : 1 }}
      className="file-item"
    >
      <img alt={`img - ${image.id}`} src={image.src} className="file-img" />
    </div>
  );
};

const ImageList = ({ images }) => {
  ...
};

export default ImageList;

Теперь наши изображения уже можно перетаскивать. Но если мы его уроним, то снова изображение вернется в исходное положение. Потому useDrag и useDrop будет справляться, пока не уроним. Если мы не изменим наше локальное состояние, оно снова вернется в исходное положение.

Чтобы обновить локальное состояние, нам нужно знать две вещи:

  • перетаскиваемый элемент
  • hovered element (элемент, на котором находится перетаскиваемый элемент)

useDrag предоставляет эту информацию через hover метод. Давайте посмотрим на это в нашем коде:

const [, drop] = useDrop({
    accept: type,
    // This method is called when we hover over an element while dragging
    hover(item) { // item is the dragged element
      if (!ref.current) {
        return;
      }
      const dragIndex = item.index;
      // current element where the dragged element is hovered on
      const hoverIndex = index;
      // If the dragged element is hovered in the same place, then do nothing
      if (dragIndex === hoverIndex) { 
        return;
      }
      // If it is dragged around other elements, then move the image and set the state with position changes
      moveImage(dragIndex, hoverIndex);
      /*
        Update the index for dragged item directly to avoid flickering
        when the image was half dragged into the next
      */
      item.index = hoverIndex;
    }
});

hoverметод будет запускаться всякий раз, когда элемент перетаскивается и наводится на этот элемент. Таким образом, когда мы начинаем перетаскивать элемент, мы получаем индекс этого элемента, а также элемент, на который мы наводим курсор. Мы пропустим это dragIndexи hoverIndexобновим состояние наших изображений.

Теперь у вас может возникнуть два вопроса:

  1. Зачем нам нужно обновлять состояние при наведении?
  2. Почему бы не обновить его при сбросе?

Можно просто обновить при сбросе. Тогда также будет работать перетаскивание и перестановка позиций. Но UX не будет хорошим.

Например, если вы перетащите одно изображение поверх другого изображения, если мы немедленно изменим положение, то это даст хорошую обратную связь пользователям, которые его перетаскивают. В противном случае они могут не знать, работает ли функция перетаскивания, пока не перетащите изображение в какое-либо положение.

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

Пока что мы просто показываем код для обновления состояния как moveImage. Посмотрим на реализацию:

/*
  filename: App.js
*/

import update from "immutability-helper";

const moveImage = (dragIndex, hoverIndex) => {
    // Get the dragged element
    const draggedImage = images[dragIndex];
    /*
      - copy the dragged image before hovered element (i.e., [hoverIndex, 0, draggedImage])
      - remove the previous reference of dragged element (i.e., [dragIndex, 1])
      - here we are using this update helper method from immutability-helper package
    */
    setImages(
      update(images, {
        $splice: [[dragIndex, 1], [hoverIndex, 0, draggedImage]]
      })
    );
};

// We will pass this function to ImageList and then to Image -> Quiet a bit of props drilling, the code can be refactored and place all the state management in ImageList itself to avoid props drilling. It's an exercise for you :)

Теперь наше приложение полностью функционально на onDrag устройствах с поддержкой событий HTML5. Но, к сожалению, это не будет работать на сенсорных устройствах.

Как я уже говорил, мы можем поддерживать сенсорные устройства, а также использовать служебную функцию. Это не лучшее решение, но оно все еще работает. Тем не менее, опыт перетаскивания не будет хорошим на сенсорном устройстве. Он просто обновляется, но вы не почувствуете, что перетаскиваете. Также возможно сделать его чистым.

import HTML5Backend from "react-dnd-html5-backend";
import TouchBackend from "react-dnd-touch-backend";

// simple way to check whether the device support touch (it doesn't check all fallback, it supports only modern browsers)
const isTouchDevice = () => {
  if ("ontouchstart" in window) {
    return true;
  }
  return false;
};

// Assigning backend based on touch support on the device
const backendForDND = isTouchDevice() ? TouchBackend : HTML5Backend;

...
return (
  ...
  <DndProvider backend={backendForDND}>
    <ImageList images={images} moveImage={moveImage} />
  </DndProvider>
)
...

Заключение

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

Мы только что коснулись того, на что способен React с точки зрения функциональности перетаскивания. Мы можем создавать очень исчерпывающие функции, используя библиотеки перетаскивания. Надеемся, что это поможет вам быстрее и увереннее создать следующую функцию перетаскивания.