Drag & drop is one of the features that can be very useful for the end-users of our application. Additionally, it is a great example to show how RxJS can be used to handle drag-and-drop functionality with ease. Let's see how we can implement the simple dragging behavior.
To follow along with all the code examples in this article, I recommend opening this Stackblitz starter example. All examples will be based on this starter project.
Define drag and drop
Before we start the implementation, let's consider what the drag and drop functionality consists of. It can be split into 3 phases:
- drag start
- drag move
- drag end (drop)
In a nutshell, drag start
happens whenever we press mouse down on a draggable item. Following that each time we move a cursor a drag move
event should be emitted. Drag move should continue, but only until we release the mouse button (mouse up event).
Basic implementation
You might have noticed that a few of the words above are bolded. This is because those specific words give us a clue on how we can implement the described behavior. For starters, we can see that 3 native events will be necessary to implement our feature:
mousedown
- for starting the draggingmousemove
- for moving the dragged elementmouseup
- for ending the dragging (dropping an element)
Let's first create Observables out of those events. They will be our basic building blocks.
import { fromEvent } from 'rxjs'
const draggableElement = document.getElementById('dragMe');
const mouseDown$ = fromEvent(draggableElement, 'mousedown');
const mouseMove$ = fromEvent(draggableElement, 'mousemove');
const mouseUp$ = fromEvent(draggableElement, 'mouseup');
We now have our base events. Now, let's create our drag event from them.
import { switchMap, takeUntil } from 'rxjs/operators';
const dragStart$ = mouseDown$;
const dragMove$ = dragStart$.pipe( // whenever we press mouse down
switchMap(() => mouseMove$).pipe( // each time we move a cursor
takeUntil(mouseUp$) // but only until we release the mouse button
),
);
As you can see, due to the very declarative syntax of RxJS, we were able to transform the previous definition.
This is a good start, but we need a bit more information in the dragMove$
Observable so that we know how far we drag the element. For that, we can use the value emitted by dragStart$
, and compare it with each value emitted by mouseMove$
:
const dragMove$ = dragStart$.pipe(
switchMap(start =>
mouseMove$.pipe(
// we transform the mouseDown and mouseMove event to get the necessary information
map(moveEvent => ({
originalEvent: moveEvent,
deltaX: moveEvent.pageX - start.pageX,
deltaY: moveEvent.pageY - start.pageY,
startOffsetX: start.offsetX,
startOffsetY: start.offsetY
})),
takeUntil(mouseUp$)
)
),
);
Now, our Observable emits all necessary information for us to move the dragged element with the mouse moving. Since observables are lazy, we need to subscribe
it to perform any action.
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';
});
This works well, but only if we don't move the mouse too fast. This is because our mouseMove$
and mouseUp$
events are listening on the dragged element itself. If the mouse moves too fast, the cursor can leave the dragged element, and then we will stop receiving the mousemove
event. The easy solution to this is to target mouseMove$
and mouseUp$
to the document
so that we receive all the mouse events even if we leave the dragged element for a moment.
const mouseMove$ = fromEvent(document, 'mousemove');
const mouseUp$ = fromEvent(document, 'mouseup');
This small change will improve the dragging behavior so that we can move the cursor freely around the whole document.
Before we continue, let's clean the code by extracting the logic we've created into a function.
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';
});
}
This way, we can easily make our code so that it allows for multiple draggable elements:
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);
In case you have any trouble during any of the steps, you can compare your solution with this example.
Emitting custom events
The above example shows that it is possible to implement a simple dragging behavior using RxJS. In real-life examples, it might be very useful to have a custom event on a draggable element so that it is easy to register your custom function to any part of the drag & drop lifecycle.
It the previous example, we defined dragStart$
and dragMove$
observables. We can use those directly to start emitting mydragstart
and mydragmove
events on the element accordingly. I've added a my
prefix to make sure I don't collide with any native event.
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();
As you might see in the example above, I'm putting dispatching logic into a tap
function. This is an approach I recommend as this allows us to combine multiple observable streams into one and call subscribe
only once:
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();
Now the only event missing is mydragend
. This event should be emitted as the last event of the mydragmove
event sequence. We can again use the RxJS operator to achieve such behavior.
const dragEnd$ = dragStart$.pipe(
switchMap(start =>
mouseMove$.pipe(
startWith(start),
map(moveEvent => ({
originalEvent: moveEvent,
deltaX: moveEvent.pageX - start.pageX,
deltaY: moveEvent.pageY - start.pageY,
startOffsetX: start.offsetX,
startOffsetY: start.offsetY
})),
takeUntil(mouseUp$),
last(),
)
)
);
Note that because we are using the last()
operator we must make sure that the stream that we apply this operator to must emit at least one value. Otherwise it will fail with Error: no elements in sequence
. To achieve that we provide the initial value using startWith
operator to pass the initial mouse position. This is important for the edge case of clicking and releasing the element without actually moving it.
And the last step would be to emit this event alongside the others
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();
This concludes the implementation. We can now use those events any way we want to.
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})`
)
);
});
You can find the whole implementation here, or you can play with it below:
Conclusion
In this article, I've shown you that you can easily implement a basic drag-and-drop behavior by using RxJS. It is a great tool for this use case as it makes managing the stream of events easier, and allows for the very declarative implementation of complex behaviors.
If you are looking for more interesting examples of how you can use the drag-and-drop events with RxJS, I recommend visiting this example.
In case you have any questions, you can always tweet or DM me at @ktrz. I'm always happy to help!