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