Multitouch gesture transformations with EaselJS
One thing I really liked about EaselJS in the first place was its support for touch events despite the lack of multitouch gestures. So I started to implement them myself as it’s not really rocket science.
Here’s a simple example with a square that can be moved around the canvas but also scaled and rotated by using two fingers. You can either try it on your iPad or by holding down the shift key on a desktop computer: Multitouch Example
By enabling touch support in EaselJs the individual events can be handled like this:
1 2 3 4 5 6 7 8 | createjs.Touch.enable(stage); var element = new createjs.Container(); element.addEventListener('pressmove', pressmove); function pressmove(event){ console.log('finger #' + event.pointerID + ' at ' + event.stageX + ', ' + event.stageY); } |
It’s actually as if you would handle mouse events with the addition of a pointerID (the ID of the touchpoint – your finger), that is dispatching the event.
With that information it’s really simple to handle the events. The most important thing is to keep track of all the touchpoint-positions for later calculations.
Abstract touchpoint-tracker class
By using some utility functions I previously wrote and like to use for creating classes – sys.js – you can create a subclass of EaselJS‘ Container that keeps track of touchpoint-positions for later calculations:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 | setPackage('com.firsara.display'); com.firsara.display.Transformable = (function(){ var Parent = createjs.Container; var Transformable = function(){ // reference to instance var self = this; self._activeFingers = 0; self._fingers = []; var _changed = false; var Init = function(){ // call super constructor if (Parent) Parent.call(self); self._initialized = true; self.addEventListener('mousedown', _mousedown); self.addEventListener('pressmove', _pressmove); self.addEventListener('pressup', _pressup); self.addEventListener('tick', _enterFrame); }; // store initial touchpoint-position var _mousedown = function(event){ if (! event.pointerID) event.pointerID = -1; self._fingers[event.pointerID] = { start: {x: event.stageX, y: event.stageY}, current: {x: event.stageX, y: event.stageY}, old: {x: event.stageX, y: event.stageY} }; _calculateActiveFingers(); self.dispatchEvent('start'); }; // update touchpoint-positions var _pressmove = function(event){ if (! event.pointerID) event.pointerID = -1; self._fingers[event.pointerID].current.x = event.stageX; self._fingers[event.pointerID].current.y = event.stageY; _calculateActiveFingers(); _changed = true; }; // if positions changed (through pressmove): dispatch update-event for later usage and keep track of old point-position // dispatch updates only on tick to save some performance var _enterFrame = function(){ if (_changed) { _changed = false; self.dispatchEvent('update'); for (var pointerID in self._fingers) { if (self._fingers[pointerID].start) { self._fingers[pointerID].old.x = self._fingers[pointerID].current.x; self._fingers[pointerID].old.y = self._fingers[pointerID].current.y; } } } }; // delete old and unused finger-positions var _pressup = function(event){ if (! event.pointerID) event.pointerID = -1; if (self._fingers[event.pointerID]) { delete(self._fingers[event.pointerID]); } _calculateActiveFingers(); self.dispatchEvent('complete'); }; // calculates currently active fingers, can be used later in subclasses var _calculateActiveFingers = function(){ self._activeFingers = 0; for (var pointerID in self._fingers) { if (self._fingers[pointerID].start) { self._activeFingers++; } } }; Init(); }; // export public Transformable definition Transformable.prototype = {}; // extend Transformable with defined parent if (Parent) sys.inherits(Transformable, Parent); // return Transformable definition to public scope return Transformable; })(); |
I won’t explain all the different functions of the class in depth, they’re pretty simple and straight forward and I’m sure you can figure it all out by yourself!
The class basically just keeps track of all the touchpoint-positions and dispatches events when positions are changed. It’s an abstract class that can be used by subclasses to calculate the transformations later on.
Implementing subclasses with transformations
With our base class Transformable we can create subclasses and calculate the individual transformations. My approach was to define three different classes, one for each type of transformation (x and y positions, scale, rotation) and one last class that uses the other ones as a mixin to combine all of the transformation-classes into single one.
This way it’s much easier to adjust behaviours and fix bugs. Plus you can also save a lot of performance by only using the class you actually need if you don’t have to have all transformations on a single element.
Let’s start with the easiest one:
rotation
Rotation can simply be set by constantly adding the difference of the angles between finger movements.
Let’s say you initially touch with two fingers at a 45° angle and move one finger in a circle-form around your object to 20°. Your object will be rotated to -25° (new angle subtracted by old angle). Apply this calculation every time the touchpoint-positions change and you’re done.
The angle between two points can be calculated by using Math.atan2:
1 2 3 | var deltaX = point1.x - point2.x; var deltaY = point1.y - point2.y; var angle = Math.atan2(deltaY, deltaX); |
Note that the angle is in radians so you’ll have to multiply by 180/Math.PI to get the corresponding angle in degrees.
With that formula we can implement a RotateClip like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 | setPackage('com.firsara.display'); com.firsara.display.RotateClip = (function(){ var Parent = com.firsara.display.Transformable; var RotateClip = function(){ var self = this; var Init = function(){ // call super constructor, only if instance is not a mixin of another class if (Parent && ! self._initialized) Parent.call(self); self.addEventListener('update', _update); }; var _update = function(event){ if (self._activeFingers > 1) { var points = []; // extract touchpoints for (var k in self._fingers) { if (self._fingers[k].current) { points.push(self._fingers[k]); if (points.length >= 2) break; } } // calculate initial angle var point1 = points[0].old; var point2 = points[1].old; var startAngle = Math.atan2((point1.y - point2.y), (point1.x - point2.x)) * (180 / Math.PI); // calculate new angle var point1 = points[0].current; var point2 = points[1].current; var currentAngle = Math.atan2((point1.y - point2.y), (point1.x - point2.x)) * (180 / Math.PI); // set rotation based on difference between the two angles self.rotation += (currentAngle - startAngle); self.dispatchEvent('rotate'); } }; Init(); }; RotateClip.prototype = {}; if (Parent) sys.inherits(RotateClip, Parent); return RotateClip; })(); |
scaling
Scaling can be calculated by dividing the distance of the current touchpoint-positions by the distance of the old touchpoint-positions.
With that being said, a ScaleClip implementation will look like the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 | setPackage('com.firsara.display'); com.firsara.display.ScaleClip = (function(){ var Parent = com.firsara.display.Transformable; var ScaleClip = function(){ var self = this; var Init = function(){ if (Parent && ! self._initialized) Parent.call(self); self.addEventListener('update', _update); }; var _getDistance = function(p1, p2) { var x = p2.x - p1.x; var y = p2.y - p1.y; return Math.sqrt((x * x) + (y * y)); }; var _update = function(event){ if (self._activeFingers > 1) { var points = []; // extract touchpoints for (var k in self._fingers) { if (self._fingers[k].current) { points.push(self._fingers[k]); if (points.length >= 2) break; } } var scale = _getDistance(points[0].current, points[1].current) / _getDistance(points[0].old, points[1].old); self.scaleX += (scale - 1); self.scaleY = self.scaleX; self.dispatchEvent('scale'); } }; Init(); }; ScaleClip.prototype = {}; if (Parent) sys.inherits(ScaleClip, Parent); return ScaleClip; })(); |
movement
As we’re talking about multiple touchpoints here, movement is not only calculated by subtracting the current x- and y position from the old one, but by calculating an average of all touchpoints first. The class I came up with looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | setPackage('com.firsara.display'); com.firsara.display.MoveClip = (function(){ var Parent = com.firsara.display.Transformable; var MoveClip = function(){ var self = this; var Init = function(){ if (Parent && ! self._initialized) Parent.call(self); self.addEventListener('update', _update); }; var _update = function(event){ var average = {x: 0, y: 0}; // caluclate average movement between all points for (var pointerID in self._fingers) { if (self._fingers[pointerID].start) { average.x += (self._fingers[pointerID].current.x - self._fingers[pointerID].old.x); average.y += (self._fingers[pointerID].current.y - self._fingers[pointerID].old.y); } } average.x /= Math.max(1, self._activeFingers); average.y /= Math.max(1, self._activeFingers); // set new positions self.x += average.x; self.y += average.y self.dispatchEvent('move'); }; Init(); }; MoveClip.prototype = {}; if (Parent) sys.inherits(MoveClip, Parent); return MoveClip; })(); |
All transformations combined
With all those classes it’s easy to create the final TransformClip that combines all three together.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | setPackage('com.firsara.display'); com.firsara.display.TransformClip = (function(){ var Parent = com.firsara.display.Transformable; var TransformClip = function(){ var self = this; var Init = function(){ // call super constructor if (Parent) Parent.call(self); // mix in other classes if (com.firsara.display.MoveClip) com.firsara.display.MoveClip.call(self); if (com.firsara.display.RotateClip) com.firsara.display.RotateClip.call(self); if (com.firsara.display.ScaleClip) com.firsara.display.ScaleClip.call(self); }; Init(); }; TransformClip.prototype = {}; if (Parent) sys.inherits(TransformClip, Parent); return TransformClip; })(); |
The final classes I built additionally fade out transformations when releasing your finger (as if you would throw some object on an icy surface). I didn’t explain that functionality here, but it’s not that complicated. You basically have to calculate an acceleration base and animate all the properties by a fraction of it. I like to use Tweenlite for those kind of things. It’s an insanely fast animation engine that outweighs jQuery by far.
Final words
It’s still an ongoing process, so you can follow the development on github: firsara/createjs-bootstrap
As I side note I have to admit that some calculations have been inspired by Hammer.js, which is an awesome framework for handling multitouch-gestures on dom elements.
Throughout my research I also found a nice article about overlaying Hammer.js on EaselJS. I didn’t try it out but I’m pretty sure it can work out well. I just like to keep my code short and simple and not using too many frameworks on top of each other!
With all that being said: happy coding!
Pingback: Fabian Irsara()