blob: b8ce1cbf85e9071fd9d2b7280cd91a4ea2ea9915 [file] [log] [blame]
// Copyright (c) 2011 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview Touch Handler. Class that handles all touch events and
* uses them to interpret higher level gestures and behaviors. TouchEvent is a
* built in mobile safari type:
* http://developer.apple.com/safari/library/documentation/UserExperience/Reference/TouchEventClassReference/TouchEvent/TouchEvent.html.
* This class is intended to work with all webkit browsers, tested on Chrome and
* iOS.
*
* The following types of gestures are currently supported. See the definition
* of TouchHandler.EventType for details.
*
* Single Touch:
* This provides simple single-touch events. Any secondary touch is
* ignored.
*
* Drag:
* A single touch followed by some movement. This behavior will handle all
* of the required events and report the properties of the drag to you
* while the touch is happening and at the end of the drag sequence. This
* behavior will NOT perform the actual dragging (redrawing the element)
* for you, this responsibility is left to the client code.
*
* Long press:
* When your element is touched and held without any drag occuring, the
* LONG_PRESS event will fire.
*/
// Use an anonymous function to enable strict mode just for this file (which
// will be concatenated with other files when embedded in Chrome)
var TouchHandler = (function() {
'use strict';
/**
* A TouchHandler attaches to an Element, listents for low-level touch (or
* mouse) events and dispatching higher-level events on the element.
* @param {!Element} element The element to listen on and fire events
* for.
* @constructor
*/
function TouchHandler(element) {
/**
* @type {!Element}
* @private
*/
this.element_ = element;
/**
* The absolute sum of all touch y deltas.
* @type {number}
* @private
*/
this.totalMoveY_ = 0;
/**
* The absolute sum of all touch x deltas.
* @type {number}
* @private
*/
this.totalMoveX_ = 0;
/**
* An array of tuples where the first item is the horizontal component of a
* recent relevant touch and the second item is the touch's time stamp. Old
* touches are removed based on the max tracking time and when direction
* changes.
* @type {!Array.<number>}
* @private
*/
this.recentTouchesX_ = [];
/**
* An array of tuples where the first item is the vertical component of a
* recent relevant touch and the second item is the touch's time stamp. Old
* touches are removed based on the max tracking time and when direction
* changes.
* @type {!Array.<number>}
* @private
*/
this.recentTouchesY_ = [];
/**
* Used to keep track of all events we subscribe to so we can easily clean
* up
* @type {EventTracker}
* @private
*/
this.events_ = new EventTracker();
}
/**
* DOM Events that may be fired by the TouchHandler at the element
*/
TouchHandler.EventType = {
// Fired whenever the element is touched as the only touch to the device.
// enableDrag defaults to false, set to true to permit dragging.
TOUCH_START: 'touchhandler:touch_start',
// Fired when an element is held for a period of time. Prevents dragging
// from occuring (even if enableDrag was set to true).
LONG_PRESS: 'touchhandler:long_press',
// If enableDrag was set to true at TOUCH_START, DRAG_START will fire when
// the touch first moves sufficient distance. enableDrag is set to true but
// can be reset to false to cancel the drag.
DRAG_START: 'touchhandler:drag_start',
// If enableDrag was true after DRAG_START, DRAG_MOVE will fire whenever the
// touch is moved.
DRAG_MOVE: 'touchhandler:drag_move',
// Fired just before TOUCH_END when a drag is released. Correlates 1:1 with
// a DRAG_START.
DRAG_END: 'touchhandler:drag_end',
// Fired whenever a touch that is being tracked has been released.
// Correlates 1:1 with a TOUCH_START.
TOUCH_END: 'touchhandler:touch_end'
};
/**
* The type of event sent by TouchHandler
* @constructor
* @param {string} type The type of event (one of Grabber.EventType).
* @param {boolean} bubbles Whether or not the event should bubble.
* @param {number} clientX The X location of the touch.
* @param {number} clientY The Y location of the touch.
* @param {!Element} touchedElement The element at the current location of the
* touch.
*/
TouchHandler.Event = function(type, bubbles, clientX, clientY,
touchedElement) {
var event = document.createEvent('Event');
event.initEvent(type, bubbles, true);
event.__proto__ = TouchHandler.Event.prototype;
/**
* The X location of the touch affected
* @type {number}
*/
event.clientX = clientX;
/**
* The Y location of the touch affected
* @type {number}
*/
event.clientY = clientY;
/**
* The element at the current location of the touch.
* @type {!Element}
*/
event.touchedElement = touchedElement;
return event;
};
TouchHandler.Event.prototype = {
__proto__: Event.prototype,
/**
* For TOUCH_START and DRAG START events, set to true to enable dragging or
* false to disable dragging.
* @type {boolean|undefined}
*/
enableDrag: undefined,
/**
* For DRAG events, provides the horizontal component of the
* drag delta. Drag delta is defined as the delta of the start touch
* position and the current drag position.
* @type {number|undefined}
*/
dragDeltaX: undefined,
/**
* For DRAG events, provides the vertical component of the
* drag delta.
* @type {number|undefined}
*/
dragDeltaY: undefined
};
/**
* Minimum movement of touch required to be considered a drag.
* @type {number}
* @private
*/
TouchHandler.MIN_TRACKING_FOR_DRAG_ = 8;
/**
* The maximum number of ms to track a touch event. After an event is older
* than this value, it will be ignored in velocity calculations.
* @type {number}
* @private
*/
TouchHandler.MAX_TRACKING_TIME_ = 250;
/**
* The maximum number of touches to track.
* @type {number}
* @private
*/
TouchHandler.MAX_TRACKING_TOUCHES_ = 5;
/**
* The maximum velocity to return, in pixels per millisecond, that is used
* to guard against errors in calculating end velocity of a drag. This is a
* very fast drag velocity.
* @type {number}
* @private
*/
TouchHandler.MAXIMUM_VELOCITY_ = 5;
/**
* The velocity to return, in pixel per millisecond, when the time stamps on
* the events are erroneous. The browser can return bad time stamps if the
* thread is blocked for the duration of the drag. This is a low velocity to
* prevent the content from moving quickly after a slow drag. It is less
* jarring if the content moves slowly after a fast drag.
* @type {number}
* @private
*/
TouchHandler.VELOCITY_FOR_INCORRECT_EVENTS_ = 1;
/**
* The time, in milliseconds, that a touch must be held to be considered
* 'long'.
* @type {number}
* @private
*/
TouchHandler.TIME_FOR_LONG_PRESS_ = 500;
TouchHandler.prototype = {
/**
* If defined, the identifer of the single touch that is active. Note that
* 0 is a valid touch identifier - it should not be treated equivalently to
* undefined.
* @type {number|undefined}
* @private
*/
activeTouch_: undefined,
/**
* @type {boolean|undefined}
* @private
*/
tracking_: undefined,
/**
* @type {number|undefined}
* @private
*/
startTouchX_: undefined,
/**
* @type {number|undefined}
* @private
*/
startTouchY_: undefined,
/**
* @type {number|undefined}
* @private
*/
endTouchX_: undefined,
/**
* @type {number|undefined}
* @private
*/
endTouchY_: undefined,
/**
* Time of the touchstart event.
* @type {number|undefined}
* @private
*/
startTime_: undefined,
/**
* The time of the touchend event.
* @type {number|undefined}
* @private
*/
endTime_: undefined,
/**
* @type {number|undefined}
* @private
*/
lastTouchX_: undefined,
/**
* @type {number|undefined}
* @private
*/
lastTouchY_: undefined,
/**
* @type {number|undefined}
* @private
*/
lastMoveX_: undefined,
/**
* @type {number|undefined}
* @private
*/
lastMoveY_: undefined,
/**
* @type {number|undefined}
* @private
*/
longPressTimeout_: undefined,
/**
* If defined and true, the next click event should be swallowed
* @type {boolean|undefined}
* @private
*/
swallowNextClick_: undefined,
/**
* Start listenting for events.
* @param {boolean=} opt_capture True if the TouchHandler should listen to
* during the capture phase.
*/
enable: function(opt_capture) {
var capture = !!opt_capture;
// Just listen to start events for now. When a touch is occuring we'll
// want to be subscribed to move and end events on the document, but we
// don't want to incur the cost of lots of no-op handlers on the document.
this.events_.add(this.element_, 'touchstart', this.onStart_.bind(this),
capture);
this.events_.add(this.element_, 'mousedown',
this.mouseToTouchCallback_(this.onStart_.bind(this)),
capture);
// If the element is long-pressed, we may need to swallow a click
this.events_.add(this.element_, 'click', this.onClick_.bind(this), true);
},
/**
* Stop listening to all events.
*/
disable: function() {
this.stopTouching_();
this.events_.removeAll();
},
/**
* Wraps a callback with translations of mouse events to touch events.
* NOTE: These types really should be function(Event) but then we couldn't
* use this with bind (which operates on any type of function). Doesn't
* JSDoc support some sort of polymorphic types?
* @param {Function} callback The event callback.
* @return {Function} The wrapping callback.
* @private
*/
mouseToTouchCallback_: function(callback) {
return function(e) {
// Note that there may be synthesizes mouse events caused by touch
// events (a mouseDown after a touch-click). We leave it up to the
// client to worry about this if it matters to them (typically a short
// mouseDown/mouseUp without a click is no big problem and it's not
// obvious how we identify such synthesized events in a general way).
var touch = {
// any fixed value will do for the identifier - there will only
// ever be a single active 'touch' when using the mouse.
identifier: 0,
clientX: e.clientX,
clientY: e.clientY,
target: e.target
};
e.touches = [];
e.targetTouches = [];
e.changedTouches = [touch];
if (e.type != 'mouseup') {
e.touches[0] = touch;
e.targetTouches[0] = touch;
}
callback(e);
};
},
/**
* Begin tracking the touchable element, it is eligible for dragging.
* @private
*/
beginTracking_: function() {
this.tracking_ = true;
},
/**
* Stop tracking the touchable element, it is no longer dragging.
* @private
*/
endTracking_: function() {
this.tracking_ = false;
this.dragging_ = false;
this.totalMoveY_ = 0;
this.totalMoveX_ = 0;
},
/**
* Reset the touchable element as if we never saw the touchStart
* Doesn't dispatch any end events - be careful of existing listeners.
*/
cancelTouch: function() {
this.stopTouching_();
this.endTracking_();
// If clients needed to be aware of this, we could fire a cancel event
// here.
},
/**
* Record that touching has stopped
* @private
*/
stopTouching_: function() {
// Mark as no longer being touched
this.activeTouch_ = undefined;
// If we're waiting for a long press, stop
window.clearTimeout(this.longPressTimeout_);
// Stop listening for move/end events until there's another touch.
// We don't want to leave handlers piled up on the document.
// Note that there's no harm in removing handlers that weren't added, so
// rather than track whether we're using mouse or touch we do both.
this.events_.remove(document, 'touchmove');
this.events_.remove(document, 'touchend');
this.events_.remove(document, 'touchcancel');
this.events_.remove(document, 'mousemove');
this.events_.remove(document, 'mouseup');
},
/**
* Touch start handler.
* @param {!TouchEvent} e The touchstart event.
* @private
*/
onStart_: function(e) {
// Only process single touches. If there is already a touch happening, or
// two simultaneous touches then just ignore them.
if (e.touches.length > 1)
// Note that we could cancel an active touch here. That would make
// simultaneous touch behave similar to near-simultaneous. However, if
// the user is dragging something, an accidental second touch could be
// quite disruptive if it cancelled their drag. Better to just ignore
// it.
return;
// It's still possible there could be an active "touch" if the user is
// simultaneously using a mouse and a touch input.
if (this.activeTouch_ !== undefined)
return;
var touch = e.targetTouches[0];
this.activeTouch_ = touch.identifier;
// We've just started touching so shouldn't swallow any upcoming click
if (this.swallowNextClick_)
this.swallowNextClick_ = false;
// Sign up for end/cancel notifications for this touch.
// Note that we do this on the document so that even if the user drags
// their finger off the element, we'll still know what they're doing.
if (e.type == 'mousedown') {
this.events_.add(document, 'mouseup',
this.mouseToTouchCallback_(this.onEnd_.bind(this)), false);
} else {
this.events_.add(document, 'touchend', this.onEnd_.bind(this), false);
this.events_.add(document, 'touchcancel', this.onEnd_.bind(this),
false);
}
// This timeout is cleared on touchEnd and onDrag
// If we invoke the function then we have a real long press
window.clearTimeout(this.longPressTimeout_);
this.longPressTimeout_ = window.setTimeout(
this.onLongPress_.bind(this),
TouchHandler.TIME_FOR_LONG_PRESS_);
// Dispatch the TOUCH_START event
if (!this.dispatchEvent_(TouchHandler.EventType.TOUCH_START, touch))
// Dragging was not enabled, nothing more to do
return;
// We want dragging notifications
if (e.type == 'mousedown') {
this.events_.add(document, 'mousemove',
this.mouseToTouchCallback_(this.onMove_.bind(this)), false);
} else {
this.events_.add(document, 'touchmove', this.onMove_.bind(this), false);
}
this.startTouchX_ = this.lastTouchX_ = touch.clientX;
this.startTouchY_ = this.lastTouchY_ = touch.clientY;
this.startTime_ = e.timeStamp;
this.recentTouchesX_ = [];
this.recentTouchesY_ = [];
this.recentTouchesX_.push(touch.clientX, e.timeStamp);
this.recentTouchesY_.push(touch.clientY, e.timeStamp);
this.beginTracking_();
},
/**
* Given a list of Touches, find the one matching our activeTouch
* identifier. Note that Chrome currently always uses 0 as the identifier.
* In that case we'll end up always choosing the first element in the list.
* @param {TouchList} touches The list of Touch objects to search.
* @return {!Touch|undefined} The touch matching our active ID if any.
* @private
*/
findActiveTouch_: function(touches) {
assert(this.activeTouch_ !== undefined, 'Expecting an active touch');
// A TouchList isn't actually an array, so we shouldn't use
// Array.prototype.filter/some, etc.
for (var i = 0; i < touches.length; i++) {
if (touches[i].identifier == this.activeTouch_)
return touches[i];
}
return undefined;
},
/**
* Touch move handler.
* @param {!TouchEvent} e The touchmove event.
* @private
*/
onMove_: function(e) {
if (!this.tracking_)
return;
// Our active touch should always be in the list of touches still active
assert(this.findActiveTouch_(e.touches), 'Missing touchEnd');
var that = this;
var touch = this.findActiveTouch_(e.changedTouches);
if (!touch)
return;
var clientX = touch.clientX;
var clientY = touch.clientY;
var moveX = this.lastTouchX_ - clientX;
var moveY = this.lastTouchY_ - clientY;
this.totalMoveX_ += Math.abs(moveX);
this.totalMoveY_ += Math.abs(moveY);
this.lastTouchX_ = clientX;
this.lastTouchY_ = clientY;
if (!this.dragging_ && (this.totalMoveY_ >
TouchHandler.MIN_TRACKING_FOR_DRAG_ ||
this.totalMoveX_ >
TouchHandler.MIN_TRACKING_FOR_DRAG_)) {
// If we're waiting for a long press, stop
window.clearTimeout(this.longPressTimeout_);
// Dispatch the DRAG_START event and record whether dragging should be
// allowed or not. Note that this relies on the current value of
// startTouchX/Y - handlers may use the initial drag delta to determine
// if dragging should be permitted.
this.dragging_ = this.dispatchEvent_(
TouchHandler.EventType.DRAG_START, touch);
if (this.dragging_) {
// Update the start position here so that drag deltas have better
// values but don't touch the recent positions so that velocity
// calculations can still use touchstart position in the time and
// distance delta.
this.startTouchX_ = clientX;
this.startTouchY_ = clientY;
this.startTime_ = e.timeStamp;
} else {
this.endTracking_();
}
}
if (this.dragging_) {
this.dispatchEvent_(TouchHandler.EventType.DRAG_MOVE, touch);
this.removeTouchesInWrongDirection_(this.recentTouchesX_,
this.lastMoveX_, moveX);
this.removeTouchesInWrongDirection_(this.recentTouchesY_,
this.lastMoveY_, moveY);
this.removeOldTouches_(this.recentTouchesX_, e.timeStamp);
this.removeOldTouches_(this.recentTouchesY_, e.timeStamp);
this.recentTouchesX_.push(clientX, e.timeStamp);
this.recentTouchesY_.push(clientY, e.timeStamp);
}
this.lastMoveX_ = moveX;
this.lastMoveY_ = moveY;
},
/**
* Filters the provided recent touches array to remove all touches except
* the last if the move direction has changed.
* @param {!Array.<number>} recentTouches An array of tuples where the first
* item is the x or y component of the recent touch and the second item
* is the touch time stamp.
* @param {number|undefined} lastMove The x or y component of the previous
* move.
* @param {number} recentMove The x or y component of the most recent move.
* @private
*/
removeTouchesInWrongDirection_: function(recentTouches, lastMove,
recentMove) {
if (lastMove && recentMove && recentTouches.length > 2 &&
(lastMove > 0 ^ recentMove > 0)) {
recentTouches.splice(0, recentTouches.length - 2);
}
},
/**
* Filters the provided recent touches array to remove all touches older
* than the max tracking time or the 5th most recent touch.
* @param {!Array.<number>} recentTouches An array of tuples where the first
* item is the x or y component of the recent touch and the second item
* is the touch time stamp.
* @param {number} recentTime The time of the most recent event.
* @private
*/
removeOldTouches_: function(recentTouches, recentTime) {
while (recentTouches.length && recentTime - recentTouches[1] >
TouchHandler.MAX_TRACKING_TIME_ ||
recentTouches.length >
TouchHandler.MAX_TRACKING_TOUCHES_ * 2) {
recentTouches.splice(0, 2);
}
},
/**
* Touch end handler.
* @param {!TouchEvent} e The touchend event.
* @private
*/
onEnd_: function(e) {
var that = this;
assert(this.activeTouch_ !== undefined, 'Expect to already be touching');
// If the touch we're tracking isn't changing here, ignore this touch end.
var touch = this.findActiveTouch_(e.changedTouches);
if (!touch) {
// In most cases, our active touch will be in the 'touches' collection,
// but we can't assert that because occasionally two touchend events can
// occur at almost the same time with both having empty 'touches' lists.
// I.e., 'touches' seems like it can be a bit more up-to-date than the
// current event.
return;
}
// This is touchEnd for the touch we're monitoring
assert(!this.findActiveTouch_(e.touches),
'Touch ended also still active');
// Indicate that touching has finished
this.stopTouching_();
if (this.tracking_) {
var clientX = touch.clientX;
var clientY = touch.clientY;
if (this.dragging_) {
this.endTime_ = e.timeStamp;
this.endTouchX_ = clientX;
this.endTouchY_ = clientY;
this.removeOldTouches_(this.recentTouchesX_, e.timeStamp);
this.removeOldTouches_(this.recentTouchesY_, e.timeStamp);
this.dispatchEvent_(TouchHandler.EventType.DRAG_END, touch);
// Note that in some situations we can get a click event here as well.
// For now this isn't a problem, but we may want to consider having
// some logic that hides clicks that appear to be caused by a touchEnd
// used for dragging.
}
this.endTracking_();
}
// Note that we dispatch the touchEnd event last so that events at
// different levels of semantics nest nicely (similar to how DOM
// drag-and-drop events are nested inside of the mouse events that trigger
// them).
this.dispatchEvent_(TouchHandler.EventType.TOUCH_END, touch);
},
/**
* Get end velocity of the drag. This method is specific to drag behavior,
* so if touch behavior and drag behavior is split then this should go with
* drag behavior. End velocity is defined as deltaXY / deltaTime where
* deltaXY is the difference between endPosition and the oldest recent
* position, and deltaTime is the difference between endTime and the oldest
* recent time stamp.
* @return {Object} The x and y velocity.
*/
getEndVelocity: function() {
// Note that we could move velocity to just be an end-event parameter.
var velocityX = this.recentTouchesX_.length ?
(this.endTouchX_ - this.recentTouchesX_[0]) /
(this.endTime_ - this.recentTouchesX_[1]) : 0;
var velocityY = this.recentTouchesY_.length ?
(this.endTouchY_ - this.recentTouchesY_[0]) /
(this.endTime_ - this.recentTouchesY_[1]) : 0;
velocityX = this.correctVelocity_(velocityX);
velocityY = this.correctVelocity_(velocityY);
return {
x: velocityX,
y: velocityY
};
},
/**
* Correct erroneous velocities by capping the velocity if we think it's too
* high, or setting it to a default velocity if know that the event data is
* bad.
* @param {number} velocity The x or y velocity component.
* @return {number} The corrected velocity.
* @private
*/
correctVelocity_: function(velocity) {
var absVelocity = Math.abs(velocity);
// We add to recent touches for each touchstart and touchmove. If we have
// fewer than 3 touches (6 entries), we assume that the thread was blocked
// for the duration of the drag and we received events in quick succession
// with the wrong time stamps.
if (absVelocity > TouchHandler.MAXIMUM_VELOCITY_) {
absVelocity = this.recentTouchesY_.length < 3 ?
TouchHandler.VELOCITY_FOR_INCORRECT_EVENTS_ :
TouchHandler.MAXIMUM_VELOCITY_;
}
return absVelocity * (velocity < 0 ? -1 : 1);
},
/**
* Handler when an element has been pressed for a long time
* @private
*/
onLongPress_: function() {
// Swallow any click that occurs on this element without an intervening
// touch start event. This simple click-busting technique should be
// sufficient here since a real click should have a touchstart first.
this.swallowNextClick_ = true;
// Dispatch to the LONG_PRESS
this.dispatchEventXY_(TouchHandler.EventType.LONG_PRESS, this.element_,
this.startTouchX_, this.startTouchY_);
},
/**
* Click handler - used to swallow clicks after a long-press
* @param {!Event} e The click event.
* @private
*/
onClick_: function(e) {
if (this.swallowNextClick_) {
e.preventDefault();
e.stopPropagation();
this.swallowNextClick_ = false;
}
},
/**
* Dispatch a TouchHandler event to the element
* @param {string} eventType The event to dispatch.
* @param {Touch} touch The touch triggering this event.
* @return {boolean|undefined} The value of enableDrag after dispatching
* the event.
* @private
*/
dispatchEvent_: function(eventType, touch) {
// Determine which element was touched. For mouse events, this is always
// the event/touch target. But for touch events, the target is always the
// target of the touchstart (and it's unlikely we can change this
// since the common implementation of touch dragging relies on it). Since
// touch is our primary scenario (which we want to emulate with mouse),
// we'll treat both cases the same and not depend on the target.
var touchedElement;
if (eventType == TouchHandler.EventType.TOUCH_START) {
touchedElement = touch.target;
} else {
touchedElement = this.element_.ownerDocument.
elementFromPoint(touch.clientX, touch.clientY);
}
return this.dispatchEventXY_(eventType, touchedElement, touch.clientX,
touch.clientY);
},
/**
* Dispatch a TouchHandler event to the element
* @param {string} eventType The event to dispatch.
@param {number} clientX The X location for the event.
@param {number} clientY The Y location for the event.
* @return {boolean|undefined} The value of enableDrag after dispatching
* the event.
* @private
*/
dispatchEventXY_: function(eventType, touchedElement, clientX, clientY) {
var isDrag = (eventType == TouchHandler.EventType.DRAG_START ||
eventType == TouchHandler.EventType.DRAG_MOVE ||
eventType == TouchHandler.EventType.DRAG_END);
// Drag events don't bubble - we're really just dragging the element,
// not affecting its parent at all.
var bubbles = !isDrag;
var event = new TouchHandler.Event(eventType, bubbles, clientX, clientY,
touchedElement);
// Set enableDrag when it can be overridden
if (eventType == TouchHandler.EventType.TOUCH_START)
event.enableDrag = false;
else if (eventType == TouchHandler.EventType.DRAG_START)
event.enableDrag = true;
if (isDrag) {
event.dragDeltaX = clientX - this.startTouchX_;
event.dragDeltaY = clientY - this.startTouchY_;
}
this.element_.dispatchEvent(event);
return event.enableDrag;
}
};
return TouchHandler;
})();