Draggable Elements with RxJS

I’ve written previously about making DOM elements draggable using a combination of mouse and touch events. Recently I discovered a more elegant way to achieve this using RxJS and Hammer.

Tools

Hammer JS needs no introduction. It is the go to library for supporting touch gestures. Plus it provides an abstraction over the browser events allowing us to handle mouse and touch at the same time.

RxJS is a reactive programming library for JavaScript. We will use it to convert events into an observable stream and for animations.

I’ve been using RxJS for a couple of years now. Mostly for state management with Angular and with redux-observable. I love working with observables. It allows me to write succinct and declarative code.

A few months ago David Khourshid introduced me to his library RxCSS and to the idea of using observables to create reactive animations. Reactive programming makes it really easy to convert events into data and drive animations. This pushed me to learn more about observables and discover lots of new patterns. In this post I am going to share one of those patterns.

⚠️ I am going to assume a basic understanding of RxJS. If you are new to RxJS then I would highly recommend reading David’s animated intro to RxJS first.

Drag Gesture

The drag gesture can be broken down into three stages: start, move & end. On start we grab the current location of the element. The move event provides the delta which we can use to move the element. Lastly, the end event provides us with a hook to do any kind of cleanup once the gesture has ended. Hammer’s Pan Recognizer provides us with panstart, panmove & panend events which work perfectly for the drag gesture.

Events to Observable

We start by creating a Hammer manager and configure it to handle pan in all directions. Rx.Observable.fromEvent allows us to convert events into an observable sequence. This one observable stream – pan$ – will allow us to subscribe to events for pan-start, pan-move and pan-end.

Composing the Drag Observable

For the drag gesture we want to create an observable stream such that it emits values from the the pan-move event once the pan-start event has been triggered and then stops emitting those values once the pan-end event is triggered.

drag$panStart$panMove$panEnd$⬇️🔁🔁🔁🔁🔁🔁🔁🔁🔁⬆️ Visualization of the drag observables. Generated using rxviz.com

The filter operator allows us to filter values based on a provided condition. We can use this to target specific events. For example, pan$.filter(e => e.type === 'panstart') to subscribe only to pan-start events. Then to generate the drag$ observable we then need to combine panstart$, panmove$ & panend$ in the following pattern:

Let’s break this down step by step. panstart$ is the observable that is driving the whole thing. When it emits the first value it switches to the panmove$ observable. This switching is done using the switchMap operator. The panmove$ observable then starts emitting the location values. We can tell it to stop when panend$ emits a value by chaining on the takeUntil operator. Therefore, all subscribers to drag$ only ever receive location values. You can see a simulated visualization of this setup here.

Now that we understand the basic structure we can flush out the details. The panmove event only provides delta values. To calculate the absolute position we need to start by getting the initial location. In this example I am getting that information from the element itself. To provide a cleanup hook we can subscribe to the move$ observable and handle it via the onComplete callback.

// Generates the drag$ observableconstdrag=({element,pan$})=>{constpanStart$=pan$.filter(e=>e.type==='panstart');constpanMove$=pan$.filter(e=>e.type==='panmove');constpanEnd$=pan$.filter(e=>e.type==='panend');panstart$.switchMap(()=>{// Get the starting point on pan-startconststart={x:+element.getAttribute('cx'),y:+element.getAttribute('cy'),};// Create observable to handle pan-move and stop on pan-endconstmove$=panmove$.map(pmEvent=>({x:start.x+pmEvent.deltaX,y:start.y+pmEvent.deltaY,})).takeUntil(panend$);// We can subscribe to move$ and handle cleanup in the onComplete callbackmove$.subscribe(null,null,()=>{/* Handle cleanup when pan ends */});returnmove$;});};

The pattern I shared above is based on the dragndrop example from the RxJS documentation.

Scaling to Canvas

Quite often I end up having to limit the element to a parent container. For example, a <circle> that can only be dragged within the <svg> container where the cx and cy values need to be calculated in the viewBox coordinate system.

This is essentially a global to local coordinate transform. With SVG this can get slightly tricky depending on how you want the SVG to scale. I generally prefer preserveAspectRatio="xMidYMid slice". This makes the SVG grow until it entirely covers the container – very similar to background-size: cover.

Smooth Motion

In the example above you’ll notice that the motion is somewhat rigid. The circle is stuck to the pointer and instantly stops wherever the pointer stops. We can make this better by adding smooth motion. This will also provide a bit of momentum to the circle.

For smooth motion I am using the LERP-ing technique. It is described in detail by David Khourshid in the An Animated Intro to RxJS article I mentioned earlier. The gist of it is that instead of using the drag$ observable directly we combine it with an animation timer. This allows us to smooth out the motion by using linear interpolation. However, we still have the possibility to subscribe to drag$ if we want access to the raw location.

Conclusion

You can see the power of RxJS here. We were able to convert events into observable streams. Then we composed those streams to create the drag$ observable. And finally we added the animation layer to smooth out the motion. The code is quite declarative. Each layer that we created is modular and can be easily composed to create complex scenarios – for example see the demo below and the one at the top of the page. Looking for more inspiration? Checkout CodePen for many more examples.