Canvas 2D Web Apps/Responsive Buttons

This chapter extends the chapter on static buttons to cover responsive buttons that change their appearance as users click them or move the mouse pointer over them.

The Example

edit

The example of this chapter (which is available online; also as a downloadable version) extends the example of the chapter on static buttons by using one of three images (“normal,” “focused,” and “pressed”) for rendering buttons depending on their current state. Thus, the example also has to include a cuiButton object for each button, which keeps track of its state.

In spite of these changes, the basic structure of the example is still the same: the function myPageProcess repaints the canvas and processes events while calling each button's process function to handle its repainting and the processing of events that affect it.

<!DOCTYPE HTML>
<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>
      function init() {
        // get images
        imageNormalButton.src = "normal.png";
        imageNormalButton.onload = cuiRepaint;
        imageFocusedButton.src = "selected.png";
        imageFocusedButton.onload = cuiRepaint;
        imagePressedButton.src = "depressed.png";
        imagePressedButton.onload = cuiRepaint;

        // set defaults for all pages
        cuiBackgroundFillStyle = "#000000";
        cuiDefaultFont = "bold 20px Helvetica, sans-serif";
        cuiDefaultFillStyle = "#FFFFFF";

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

      // create images for the buttons
      var imageNormalButton = new Image();
      var imageFocusedButton = new Image();
      var imagePressedButton = new Image();

      // create a color
      var myColor = "#000000";

      // create buttons
      var button0 = new cuiButton();
      var button1 = new cuiButton();
      var button2 = new cuiButton();

      // create a new page of size 400x300 and attach myPageProcess
      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 buttons
        if (button0.process(event, 50, 50, 80, 50, "red",
          imageNormalButton, imageFocusedButton, imagePressedButton)) {
          if (button0.isClicked()) {
            myColor = "#FF0000";
            cuiRepaint();
          }
          return true;
        }
        if (button1.process(event, 150, 50, 80, 50, "green",
          imageNormalButton, imageFocusedButton, imagePressedButton)) {
          if (button1.isClicked()) {
            myColor = "#00FF00";
            cuiRepaint();
          }
          return true;
        }
        if (button2.process(event, 250, 50, 80, 50, "blue",
          imageNormalButton, imageFocusedButton, imagePressedButton)) {
          if (button2.isClicked()) {
            myColor = "#0000FF";
            cuiRepaint();
          }
          return true;
        }

        // repaint this page?
        if (null == event) {
          // draw color box
          cuiContext.fillStyle = myColor;
          cuiContext.fillRect(150, 150, 80, 80);

          // background
          cuiContext.fillStyle = "#404040";
          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>

Discussion

edit

This discussion assumes that you have read the chapter on static buttons and focuses on differences to the example presented in that chapter.

There are now three images (imageNormalButton, imageFocusedButton, and imagePressedButton). We set onload to cuiRepaint() for all of them to make sure that they are correctly rendered as soon as they are loaded.

The code no longer sets myPage.isDraggableWithOneFinger to false because we don't let users change the color to black by clicking on the background and can therefore allow them to drag the page instead.

The main new feature of this example are three cuiButton objects:

      // create buttons
      var button0 = new cuiButton();
      var button1 = new cuiButton();
      var button2 = new cuiButton();

Note that these objects constructors have no arguments because the objects all start in the “normal” state and their appearance is defined by the call to their process function. For the same reasons, most of the constructors of GUI elements in cui2d have no arguments. (cuiPage is an exception, mainly because its process function is only called internally in the render loop of cui2d.)

The three buttons are rendered and processed by calling their process function:

        // draw and react to buttons
        if (button0.process(event, 50, 50, 80, 50, "red",
          imageNormalButton, imageFocusedButton, imagePressedButton)) {
          if (button0.isClicked()) {
            myColor = "#FF0000";
            cuiRepaint();
          }
          return true;
        }
        if (button1.process(event, 150, 50, 80, 50, "green",
          imageNormalButton, imageFocusedButton, imagePressedButton)) {
          if (button1.isClicked()) {
            myColor = "#00FF00";
            cuiRepaint();
          }
          return true;
        }
        if (button2.process(event, 250, 50, 80, 50, "blue",
          imageNormalButton, imageFocusedButton, imagePressedButton)) {
          if (button2.isClicked()) {
            myColor = "#0000FF";
            cuiRepaint();
          }
          return true;
        }

A button's process function either repaints the button (for null == event) or tries to process the event (for null != event). It returns true if the event has been processed. In this case, we check whether the button is clicked with the isClicked() method. If this is the case, myColor is set accordingly and cuiRepaint() is called to request a repaint of the canvas.

Stepping Back

edit

At this point, it might be worthwhile to step back a bit and look at the structure of the code. The general structure of a call to a widget in cui2d is:

var <widget> = new <widget type>();

...

if (<widget>.process(<event>, <configuration arguments>)) {

<handle widget events>

return true;

}

The important point is that all the relevant information is in one place:

  • The layout of the widget on the page is specified by the configuration arguments of the widget's process function
  • The appearance of the widget is also specified by the configuration arguments.
  • The reaction to any widget events by the program is specified right after the call to the widget's process function.

The only part that is not in the same place is the call to the constructor. However, that call usually provides no relevant information since most widget constructors in cui2d have no arguments. Thus, all the relevant parts are together in one place in the code.

Compare this with standard GUI programming where it is considered good programming style to separate the definition of the layout from the definition of the appearance and from the event handling — often even across different files. While there might be good arguments to separate these things, it makes code necessarily more difficult to read and to change; thus, prototyping is more difficult, which is likely to result in less prototyping and, therefore, in worse products.

Implementation of cuiButton

edit

The cuiButton type is implemented in cui2d.js. It defines a constructor without arguments, which creates a new object in the initial state. This initial state is always the state before users have interacted with the object:

/**
 * Buttons are clickable rectangular regions.
 * @typedef cuiButton
 */

/**
 * Creates a new cuiButton.
 * @constructor
 */
function cuiButton() {
  this.isPointerInside = false; // mouse or touch point inside rectangle?
  this.isPointerDown = false; // mouse button down or finger down _on_this_button_?
  this.identifier = -1; // the identifier of the touch point
  this.hasTriggeredClick = false; // click event has been triggered?
}

Then a function to check whether the button has been clicked is defined:

/** Returns whether the button has just been clicked. */
cuiButton.prototype.isClicked = function() {
  return this.hasTriggeredClick;
}

Furthermore, a process function is defined. For event == null this is just a function for repainting the button. For event != null it is probably best to think of it as the step of an automaton; i.e., based on the current state of the button (as defined in this) and the event, a new state is set. Note that it is usually best to let the top-level if statements distinguish between different values of the variables in this (for example: if (!this.isPointerInside && !this.isPointerDown)) because this represents the state of the button. Only inside such if statements should the code distinguish between different kinds of events (for example: if (isIn && ("touchend" == event.type))). This structure allows programmers to easily check that all states and all relevant events for all states are covered. Using this structure in combination with appropriate indentation also allows readers to easily identify a certain state and all transitions from that state — which is exactly what a graphical state-transition diagram is also good for. (In fact, if the code describing the transitions is well structured, well commented, and well formatted, it might be almost as readable as a state-transition diagram but it has the advantage of being machine-readable.)

/**
 * Either process the event (if event != null) and return true if the event has been processed,
 * or draw the appropriate image for the button state in the rectangle
 * with a text string on top of it (if event == null) and return false.
 */
cuiButton.prototype.process = function(event, x, y, width, height,
  text, imageNormal, imageFocused, imagePressed) {
  // choose appropriate image
  var image = imageNormal;
  if (this.isPointerDown && this.isPointerInside) {
    image = imagePressed;
  }
  else if (this.isPointerDown || this.isPointerInside) {
    image = imageFocused;
  }

  // check or repaint button
  var isIn = cuiIsInsideRectangle(event, x, y, 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 isReleased (this is set only once after the click and has to be cleared afterwards)
  this.hasTriggeredClick = false;

  // 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) && event.identifier >= 0) {
      return false; // ignore mouse (except mousedown) if we are tracking a touch point
    }
  }

  // state changes
  if (!this.isPointerInside && !this.isPointerDown) { // passive button 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
      }
      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 button 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
      }
      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.isPointerInside && this.isPointerDown) { // focused button state (pushed previously)
    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
      }
      cuiRepaint();
      return true;
    }
    else if (isIn && ("mouseup" == event.type || ("mousemove" == event.type && 0 == event.buttons))) {
      this.isPointerDown = false;
      this.isPointerInside = true;
      this.identifier = -1; // mouse
      this.hasTriggeredClick = true;
      cuiRepaint();
      return true;
    }
    else if (isIn && ("touchend" == event.type)) {
      this.isPointerDown = false;
      this.isPointerInside = false;
      this.hasTriggeredClick = true;
      cuiRepaint();
      return true;
    }
    else if (isIn && ("touchcancel" == event.type)) {
      this.isPointerDown = false;
      this.isPointerInside = false;
      cuiRepaint();
      return true;
    }
    else if (isIn && ("touchmove" == event.type || ("mousemove" == event.type && 0 < event.buttons))) {
      this.isPointerInside = true;
      cuiRepaint();
      return true;
    }
    else if (!isIn && ("mouseup" == event.type || ("mousemove" == event.type && 0 == event.buttons) ||
      "touchend" == event.type || "touchcancel" == event.type)) {
      this.isPointerDown = false;
      this.isPointerInside = false;
      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 if ("touchmove" == event.type || "mousemove" == event.type) {
      return true; // this is our event, we feel responsible for it
    }
    else {
      return false; // none of our business
    }
  }
  else if (this.isPointerInside && this.isPointerDown) { // depressed button 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
      }
      cuiRepaint();
      return true;
    }
    else if (isIn && ("mouseup" == event.type || ("mousemove" == event.type && 0 == event.buttons))) {
      this.isPointerDown = false;
      this.isPointerInside = true;
      this.identifier = -1; // mouse
      this.hasTriggeredClick = true;
      cuiRepaint();
      return true;
    }
    else if (isIn && ("touchend" == event.type)) {
      this.isPointerDown = false;
      this.isPointerInside = false;
      this.hasTriggeredClick = true;
      cuiRepaint();
      return true;
    }
    else if (isIn && ("touchcancel" == event.type)) {
      this.isPointerDown = false;
      this.isPointerInside = false;
      cuiRepaint();
      return true;
    }
    else if (!isIn && ("mouseup" == event.type || ("mousemove" == event.type && 0 == event.buttons) ||
      "touchend" == event.type || "touchcancel" == event.type)) {
      this.isPointerDown = false;
      this.isPointerInside = false;
      cuiRepaint();
      return true;
    }
    else if (!isIn && ("touchmove" == event.type || ("mousemove" == event.type && 0 < event.buttons))) {
      this.isPointerInside = false;
      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 if ("touchmove" == event.type || "mousemove" == event.type) {
      return true; // this is our event, we feel responsible for it
    }
    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.