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:
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:
- Start running the action
- 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()