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 /**
  9   @namespace
 10 
 11   You can mix in SC.Gesturable to your views to add some support for recognizing gestures.
 12 
 13   SproutCore views have built-in touch events. However, sometimes you may want
 14   to recognize gestures like tap, pinch, swipe, etc. This becomes tedious if you
 15   need to do this often, and more so if you need to check for multiple possible
 16   gestures on the same view.
 17 
 18   SC.Gesturable allows you to define a collection of gestures (SC.Gesture objects)
 19   that your view should recognize. When a gesture is recognized, methods will be
 20   called on the view:
 21 
 22     - [gestureName](gesture, args...): called when the gesture has occurred. This is
 23       useful for event-style gestures, where you aren't interested in when it starts or
 24       ends, but just that it has occurred. SC.SwipeGesture triggers this after the
 25       swipe has moved a minimum amount—40px by default.
 26     - [gestureName]Start(gesture, args...): called when the gesture is first recognized.
 27       For instance, a swipe gesture may be recognized after the finger has moved a
 28       minimum distance in a horizontal.
 29     - [gestureName]Changed(gesture, args...): called when some property of the gesture
 30       has changed. For instance, this may be called continuously as the user swipes as
 31       the swipe's distance changes.
 32     - [gestureName]Cancelled(gesture, args...): called when a gesture, for one reason
 33       or another, is no longer recognized. For instance, a horizontal swipe gesture
 34       could cancel if the user moves too far in a vertical direction.
 35     - [gestureName]End(gesture, args...): called when a gesture ends. A swipe would end
 36       when the user lifts their finger.
 37 
 38   Each of these methods is passed the gesture instance, in addition to any arguments
 39   the gesture sends for your convenience. The default swipe gesture sends an SC.Touch
 40   instance, the swipe direction, and the distance the swipe has moved in that direction.
 41 
 42   Using SC.Gesturable
 43   -------------------
 44 
 45   To make your view recognize gestures, mix in Gesturable and add items to the 'gestures'
 46   property:
 47 
 48       SC.View.extend(SC.Gesturable, {
 49         gestures: [SC.PinchGesture, 'mySwipeGesture'],
 50 
 51         // specifying as a string allows you to configure it:
 52         mySwipeGesture: SC.SwipeGesture.extend({
 53           direction: SC.SWIPE_VERTICAL,
 54           startDistance: 3,
 55           swipeDistance: 20
 56         }),
 57 
 58         // handle the swipe action
 59         swipe: function(touch, direction) {
 60           console.error("Swiped! In direction: " + direction);
 61         },
 62 
 63         swipeStart: function(touch, direction, delta) {
 64           console.error("Swipe started in direction: " + direction + "; dist: " + delta);
 65         },
 66 
 67         swipeChanged: function(touch, direction, delta) {
 68           console.error("Swipe continued in direction: " + direction + "; dist: " + delta);
 69         },
 70 
 71         swipeEnd: function(touch, direction, delta) {
 72           console.error("Completed swipe in direction: " + direction + "; dist: " + delta);
 73         }
 74 
 75       })
 76 
 77   @extends SC.ObjectMixinProtocol
 78   @extends SC.ResponderProtocol
 79 */
 80 SC.Gesturable = {
 81 
 82   /** @private An array of all gestures currently interested in the touch session.
 83 
 84     @type Array
 85     @default null
 86   */
 87   _sc_interestedGestures: null,
 88 
 89   /** @private An array of the touches that are currently active in a touch session.
 90 
 91     @type Array
 92     @default null
 93   */
 94   _sc_touchesInSession: null,
 95 
 96   /**
 97     Gestures need to understand multiple touches.
 98 
 99     @type Boolean
100     @default true
101     @see SC.View#acceptsMultitouch
102   */
103   acceptsMultitouch: true,
104 
105   /**
106     @type Array
107     @default ['gestures']
108     @see SC.Object#concatenatedProperties
109   */
110   concatenatedProperties: ['gestures'],
111 
112   /**
113     The gestures that the view will support. This property must be set on the consumer of
114     `SC.Gesturable` before it is initialized.
115 
116     These gestures should be objects that extend the `SC.Gesture` class. You can use SproutCore's
117     pre-built gestures or create your own. If you create your own, you can use a property name
118     in the list of gestures to refer to the actual gesture class, similar to how the childViews
119     array works. For example,
120 
121         gestures: [SC.PinchGesture, 'mySwipeGesture'],
122 
123         // Specifying the Gesture by property name allows you to configure it.
124         mySwipeGesture: SC.SwipeGesture.extend({
125           direction: SC.SWIPE_VERTICAL,
126           startDistance: 3,
127           swipeDistance: 20
128         }),
129 
130     Note that `gestures` is a *concatenated property*, which means that it will not be overwritten
131     by subclasses. So for example, if the base class lists gestures as `[SC.PinchGesture]` and its
132     subclass lists gestures as `[SC.TapGesture]`, the actual gestures supported by the subclass will
133     be `[SC.PinchGesture, SC.TapGesture]`.
134 
135     @type Array
136     @default null
137   */
138   gestures: null,
139 
140   /** @private Shared method for finishing a touch.
141 
142     @param {SC.Touch} touch The touch that ended or cancelled.
143     @param {Boolean} wasCancelled Whether the touch was cancelled or not (i.e. ended normally).
144   */
145   _sc_gestureTouchFinish: function (touch, wasCancelled) {
146     var touchesInSession = this._sc_touchesInSession,
147         touchIndexInSession = touchesInSession.indexOf(touch);
148 
149     // Decrement our list of touches that are being acted upon.
150     touchesInSession.replace(touchIndexInSession, 1);
151 
152     var gestures = this._sc_interestedGestures,
153         idx,
154         gesture;
155 
156     // Loop through the gestures in reverse, as the list may be mutated.
157     for (idx = gestures.length - 1; idx >= 0; idx--) {
158       var isInterested;
159 
160       gesture = gestures[idx];
161 
162       if (wasCancelled) {
163         isInterested = gesture.touchCancelledInSession(touch, touchesInSession);
164       } else {
165         isInterested = gesture.touchEndedInSession(touch, touchesInSession);
166       }
167 
168       // If the gesture is no longer interested in *any* touches for this session, remove it.
169       if (!isInterested) {
170         // Tell the gesture that the touch session has ended for it.
171         gesture.touchSessionCancelled();
172 
173         gestures.replace(idx, 1);
174       }
175     }
176 
177     // Once there are no more touches in the session, reset the interested gestures.
178     if (touchesInSession.length === 0) {
179       // Notify all remaining interested gestures that the touch session has finished cleanly.
180       var len;
181 
182       for (idx = 0, len = gestures.length; idx < len; idx++) {
183         gesture = gestures[idx];
184 
185         gesture.touchSessionEnded();
186       }
187 
188       // Clear out the current cache of interested gestures for the session.
189       this._sc_interestedGestures.length = 0;
190     }
191   },
192 
193   /**
194     When SC.Gesturable initializes, any gestures named on the view are instantiated.
195 
196     @see SC.ObjectMixinProtocol#initMixin
197   */
198   initMixin: function() {
199     //@if(debug)
200     if (SC.none(this.gestures)) {
201       SC.error("Developer Error: When mixing in SC.Gesturable, you must define a list of gestures to use.");
202     }
203     //@endif
204     this.createGestures();
205   },
206 
207   /** @private  Instantiates the gestures. */
208   createGestures: function() {
209     var gestures = this.get("gestures"),
210         len = gestures.length,
211         instantiatedGestures = [],
212         idx;
213 
214     // loop through all gestures
215     for (idx = 0; idx < len; idx++) {
216       var gesture;
217 
218       // get the proper gesture
219       if (SC.typeOf(gestures[idx]) === SC.T_STRING) {
220         gesture = this.get(gestures[idx]);
221       } else {
222         gesture = gestures[idx];
223       }
224 
225       // if it was not found, well, that's an error.
226       if (!gesture) {
227         throw new Error("Developer Error: Could not find gesture named '" + gestures[idx] + "' on view.");
228       }
229 
230       // if it is a class, instantiate (it really ought to be a class...)
231       if (gesture.isClass) {
232         gesture = gesture.create({
233           view: this
234         });
235       }
236 
237       // and set the gesture instance and add it to the array.
238       if (SC.typeOf(gestures[idx]) === SC.T_STRING) this[gestures[idx]] = gesture;
239       instantiatedGestures.push(gesture);
240     }
241 
242     this.set("gestures", instantiatedGestures);
243   },
244 
245   /**
246     Handles touch start by handing it to the gesture recognizing code.
247 
248     If you override touchStart, you will need to call gestureTouchStart to
249     give the gesture system control of the touch. You will continue to get
250     events until if and when a gesture decides to take "possession" of a touch—
251     at this point, you will get a [gestureName]Start event.
252 
253     You do not have to call gestureTouchStart immediately; you can call it
254     at any time. This allows you to avoid passing control until _after_ you
255     have determined your own touchStart, touchesDragged, and touchEnd methods
256     are not going to handle it.
257 
258     @param {SC.Touch} touch The touch that started.
259     @returns {Boolean} Whether the touch should be claimed by the view or not.
260     @see SC.ResponderProtocol#touchStart
261   */
262   touchStart: function(touch) {
263     return this.gestureTouchStart(touch);
264   },
265 
266   /**
267     Tells the gesture recognizing code about touches moving.
268 
269     If you override touchesDragged, you will need to call gestureTouchesDragged
270     (at least for any touches you called gestureTouchStart for in touchStart) to
271     allow the gesture system to update.
272 
273     @see SC.ResponderProtocol#touchesDragged
274   */
275   touchesDragged: function(evt, touches) {
276     this.gestureTouchesDragged(evt, touches);
277   },
278 
279   /**
280     Tells the gesture recognizing code about a touch ending.
281 
282     If you override touchEnd, you will need to call gestureTouchEnd
283     for any touches you called gestureTouchStart for in touchStart (if overridden).
284 
285     @param {SC.Touch} touch The touch that ended.
286     @see SC.ResponderProtocol#touchEnd
287   */
288   touchEnd: function(touch) {
289     this.gestureTouchEnd(touch);
290   },
291 
292   /**
293     Tells the gesture recognizing code about a touch cancelling.
294 
295     If you override touchCancelled, you will need to call gestureTouchCancelled
296     for any touches you called gestureTouchStart for in touchStart (if overridden).
297 
298     @param {SC.Touch} touch The touch that cancelled.
299     @see SC.ResponderProtocol#touchCancelled
300   */
301   touchCancelled: function (touch) {
302     this.gestureTouchCancelled(touch);
303   },
304 
305   /**
306     Called by a gesture that has lost interest in the entire touch session, likely due to too much
307     time having passed since `gestureTouchStart` or `gestureTouchesDragged` having been called.
308 
309     Simply removes the gesture from the list of interested gestures and calls
310     `touchSessionCancelled` on the gesture.
311   */
312   gestureLostInterest: function (gesture) {
313     var gestures = this._sc_interestedGestures,
314         gestureIndex = gestures.indexOf(gesture);
315 
316     // Remove the gesture.
317     if (gestureIndex >= 0) {
318       gesture.touchSessionCancelled();
319 
320       gestures.replace(gestureIndex, 1);
321     }
322   },
323 
324   /**
325     Tells the gesture recognizing system about a new touch. This notifies all gestures of a new
326     touch session starting (if there were no previous touches) or notifies all interested gestures
327     that a touch has been added.
328 
329     As touches are added beyond the first touch, gestures may "lose interest" in the touch session.
330     For example, a gesture may explicitly want only a single touch and if a second touch appears,
331     the gesture may not want any further updates on this touch session (even if the second touch
332     ends again).
333 
334     @param {SC.Touch} touch The touch that started.
335     @returns {Boolean} Whether any gesture is interested in the touch or not.
336   */
337   gestureTouchStart: function (touch) {
338     var interestedGestures = this._sc_interestedGestures,
339         touchesInSession = this._sc_touchesInSession,
340         claimedTouch = false,
341         idx;
342 
343     // Instantiate once.
344     if (touchesInSession === null) {
345       touchesInSession = this._sc_touchesInSession = [];
346       interestedGestures = this._sc_interestedGestures = [];
347     }
348 
349     // When there are no touches in the session, check all gestures.
350     if (touchesInSession.length === 0) {
351       var gestures = this.get("gestures"),
352           len;
353 
354       for (idx = 0, len = gestures.length; idx < len; idx++) {
355         var gesture = gestures[idx];
356 
357         gesture.touchSessionStarted(touch);
358         interestedGestures.push(gesture);
359       }
360 
361       // Keep this touch.
362       claimedTouch = true;
363 
364     // Only check gestures that are interested.
365     } else {
366       // Loop through the gestures in reverse, as the list may be mutated.
367       for (idx = interestedGestures.length - 1; idx >= 0; idx--) {
368         var interestedGesture = interestedGestures[idx],
369             isInterested;
370 
371         // Keep only the gestures still interested in the touch.
372         isInterested = interestedGesture.touchAddedToSession(touch, touchesInSession);
373 
374         if (isInterested) {
375           // Keep this touch.
376           claimedTouch = true;
377         } else {
378           // Tell the gesture that the touch session has ended for it.
379           interestedGesture.touchSessionCancelled();
380 
381           interestedGestures.replace(idx, 1);
382         }
383       }
384     }
385 
386     // If any gesture is interested in the new touch. Add it to the list of touches in the session.
387     if (claimedTouch) {
388       touchesInSession.push(touch);
389     }
390 
391     return claimedTouch;
392   },
393 
394   /**
395     Tells the gesture recognition system that touches have moved.
396 
397     @param {SC.Event} evt The touch event.
398     @param {Array} touches The touches previously claimed by this view.
399     @returns {void}
400   */
401   gestureTouchesDragged: function (evt, touches) {
402     var gestures = this._sc_interestedGestures,
403         touchesInSession = this._sc_touchesInSession;
404 
405     // Loop through the gestures in reverse, as the list may be mutated.
406     for (var i = gestures.length - 1; i >= 0; i--) {
407       var gesture = gestures[i],
408           isInterested = gesture.touchesMovedInSession(touchesInSession);
409 
410       // If the gesture is no longer interested in *any* touches for this session, remove it.
411       if (!isInterested) {
412         // Tell the gesture that the touch session has ended for it.
413         gesture.touchSessionCancelled();
414 
415         gestures.replace(i, 1);
416 
417         // TODO: When there are no more interested gestures? Do what with the touches? Anything?
418       }
419     }
420   },
421 
422   /**
423     Tells the gesture recognition system that an unassigned touch has ended.
424 
425     This informs all of the gestures that the touch ended. The touch is
426     an unassigned touch as, if it were assigned to a gesture, it would have
427     been sent directly to the gesture, bypassing this view.
428   */
429   gestureTouchEnd: function(touch) {
430     this._sc_gestureTouchFinish(touch, false);
431   },
432 
433   /**
434     Tells the gesture recognition system that an unassigned touch has cancelled.
435 
436     This informs all of the gestures that the touch cancelled. The touch is
437     an unassigned touch as, if it were assigned to a gesture, it would have
438     been sent directly to the gesture, bypassing this view.
439   */
440   gestureTouchCancelled: function(touch) {
441     this._sc_gestureTouchFinish(touch, true);
442   }
443 };
444