Canvas 2D Web Apps/Draggable Objects

This chapter introduces draggable objects, i.e., images that can be dragged around with a mouse or a one-finger touch gesture. One notable feature of the presented approach is that any number of objects can be dragged simultaneously on a multi-touch device. Moreover, application programmers don't have to worry about the handling of multiple simultaneous touch events: the process function receives only events with a single pair of coordinates and processes only one object at a time.

The Example

edit

The example of this chapter (which is available online; also as a downloadable version) shows two draggable objects: one cannot be dragged more than 100 pixels down; the second can be dragged only on a line between two points and snaps to one of the endpoints if it is no longer dragged. The following sections discuss how to set up these draggable objects. See the chapter on the framework for other parts.

The code creates one page with two draggable objects using three images (see the chapter on responsive buttons for a discussion of images):

html>
  <head>
    <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
    <meta name="viewport"
      content="width=device-width, initial-scale=1.0, user-scalable=no">

    <script src="cui2d.js"></script>

    <script>
      function init() {
        // get images
        imageNormalAlien.src = "alien_sleepy.png";
        imageNormalAlien.onload = cuiRepaint;
        imageFocusedAlien.src = "alien_wow.png";
        imageFocusedAlien.onload = cuiRepaint;
        imageGrabbedOnceAlien.src = "alien_smiley.png";
        imageGrabbedOnceAlien.onload = cuiRepaint;

        // set defaults for all pages
        cuiBackgroundFillStyle = "#000080";

        // initialize cui2d and start with myPage
        cuiInit(myPage);
      }

      // create images for the smiley
      var imageNormalAlien = new Image();
      var imageFocusedAlien = new Image();
      var imageGrabbedOnceAlien = new Image();

      // create draggable objects
      var draggable0 = new cuiDraggable();
      var draggable1 = new cuiDraggable();

      // create a page
      var myPage = new cuiPage(400, 300, myPageProcess);

      // a function to repaint the canvas and return false (if null == event)
      // or to process user events (if null != event) and return true
      // if the event has been processed
      function myPageProcess(event) {

        // draw and react to draggable
        if (draggable0.process(event, 50, 50, 80, 80, null,
          imageNormalAlien, imageFocusedAlien, imageGrabbedOnceAlien,
          cuiConstants.isDraggableWithOneFinger)) {
          if (draggable0.translationY > 100) {
            draggable0.translationY = 100;  // don't move further
          }
          cuiRepaint();
          return true;
        }
        if (draggable1.process(event, 150, 50, 80, 80, null,
          imageNormalAlien, imageFocusedAlien, imageGrabbedOnceAlien,
          cuiConstants.isDraggableWithOneFinger)) {
          draggable1.translationX = 0; // stay on line
          if (draggable1.translationY < 0) {
            draggable1.translationY = 0; // don't move beyond 0
          }
          else if (draggable1.translationY > 100) {
            draggable1.translationY = 100; // don't move beyond 100
          }
          if (!draggable1.isPointerDown) { // no more dragging?
            if (draggable1.translationY < 50) { // y coordinate < 50
              draggable1.translationY = 0; // snap to 0
            }
            else {
              draggable1.translationY = 100; // else snap to 100
            }
          }
          cuiRepaint();
          return true;
        }

        // repaint this page?
        if (null == event) {
          // background
          cuiContext.fillStyle = "#A0A0A0";
          cuiContext.fillRect(0, 0, this.width, this.height);
        }

        return false; // event has not been processed
      }
    </script>
  </head>

  <body bgcolor="#000000" onload="init()"
    style="-webkit-user-drag:none; -webkit-user-select:none; ">
    <span style="color:white;">A canvas element cannot be displayed.</span>
  </body>
</html>

The definition of the two global variables for the draggable objects is quite straightforward:

      // create draggable objects
      var draggable0 = new cuiDraggable();
      var draggable1 = new cuiDraggable();

The process function of the page handles these two objects. The first one is processed by this code:

        if (draggable0.process(event, 50, 50, 80, 80, null,
          imageNormalAlien, imageFocusedAlien, imageGrabbedOnceAlien,
          cuiConstants.isDraggableWithOneFinger)) {
          if (draggable0.translationY > 100) {
            draggable0.translationY = 100;  // don't move further
          }
          cuiRepaint();
          return true;
        }

The process function of the draggable objects requires the event, coordinates of a rectangle, a text string, and three images similar to those of responsive buttons. Additionally, the constant cuiConstants.isDraggableWithOneFinger specifies that dragging with one finger should be possible. It returns true if it has processed the event. Our reaction here is to check whether the object has been translated (i.e. moved) more than 100 pixels down from its original position. In that case, the translation is set to those 100 pixels. Thus, the object cannot be dragged beyond that line.

The second draggable object is handled by this code:

        if (draggable1.process(event, 150, 50, 80, 80, null,
          imageNormalAlien, imageFocusedAlien, imageGrabbedOnceAlien,
          cuiConstants.isDraggableWithOneFinger)) {
          draggable1.translationX = 0; // stay on line
          if (draggable1.translationY < 0) {
            draggable1.translationY = 0; // don't move beyond 0
          }
          else if (draggable1.translationY > 100) {
            draggable1.translationY = 100; // don't move beyond 100
          }
          if (!draggable1.isPointerDown) { // no more dragging?
            if (draggable1.translationY < 50) { // y coordinate < 50
              draggable1.translationY = 0; // snap to 0
            }
            else {
              draggable1.translationY = 100; // else snap to 100
            }
          }
          cuiRepaint();
          return true;
        }

Here, the translation in x direction (translationX) is set to 0; i.e. the object cannot move horizontally. Furthermore, the translation in y direction is restricted to a range of 0 to 100 pixels. Lastly, if the dragging has stopped (!draggable1.isPointerDown), the translation in y direction is set either to 0 or 100, i.e. it snaps to one of these points.

Of course, there are many more possibilities of reacting to the dragging of objects; for example, the detection of flick gestures.

Implementation of Draggable Objects

edit

The implementation of draggable objects is actually more similar to the implementation of buttons than one might expect. First a constructor is defined and some methods to determine whether and how the object has been clicked:

/**
 * @class cuiDraggable
 * @classdesc Draggables can be translated by dragging.
 *
 * @desc Create a new cuiDraggable.
 */
function cuiDraggable() {
  /**
   * Difference in x coordinate by which the centre of the draggable has been moved relative to its
   * initial position (specified by x + 0.5 * width with the arguments of {@link cuiDraggable#process}).
   * @member {number} cuiDraggable.translationX
   */
  this.translationX = 0;
  /**
   * Difference in y coordinate by which the centre of the draggable has been moved relative to its
   * initial position (specified by y + 0.5 * height with the arguments of {@link cuiDraggable#process}).
   * @member {number} cuiDraggable.translationY
   */
  this.translationY = 0;
  /**
   * Flag specifying whether a mouse button or finger is inside the object's rectangle.
   * @member {boolean} cuiDraggable.isPointerInside
   */
  this.isPointerInside = false;
  /**
   * Flag specifying whether a mouse button or finger is pushing the object or has been
   * pushing the object and is still held down (but may have moved outside the object's
   * rectangle).
   * @member {boolean} cuiDraggable.isPointerDown
   */
  this.isPointerDown = false;
  this.hasTriggeredClick = false; // click event has been triggered?
  this.hasTriggeredDoubleClick = false; // double click event has been triggered?
  this.hasTriggeredHold = false; // hold event has been triggered?
  this.timeDown = 0; // time in milliseconds after January 1, 1970 when the pointer went down
  this.eventXDown = 0; // x coordinate of the event when the pointer went down
  this.eventYDown = 0; // y coordinate of the event when the pointer went down
  this.identifier = -1; // identifier of the touch point
  this.translationXDown = 0; // value of translationX when the pointer went down
  this.translationYDown = 0; // value of translationY when the pointer went down
}

/**
 * Determine whether the draggable has just been clicked.
 * @returns {boolean} True if the draggable has been clicked, false otherwise.
 */
cuiDraggable.prototype.isClicked = function() {
  return this.hasTriggeredClick;
}

/**
 * Determine whether the draggable has just been double clicked.
 * @returns {boolean} True if the button has been double clicked, false otherwise.
 */
cuiDraggable.prototype.isDoubleClicked = function() {
  return this.hasTriggeredDoubleClick;
}

/**
 * Determine whether the pointer has just been held down longer than {@link cuiTimeUntilHold}.
 * @returns {boolean} True if the pointer has just been held down long enough, false otherwise.
 */
cuiDraggable.prototype.isHeldDown = function() {
  return this.hasTriggeredHold;
}

Draggable objects have considerably more members than buttons because they have to keep track of various positions during the dragging.

The process function computes changes of these members depending on the state of the objects and of the current event:

/**
 * Either process the event (if event != null) and return true if the event has been processed,
 * or draw the appropriate image for the object state in the rectangle
 * with a text string on top of it (if event == null) and return false.
 * This function is usually called by {@link cuiPage.process} of a {@link cuiPage}.
 * @param {Object} event – An object describing a user event by its "type", coordinates in
 * page coordinates ("eventX" and "eventY"), an "identifier" for touch events, and optionally
 * "buttons" to specify which mouse buttons are depressed. If null, the function should
 * redraw the object.
 * @param {number} x – The x coordinate of the top, left corner of the object's rectangle.
 * @param {number} y – The y coordinate of the top, left corner of the object's rectangle.
 * @param {number} width – The width of the object's rectangle.
 * @param {number} height – The height of the object's rectangle.
 * @param {string} text – A text that is written at the center of the rectangle. (May be null).
 * @param {Object} imageNormal – An image to be drawn inside the object's rectangle if there
 * are no user interactions. (May be null.)
 * @param {Object} imageFocused – An image to be drawn inside the object's rectangle if the
 * mouse hovers over the object's rectangle or a touch point moves into it. (May be null.)
 * @param {Object} imagePressed – An image to be drawn inside the object's rectangle if a
 * mouse button is pushed or the object is touched. (May be null.)
 * @param {number} interactionBits – The forms of interaction, either {@link cuiConstants.none} or a bitwise-or
 * of other constants in {@link cuiConstants}, e.g.
 * cuiConstants.isDraggableWithOneFinger | cuiConstants.isLimitedToHorizontalDragging.
 * @returns {boolean} True if event != null and the event has been processed (implying that
 * no other GUI elements should process it). False otherwise.
 */
cuiDraggable.prototype.process = function(event, x, y, width, height,
  text, imageNormal, imageFocused, imagePressed, interactionBits) {
  // choose appropriate image
  var image = imageNormal;
  if (this.isPointerDown) {
    image = imagePressed;
  }
  else if (this.isPointerInside) {
    image = imageFocused;
  }

  // check or repaint button
  var isIn = cuiIsInsideRectangle(event, x + this.translationX, y + this.translationY,
    width, height, text, image);
    // note that the event might be inside the rectangle (isIn == true)
    // but the state might still be isPointerDown == false (e.g. for touchend or
    // touchcancel or if the pointer went down outside of the button)

  // react to event
  if (null == event) {
    return false; // no event to process
  }

  // clear trigger events (these are set only once after the event and have to be cleared afterwards)
  this.hasTriggeredClick = false;
  this.hasTriggeredDoubleClick = false;
  this.hasTriggeredHold = false;

  // process double click events
  if ("dblclick" == event.type) {
    this.hasTriggeredDoubleClick = isIn;
    return isIn;
  }

  // process our hold events
  if ("mousehold" == event.type) {
    if (event.timeDown == this.timeDown && event.identifier == this.identifier &&
      this.isPointerDown) {
      this.hasTriggeredHold = true;
      return true;
    }
    return false;
  }

  // process other events
  if ("wheel" == event.type || "mousewheel" == event.type) {
    return isIn; // give directly to caller
  }

  // ignore mouse or touch points that are not the tracked point (apart from mousedown and touchstart)
  if (this.isPointerInside || this.isPointerDown) {
    if ("touchend" == event.type || "touchmove" == event.type || "touchcancel" == event.type) {
      if (event.identifier != this.identifier) {
        return false; // ignore all other touch points except "touchstart" events
      }
    }
    else if (("mousemove" == event.type || "mouseup" == event.type) && this.identifier >= 0) {
      return false; // ignore mouse (except mousedown) if we are tracking a touch point
    }
  }

  // state changes
  if (!this.isPointerInside && !this.isPointerDown) { // passive object state
    if (isIn && ("mousedown" == event.type || "touchstart" == event.type)) {
      this.isPointerDown = true;
      this.isPointerInside = true;
      if ("touchstart" == event.type) {
        this.identifier = event.identifier;
      }
      else {
        this.identifier = -1; // mouse
      }
      this.timeDown = (new Date()).getTime();
      setTimeout(cuiSendHoldEvent, cuiTimeUntilHold, event.clientX, event.clientY, this.identifier, this.timeDown);
      this.translationXDown = this.translationX;
      this.translationYDown = this.translationY;
      this.eventXDown = event.eventX;
      this.eventYDown = event.eventY;
      cuiRepaint();
      return true;
    }
    else if (isIn && ("mousemove" == event.type || "mouseup" == event.type ||
      "touchmove" == event.type)) {
      this.isPointerDown = false;
      this.isPointerInside = true;
      if ("touchmove" == event.type) {
        this.identifier = event.identifier;
      }
      else {
        this.identifier = -1; // mouse
      }
      cuiRepaint();
      return true;
    }
    else {
      return false; // none of our business
    }
  }
  else if (this.isPointerInside && !this.isPointerDown) { // focused object state (not pushed yet)
    if (isIn && ("mousedown" == event.type || "touchstart" == event.type)) {
      this.isPointerDown = true;
      this.isPointerInside = true;
      if ("touchstart" == event.type) {
        this.identifier = event.identifier;
      }
      else {
        this.identifier = -1; // mouse
      }
      this.timeDown = (new Date()).getTime();
      setTimeout(cuiSendHoldEvent, cuiTimeUntilHold, event.clientX, event.clientY, this.identifier, this.timeDown);
      this.translationXDown = this.translationX;
      this.translationYDown = this.translationY;
      this.eventXDown = event.eventX;
      this.eventYDown = event.eventY;
      cuiRepaint();
      return true;
    }
    else if (isIn && ("touchend" == event.type || "touchcancel" == event.type)) {
      this.isPointerDown = false;
      this.isPointerInside = false;
      cuiRepaint();
      return true;
    }
    else if (!isIn && ("touchmove" == event.type || "touchend" == event.type ||
      "touchcancel" == event.type || "mousemove" == event.type || "mouseup" == event.type)) {
      this.isPointerDown = false;
      this.isPointerInside = false;
      cuiRepaint();
      return false; // none of our business
    }
    else {
      return false; // none of our business
    }
  }
  else if (this.isPointerDown) { // grabbed object state
    if (isIn && ("mousedown" == event.type || "touchstart" == event.type)) {
      this.isPointerDown = true;
      this.isPointerInside = true;
      if ("touchstart" == event.type) {
        this.identifier = event.identifier;
      }
      else {
        this.identifier = -1; // mouse
      }
      this.timeDown = (new Date()).getTime();
      setTimeout(cuiSendHoldEvent, cuiTimeUntilHold, event.clientX, event.clientY, this.identifier, this.timeDown);
      this.translationXDown = this.translationX;
      this.translationYDown = this.translationY;
      this.eventXDown = event.eventX;
      this.eventYDown = event.eventY;
      cuiRepaint();
      return true;
    }
    else if ("mouseup" == event.type || ("mousemove" == event.type && 0 == event.buttons)) {
      this.isPointerDown = false;
      this.isPointerInside = isIn;
      this.identifier = -1; // mouse
      this.hasTriggeredClick = true;
      cuiRepaint();
      return true;
    }
    else if ("touchend" == event.type) {
      this.isPointerDown = false;
      this.isPointerInside = false;
      this.hasTriggeredClick = true;
      cuiRepaint();
      return true;
    }
    else if ("touchcancel" == event.type) {
      this.isPointerDown = false;
      this.isPointerInside = false;
      cuiRepaint();
      return true;
    }
    else if ("touchmove" == event.type || ("mousemove" == event.type)) {
      this.isPointerInside = isIn;
      if (cuiConstants.isDraggableWithOneFinger & interactionBits) {
        if (!(cuiConstants.isLimitedToVerticalDragging & interactionBits)) {
          this.translationX = this.translationXDown + (event.eventX  this.eventXDown);
        }
        if (!(cuiConstants.isLimitedToHorizontalDragging & interactionBits)) {
          this.translationY = this.translationYDown + (event.eventY  this.eventYDown);
        }
      }
      cuiRepaint();
      return true;
    }
    else if (!isIn && (("mousedown" == event.type && this.identifier < 0) ||
      ("touchstart" == event.type && this.identifier == event.identifier))) {
      this.isPointerDown = false;
      this.isPointerInside = false;
      return false; // none of our business
    }
    else {
      return false; // none of our business
    }
  }
  // unreachable code
  return false;
}


< Canvas 2D Web Apps

Unless stated otherwise, all example source code on this page is granted to the public domain.