Touching ClojureScript
You can touch it yourself.
As a Windows user, I'm used to touching my laptop screen, and most of your users today are probably used to fingering, rather than clicking, the internet on their phones or tablets. Hammer.js is a frictionless JavaScript library for working with touch gestures, and ClojureScript's interop makes it smooth to use. Here we'll add pinch-to-zoom support to an image.
Hammer uses things called Recognisers to listen for (feel?) touch gestures. We'll be using three of them: pinch, pan and tap. We'll need to make and configure an instance of Hammer.Manager, and the same for each of the three Recognisers; then we'll plug them together, and finally we'll add some handler functions to do the actual work.
Creating the Zoomer
In this code I'm trying out Peter Taoussanis' naming conventions from his encore library. Hopefully it's all clear.
Dependencies
We don't need much for this, just Hammer and Reagent (you could just as easily use Om, Quiescent, Rum or some other React wrapper):
The Zoomer component
Let's start by creating a new Reagent component, which contains an image:
We've defined three local atoms, which will store our Hammer.Manager
as well as the current state of the user's interaction (that we've called a !zoom
). We use that state to apply a transform style to the image, using a function called transform
:
By using translate3d
instead of playing around with the CSS top
and left
properties, we give the browser the chance to hand off the transformations we'll be applying to a GPU, giving us a significant performance boost. There are three properties we're interested in: the x and y pan positions, and a scaling multiplier.
Hammer time
Now we've got a component that displays an image. The Hammer.Manager
needs to bind to a DOM node, so we have to create it after React has mounted our image component. We can add a :component-did-mount
lifecycle handler, which gets a reference to itself passed in by Reagent.
Note: you'll notice we've used the js-invoke
function to call the manager's functions - the stubs in the [cljsjs/hammer "2.0.4-5"]
jar don't work, so we call the manager directly.
Here's our :component-did-mount
lifecycle fn:
We've created a new Hammer.Manager
called mc
and added to it a new Hammer.Tap
Recogniser called tap
. In tap
's constructor we've defined a new event called doubletap
, which we've defined to be two of the tap events that the Hammer.Tap
Recogniser knows to look out for Finally we've stored the Manager in an atom. Why? So we can clean it up when the component unmounts with a :component-will-unmount
lifecycle fn:
Gesticulating
Great! We've set up Hammer to watch for gestures. We'd like the user to be able to zoom with doubletaps, pan the image by pressing and dragging, and also pinch-to-zoom.
Doubletap
In order for Hammer to detect gestures, we have to give it Recognisers. Here we've created one and at the same time defined a new event called doubletap
, which we define as being two of the built-in tap events. When we see one, we've asked Hammer to call our handler function, which sets the current scaling multiplier to 2 - i.e. we'll double the size of the image. If the scale has already changed, we reset it to 1 instead.
Pan
We need to add a Hammer.Pan
Recogniser and attach it to the Manager in the same way:
Here we're allowing the user to pan the image in any direction (by default up and down are disabled, so as to avoid interfering with scroll), and we aren't requiring a threshold of movement to be reached before we respond to the gesture - we want panning to be precise and immediate.
There's a hitch here though: the translate3d
CSS transform takes an absolute value, whereas Hammer's gesture events are giving us deltas between the state at the start of the gesture and now. That's where our !start-zoom
atom comes in - it's a place we can record the current state each time the user starts a new gesture. Hammer allows us to detect the start and end of each gesture as well as the gesture itself, in the case of pan these are called panstart
and pan
(all the events provided by each Recogniser are listed in the docs). When the user starts panning, we record the current zoom in the !start-zoom
atom; then as they pan around, we can add the deltas from the incoming events to the values stored in there.
Pinch
Enabling pinch-to-zoom is very similar. It's natural to want to both pinch and pan at the same time, and we could allow this using Hammer's recognizeWith
support, which allows the detection of multiple gestures simultaneously; luckily for us though, Hammer realises that this is a common requirement and the Hammer.Pinch
Recogniser already has a pinchmove
event that does exactly what we want. As with pan, we want to record the starting pan and scale so we can apply the delta from each event of the current gesture:
Note: the (.preventDefault %)
lines stop the doubletap
gesture from falling through to components underneath our zoomer, and eventually to the browser - this is important for preventing browser zoom on mobile safari, which doesn't respect user-scalable=no
.
Touch me
You can find the source on GitHub. Here's a running example.
The final zoomer.core
namespace looks like this: