JS 201 - Drag and Drop

  1. Complex custom interactions

    Drag and drop is a complex interaction that requires many pieces of logic working together in order to work. Sure, you could use a JQuery library to do it all for you, but then you'd be restricted by the libraries functionality if you wanted to expand it or try something new and innovative. With that in mind, let's recreate a common interaction.

    Start a new project with the following code:

    .container
      #draggable
    
    #draggable
      width: 100px
      height: 100px
      background: $p400
      position: absolute
      cursor: grab
      cursor: -webkit-grab
      top: 0
      left: 0
    

    DEMO

  2. Adding our events

    Next lets bind our events. We need three events: `mousedown` for when they initially click, `mousemove` for when they're dragging, and `mouseup` for when they're releasing.

    var proto = {
      init: function(){
        document.querySelector('#draggable').addEventListener('mousedown', proto.mouseDown)
      },
      mouseDown: function(){
        console.log('mousedown')
        document.addEventListener('mouseup', proto.mouseUp)
        document.addEventListener('mousemove', proto.mouseMove)
      },
      mouseUp: function(){
        console.log('mouseup')
        document.removeEventListener('mouseup', proto.mouseUp)
        document.removeEventListener('mousemove', proto.mouseMove)
      },
      mouseMove: function(){
        console.log('mousemove')
      }
    }
    
    document.addEventListener('DOMContentLoaded', proto.init)
    

    DEMO

    Open the console and click and drag the square. You should see the events firing.

    Note a couple things about our events. We only bind the `mousemove` and `mouseup` events when we've click down. If we had these listeners on all the time, `mousemove` would be firing continuously. Another interesting thing is that while we bind `mousedown` on the element itself, we bind `mousemove` and `mouseup` on the document level. This is for two reasons - one is that if you're moving your mouse quickly, it's often easy to out-pace the dragged element, leaving it behind in the dust. The other is often times we use drag and drop interactions in sliders, which have constrained bounds. If our mouse goes past those bounds, we still want the `mouseup` to trigger when it's released.

  3. Naive dragging

    Now lets implement the most basic of dragging functionality. We're simply going to set the position of the square equal to the position of the mouse.

    var proto = {
      init: function(){
        proto.dom = document.querySelector('#draggable')
        proto.dom.addEventListener('mousedown', proto.mouseDown)
      },
      dom: null,
      mouseDown: function(){
        document.addEventListener('mouseup', proto.mouseUp)
        document.addEventListener('mousemove', proto.mouseMove)
      },
      mouseUp: function(){
        document.removeEventListener('mouseup', proto.mouseUp)
        document.removeEventListener('mousemove', proto.mouseMove)
      },
      mouseMove: function(e){
        proto.dom.style.left = e.clientX
      }
    }
    
    document.addEventListener('DOMContentLoaded', proto.init)
    

    DEMO

    Here we import the event data into the `mouseMove` function by adding it as an argument, `e`. When events are fired they automatically populate this value with data from the event. In this situation, a `mousemove` event gives us the distance of the mouse from the left of the browser window with the `e.clientX` value, which we assign to the left value of our draggable element.

    Clearly this has major issues. The distance between the mouse and the left side of the browser should not necessarily always be the distance between the square and the left side of the container.

    Also worth noting - I'm now storing `proto.dom` as a reference to the dragged element. Since I'm now referring to it twice, I want to ensure that I store it as a variable rather than use `querySelector` every time.

  4. Using the delta

    Instead of the naive approach, let's instead track the position of the square and change it by the distance the mouse has moved from where it first clicked down (the delta). To do this, we need to store the location where we clicked.

    var proto = {
      init: function(){
        proto.dom = document.querySelector('#draggable')
        proto.dom.addEventListener('mousedown', proto.mouseDown)
      },
      dom: null,
      xDown: 0,
      mouseDown: function(e){
        proto.xDown = e.clientX
        document.addEventListener('mouseup', proto.mouseUp)
        document.addEventListener('mousemove', proto.mouseMove)
      },
      mouseUp: function(){
        document.removeEventListener('mouseup', proto.mouseUp)
        document.removeEventListener('mousemove', proto.mouseMove)
      },
      mouseMove: function(e){
        var delta = e.clientX - proto.xDown
        console.log("Distance mouse has moved: " + delta)
        proto.dom.style.left = delta
      }
    }
    
    document.addEventListener('DOMContentLoaded', proto.init)
    

    DEMO

    This implementation works pretty well! It follows the mouse nicely when you drag it; or at least it does until you try it a second time. Because we're only setting it to the delta, it resets to 0 every time click on it. What we really need to do is track the x position of the element, and add the delta to that position.

  5. Initial position awareness

    Now we're gonna use a new variable `x` to track that initial position.

    var proto = {
      init: function(){
        proto.dom = document.querySelector('#draggable')
        proto.dom.addEventListener('mousedown', proto.mouseDown)
      },
      dom: null,
      xDown: 0,
      x: 0,
      mouseDown: function(e){
        proto.xDown = e.clientX
        document.addEventListener('mouseup', proto.mouseUp)
        document.addEventListener('mousemove', proto.mouseMove)
      },
      mouseUp: function(){
        proto.x = parseInt(proto.dom.style.left)
        document.removeEventListener('mouseup', proto.mouseUp)
        document.removeEventListener('mousemove', proto.mouseMove)
      },
      mouseMove: function(e){
        var delta = e.clientX - proto.xDown
        proto.dom.style.left = proto.x + delta
      }
    }
    
    document.addEventListener('DOMContentLoaded', proto.init)
    

    DEMO

    Much better. Now we have a functional drag/drop implementation that stays where it should. There are still pieces of polish we could add to this though: a lift effect when the object is picked up, disabling selection on the page so text doesnt randomly highlight when objects are being dragged around, bi-directional dragging, etc. But lucky for you that's what exercises are for!

Exercises

  1. Disable highlighting when dragging

    Note that when you click and drag the box, if you move your mouse over text while dragging it will begin to highlight it. This is not a good user interaction as it isn't what their intent is while dragging. In order to combat this, add a class to the `body` of the page that when added disables highlighting globally whenever the element is being dragged. Be sure to remove the class when the dragging stops.

    For reference, the css to disable text selection is:

    user-select: none
    

    DEMO

    Answer

    body.dragging
      user-select: none
    
    #draggable
      width: 100px
      height: 100px
      background: $p400
      position: absolute
      cursor: grab
      cursor: -webkit-grab
      top: 0
      left: 0
    var proto = {
      init: function(){
        proto.dom = document.querySelector('#draggable')
        proto.dom.addEventListener('mousedown', proto.mouseDown)
      },
      dom: null,
      xDown: 0,
      x: 0,
      mouseDown: function(e){
        proto.xDown = e.clientX
        document.addEventListener('mouseup', proto.mouseUp)
        document.addEventListener('mousemove', proto.mouseMove)
        document.body.classList.add('dragging')
      },
      mouseUp: function(){
        proto.x = parseInt(proto.dom.style.left)
        document.removeEventListener('mouseup', proto.mouseUp)
        document.removeEventListener('mousemove', proto.mouseMove)
        document.body.classList.remove('dragging')
      },
      mouseMove: function(e){
        var delta = e.clientX - proto.xDown
        proto.dom.style.left = proto.x + delta
      }
    }
    
    document.addEventListener('DOMContentLoaded', proto.init)
    
  2. Add multi-axis dragging

    Dragging just on the x-axis is boring. Add support for the y-axis as well.

    DEMO

    Answer

    var proto = {
      init: function(){
        proto.dom = document.querySelector('#draggable')
        proto.dom.addEventListener('mousedown', proto.mouseDown)
      },
      dom: null,
      xDown: 0,
      yDown: 0,
      x: 0,
      y: 0,
      mouseDown: function(e){
        proto.xDown = e.clientX
        proto.yDown = e.clientY
        document.addEventListener('mouseup', proto.mouseUp)
        document.addEventListener('mousemove', proto.mouseMove)
        document.body.classList.add('dragging')
      },
      mouseUp: function(){
        proto.x = parseInt(proto.dom.style.left)
        proto.y = parseInt(proto.dom.style.top)
        document.removeEventListener('mouseup', proto.mouseUp)
        document.removeEventListener('mousemove', proto.mouseMove)
        document.body.classList.remove('dragging')
      },
      mouseMove: function(e){
        var xDelta = e.clientX - proto.xDown
        var yDelta = e.clientY - proto.yDown
        proto.dom.style.left = proto.x + xDelta
        proto.dom.style.top = proto.y + yDelta
      }
    }
    
    document.addEventListener('DOMContentLoaded', proto.init)
    
  3. Provide visual feedback

    Right now there's no way for the user to know that the element can be dragged around until you move the mouse. Users are dumb - if you don't provide visual feedback even for simple actions, they may miss what just happened.

    Add a class to the element itself that makes it lift off the page slightly when it's being dragged. For bonus points make the cursor change to `grabbing` and `-webkit-grabbing` as well.

    DEMO

    Answer

    //adding and removing the dragging class from proto.dom
    
    mouseDown: function(e){
      proto.xDown = e.clientX
      proto.yDown = e.clientY
      document.addEventListener('mouseup', proto.mouseUp)
      document.addEventListener('mousemove', proto.mouseMove)
      document.body.classList.add('dragging')
      proto.dom.classList.add('dragging')
    },
    mouseUp: function(){
      proto.x = parseInt(proto.dom.style.left)
      proto.y = parseInt(proto.dom.style.top)
      document.removeEventListener('mouseup', proto.mouseUp)
      document.removeEventListener('mousemove', proto.mouseMove)
      document.body.classList.remove('dragging')
      proto.dom.classList.remove('dragging')
    },
    
    #draggable
      width: 100px
      height: 100px
      background: $p400
      position: absolute
      cursor: grab
      cursor: -webkit-grab
      top: 0
      left: 0
      transition: transform 0.2s $ak-ease-out, box-shadow 0.2s $ak-ease-out
      transform: translateY(0)
      box-shadow: 0px 5px 2px 2px rgba(0,0,0,0)
      &.dragging
        transform: translateY(-4px)
        box-shadow: 0px 5px 2px 2px rgba(0,0,0,0.5)
        cursor: grabbing
        cursor: -webkit-grabbing