Buttons with Hold Events in Angular.js

Sage Griffin

Creating an interaction with a simple button in Angular only requires adding the ngClick directive. However, sometimes an on click style interaction isn’t sufficient. Let’s take a look at how we can have a button which performs an action as long as it’s pressed.

For the example, we’ll use two buttons which can be used to zoom a camera in and out. We want the camera to continue zooming, until the button is released. The final effect will work like this:

Zooming in Martial Codex

Our template might look something like this:

<a href while-pressed="zoomOut()">
  <i class="fa fa-minus">_
</a>
<a href while-pressed="zoomIn()">
  <i class="fa fa-plus">_
</a>

We’re making a subtle assumption with this interface. By adding the parenthesis, we imply that whilePressed will behave similarly to ngClick. The given value is an expression that will get evaluated continuously when the button is pressed, rather than us handing it a function object for it to call. In practice, we can use the '&' style of arguments in our directive to capture the expression. You can find more information about the different styles of scopes here.

whilePressed = ->
  restrict: "A"

  scope:
    whilePressed: '&'

Binding the Events

When defining more complex interactions such as this one, Angular’s built-in directives won’t give us the control we need. Instead, we’ll fall back to manual event binding on the element. For clarity, I tend prefer to separate the callback function from the event bindings. Since we’re manipulating the DOM, our code will go into a link function. Our initial link function will look like this:

link: (scope, elem, attrs) ->
  action = scope.whilePressed

  bindWhilePressed = ->
    elem.on("mousedown", beginAction)

  beginAction = (e) ->
    e.preventDefault()
    # Do stuff

  bindWhilePressed()

Inside of our action we’ll need to do two things:

  1. Start running the action
  2. Bind to mouseup to stop running the action.

For running the action, we’ll use Angular’s $interval service. $interval is a wrapper around JavaScript’s setInterval, but gives us a promise interface, better testability, and hooks into Angular’s digest cycle.

In addition to running the action continuously, we’ll also want to run it immediately to avoid a delay. We’ll run the action every 15 milliseconds, as this will roughly translate to once per browser frame.

+TICK_LENGTH = 15
+
-whilePressed = ->
+whilePressed = ($interval) ->
   restrict: "A"

   link:
     action = scope.whilePressed

@@ -23,7 +24,7 @@
     beginAction = (e) ->
       e.preventDefault()
+      action()
+      $interval(action, TICK_LENGTH)
+      bindEndAction()

In our beginAction function, we call bindEndAction to set up the events to stop running the event. We know that we’ll at least want to bind to mouseup on our button, but we have to decide how to handle users who move the mouse off of the button before releasing it. We can handle this by listening for mouseleave on the element, in addition to mouseup.

bindEndAction = ->
  elem.on('mouseup', endAction)
  elem.on('mouseleave', endAction)

In our endAction function, we’ll want to cancel the $interval for our action, and unbind the event listeners for mouseup and mouseleave.

unbindEndAction = ->
  elem.off('mouseup', endAction)
  elem.off('mouseleave', endAction)

endAction = ->
  $interval.cancel(intervalPromise)
  unbindEndAction()

We’ll also need to store the promise that $interval returned so that we can cancel it when the mouse is released.

 whilePressed = ($parse, $interval) ->
   link: (scope, elem, attrs) ->
     action = scope.whilePressed
+    intervalPromise = null

     bindWhilePressed = ->
       elem.on('mousedown', beginAction)
@@ -23,7 +24,7 @@
     beginAction = (e) ->
       e.preventDefault()
       action()
-      $interval(action, TICK_LENGTH)
+      intervalPromise = $interval(action, TICK_LENGTH)
       bindEndAction()

Cleaning Up

Generally I consider it a smell to have an isolated scope on any directive that isn’t an element. Each DOM element can only have one isolated scope, and attribute directives are generally meant to be composed. So let’s replace our scope with a manual use of $parse instead.

$parse takes in an expression, and will return a function that can be called with a scope and an optional hash of local variables. This means we can’t call action directly anymore, and instead need a wrapper function which will pass in the scope for us.

-whilePressed = ($interval) ->
-  scope:
-    whilePressed: "&"
-
+whilePressed = ($parse, $interval) ->
   link: (scope, elem, attrs) ->
-    action = scope.whilePressed
+    action = $parse(attrs.whilePressed)
     intervalPromise = null

     bindWhilePressed = ->
@@ -26,14 +23,17 @@ whilePressed = ($interval) ->

     beginAction = (e) ->
       e.preventDefault()
-      action()
-      intervalPromise = $interval(action, TICK_LENGTH)
+      tickAction()
+      intervalPromise = $interval(tickAction, TICK_LENGTH)
       bindEndAction()

     endAction = ->
       $interval.cancel(intervalPromise)
       unbindEndAction()

+    tickAction = ->
+      action(scope)

And that’s it. Our end result is a nicely decoupled Angular UI component that can easily be reused across applications. The final code looks like this.

TICK_LENGTH = 15

whilePressed = ($parse, $interval) ->
  restrict: "A"

  link: (scope, elem, attrs) ->
    action = $parse(attrs.whilePressed)
    intervalPromise = null

    bindWhilePressed = ->
      elem.on('mousedown', beginAction)

    bindEndAction = ->
      elem.on('mouseup', endAction)
      elem.on('mouseleave', endAction)

    unbindEndAction = ->
      elem.off('mouseup', endAction)
      elem.off('mouseleave', endAction)

    beginAction = (e) ->
      e.preventDefault()
      tickAction()
      intervalPromise = $interval(tickAction, TICK_LENGTH)
      bindEndAction()

    endAction = ->
      $interval.cancel(intervalPromise)
      unbindEndAction()

    tickAction = ->
      action(scope)

    bindWhilePressed()