Как реализовать drag & drop используя RxJS
Эта статья — перевод оригинальной статьи Chris Trześniewski "How to implement drag & drop using RxJS". Также я веду телеграм канал “Frontend по-флотски”, где рассказываю про интересные вещи из мира разработки интерфейсов.
Вступление
Drag & drop - одна из функций, которая может быть очень полезна для пользователей нашего приложения. Кроме того, это отличный пример, показывающий, как RxJS можно использовать для простой реализации функционала перетаскивания. Давайте посмотрим, как мы можем это сделать.
Чтобы следовать всем примерам кода в этой статье, я рекомендую открыть этот стартовый пример. Все примеры будут основаны на нём.
Определение drag & drop
Прежде чем приступить к реализации, давайте рассмотрим, из чего состоит drag & drop. Его можно разделить на 3 этапа:
drag start
drag move
drag end (drop)
Вкратце, запуск перетаскивания происходит всякий раз, когда мы нажимаем мышью на элемент. После этого каждый раз, когда мы перемещаем курсор, должно генерироваться событие перетаскивания. Перетаскивание должно продолжаться, но только до тех пор, пока мы не отпустим кнопку мыши (событие «mouse up»).
Базовая реализация
Вы могли заметить, что некоторые из вышеперечисленных слов выделены жирным шрифтом. Это потому, что эти конкретные слова дают нам представление о том, как мы можем реализовать описанное поведение. Для начала мы видим, что для реализации нашей функции потребуются 3 наших события:
mousedown - для начала перетаскивания
mousemove - для перемещения элемента
mouseup - для завершения перетаскивания (drop)
Давайте сначала создадим Observables из этих событий. Они будут нашими основными строительными блоками.
import { fromEvent } from 'rxjs'
const draggableElement = document.getElementById('dragMe');
const mouseDown$ = fromEvent(draggableElement, 'mousedown');
const mouseMove$ = fromEvent(draggableElement, 'mousemove');
const mouseUp$ = fromEvent(draggableElement, 'mouseup');
Теперь у нас есть базовые события. Теперь давайте создадим из них наше событие перетаскивания.
import { switchMap, takeUntil } from 'rxjs/operators';
const dragStart$ = mouseDown$;
const dragMove$ = dragStart$.pipe( // всякий раз, когда мы нажимаем на кнопку мышку
switchMap(() => mouseMove$).pipe( // каждый раз, когда мы перемещаем курсор
takeUntil(mouseUp$) // но только пока мы не отпустим кнопку мыши
),
);
Как видите, благодаря очень декларативному синтаксису RxJS мы смогли реализовать наша начальное определение drag & drop'а.
Это хорошее начало, но нам нужно немного больше информации в dragMove$ Observable, чтобы мы знали, как далеко мы перетаскиваем элемент. Для этого мы можем использовать значение, отправляемое из dragStart$, и сравнивать его с каждым значением, отправляемым из mouseMove$:
const dragMove$ = dragStart$.pipe(
switchMap(start =>
mouseMove$.pipe(
// Мы преобразуем события mouseDown и mouseMove чтобы получить необходимую информацию
map(moveEvent => ({
originalEvent: moveEvent,
deltaX: moveEvent.pageX - start.pageX,
deltaY: moveEvent.pageY - start.pageY,
startOffsetX: start.offsetX,
startOffsetY: start.offsetY
})),
takeUntil(mouseUp$)
)
),
);
Теперь наш Observable отправляет всю необходимую информацию, чтобы мы могли перемещать перетаскиваемый элемент с помощью мыши. Поскольку наблюдаемые объекты ленивы, нам нужно подписаться на них для выполнения любых действий.
dragMove$.subscribe(move => {
const offsetX = move.originalEvent.x - move.startOffsetX;
const offsetY = move.originalEvent.y - move.startOffsetY;
draggableElement.style.left = offsetX + 'px';
draggableElement.style.top = offsetY + 'px';
});
Это работает хорошо, но только если мы не перемещаем мышь слишком быстро. Потому что наши события mouseMove$ и mouseUp$ прослушивают сам перетаскиваемый элемент. Если мышь перемещается слишком быстро, курсор может покинуть перетаскиваемый элемент, и тогда мы перестанем получать событие mousemove. Простое решение - нацелить mouseMove$ и mouseUp$ на document, чтобы мы получали все события мыши, даже если на мгновение оставим перетаскиваемый элемент.
const mouseMove$ = fromEvent(document, 'mousemove');
const mouseUp$ = fromEvent(document, 'mouseup');
Это небольшое изменение улучшит поведение перетаскивания, чтобы мы могли свободно перемещать курсор по всей странице.
Прежде чем продолжить, давайте очистим код, извлекая созданную нами логику в функцию.
const mouseMove$ = fromEvent(document, 'mousemove');
const mouseUp$ = fromEvent(document, 'mouseup');
const draggableElement = document.getElementById('dragMe');
createDraggableElement(draggableElement);
function createDraggableElement(element) {
const mouseDown$ = fromEvent(element, 'mousedown');
const dragStart$ = mouseDown$;
const dragMove$ = dragStart$.pipe(
switchMap(start =>
mouseMove$.pipe(
map(moveEvent => ({
originalEvent: moveEvent,
deltaX: moveEvent.pageX - start.pageX,
deltaY: moveEvent.pageY - start.pageY,
startOffsetX: start.offsetX,
startOffsetY: start.offsetY
})),
takeUntil(mouseUp$)
)
)
);
dragMove$.subscribe(move => {
const offsetX = move.originalEvent.x - move.startOffsetX;
const offsetY = move.originalEvent.y - move.startOffsetY;
element.style.left = offsetX + 'px';
element.style.top = offsetY + 'px';
});
}
Таким образом, мы можем легко сделать наш код так, чтобы он позволял использовать несколько перетаскиваемых элементов:
appDiv.innerHTML = `
<h1>RxJS Drag and Drop</h1>
<div class="draggable"></div>
<div class="draggable"></div>
<div class="draggable"></div>
`;
const draggableElements = document.getElementsByClassName('draggable');
Array.from(draggableElements).forEach(createDraggableElement);
Если у вас возникнут проблемы на любом из этапов, вы можете сравнить свое решение с этим примером.
Отправляем кастомные события
Приведенный выше пример показывает, что можно реализовать простое поведение перетаскивания с помощью RxJS. В реальных примерах может быть очень полезно иметь настраиваемое событие для перетаскиваемого элемента, чтобы было легко зарегистрировать настраиваемую функцию в любой части жизненного цикла drag & drop'а.
В предыдущем примере мы определили наблюдаемые dragStart$ и dragMove$. Мы можем использовать их напрямую, чтобы начать генерировать события mydragstart и mydragmove в элементе соответственно. Я добавил свой префикс, чтобы убедиться, что я не сталкиваюсь с каким-либо нативным событием.
import { tap } from 'rxjs/operators';
dragStart$
.pipe(
tap(event => {
element.dispatchEvent(
new CustomEvent('mydragstart', { detail: event })
);
})
)
.subscribe();
dragMove$
.pipe(
tap(event => {
element.dispatchEvent(
new CustomEvent('mydragmove', { detail: event })
);
})
)
.subscribe();
Как вы могли видеть в приведенном выше примере, я помещаю логику генерирования события в функцию tap. Я рекомендую этот подход, поскольку он позволяет нам объединить несколько наблюдаемых потоков в один и вызвать подписку только один раз:
import { combineLatest } from 'rxjs';
combineLatest([
dragStart$.pipe(
tap(event => {
element.dispatchEvent(
new CustomEvent('mydragstart', { detail: event })
);
})
),
dragMove$.pipe(
tap(event => {
element.dispatchEvent(
new CustomEvent('mydragmove', { detail: event })
);
})
)
]).subscribe();
Теперь отсутствует единственное событие - mydragend. Это событие должно быть отправлено как последнее событие в последовательности событий mydragmove. Мы снова можем использовать оператор RxJS для достижения такого поведения.
const dragEnd$ = dragStart$.pipe(
switchMap(start =>
mouseMove$.pipe(
map(moveEvent => ({
originalEvent: moveEvent,
deltaX: moveEvent.pageX - start.pageX,
deltaY: moveEvent.pageY - start.pageY,
startOffsetX: start.offsetX,
startOffsetY: start.offsetY
})),
takeUntil(mouseUp$),
last(),
)
)
);
И последним шагом нужно отправлять это событие вместе с другими.
combineLatest([
dragStart$.pipe(
tap(event => {
element.dispatchEvent(
new CustomEvent('mydragstart', { detail: event })
);
})
),
dragMove$.pipe(
tap(event => {
element.dispatchEvent(new CustomEvent('mydragmove', { detail: event }));
})
),
dragEnd$.pipe(
tap(event => {
element.dispatchEvent(new CustomEvent('mydragend', { detail: event }));
})
)
]).subscribe();
На этом реализация завершена. Теперь мы можем использовать эти события как захотим.
Array.from(draggableElements).forEach((element, i) => {
element.addEventListener('mydragstart', () =>
console.log(`mydragstart on element #${i}`)
);
element.addEventListener('mydragmove', event =>
console.log(
`mydragmove on element #${i}`,
`delta: (${event.detail.deltaX}, ${event.detail.deltaY})`
)
);
element.addEventListener('mydragend', event =>
console.log(
`mydragend on element #${i}`,
`delta: (${event.detail.deltaX}, ${event.detail.deltaY})`
)
);
});
Вы можете найти полную реализацию здесь.
Заключение
В этой статье я показал вам, что вы можете легко реализовать базовое поведение drag & drop с помощью RxJS. Это отличный инструмент для этого, поскольку он упрощает управление потоком событий и позволяет очень декларативно реализовать сложное поведение.
Если вы ищете более интересные примеры того, как вы можете использовать события drag & drop с RxJS, я рекомендую посетить этот пример.
Если у вас есть какие-либо вопросы, вы всегда можете написать мне в Твиттере на @ktrz. Всегда рад помочь!