1 // ==========================================================================
  2 // Project:   SproutCore - JavaScript Application Framework
  3 // Copyright: ©2006-2011 Strobe Inc. and contributors.
  4 //            Portions ©2008-2011 Apple Inc. All rights reserved.
  5 // License:   Licensed under MIT license (see license.js)
  6 // ==========================================================================
  7 
  8 sc_require("system/gesture");
  9 
 10 
 11 /**
 12   @static
 13   @type String
 14   @constant
 15 */
 16 SC.SWIPE_HORIZONTAL = [0, 180];
 17 
 18 /**
 19   @static
 20   @type String
 21   @constant
 22 */
 23 SC.SWIPE_VERTICAL = [90, -90];
 24 
 25 /**
 26   @static
 27   @type String
 28   @constant
 29 */
 30 SC.SWIPE_ANY = null;
 31 
 32 /**
 33   @static
 34   @type String
 35   @constant
 36 */
 37 SC.SWIPE_LEFT = [180];
 38 
 39 /**
 40   @static
 41   @type String
 42   @constant
 43 */
 44 SC.SWIPE_RIGHT = [0];
 45 
 46 /**
 47   @static
 48   @type String
 49   @constant
 50 */
 51 SC.SWIPE_UP = [-90];
 52 
 53 /**
 54   @static
 55   @type String
 56   @constant
 57 */
 58 SC.SWIPE_DOWN = [90];
 59 
 60 /**
 61   ## What is a "swipe"?
 62 
 63   A swipe is one or more quick unidirectionally moving touches that end abruptly. By this, it is
 64   meant that at some point the touches begin to move quickly, `swipeVelocity` in a single direction,
 65   `angles`, and cover a fair amount of distance, `swipeDistance`, before ending.
 66 
 67   The single direction that the touches move in, must follow one of the angles specified in the
 68   `angles` array. However, the touches do not need to precisely match an angle and may vary by
 69   an amount above or below the angle as defined by the `tolerance` property.
 70 
 71   Because the swipe is the last moment of the touch session, the swipe gesture is always interested
 72   in a touch session. As long as the last distance traveled is great enough and at an
 73   approved angle, then a swipe will trigger.
 74 
 75   @class
 76   @extends SC.Gesture
 77 */
 78 SC.SwipeGesture = SC.Gesture.extend(
 79 /** @scope SC.SwipeGesture.prototype */ {
 80 
 81   //
 82   // - Properties --------------------------------------------------------------------
 83   //
 84 
 85   /** @private The last approved angle. Is set as long as a swipe appears valid. */
 86   _sc_lastAngle: null,
 87 
 88   /** @private The last computed distance. Is set as long as a swipe appears valid. */
 89   _sc_lastDistance: null,
 90 
 91   /** @private The number of touches in the current swipe. */
 92   _sc_numberOfTouches: 0,
 93 
 94   /** @private The initial point where a swipe appears to begin. */
 95   _sc_swipeAnchorX: null,
 96 
 97   /** @private The initial point where a swipe appears to begin. */
 98   _sc_swipeAnchorY: null,
 99 
100   /** @private The last time a movement in a swipe was recorded. */
101   _sc_swipeLastMovedAt: null,
102 
103   /**
104     The angles that the swipe will accept, between 0° and ±180°. The angles start from the right
105     side (0°) and end at the left side (±180°). With the positive angles passing through *down*
106     (+90°) and the negative angles passing through *up* (-90°). The following ASCII art shows the
107     directions of the angles,
108 
109 
110                         -90° (up)
111                           |
112                           |
113         (left) ± 180° --------- 0° (right)
114                           |
115                           |
116                  (down) +90°
117 
118     To make this easier, there are several predefined angles arrays that you can use,
119 
120     * SC.SWIPE_HORIZONTAL ([180, 0]), i.e. left or right
121     * SC.SWIPE_VERTICAL ([-90, 90]), i.e. up or down
122     * SC.SWIPE_ANY (null), i.e. 0° to up, down, left or right
123     * SC.SWIPE_LEFT ([180]), i.e. left only
124     * SC.SWIPE_RIGHT ([0]), i.e. right only
125     * SC.SWIPE_UP ([-90]), i.e. up only
126     * SC.SWIPE_DOWN ([90]), down only
127 
128     However, you can provide any combination of angles that you want. For example, to support
129     45° angled swipes to the right and straight swipes to the left, we would use,
130 
131        angles: [180, -45, 45] // 180° straight left, -45° up & right, 45° down & right
132 
133     ## How to use the angles.
134 
135     When the `swipe` event fires, the angle of the swipe is passed to your view allowing you to
136     recognize which of the supported angles matched the swipe.
137 
138     Note, there is one special case, as defined by `SC.SWIPE_ANY`, which is to set angles to `null`
139     in order to support swipes in *any* direction. The code will look for a swipe (unidirectional
140     fast motion) in any direction and pass the observed angle to the `swipe` handler.
141 
142     @type Array
143     @default 24
144   */
145   // This is a computed property in order to provide backwards compatibility for direction.
146   // When direction is removed completely, this can become a simple `SC.SWIPE_HORIZONTAL` value.
147   angles: function (key, value) {
148     var direction = this.get('direction'),
149       ret = SC.SWIPE_HORIZONTAL;
150 
151     // Backwards compatibility support
152     if (!SC.none(direction)) {
153       //@if(debug)
154       SC.warn('Developer Warning: The direction property of SC.SwipeGesture has been renamed to angles.');
155       //@endif
156 
157       return direction;
158     }
159 
160     if (!SC.none(value)) { ret = value; }
161 
162     return ret;
163   }.property('direction').cacheable(),
164 
165 
166   /** @deprecated Version 1.11. Please use the `angles` property instead.
167     @type Array
168     @default SC.SWIPE_HORIZONTAL
169   */
170   direction: SC.SWIPE_HORIZONTAL,
171 
172   /**
173     @type String
174     @default "swipe"
175     @readOnly
176   */
177   name: "swipe",
178 
179   /**
180     The distance in pixels that touches must move in a single direction to be far enough in order to
181     be considered a swipe. If the touches don't move `swipeDistance` amount of pixels, then the
182     gesture will not trigger.
183 
184     @type Number
185     @default 40
186   */
187   swipeDistance: 40,
188 
189   /**
190     The velocity in pixels per millisecond that touches must be traveling to begin a swipe motion.
191     If the touches are moving slower than the velocity, the swipe start point won't be set.
192 
193     @type Number
194     @default 0.5
195   */
196   swipeVelocity: 0.5,
197 
198   /**
199     Amount of degrees that a touch is allowed to vary off of the target angle(s).
200 
201     @type Number
202     @default 15
203   */
204   tolerance: 15,
205 
206   //
207   // - Methods --------------------------------------------------------------------
208   //
209 
210   /** @private Cleans up the touch session. */
211   _sc_cleanUpTouchSession: function (wasCancelled) {
212     // Clean up.
213     this._sc_numberOfTouches = 0;
214     this._sc_lastDistance = null;
215     this._sc_swipeStartedAt = null;
216     this._sc_lastAngle = null;
217     this._sc_swipeAnchorX = null;
218     this._sc_swipeAnchorY = null;
219   },
220 
221   /** @private Timer used to tell if swipe was too slow. */
222   _sc_swipeTooSlow: function () {
223     // The session took to long to finish from when a swipe appeared to start. Reset.
224     this._sc_cleanUpTouchSession();
225   },
226 
227   /**
228     The swipe gesture is always interested in a touch session, because it is only concerned in how
229     the session ends. If it ends with a fast unidirectional sliding movement, then it is a swipe.
230 
231     Note, that for multiple touches, touches are expected to start while other touches are already
232     moving. When touches are added we update the swipe start position. This means that inadvertent
233     taps that occur while swiping could break a swipe recognition by making the swipe too short to
234     register.
235 
236     @param {SC.Touch} touch The touch to be added to the session.
237     @param {Array} touchesInSession The touches already in the session.
238     @returns {Boolean} True as long as the new touch doesn't start too late after the first touch.
239     @see SC.Gesture#touchAddedToSession
240     */
241   // TODO: What about first touch starts moving, second touch taps, first touch finishes?
242   // TODO: What about first touch starts tap, second touch starts moving, first touch finishes tap, second touch finishes?
243   touchAddedToSession: function (touch, touchesInSession) {
244     // Get the averaged touches for the the view. Because pinch is always interested in every touch
245     // the touchesInSession will equal the touches for the view.
246     var avgTouch = touch.averagedTouchesForView(this.view, true);
247 
248     this._sc_swipeAnchorX = avgTouch.x;
249     this._sc_swipeAnchorY = avgTouch.y;
250 
251     return true;
252   },
253 
254   /**
255     The swipe gesture is always interested in a touch session, because it is only concerned in how
256     the session ends. If it ends with a fast unidirectional sliding movement, then it is a swipe.
257 
258     Note, that a touch may cancel while swiping (went off screen inadvertently). Because of this we
259     don't immediately reduce the number of touches in the swipe, because if the rest of the touches
260     end right away in a swipe, it's best to consider the cancelled touch as part of the group.
261 
262     @param {SC.Touch} touch The touch to be removed from the session.
263     @param {Array} touchesInSession The touches in the session.
264     @returns {Boolean} True
265     @see SC.Gesture#touchCancelledInSession
266     */
267   touchCancelledInSession: function (touch, touchesInSession) {
268     return true;
269   },
270 
271   /**
272     The swipe gesture is always interested in a touch session, because it is only concerned in how
273     the session ends. If it ends with a fast unidirectional sliding movement, then it is a swipe.
274 
275     Note, that touches are expected to end while swiping. Because of this we don't immediately
276     reduce the number of touches in the swipe, because if the rest of the touches also end right
277     away in a swiping motion, it's best to consider this ended touch as part of the group.
278 
279     @param {SC.Touch} touch The touch to be removed from the session.
280     @param {Array} touchesInSession The touches in the session.
281     @returns {Boolean} True if it is the first touch to end or a subsequent touch that ends not too long after the first touch ended.
282     @see SC.Gesture#touchEndedInSession
283   */
284   touchEndedInSession: function (touch, touchesInSession) {
285     return true;
286   },
287 
288   /** @private Test the given angle against an approved angle. */
289   _sc_testAngle: function (absoluteCurrentAngle, currentIsPositive, approvedAngle, tolerance) {
290     var angleIsPositive = approvedAngle >= 0,
291         absoluteAngle = !angleIsPositive ? Math.abs(approvedAngle) : approvedAngle,
292         upperBound = absoluteAngle + tolerance,
293         lowerBound = absoluteAngle - tolerance,
294         ret = false;
295 
296     if (lowerBound <= absoluteCurrentAngle && absoluteCurrentAngle <= upperBound) {
297       // Special case: ex. Don't confuse -45° with 45° or vice versa.
298       var upperBoundIsPositive = upperBound >= 0 && upperBound <= 180,
299           lowerBoundIsPositive = lowerBound >= 0;
300 
301       ret = upperBoundIsPositive === lowerBoundIsPositive ? currentIsPositive === angleIsPositive : true;
302     }
303 
304     return ret;
305   },
306 
307   /**
308     The swipe gesture is always interested in a touch session, because it is only concerned in how
309     the session ends. If it ends with a fast unidirectional sliding movement, then it is a swipe.
310 
311     @param {Array} touchesInSession All touches in the session.
312     @returns {Boolean} True as long as none of the touches have moved too far off-axis to be a clean swipe.
313     @see SC.Gesture#touchesMovedInSession
314     */
315   touchesMovedInSession: function (touchesInSession) {
316     // Get the averaged touches for the the view. Because swipe is always interested in every touch
317     // (or none) the touchesInSession will equal the touches for the view.
318     var angles = this.get('direction'),
319         avgTouch = touchesInSession[0].averagedTouchesForView(this.view),
320         xDiff = avgTouch.x - this._sc_swipeAnchorX,
321         yDiff = avgTouch.y - this._sc_swipeAnchorY,
322         currentAngle = Math.atan2(yDiff, xDiff) * (180 / Math.PI),
323         absoluteCurrentAngle = Math.abs(currentAngle),
324         currentIsPositive = currentAngle >= 0,
325         tolerance = this.get('tolerance'),
326         approvedAngle = null,
327         angle;
328 
329     // There is one special case, when angles is null, allow all angles.
330     if (angles === null) {
331       // Use the last angle against itself.
332       angle = this._sc_lastAngle;
333 
334       if (angle !== null) {
335         var withinLastAngle = this._sc_testAngle(absoluteCurrentAngle, currentIsPositive, angle, tolerance);
336 
337         // If still within the start angle, leave it going.
338         if (withinLastAngle) {
339           approvedAngle = angle;
340         } else {
341           approvedAngle = currentAngle;
342         }
343       } else {
344         approvedAngle = currentAngle;
345       }
346 
347     // Check against approved angles.
348     } else {
349       for (var i = 0, len = angles.length; i < len; i++) {
350         angle = angles[i];
351 
352         // If the current angle is within the tolerance of the given angle, it's a match.
353         if (this._sc_testAngle(absoluteCurrentAngle, currentIsPositive, angle, tolerance)) {
354           approvedAngle = angle;
355 
356           break; // No need to continue.
357         }
358       }
359     }
360 
361     // Got angle.
362     if (approvedAngle !== null) {
363       // Same angle. Ensure we're traveling fast enough to keep the angle.
364       if (this._sc_lastAngle === approvedAngle) {
365         // Get distance between the anchor and current average point.
366         var dx = Math.abs(xDiff),
367             dy = Math.abs(yDiff),
368             now = Date.now(),
369             distance,
370             velocity;
371 
372         distance = Math.pow(dx * dx + dy * dy, 0.5);
373         velocity = distance / (now - this._sc_swipeStartedAt);
374 
375         // If velocity is too slow, lost swipe.
376         var minimumVelocity = this.get('swipeVelocity');
377         if (velocity < minimumVelocity) {
378           this._sc_lastAngle = null;
379           this._sc_swipeAnchorX = avgTouch.x;
380           this._sc_swipeAnchorY = avgTouch.y;
381           this._sc_lastDistance = 0;
382           this._sc_swipeStartedAt = null;
383         } else {
384           // Track how far we've gone in this approved direction.
385           this._sc_lastDistance = distance;
386           this._sc_swipeLastMovedAt = Date.now();
387         }
388 
389       // This is the first matched angle or a new direction. Track its values for future comparison.
390       } else {
391         // Track the current approved angle and when we started going on it.
392         this._sc_lastAngle = approvedAngle;
393         this._sc_swipeStartedAt = Date.now();
394 
395         // Use the current number of touches as the number in the session. Some may get cancelled.
396         this._sc_numberOfTouches = touchesInSession.length;
397       }
398 
399     // No angle or lost the angle.
400     } else {
401       this._sc_lastAngle = null;
402       this._sc_swipeAnchorX = avgTouch.x;
403       this._sc_swipeAnchorY = avgTouch.y;
404       this._sc_lastDistance = 0;
405       this._sc_swipeStartedAt = null;
406     }
407 
408     return true;
409   },
410 
411   /**
412     Cleans up all touch session variables.
413 
414     @returns {void}
415     @see SC.Gesture#touchSessionCancelled
416     */
417   touchSessionCancelled: function () {
418     // Clean up.
419     this._sc_cleanUpTouchSession(true);
420   },
421 
422   /**
423     Cleans up all touch session variables and triggers the gesture.
424 
425     @returns {void}
426     @see SC.Gesture#touchSessionEnded
427     */
428   touchSessionEnded: function () {
429     // Watch out for touches that move far and fast, but then hesitate too long before ending.
430     var notTooLongSinceLastMove = (Date.now() - this._sc_swipeLastMovedAt) < 200;
431 
432     // If an approved angle remained set, the distance was far enough and it wasn't too long since
433     // the last movement, trigger the gesture, 'swipe'.
434     if (this._sc_lastAngle !== null &&
435       this._sc_lastDistance > this.get('swipeDistance') &&
436       notTooLongSinceLastMove) {
437       this.trigger(this._sc_lastAngle, this._sc_numberOfTouches);
438     }
439 
440     // Clean up (will fire tapEnd if _sc_isTapping is true).
441     this._sc_cleanUpTouchSession(false);
442   },
443 
444   touchSessionStarted: function (touch) {
445     this._sc_swipeAnchorX = touch.pageX;
446     this._sc_swipeAnchorY = touch.pageY;
447   }
448 
449 });
450