Draggable objects

from Red Blob Games
7 Nov 2018

Table of Contents

On many of my pages I want to drag objects around. I use d3.drag for this when I’m using d3.js, but I also have many projects where I’m not using d3.js.

I have my own home-grown draggable.js library but it started out pretty limited, and I keep adding random features to it as I need them. I wanted to redesign its interface to be both easier to use and amenable to extension.

Draggable v1 handles SVG coordinates, so it’s mapped the pixel coordinates into the SVG coordinate space automatically. It doesn’t translate through CSS transforms.

I want to design Draggable v2 to have similar features but a nicer interface.

#Move SVG shape

{
    let svg = document.querySelector("#example-svg-move-1");
    let g = svg.querySelector("g");
    makeDraggable(svg, g,
              (begin, current, state) => {
                 g.setAttribute('transform', `translate(${current.x}, ${current.y})`);
              });
}

If you play with this though you’ll notice that it “snaps” to the mouse location. The fix is to remember where you started the drag, and remember the offset between that and the center. Then apply that same offset later. The state parameter is there to let you remember per-drag state like the offset. Try dragging from the corner of the object and see how it behaves differently than above:

{
    let svg = document.querySelector("#example-svg-move-2");
    let g = svg.querySelector("g");
    let pos = {x: 0, y: 0};
    makeDraggable(svg, g,
              (begin, current, state) => {
                 if (!state) {
                     return {dx: pos.x - begin.x, dy: pos.y - begin.y};
                 }
                 pos.x = current.x + state.dx;
                 pos.y = current.y + state.dy;
                 g.setAttribute('transform', `translate(${pos.x}, ${pos.y})`);
                 return state;
              });
}

There are some things I don’t like here:

  1. The state is being returned and passed around instead of using a more conventional way of storing state. The reason is that there’s no per drag operation object where I can store that state.
  2. The handler has to check whether state is defined in order to figure out whether the drag started. And then it does something different.

What if I use Javascript’s features to my advantage? In Javascript, this is not bound to the object where the method is defined but instead where the method is called. This confuses everyone. But it turns to be useful! If I create a new object representing each drag operation, I can use that to store the state.

{
    let svg = document.querySelector("#example-svg-move-3");
    let g = svg.querySelector("g");
    let pos = {x: 0, y: 0};
    new Draggable({
      el: g,
      parent: svg,
      start(event) {
        this.state = {dx: pos.x - event.x, dy: pos.y - event.y};
      },
      drag(event) {
        pos.x = event.x + this.state.dx;
        pos.y = event.y + this.state.dy;
        g.setAttribute('transform', `translate(${pos.x}, ${pos.y})`);
      }
    });
}

#Change marker during drag operation

Another thing I didn’t need initially but added later, in an ugly way, was detecting the end-drag operation. In this example I set the state during the drag and then reset it.

{
    let svg = document.querySelector("#example-start-end-1");
    let g = svg.querySelector("g");
    let circle = g.querySelector("circle");
    makeDraggable(svg, g,
              (begin, current, state) => {
                 if (!state) { circle.setAttribute('fill', "yellow"); }
                 g.setAttribute('transform', `translate(${current.x}, ${current.y})`);
              })
    .onDragEnd(() => {
       circle.setAttribute('fill', "black");
    });
}

With the new interface, I think it’s a bit cleaner and easier to read:

{
    let svg = document.querySelector("#example-start-end-2");
    let g = svg.querySelector("g");
    let circle = g.querySelector("circle");
    new Draggable({
      el: g,
      parent: svg,
      start(_event) {
        circle.setAttribute('fill', "yellow");
      },
      drag(event) {
        g.setAttribute('transform', `translate(${event.x}, ${event.y})`);
      },
      end(_event) {
        circle.setAttribute('fill', "black");
      }
    });
}

#Paint on canvas

Let’s try a different type of operation. Instead of moving something, use the position to draw on a canvas. Because the dom object isn’t moving, we don’t need a separate reference element.

{
    let canvas = document.querySelector("#example-canvas-1");
    let ctx = canvas.getContext('2d');
    makeDraggable(canvas, canvas,
              (begin, current, state) => {
                ctx.fillRect(current.x-1, current.y-1, 3, 3);
              });
}
{
    let canvas = document.querySelector("#example-canvas-2");
    let ctx = canvas.getContext('2d');
    new Draggable({
      el: canvas,
      drag(event) {
          ctx.fillRect(event.x-1, event.y-1, 3, 3);
      }
    });
}

#Remove event handlers

Let’s try removing the event handlers after moving a certain distance. With the old interface, I have to keep a reference to the returned value.

{
    let canvas = document.querySelector("#example-cleanup-1");
    let ctx = canvas.getContext('2d');
    let count = 100;
    let draggable = makeDraggable(canvas, canvas,
              (begin, current, state) => {
                ctx.fillRect(current.x-1, current.y-1, 3, 3);
                if (--count <= 0) {
                  console.log('cleanup example: count is ', count);
                  draggable.cleanup();
                }
              });
}

With the new interface, this points to an object with an uninstall method.

{
    let canvas = document.querySelector("#example-cleanup-2");
    let ctx = canvas.getContext('2d');
    let count = 100;
    new Draggable({
        el: canvas,
        drag(event) {
            ctx.fillRect(event.x-1, event.y-1, 3, 3);
            if (--count <= 0) {
                console.log('cleanup example: count is ', count);
                this.uninstall();
            }
        }
    });
}

#Right mouse drag

The code doesn’t allow this right now. I think I’d implement it by having a default filter that determines whether mouseDown returns immediately, and then you can pass in your own filter.

#Cancel current drag

Not implemented.

#Vue integration

A directive can do this. However, because this is normally bound to the Vue component, I pass the drag operation as a separate parameter:

Vue.directive('draggable', {
    bind(el, binding) {
        Vue.nextTick(() => {
            // Have to wait for next tick so that ownerSVGElement is set
            const props = binding.value;
            el.__draggable__ = new Draggable({
                reference: el.ownerSVGElement,
                el: el,
                start(event) { props.start && props.start(this, event); },
                drag(event) { props.drag && props.drag(this, event); },
                end(event) { props.end && props.end(this, event); },
            });
        });
    },
    unbind(el, binding) {
        el.__draggable__.uninstall();
        delete el.__draggable__;
    }
});

The use would be something like:

<my-component v-draggable="{start, drag}"/>

and then it’ll call the start, drag methods on your component with operation, event as parameters.

#Touch vs click

The event object has a mouse_button or touch_identifier that tells you whether it was a mouse drag or a touch drag, and which button was used.

#Multitouch

I treat each finger as a separate drag and don’t have a way to act on a set of fingers at once.

#10  Initial experiences

I used this in two real projects. The interface does seem cleaner. However the use of this interferes with existing uses of this, so I had to resort to the that = this trick. :-(

Email me at , or tweet to @redblobgames, or post a public comment: