1 // Copyright (c) 2012 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
6 * @fileoverview Touch Handler. Class that handles all touch events and
7 * uses them to interpret higher level gestures and behaviors. TouchEvent is a
8 * built in mobile safari type:
9 * http://developer.apple.com/safari/library/documentation/UserExperience/Reference/TouchEventClassReference/TouchEvent/TouchEvent.html.
10 * This class is intended to work with all webkit browsers, tested on Chrome and
13 * The following types of gestures are currently supported. See the definition
14 * of TouchHandler.EventType for details.
17 * This provides simple single-touch events. Any secondary touch is
21 * A single touch followed by some movement. This behavior will handle all
22 * of the required events and report the properties of the drag to you
23 * while the touch is happening and at the end of the drag sequence. This
24 * behavior will NOT perform the actual dragging (redrawing the element)
25 * for you, this responsibility is left to the client code.
28 * When your element is touched and held without any drag occuring, the
29 * LONG_PRESS event will fire.
32 // Use an anonymous function to enable strict mode just for this file (which
33 // will be concatenated with other files when embedded in Chrome)
34 cr.define('cr.ui', function() {
38 * A TouchHandler attaches to an Element, listents for low-level touch (or
39 * mouse) events and dispatching higher-level events on the element.
40 * @param {!Element} element The element to listen on and fire events
44 function TouchHandler(element) {
49 this.element_ = element;
52 * The absolute sum of all touch y deltas.
59 * The absolute sum of all touch x deltas.
66 * An array of tuples where the first item is the horizontal component of a
67 * recent relevant touch and the second item is the touch's time stamp. Old
68 * touches are removed based on the max tracking time and when direction
70 * @type {!Array.<number>}
73 this.recentTouchesX_ = [];
76 * An array of tuples where the first item is the vertical component of a
77 * recent relevant touch and the second item is the touch's time stamp. Old
78 * touches are removed based on the max tracking time and when direction
80 * @type {!Array.<number>}
83 this.recentTouchesY_ = [];
86 * Used to keep track of all events we subscribe to so we can easily clean
88 * @type {EventTracker}
91 this.events_ = new EventTracker();
96 * DOM Events that may be fired by the TouchHandler at the element
98 TouchHandler.EventType = {
99 // Fired whenever the element is touched as the only touch to the device.
100 // enableDrag defaults to false, set to true to permit dragging.
101 TOUCH_START: 'touchHandler:touch_start',
103 // Fired when an element is held for a period of time. Prevents dragging
104 // from occuring (even if enableDrag was set to true).
105 LONG_PRESS: 'touchHandler:long_press',
107 // If enableDrag was set to true at TOUCH_START, DRAG_START will fire when
108 // the touch first moves sufficient distance. enableDrag is set to true but
109 // can be reset to false to cancel the drag.
110 DRAG_START: 'touchHandler:drag_start',
112 // If enableDrag was true after DRAG_START, DRAG_MOVE will fire whenever the
114 DRAG_MOVE: 'touchHandler:drag_move',
116 // Fired just before TOUCH_END when a drag is released. Correlates 1:1 with
118 DRAG_END: 'touchHandler:drag_end',
120 // Fired whenever a touch that is being tracked has been released.
121 // Correlates 1:1 with a TOUCH_START.
122 TOUCH_END: 'touchHandler:touch_end',
124 // Fired whenever the element is tapped in a short time and no dragging is
126 TAP: 'touchHandler:tap'
131 * The type of event sent by TouchHandler
134 * @param {string} type The type of event (one of cr.ui.Grabber.EventType).
135 * @param {boolean} bubbles Whether or not the event should bubble.
136 * @param {number} clientX The X location of the touch.
137 * @param {number} clientY The Y location of the touch.
138 * @param {!Element} touchedElement The element at the current location of the
141 TouchHandler.Event = function(type, bubbles, clientX, clientY,
143 var event = document.createEvent('Event');
144 event.initEvent(type, bubbles, true);
145 event.__proto__ = TouchHandler.Event.prototype;
148 * The X location of the touch affected
151 event.clientX = clientX;
154 * The Y location of the touch affected
157 event.clientY = clientY;
160 * The element at the current location of the touch.
163 event.touchedElement = touchedElement;
168 TouchHandler.Event.prototype = {
169 __proto__: Event.prototype,
172 * For TOUCH_START and DRAG START events, set to true to enable dragging or
173 * false to disable dragging.
174 * @type {boolean|undefined}
176 enableDrag: undefined,
179 * For DRAG events, provides the horizontal component of the
180 * drag delta. Drag delta is defined as the delta of the start touch
181 * position and the current drag position.
182 * @type {number|undefined}
184 dragDeltaX: undefined,
187 * For DRAG events, provides the vertical component of the
189 * @type {number|undefined}
191 dragDeltaY: undefined
195 * Maximum movement of touch required to be considered a tap.
199 TouchHandler.MAX_TRACKING_FOR_TAP_ = 8;
203 * The maximum number of ms to track a touch event. After an event is older
204 * than this value, it will be ignored in velocity calculations.
208 TouchHandler.MAX_TRACKING_TIME_ = 250;
212 * The maximum number of touches to track.
216 TouchHandler.MAX_TRACKING_TOUCHES_ = 5;
220 * The maximum velocity to return, in pixels per millisecond, that is used
221 * to guard against errors in calculating end velocity of a drag. This is a
222 * very fast drag velocity.
226 TouchHandler.MAXIMUM_VELOCITY_ = 5;
230 * The velocity to return, in pixel per millisecond, when the time stamps on
231 * the events are erroneous. The browser can return bad time stamps if the
232 * thread is blocked for the duration of the drag. This is a low velocity to
233 * prevent the content from moving quickly after a slow drag. It is less
234 * jarring if the content moves slowly after a fast drag.
238 TouchHandler.VELOCITY_FOR_INCORRECT_EVENTS_ = 1;
241 * The time, in milliseconds, that a touch must be held to be considered
246 TouchHandler.TIME_FOR_LONG_PRESS_ = 500;
248 TouchHandler.prototype = {
250 * If defined, the identifer of the single touch that is active. Note that
251 * 0 is a valid touch identifier - it should not be treated equivalently to
253 * @type {number|undefined}
256 activeTouch_: undefined,
259 * @type {boolean|undefined}
262 tracking_: undefined,
265 * @type {number|undefined}
268 startTouchX_: undefined,
271 * @type {number|undefined}
274 startTouchY_: undefined,
277 * @type {number|undefined}
280 endTouchX_: undefined,
283 * @type {number|undefined}
286 endTouchY_: undefined,
289 * Time of the touchstart event.
290 * @type {number|undefined}
293 startTime_: undefined,
296 * The time of the touchend event.
297 * @type {number|undefined}
303 * @type {number|undefined}
306 lastTouchX_: undefined,
309 * @type {number|undefined}
312 lastTouchY_: undefined,
315 * @type {number|undefined}
318 lastMoveX_: undefined,
321 * @type {number|undefined}
324 lastMoveY_: undefined,
327 * @type {number|undefined}
330 longPressTimeout_: undefined,
333 * If defined and true, the next click event should be swallowed
334 * @type {boolean|undefined}
337 swallowNextClick_: undefined,
343 draggingEnabled_: false,
346 * Start listenting for events.
347 * @param {boolean=} opt_capture True if the TouchHandler should listen to
348 * during the capture phase.
349 * @param {boolean=} opt_mouse True if the TouchHandler should generate
350 * events for mouse input (in addition to touch input).
352 enable: function(opt_capture, opt_mouse) {
353 var capture = !!opt_capture;
355 // Just listen to start events for now. When a touch is occuring we'll
356 // want to be subscribed to move and end events on the document, but we
357 // don't want to incur the cost of lots of no-op handlers on the document.
358 this.events_.add(this.element_, 'touchstart', this.onStart_.bind(this),
361 this.events_.add(this.element_, 'mousedown',
362 this.mouseToTouchCallback_(this.onStart_.bind(this)),
366 // If the element is long-pressed, we may need to swallow a click
367 this.events_.add(this.element_, 'click', this.onClick_.bind(this), true);
371 * Stop listening to all events.
373 disable: function() {
374 this.stopTouching_();
375 this.events_.removeAll();
379 * Wraps a callback with translations of mouse events to touch events.
380 * NOTE: These types really should be function(Event) but then we couldn't
381 * use this with bind (which operates on any type of function). Doesn't
382 * JSDoc support some sort of polymorphic types?
383 * @param {Function} callback The event callback.
384 * @return {Function} The wrapping callback.
387 mouseToTouchCallback_: function(callback) {
389 // Note that there may be synthesizes mouse events caused by touch
390 // events (a mouseDown after a touch-click). We leave it up to the
391 // client to worry about this if it matters to them (typically a short
392 // mouseDown/mouseUp without a click is no big problem and it's not
393 // obvious how we identify such synthesized events in a general way).
395 // any fixed value will do for the identifier - there will only
396 // ever be a single active 'touch' when using the mouse.
403 e.targetTouches = [];
404 e.changedTouches = [touch];
405 if (e.type != 'mouseup') {
406 e.touches[0] = touch;
407 e.targetTouches[0] = touch;
414 * Begin tracking the touchable element, it is eligible for dragging.
417 beginTracking_: function() {
418 this.tracking_ = true;
422 * Stop tracking the touchable element, it is no longer dragging.
425 endTracking_: function() {
426 this.tracking_ = false;
427 this.dragging_ = false;
428 this.totalMoveY_ = 0;
429 this.totalMoveX_ = 0;
433 * Reset the touchable element as if we never saw the touchStart
434 * Doesn't dispatch any end events - be careful of existing listeners.
436 cancelTouch: function() {
437 this.stopTouching_();
439 // If clients needed to be aware of this, we could fire a cancel event
444 * Record that touching has stopped
447 stopTouching_: function() {
448 // Mark as no longer being touched
449 this.activeTouch_ = undefined;
451 // If we're waiting for a long press, stop
452 window.clearTimeout(this.longPressTimeout_);
454 // Stop listening for move/end events until there's another touch.
455 // We don't want to leave handlers piled up on the document.
456 // Note that there's no harm in removing handlers that weren't added, so
457 // rather than track whether we're using mouse or touch we do both.
458 this.events_.remove(document, 'touchmove');
459 this.events_.remove(document, 'touchend');
460 this.events_.remove(document, 'touchcancel');
461 this.events_.remove(document, 'mousemove');
462 this.events_.remove(document, 'mouseup');
466 * Touch start handler.
467 * @param {!TouchEvent} e The touchstart event.
470 onStart_: function(e) {
471 // Only process single touches. If there is already a touch happening, or
472 // two simultaneous touches then just ignore them.
473 if (e.touches.length > 1)
474 // Note that we could cancel an active touch here. That would make
475 // simultaneous touch behave similar to near-simultaneous. However, if
476 // the user is dragging something, an accidental second touch could be
477 // quite disruptive if it cancelled their drag. Better to just ignore
481 // It's still possible there could be an active "touch" if the user is
482 // simultaneously using a mouse and a touch input.
483 if (this.activeTouch_ !== undefined)
486 var touch = e.targetTouches[0];
487 this.activeTouch_ = touch.identifier;
489 // We've just started touching so shouldn't swallow any upcoming click
490 if (this.swallowNextClick_)
491 this.swallowNextClick_ = false;
493 this.disableTap_ = false;
495 // Sign up for end/cancel notifications for this touch.
496 // Note that we do this on the document so that even if the user drags
497 // their finger off the element, we'll still know what they're doing.
498 if (e.type == 'mousedown') {
499 this.events_.add(document, 'mouseup',
500 this.mouseToTouchCallback_(this.onEnd_.bind(this)), false);
502 this.events_.add(document, 'touchend', this.onEnd_.bind(this), false);
503 this.events_.add(document, 'touchcancel', this.onEnd_.bind(this),
507 // This timeout is cleared on touchEnd and onDrag
508 // If we invoke the function then we have a real long press
509 window.clearTimeout(this.longPressTimeout_);
510 this.longPressTimeout_ = window.setTimeout(
511 this.onLongPress_.bind(this),
512 TouchHandler.TIME_FOR_LONG_PRESS_);
514 // Dispatch the TOUCH_START event
515 this.draggingEnabled_ =
516 !!this.dispatchEvent_(TouchHandler.EventType.TOUCH_START, touch);
518 // We want dragging notifications
519 if (e.type == 'mousedown') {
520 this.events_.add(document, 'mousemove',
521 this.mouseToTouchCallback_(this.onMove_.bind(this)), false);
523 this.events_.add(document, 'touchmove', this.onMove_.bind(this), false);
526 this.startTouchX_ = this.lastTouchX_ = touch.clientX;
527 this.startTouchY_ = this.lastTouchY_ = touch.clientY;
528 this.startTime_ = e.timeStamp;
530 this.recentTouchesX_ = [];
531 this.recentTouchesY_ = [];
532 this.recentTouchesX_.push(touch.clientX, e.timeStamp);
533 this.recentTouchesY_.push(touch.clientY, e.timeStamp);
535 this.beginTracking_();
539 * Given a list of Touches, find the one matching our activeTouch
540 * identifier. Note that Chrome currently always uses 0 as the identifier.
541 * In that case we'll end up always choosing the first element in the list.
542 * @param {TouchList} touches The list of Touch objects to search.
543 * @return {!Touch|undefined} The touch matching our active ID if any.
546 findActiveTouch_: function(touches) {
547 assert(this.activeTouch_ !== undefined, 'Expecting an active touch');
548 // A TouchList isn't actually an array, so we shouldn't use
549 // Array.prototype.filter/some, etc.
550 for (var i = 0; i < touches.length; i++) {
551 if (touches[i].identifier == this.activeTouch_)
558 * Touch move handler.
559 * @param {!TouchEvent} e The touchmove event.
562 onMove_: function(e) {
566 // Our active touch should always be in the list of touches still active
567 assert(this.findActiveTouch_(e.touches), 'Missing touchEnd');
570 var touch = this.findActiveTouch_(e.changedTouches);
574 var clientX = touch.clientX;
575 var clientY = touch.clientY;
577 var moveX = this.lastTouchX_ - clientX;
578 var moveY = this.lastTouchY_ - clientY;
579 this.totalMoveX_ += Math.abs(moveX);
580 this.totalMoveY_ += Math.abs(moveY);
581 this.lastTouchX_ = clientX;
582 this.lastTouchY_ = clientY;
585 this.totalMoveY_ <= TouchHandler.MAX_TRACKING_FOR_TAP_ ||
586 this.totalMoveX_ <= TouchHandler.MAX_TRACKING_FOR_TAP_;
589 this.disableTap_ = true;
591 if (this.draggingEnabled_ && !this.dragging_ && !couldBeTap) {
592 // If we're waiting for a long press, stop
593 window.clearTimeout(this.longPressTimeout_);
595 // Dispatch the DRAG_START event and record whether dragging should be
596 // allowed or not. Note that this relies on the current value of
597 // startTouchX/Y - handlers may use the initial drag delta to determine
598 // if dragging should be permitted.
599 this.dragging_ = this.dispatchEvent_(
600 TouchHandler.EventType.DRAG_START, touch);
602 if (this.dragging_) {
603 // Update the start position here so that drag deltas have better
604 // values but don't touch the recent positions so that velocity
605 // calculations can still use touchstart position in the time and
607 this.startTouchX_ = clientX;
608 this.startTouchY_ = clientY;
609 this.startTime_ = e.timeStamp;
615 if (this.dragging_) {
616 this.dispatchEvent_(TouchHandler.EventType.DRAG_MOVE, touch);
618 this.removeTouchesInWrongDirection_(this.recentTouchesX_,
619 this.lastMoveX_, moveX);
620 this.removeTouchesInWrongDirection_(this.recentTouchesY_,
621 this.lastMoveY_, moveY);
622 this.removeOldTouches_(this.recentTouchesX_, e.timeStamp);
623 this.removeOldTouches_(this.recentTouchesY_, e.timeStamp);
624 this.recentTouchesX_.push(clientX, e.timeStamp);
625 this.recentTouchesY_.push(clientY, e.timeStamp);
628 this.lastMoveX_ = moveX;
629 this.lastMoveY_ = moveY;
633 * Filters the provided recent touches array to remove all touches except
634 * the last if the move direction has changed.
635 * @param {!Array.<number>} recentTouches An array of tuples where the first
636 * item is the x or y component of the recent touch and the second item
637 * is the touch time stamp.
638 * @param {number|undefined} lastMove The x or y component of the previous
640 * @param {number} recentMove The x or y component of the most recent move.
643 removeTouchesInWrongDirection_: function(recentTouches, lastMove,
645 if (lastMove && recentMove && recentTouches.length > 2 &&
646 (lastMove > 0 ^ recentMove > 0)) {
647 recentTouches.splice(0, recentTouches.length - 2);
652 * Filters the provided recent touches array to remove all touches older
653 * than the max tracking time or the 5th most recent touch.
654 * @param {!Array.<number>} recentTouches An array of tuples where the first
655 * item is the x or y component of the recent touch and the second item
656 * is the touch time stamp.
657 * @param {number} recentTime The time of the most recent event.
660 removeOldTouches_: function(recentTouches, recentTime) {
661 while (recentTouches.length && recentTime - recentTouches[1] >
662 TouchHandler.MAX_TRACKING_TIME_ ||
663 recentTouches.length >
664 TouchHandler.MAX_TRACKING_TOUCHES_ * 2) {
665 recentTouches.splice(0, 2);
671 * @param {!TouchEvent} e The touchend event.
674 onEnd_: function(e) {
676 assert(this.activeTouch_ !== undefined, 'Expect to already be touching');
678 // If the touch we're tracking isn't changing here, ignore this touch end.
679 var touch = this.findActiveTouch_(e.changedTouches);
681 // In most cases, our active touch will be in the 'touches' collection,
682 // but we can't assert that because occasionally two touchend events can
683 // occur at almost the same time with both having empty 'touches' lists.
684 // I.e., 'touches' seems like it can be a bit more up-to-date than the
689 // This is touchEnd for the touch we're monitoring
690 assert(!this.findActiveTouch_(e.touches),
691 'Touch ended also still active');
693 // Indicate that touching has finished
694 this.stopTouching_();
696 if (this.tracking_) {
697 var clientX = touch.clientX;
698 var clientY = touch.clientY;
700 if (this.dragging_) {
701 this.endTime_ = e.timeStamp;
702 this.endTouchX_ = clientX;
703 this.endTouchY_ = clientY;
705 this.removeOldTouches_(this.recentTouchesX_, e.timeStamp);
706 this.removeOldTouches_(this.recentTouchesY_, e.timeStamp);
708 this.dispatchEvent_(TouchHandler.EventType.DRAG_END, touch);
710 // Note that in some situations we can get a click event here as well.
711 // For now this isn't a problem, but we may want to consider having
712 // some logic that hides clicks that appear to be caused by a touchEnd
713 // used for dragging.
718 this.draggingEnabled_ = false;
720 // Note that we dispatch the touchEnd event last so that events at
721 // different levels of semantics nest nicely (similar to how DOM
722 // drag-and-drop events are nested inside of the mouse events that trigger
724 this.dispatchEvent_(TouchHandler.EventType.TOUCH_END, touch);
725 if (!this.disableTap_)
726 this.dispatchEvent_(TouchHandler.EventType.TAP, touch);
730 * Get end velocity of the drag. This method is specific to drag behavior,
731 * so if touch behavior and drag behavior is split then this should go with
732 * drag behavior. End velocity is defined as deltaXY / deltaTime where
733 * deltaXY is the difference between endPosition and the oldest recent
734 * position, and deltaTime is the difference between endTime and the oldest
736 * @return {Object} The x and y velocity.
738 getEndVelocity: function() {
739 // Note that we could move velocity to just be an end-event parameter.
740 var velocityX = this.recentTouchesX_.length ?
741 (this.endTouchX_ - this.recentTouchesX_[0]) /
742 (this.endTime_ - this.recentTouchesX_[1]) : 0;
743 var velocityY = this.recentTouchesY_.length ?
744 (this.endTouchY_ - this.recentTouchesY_[0]) /
745 (this.endTime_ - this.recentTouchesY_[1]) : 0;
747 velocityX = this.correctVelocity_(velocityX);
748 velocityY = this.correctVelocity_(velocityY);
757 * Correct erroneous velocities by capping the velocity if we think it's too
758 * high, or setting it to a default velocity if know that the event data is
760 * @param {number} velocity The x or y velocity component.
761 * @return {number} The corrected velocity.
764 correctVelocity_: function(velocity) {
765 var absVelocity = Math.abs(velocity);
767 // We add to recent touches for each touchstart and touchmove. If we have
768 // fewer than 3 touches (6 entries), we assume that the thread was blocked
769 // for the duration of the drag and we received events in quick succession
770 // with the wrong time stamps.
771 if (absVelocity > TouchHandler.MAXIMUM_VELOCITY_) {
772 absVelocity = this.recentTouchesY_.length < 3 ?
773 TouchHandler.VELOCITY_FOR_INCORRECT_EVENTS_ :
774 TouchHandler.MAXIMUM_VELOCITY_;
776 return absVelocity * (velocity < 0 ? -1 : 1);
780 * Handler when an element has been pressed for a long time
783 onLongPress_: function() {
784 // Swallow any click that occurs on this element without an intervening
785 // touch start event. This simple click-busting technique should be
786 // sufficient here since a real click should have a touchstart first.
787 this.swallowNextClick_ = true;
788 this.disableTap_ = true;
790 // Dispatch to the LONG_PRESS
791 assert(typeof this.startTouchX_ == 'number');
792 assert(typeof this.startTouchY_ == 'number');
793 this.dispatchEventXY_(TouchHandler.EventType.LONG_PRESS, this.element_,
794 /** @type {number} */(this.startTouchX_),
795 /** @type {number} */(this.startTouchY_));
799 * Click handler - used to swallow clicks after a long-press
800 * @param {!Event} e The click event.
803 onClick_: function(e) {
804 if (this.swallowNextClick_) {
807 this.swallowNextClick_ = false;
812 * Dispatch a TouchHandler event to the element
813 * @param {string} eventType The event to dispatch.
814 * @param {Touch} touch The touch triggering this event.
815 * @return {boolean|undefined} The value of enableDrag after dispatching
819 dispatchEvent_: function(eventType, touch) {
821 // Determine which element was touched. For mouse events, this is always
822 // the event/touch target. But for touch events, the target is always the
823 // target of the touchstart (and it's unlikely we can change this
824 // since the common implementation of touch dragging relies on it). Since
825 // touch is our primary scenario (which we want to emulate with mouse),
826 // we'll treat both cases the same and not depend on the target.
827 /** @type {Element} */
829 if (eventType == TouchHandler.EventType.TOUCH_START) {
830 touchedElement = assertInstanceof(touch.target, Element);
832 touchedElement = assert(this.element_.ownerDocument.
833 elementFromPoint(touch.clientX, touch.clientY));
836 return this.dispatchEventXY_(eventType, touchedElement, touch.clientX,
841 * Dispatch a TouchHandler event to the element
842 * @param {string} eventType The event to dispatch.
843 * @param {!Element} touchedElement
844 * @param {number} clientX The X location for the event.
845 * @param {number} clientY The Y location for the event.
846 * @return {boolean|undefined} The value of enableDrag after dispatching
850 dispatchEventXY_: function(eventType, touchedElement, clientX, clientY) {
851 var isDrag = (eventType == TouchHandler.EventType.DRAG_START ||
852 eventType == TouchHandler.EventType.DRAG_MOVE ||
853 eventType == TouchHandler.EventType.DRAG_END);
855 // Drag events don't bubble - we're really just dragging the element,
856 // not affecting its parent at all.
857 var bubbles = !isDrag;
859 var event = new TouchHandler.Event(eventType, bubbles, clientX, clientY,
862 // Set enableDrag when it can be overridden
863 if (eventType == TouchHandler.EventType.TOUCH_START)
864 event.enableDrag = false;
865 else if (eventType == TouchHandler.EventType.DRAG_START)
866 event.enableDrag = true;
869 event.dragDeltaX = clientX - this.startTouchX_;
870 event.dragDeltaY = clientY - this.startTouchY_;
873 this.element_.dispatchEvent(event);
874 return event.enableDrag;
879 TouchHandler: TouchHandler