На этом этапе добавим в наш калькулятор интерфейс для перетаскивания элементов с помощью мыши.
Предыдущий материал: Калькулятор-конвертер на базе React. Часть 6: Добавляем конвертер
Зачем нужен Drag&Drop
Вы наверняка уже видели подобные интерфейсы как на компьютере, так и на мобильных устройствах. Идея заключается в «физическом» взаимодействии с объектом, когда вы используете курсор, чтобы перенести элемент из одной части приложения в другую. Это довольно популярный вариант управления программой, так как с помощью Drag&Drop можно многие задачи сделать легче в исполнении, очевиднее для поиска и логичнее с точки зрения пользовательского опыта. А иногда такой способ управления становится чуть ли не единственным для реализации некоторых интересных функциональных решений.
Мы будем вдохновляться приложением Calzy, где Drag&Drop, помимо перемещения результатов расчета в Memory Zone, используется также для быстрых подсчетов с перетаскиванием решенных примеров из истории расчетов в текущий пример. В Calzy это работает так:
-
Вы берете какой-то элемент из истории расчетов.
-
Тащите его поверх одного из выражений (+, -, /, *).
-
Отпускаете и получаете результата на основании выбранного выражения.
Мы будем внедрять в наше приложение аналогичную логику.
Сам материал будет полезен даже тем, кто не делает с нами калькулятор, а просто ищет способ внедрения Drag&Drop.
Создаем компонент для управления элементами
Для реализации даже базовой функциональности Drag&Drop нам придется написать довольно большое количество кода, а это наталкивает на мысль о том, что стоило бы перенести эту логику в отдельный файл. Как вариант, можно было бы создать отдельный элемент с названием dragging.js и импортировать из него функцию для управления данными. Но в таком случае мы бы потеряли контроль над частью данных внутри компонентов, так как они генерируются и хранятся в замкнутом виде, и большую их часть не удалось бы отыскать. Поэтому мы будем использовать компонент, чтобы можно было сделать запрос и к данным внутри приложения, и к применяемым методам, позволяющим обновлять информацию в интерфейсе программы.
Поэтому первым шагом станет создание нового файла в директории src. Назовем его Dragging.jsx.
Добавляем draggable-элементы
Логика нашего Drag&Drop-интерфейса будет построена вокруг трех столпов.
-
Компонент Dragging, который содержит в себе функции, отвечающие за перемещение мыши.
-
Элементы с классом draggable, которые как раз и будут подвержены перемещению при помощи курсора.
-
Элементы с классом droppable. Те самые, что будут реагировать на «падение» на них других блоков данных.
Поэтому перед тем, как начать конструировать блок Dragging и добавлять основные функции взаимодействия с Drag&Drop-логикой, надо пометить нужные элементы в коде соответствующими классами.
Начнем с Draggable. По нашей задумке в приложении должна быть возможность с помощью курсора перетягивать записи из истории подсчетов, то есть кнопки, формируемые в блоке History. Нужно открыть компонент History и в переменную results, где генерируются кнопки, добавить класс draggable.
<Button className = 'draggable'> </Button>
Этого достаточно. Главное, не перепутайте отдельные кнопки со всем блоком History.
Добавляем droppable-элементы
Теперь аналогичную процедуру провернем с droppable-элементами. Здесь та же логика. Надо найти компоненты, на которые можно будет скидывать другие блоки интерфейса и пометить их классом droppable. В нашем случае такими элементами станут кнопки с выражениями.
По тому же принципу находим компонент, где отрисовываются нужные нам кнопки, и прописываем className.
<Button className="droppable"> </Button>
Теперь у нас есть аж два из трех фрагментов, необходимых для реализации Drag&Drop.
Реализуем компонент Dragging
Dragging станет компонентом-оболочкой для интерфейса ClickCalc. Его основная задача – объединить элементы интерфейса из двух частей программы в единый блок, в котором можно управлять отдельными кусками кода.
Объективно – это не самый удобный интерфейс с точки зрения реализации, так как он довольно узконаправленный, и без трудозатрат перенести его в реалии более комплексного приложения не получится. Тем не менее в нашем случае он работает более чем исправно, поэтому мы будем придерживаться такой логики, не углубляясь в более специфичные и сложные структуры в духе сторонних библиотек от Atlassian (React Beautiful DnD) и других разработчиков.
Сначала в файле Dragging.jsx создадим функцию Dragging и опишем в ней два первичных метода.
-
Объявляем Dragging:
function Dragging(props) { }
-
В теле Dragging создаем переменную elements для хранения всех draggable. Так как draggable-элементы находятся в другом файле и недоступны внутри Dragging, нам нужно найти их при помощи классического перебора querySelectorAll и из полученного списка HTML-ветвей сделать массив (чтобы можно было работать с ним дальше):
const elements = Array.from(document.querySelectorAll('.draggable'))
-
Перебираем список draggable и для каждого из них создаем eventListener, который будет активировать функцию drag при наведении мыши.
elements.map(e => e.addEventListener('mouseenter', function(e) { drag(e) }))
Описанный выше процесс необходим, так как применение функции drag (она отвечает за перемещение объекта) на всех draggable-кнопках заставит ползать по интерфейсу весь блок History, а нам это не нужно. С помощью addEventListener мы гарантируем себе, что Drag&Drop сработает только на одном элементе – на том, на который мы заранее навели мышь, и точно не на его соседях (об этом позаботится код внутри drag).
Но здесь возникает еще одна проблема. Дело в том, что до появления первой кнопки в блоке History, метод querySelectorAll не сможет найти ни одного draggable. Чтобы этого избежать, нужно скрыть перебор HTML-элементов в блок useEffect.
-
Сначала импортируем в файл Dragging.jsx хук useEffect:
import { useEffect } from 'react'
-
Затем добавляем метод useEffect в функцию Dragging и упаковываем в него прописанные ранее фрагменты кода.
useEffect(() => { const elements = Array.from(document.querySelectorAll('.draggable')) elements.map(e => e.addEventListener('mouse enter', function(e) { drag(e) })) })
Также в Dragging должен быть блок return (это все-таки компонент, а компонент обязан возвращать какой-то кусок интерфейса). Для этого создадим метод return и пропишем внутри него свойство props.children (оно рендерит те HTML-ветки, что входят в вышестоящий компонент).
return ( <Box> {props.children} </Box> )
И не забываем экспортировать компонент Dragging, чтобы к нему был доступ извне.
export default Dragging
Реализуем контроль над перемещением мыши
Теперь переключаемся на функцию перемещения объектов. Такая логика включена в HTML5 по умолчанию, но она работает не совсем так, как нам нужно. При помощи стандартных инструментов HTML5 получится реализовать перемещение объектов, только с не самым симпатичным визуальным эффектом и далеко не со всеми функциями, доступными в сторонних библиотеках или при реализации через отслеживание координат мыши.
Для этого создадим функцию drag. Она будет создавать для нас элемент, управляемый перетягиванием. А далее будет срабатывать метод onmousedown, регистрирующий зажатие левой клавиши мыши и меняющий свойства элемента draggable так, чтобы он мог свободно перемещаться по экрану.
-
Объявляем внутри компонента Dragging функцию drag.
function drag(element) { }
-
В теле функции создаем переменную draggable и приравниваем ее к значению target у аргумента drag.
let draggable = element.target
-
Затем подключаем метод onmousedown и создаем внутри анонимную функцию для изменения положения кнопки с классом draggable.
draggable.onmousedown = function(event) { }
-
В теле метода onmousedown меняем свойства draggable.
-
Указываем положение absolute, чтобы элемент не «спотыкался» о своих соседей:
draggable.style.position = 'absolute'
-
Меняем z-index на 1000, чтобы элемент точно отображался поверх всех остальных частей интерфейса.
draggable.style.zIndex = 1000
-
Прикрепляем draggable к body для свободного перемещения внутри приложения.
document.body.append(draggable)
-
Внедряем метод moveAt, чтобы сместить объект.
moveAt(event.pageX, event.pageY)
-
Объявляем функцию moveAt, чтобы отслеживать координаты курсора.
function moveAt(pageX, pageY) { }
-
В теле moveAt создаем переменные, хранящие информацию о текущем положение элемента.
Готово. Теперь мы можем мышью таскать по экрану кнопки из History, но свою задачу выполнять они не смогут. Нужно настроить взаимодействие с элементами Droppable.
Реализуем реакцию на попадание элемента в зону Droppable
Будем действовать по схеме, предложенной в учебнике JavaScript.info. Настроим программу так, чтобы во время движения мыши каждое мгновение совершалась проверка на наличие под перемещаемым объектом какого-либо HTML-блока, соответствующего нашим критериям (классу droppable, например). Для этого каждый раз при перемещении курсора сдвигаемый элемент будет на мгновение исчезать с экрана, раскрывая положение контента под ним. В этот момент приложение будет засекать этот элемент и проверять, не он ли нам нужен.
Вот как выглядит код. Разберем его построчно.
-
Объявляем переменную currentDroppable. По умолчанию она будет равна null.
let currentDroppable = null
-
Объявляем метод onMouseMove:
function onMouseMove(event) { }
-
В теле метода регистрируем движения мыши и двигаем вместе с ней объект:
moveAt(event.pageX, event.pageY)
-
Скрываем draggable:
draggabe.hidden = true
-
В этот момент создаем элемент под сдвигаемой кнопкой:
let elemBelow = document.elementFromPoint(event.clientX, event.clientY)
-
Снова показываем draggable:
draggable.hidden = false
-
Делаем проверку на наличие искомого элемента под кнопкой:
if (!elementBelow) return
-
Если элемент найден и функция не прервалась, то создается переменная со свойствами фрагмента droppable под курсором:
let droppableBelow = elemBelow.closest('.droppable')
-
Потом проверяем совпадение между currentDroppable и droppableBelow:
if (currentDroppable != droppableBelow) { }
-
Внутри делаем еще одну проверку c запуском метода leaveDroppable:
if (currentDroppable) { leaveDroppable(currentDroppable) }
-
Переназначим переменную currentDroppable:
currentDroppable = droppableBelow
-
Потом повторяем проверку с методом enterDroppable:
if (currentDroppable) { enterDroppable(currentDroppable) }
Описываем поведение мыши при наведении на разные части интерфейса
Мышь двигается. Объект под ней тоже. Осталось добавить сему действу смысла.
Мы использовали выше два метода: enterDroppable и leaveDroppable. Думаю, по названию понятно, что один отвечает за поведение программы, когда элемент наведен на Droppable, а второй – наоборот.
Я добавил в enterDroppable следующую логику:
-
Внутри создал функцию для метода onmouseup. Она срабатывает, когда сочетаются два сценария – курсор стоит над droppable и пользователь отпускает кнопку мыши.
draggable.onmouseup = function() { }
-
В методе onmouseup удаляю перетаскиваемую кнопку:
draggable.style.display = 'none'
-
Потом активирую функцию изменения результата. Она как раз производит сложение значения в перетаскиваемой кнопке со значением выражения, на которое мы скидываем кнопку.
props.setResult(eval(`${props.result} ${elem.innerHTML} ${draggable.innerHTML}`))
-
Удаляем listener, чтобы никто больше не следил за перемещением курсора.
document.removeEventListener('mouseove', onMouseMove);
-
«Обнуляем» значения draggable и прерываем функцию:
draggable = null; return
Также здесь стоит предусмотреть поведение курсора, если человек не отпустит кнопку мыши. Хорошим вариантом будет показывать человеку, что над элементом интерфейса можно сбросить объект, находящийся в его руках. Поэтому я меняю вид курсора на значок copy.
draggable.style.cursor = copy
Пусть вас не смущает название. Это общепринятый стандарт.
Далее предусматриваем поведение при покидании зоны Droppable. Я просто возвращаю курсор в его привычный облик.
Отдельно стоит предусмотреть поведение объекта и курсора, если человек отпустит кнопку мыши где-то мимо Droppable. Я сделал так:
-
После подключения addEventListener обращаюсь к методу onmouseup:
draggable.onmouseup = function() { }
-
В теле вновь созданной функции снова заставляю перетаскиваемый элемент пропасть:
draggable.style.display = 'none'
-
Удаляю addEventListener, отвечающий за отслеживание перемещение мыши.
-
Использую метод setHistory, чтобы «вернуть» на место брошенный блок с решенным примером из истории.
props.setHistory(draggable.innerHTML)
-
И обнуляю значения draggable.
draggable = null
Подключаем Dragging-компонент к нашему приложению
Теперь необходимо обернуть наш ClickCalc в Dragging, чтобы описанный выше код заработал.
Обернув интерфейс калькулятора в Dragging, в качестве пропсов передаем в него текущий результат вычислений, метод обновления переменной result и метод обновления переменной history из Calculator.
В конце посоветую лишь внести небольшие визуальные изменения в блок History, задав ему фиксированную высоту, чтобы он не «прыгал» при вытаскивании из него кнопок.
Вместо заключения
Получилось громоздко, но это рабочая реализация, отлично справляющаяся со своей основной задачей. Теперь у нас есть полноценно работающий Drag&Drop, и вы можете легко расширить его функциональность или применить в других приложениях.
Продолжение: Калькулятор-конвертер на базе React. Часть 8: Конвертер валют и собственный парсер данных
Комментарии
Что-то именно в этой части Калькулятора и нестыковочки есть (создаем прослушиватель одного события, а удаляем совсем другого) и ошибки (имена переменных). Да и такое ощущение, что Вы несколько спешили и не во всех моментах понятно какой код в каком месте должен располагаться. А выкладывание полного листинга программы - это хороший тон в такого рода обучающих проектах.
В результате написания кода из этой части Drag&Drop работает с ошибками...