UNPKG

@qooxdoo/framework

Version:

The JS Framework for Coders

911 lines (786 loc) 29 kB
/* ************************************************************************ qooxdoo - the new era of web development http://qooxdoo.org Copyright: 2014 1&1 Internet AG, Germany, http://www.1und1.de License: MIT: https://opensource.org/licenses/MIT See the LICENSE file in the project's top-level directory for details. Authors: * Christopher Zuendorf (czuendorf) * Daniel Wagner (danielwagner) ************************************************************************ */ /** * Listens for (native or synthetic) pointer events and fires events * for gestures like "tap" or "swipe" */ qx.Bootstrap.define("qx.event.handler.GestureCore", { extend : Object, implement: [ qx.core.IDisposable ], statics : { TYPES : ["tap", "swipe", "longtap", "dbltap", "track", "trackstart", "trackend", "rotate", "pinch", "roll"], GESTURE_EVENTS : ["gesturebegin", "gesturefinish", "gesturemove", "gesturecancel"], /** @type {Map} Maximum distance between a pointer-down and pointer-up event, values are configurable */ TAP_MAX_DISTANCE : {"touch": 40, "mouse": 5, "pen": 20}, // values are educated guesses /** @type {Map} Maximum distance between two subsequent taps, values are configurable */ DOUBLETAP_MAX_DISTANCE : {"touch": 10, "mouse": 4, "pen": 10}, // values are educated guesses /** @type {Map} The direction of a swipe relative to the axis */ SWIPE_DIRECTION : { x : ["left", "right"], y : ["up", "down"] }, /** * @type {Integer} The time delta in milliseconds to fire a long tap event. */ LONGTAP_TIME : 500, /** * @type {Integer} Maximum time between two tap events that will still trigger a * dbltap event. */ DOUBLETAP_TIME : 500, /** * @type {Integer} Factor which is used for adapting the delta of the mouse wheel * event to the roll events, */ ROLL_FACTOR: 18, /** * @type {Integer} Factor which is used for adapting the delta of the touchpad gesture * event to the roll events, */ TOUCHPAD_ROLL_FACTOR: 1, /** * @type {Integer} Minimum number of wheel events to receive during the * TOUCHPAD_WHEEL_EVENTS_PERIOD to detect a touchpad. */ TOUCHPAD_WHEEL_EVENTS_THRESHOLD: 10, /** * @type {Integer} Period (in ms) during which the wheel events are counted in order * to detect a touchpad. */ TOUCHPAD_WHEEL_EVENTS_PERIOD: 100, /** * @type {Integer} Timeout (in ms) after which the touchpad detection is reset if no wheel * events are received in the meantime. */ TOUCHPAD_WHEEL_EVENTS_TIMEOUT: 5000 }, /** * @param target {Element} DOM Element that should fire gesture events * @param emitter {qx.event.Emitter?} Event emitter (used if dispatchEvent * is not supported, e.g. in IE8) */ construct : function(target, emitter) { this.__defaultTarget = target; this.__emitter = emitter; this.__gesture = {}; this.__lastTap = {}; this.__stopMomentum = {}; this.__momentum = {}; this.__rollEvents = []; this._initObserver(); }, members : { __defaultTarget : null, __emitter : null, __gesture : null, __eventName : null, __primaryTarget : null, __isMultiPointerGesture : null, __initialAngle : null, __lastTap : null, __rollImpulseId : null, __stopMomentum : null, __initialDistance : null, __momentum : null, __rollEvents : null, __rollEventsCountStart : 0, __rollEventsCount : 0, __touchPadDetectionPerformed : false, __lastRollEventTime: 0, /** * Register pointer event listeners */ _initObserver : function() { qx.event.handler.GestureCore.GESTURE_EVENTS.forEach(function(gestureType) { qxWeb(this.__defaultTarget).on(gestureType, this.checkAndFireGesture, this); }.bind(this)); if (qx.core.Environment.get("engine.name") == "mshtml" && qx.core.Environment.get("browser.documentmode") < 9) { qxWeb(this.__defaultTarget).on("dblclick", this._onDblClick, this); } // list to wheel events var data = qx.core.Environment.get("event.mousewheel"); qxWeb(data.target).on(data.type, this._fireRoll, this); }, /** * Remove native pointer event listeners. */ _stopObserver : function() { qx.event.handler.GestureCore.GESTURE_EVENTS.forEach(function(pointerType) { qxWeb(this.__defaultTarget).off(pointerType, this.checkAndFireGesture, this); }.bind(this)); if (qx.core.Environment.get("engine.name") == "mshtml" && qx.core.Environment.get("browser.documentmode") < 9) { qxWeb(this.__defaultTarget).off("dblclick", this._onDblClick, this); } var data = qx.core.Environment.get("event.mousewheel"); qxWeb(data.target).off(data.type, this._fireRoll, this); }, /** * Checks if a gesture was made and fires the gesture event. * * @param domEvent {qx.event.type.Pointer} DOM event * @param type {String ? null} type of the event * @param target {Element ? null} event target */ checkAndFireGesture : function(domEvent, type, target) { if (!type) { type = domEvent.type; } if (!target) { target = qx.bom.Event.getTarget(domEvent); } if (type == "gesturebegin") { this.gestureBegin(domEvent, target); } else if (type == "gesturemove") { this.gestureMove(domEvent, target); } else if (type == "gesturefinish") { this.gestureFinish(domEvent, target); } else if (type == "gesturecancel") { this.gestureCancel(domEvent.pointerId); } }, /** * Helper method for gesture start. * * @param domEvent {Event} DOM event * @param target {Element} event target */ gestureBegin : function(domEvent, target) { if (this.__gesture[domEvent.pointerId]) { this.__stopLongTapTimer(this.__gesture[domEvent.pointerId]); delete this.__gesture[domEvent.pointerId]; } /* If the dom event's target or one of its ancestors have a gesture handler, we don't need to fire the gesture again since it bubbles. */ if (this._hasIntermediaryHandler(target)) { return; } this.__gesture[domEvent.pointerId] = { "startTime" : new Date().getTime(), "lastEventTime" : new Date().getTime(), "startX" : domEvent.clientX, "startY" : domEvent.clientY, "clientX" : domEvent.clientX, "clientY" : domEvent.clientY, "velocityX" : 0, "velocityY" : 0, "target" : target, "isTap" : true, "isPrimary" : domEvent.isPrimary, "longTapTimer" : window.setTimeout( this.__fireLongTap.bind(this, domEvent, target), qx.event.handler.GestureCore.LONGTAP_TIME ) }; if(domEvent.isPrimary) { this.__isMultiPointerGesture = false; this.__primaryTarget = target; this.__fireTrack("trackstart", domEvent, target); } else { this.__isMultiPointerGesture = true; if(Object.keys(this.__gesture).length === 2) { this.__initialAngle = this._calcAngle(); this.__initialDistance = this._calcDistance(); } } }, /** * Helper method for gesture move. * * @param domEvent {Event} DOM event * @param target {Element} event target */ gestureMove : function(domEvent, target) { var gesture = this.__gesture[domEvent.pointerId]; if (gesture) { var oldClientX = gesture.clientX; var oldClientY = gesture.clientY; gesture.clientX = domEvent.clientX; gesture.clientY = domEvent.clientY; gesture.lastEventTime = new Date().getTime(); if(oldClientX) { gesture.velocityX = gesture.clientX - oldClientX; } if(oldClientY) { gesture.velocityY = gesture.clientY - oldClientY; } if (Object.keys(this.__gesture).length === 2) { this.__fireRotate(domEvent, gesture.target); this.__firePinch(domEvent, gesture.target); } if(!this.__isMultiPointerGesture) { this.__fireTrack("track", domEvent, gesture.target); this._fireRoll(domEvent, "touch", gesture.target); } // abort long tap timer if the distance is too big if (gesture.isTap) { gesture.isTap = this._isBelowTapMaxDistance(domEvent); if (!gesture.isTap) { this.__stopLongTapTimer(gesture); } } } }, /** * Checks if a DOM element located between the target of a gesture * event and the element this handler is attached to has a gesture * handler of its own. * * @param target {Element} The gesture event's target * @return {Boolean} */ _hasIntermediaryHandler: function(target) { while (target && target !== this.__defaultTarget) { if (target.$$gestureHandler) { return true; } target = target.parentNode; } return false; }, /** * Helper method for gesture end. * * @param domEvent {Event} DOM event * @param target {Element} event target */ gestureFinish : function(domEvent, target) { // If no start position is available for this pointerup event, cancel gesture recognition. if (!this.__gesture[domEvent.pointerId]) { return; } var gesture = this.__gesture[domEvent.pointerId]; // delete the long tap this.__stopLongTapTimer(gesture); /* If the dom event's target or one of its ancestors have a gesture handler, we don't need to fire the gesture again since it bubbles. */ if (this._hasIntermediaryHandler(target)) { return; } // always start the roll impulse on the original target this.__handleRollImpulse( gesture.velocityX, gesture.velocityY, domEvent, gesture.target ); this.__fireTrack("trackend", domEvent, gesture.target); if (gesture.isTap) { if (target !== gesture.target) { delete this.__gesture[domEvent.pointerId]; return; } this._fireEvent(domEvent, "tap", domEvent.target || target); var isDblTap = false; if (Object.keys(this.__lastTap).length > 0) { // delete old tap entries var limit = Date.now() - qx.event.handler.GestureCore.DOUBLETAP_TIME; for (var time in this.__lastTap) { if (time < limit) { delete this.__lastTap[time]; } else { var lastTap = this.__lastTap[time]; var isBelowDoubleTapDistance = this.__isBelowDoubleTapDistance( lastTap.x, lastTap.y, domEvent.clientX, domEvent.clientY, domEvent.getPointerType() ); var isSameTarget = lastTap.target === (domEvent.target || target); var isSameButton = lastTap.button === domEvent.button; if (isBelowDoubleTapDistance && isSameButton && isSameTarget) { isDblTap = true; delete this.__lastTap[time]; this._fireEvent(domEvent, "dbltap", domEvent.target || target); } } } } if (!isDblTap) { this.__lastTap[Date.now()] = { x: domEvent.clientX, y: domEvent.clientY, target: domEvent.target || target, button: domEvent.button }; } } else if (!this._isBelowTapMaxDistance(domEvent)) { var swipe = this.__getSwipeGesture(domEvent, target); if (swipe) { domEvent.swipe = swipe; this._fireEvent(domEvent, "swipe", gesture.target || target); } } delete this.__gesture[domEvent.pointerId]; }, /** * Stops the momentum scrolling currently running. * * @param id {Integer} The timeoutId of a 'roll' event */ stopMomentum : function(id) { this.__stopMomentum[id] = true; }, /** * Cancels the gesture if running. * @param id {Number} The pointer Id. */ gestureCancel : function(id) { if (this.__gesture[id]) { this.__stopLongTapTimer(this.__gesture[id]); delete this.__gesture[id]; } if (this.__momentum[id]) { this.stopMomentum(this.__momentum[id]); delete this.__momentum[id]; } }, /** * Update the target of a running gesture. This is used in virtual widgets * when the DOM element changes. * * @param id {String} The pointer id. * @param target {Element} The new target element. * @internal */ updateGestureTarget : function(id, target) { this.__gesture[id].target = target; }, /** * Method which will be called recursively to provide a momentum scrolling. * @param deltaX {Number} The last offset in X direction * @param deltaY {Number} The last offset in Y direction * @param domEvent {Event} The original gesture event * @param target {Element} The target of the momentum roll events * @param time {Number ?} The time in ms between the last two calls */ __handleRollImpulse : function(deltaX, deltaY, domEvent, target, time) { var oldTimeoutId = domEvent.timeoutId; if (!time && this.__momentum[domEvent.pointerId]) { // new roll impulse started, stop the old one this.stopMomentum(this.__momentum[domEvent.pointerId]); } // do nothing if we don't need to scroll if ((Math.abs(deltaY) < 1 && Math.abs(deltaX) < 1) || this.__stopMomentum[oldTimeoutId] || !this.getWindow()) { delete this.__stopMomentum[oldTimeoutId]; delete this.__momentum[domEvent.pointerId]; return; } if (!time) { time = 1; var startFactor = 2.8; deltaY = deltaY / startFactor; deltaX = deltaX / startFactor; } time += 0.0006; deltaY = deltaY / time; deltaX = deltaX / time; // set up a new timer with the new delta var timeoutId = qx.bom.AnimationFrame.request( qx.lang.Function.bind( function(deltaX, deltaY, domEvent, target, time) { this.__handleRollImpulse(deltaX, deltaY, domEvent, target, time); }, this, deltaX, deltaY, domEvent, target, time) ); deltaX = Math.round(deltaX * 100) / 100; deltaY = Math.round(deltaY * 100) / 100; // scroll the desired new delta domEvent.delta = { x: -deltaX, y: -deltaY }; domEvent.momentum = true; domEvent.timeoutId = timeoutId; this.__momentum[domEvent.pointerId] = timeoutId; this._fireEvent(domEvent, "roll", domEvent.target || target); }, /** * Calculates the angle of the primary and secondary pointer. * @return {Number} the rotation angle of the 2 pointers. */ _calcAngle : function() { var pointerA = null; var pointerB = null; for (var pointerId in this.__gesture) { var gesture = this.__gesture[pointerId]; if (pointerA === null) { pointerA = gesture; } else { pointerB = gesture; } } var x = pointerA.clientX - pointerB.clientX; var y = pointerA.clientY - pointerB.clientY; return (360 + Math.atan2(y, x) * (180/Math.PI)) % 360; }, /** * Calculates the scaling distance between two pointers. * @return {Number} the calculated distance. */ _calcDistance : function() { var pointerA = null; var pointerB = null; for (var pointerId in this.__gesture) { var gesture = this.__gesture[pointerId]; if (pointerA === null) { pointerA = gesture; } else { pointerB = gesture; } } var scale = Math.sqrt( Math.pow(pointerA.clientX - pointerB.clientX, 2) + Math.pow(pointerA.clientY - pointerB.clientY, 2)); return scale; }, /** * Checks if the distance between the x/y coordinates of DOM event * exceeds TAP_MAX_DISTANCE and returns the result. * * @param domEvent {Event} The DOM event from the browser. * @return {Boolean|null} true if distance is below TAP_MAX_DISTANCE. */ _isBelowTapMaxDistance: function(domEvent) { var delta = this._getDeltaCoordinates(domEvent); var maxDistance = qx.event.handler.GestureCore.TAP_MAX_DISTANCE[domEvent.getPointerType()]; if (!delta) { return null; } return (Math.abs(delta.x) <= maxDistance && Math.abs(delta.y) <= maxDistance); }, /** * Checks if the distance between the x1/y1 and x2/y2 is * below the TAP_MAX_DISTANCE and returns the result. * * @param x1 {Number} The x position of point one. * @param y1 {Number} The y position of point one. * @param x2 {Number} The x position of point two. * @param y2 {Number} The y position of point two. * @param type {String} The pointer type e.g. "mouse" * @return {Boolean} <code>true</code>, if points are in range */ __isBelowDoubleTapDistance : function(x1, y1, x2, y2, type) { var clazz = qx.event.handler.GestureCore; var inX = Math.abs(x1 - x2) < clazz.DOUBLETAP_MAX_DISTANCE[type]; var inY = Math.abs(y1 - y2) < clazz.DOUBLETAP_MAX_DISTANCE[type]; return inX && inY; }, /** * Calculates the delta coordinates in relation to the position on <code>pointerstart</code> event. * @param domEvent {Event} The DOM event from the browser. * @return {Map} containing the deltaX as x, and deltaY as y. */ _getDeltaCoordinates : function(domEvent) { var gesture = this.__gesture[domEvent.pointerId]; if (!gesture) { return null; } var deltaX = domEvent.clientX - gesture.startX; var deltaY = domEvent.clientY - gesture.startY; var axis = "x"; if (Math.abs(deltaX / deltaY) < 1) { axis = "y"; } return { "x": deltaX, "y": deltaY, "axis": axis }; }, /** * Fire a gesture event with the given parameters * * @param domEvent {Event} DOM event * @param type {String} type of the event * @param target {Element ? null} event target * @return {qx.Promise?} a promise, if one or more of the event handlers returned a promise */ _fireEvent : function(domEvent, type, target) { // The target may have been removed, e.g. menu hide on tap if (!this.__defaultTarget) { return; } var evt; if (qx.core.Environment.get("event.dispatchevent")) { evt = new qx.event.type.dom.Custom(type, domEvent, { bubbles: true, swipe: domEvent.swipe, scale: domEvent.scale, angle: domEvent.angle, delta: domEvent.delta, pointerType: domEvent.pointerType, momentum : domEvent.momentum }); return target.dispatchEvent(evt); } else if (this.__emitter) { evt = new qx.event.type.dom.Custom(type, domEvent, { target : this.__defaultTarget, currentTarget : this.__defaultTarget, srcElement : this.__defaultTarget, swipe: domEvent.swipe, scale: domEvent.scale, angle: domEvent.angle, delta: domEvent.delta, pointerType: domEvent.pointerType, momentum : domEvent.momentum }); this.__emitter.emit(type, domEvent); } }, /** * Fire "tap" and "dbltap" events after a native "dblclick" * event to fix IE 8's broken mouse event sequence. * * @param domEvent {Event} dblclick event */ _onDblClick : function(domEvent) { var target = qx.bom.Event.getTarget(domEvent); this._fireEvent(domEvent, "tap", target); this._fireEvent(domEvent, "dbltap", target); }, /** * Returns the swipe gesture when the user performed a swipe. * * @param domEvent {Event} DOM event * @param target {Element} event target * @return {Map|null} returns the swipe data when the user performed a swipe, null if the gesture was no swipe. */ __getSwipeGesture : function(domEvent, target) { var gesture = this.__gesture[domEvent.pointerId]; if (!gesture) { return null; } var clazz = qx.event.handler.GestureCore; var deltaCoordinates = this._getDeltaCoordinates(domEvent); var duration = new Date().getTime() - gesture.startTime; var axis = (Math.abs(deltaCoordinates.x) >= Math.abs(deltaCoordinates.y)) ? "x" : "y"; var distance = deltaCoordinates[axis]; var direction = clazz.SWIPE_DIRECTION[axis][distance < 0 ? 0 : 1]; var velocity = (duration !== 0) ? distance / duration : 0; var swipe = { startTime: gesture.startTime, duration: duration, axis: axis, direction: direction, distance: distance, velocity: velocity }; return swipe; }, /** * Fires a track event. * * @param type {String} the track type * @param domEvent {Event} DOM event * @param target {Element} event target */ __fireTrack : function(type, domEvent, target) { domEvent.delta = this._getDeltaCoordinates(domEvent); this._fireEvent(domEvent, type, domEvent.target || target); }, /** * Fires a roll event. * * @param domEvent {Event} DOM event * @param target {Element} event target * @param rollFactor {Integer} the roll factor to apply */ __fireRollEvent: function (domEvent, target, rollFactor) { domEvent.delta = { x: qx.util.Wheel.getDelta(domEvent, "x") * rollFactor, y: qx.util.Wheel.getDelta(domEvent, "y") * rollFactor }; domEvent.delta.axis = Math.abs(domEvent.delta.x / domEvent.delta.y) < 1 ? "y" : "x"; domEvent.pointerType = "wheel"; this._fireEvent(domEvent, "roll", domEvent.target || target); }, /** * Triggers the adaptative roll scrolling. * * @param target {Element} event target */ __performAdaptativeRollScrolling: function (target) { var rollFactor = qx.event.handler.GestureCore.ROLL_FACTOR; if (qx.util.Wheel.IS_TOUCHPAD) { // The domEvent was generated by a touchpad rollFactor = qx.event.handler.GestureCore.TOUCHPAD_ROLL_FACTOR; } this.__lastRollEventTime = new Date().getTime(); var reLength = this.__rollEvents.length; for (var i = 0; i < reLength; i++) { var domEvent = this.__rollEvents[i]; this.__fireRollEvent(domEvent, target, rollFactor); } this.__rollEvents = []; }, /** * Ends touch pad detection process. */ __endTouchPadDetection: function () { if (this.__rollEvents.length > qx.event.handler.GestureCore.TOUCHPAD_WHEEL_EVENTS_THRESHOLD) { qx.util.Wheel.IS_TOUCHPAD = true; } else { qx.util.Wheel.IS_TOUCHPAD = false; } if (qx.core.Environment.get("qx.debug.touchpad.detection")) { qx.log.Logger.debug(this, "IS_TOUCHPAD : " + qx.util.Wheel.IS_TOUCHPAD); } this.__touchPadDetectionPerformed = true; }, /** * Is touchpad detection enabled ? Default implementation activates it only for Mac OS after Sierra (>= 10.12). * @return {boolean} true if touchpad detection should occur. * @internal */ _isTouchPadDetectionEnabled: function () { return qx.core.Environment.get("os.name") == "osx" && qx.core.Environment.get("os.version") >= 10.12; }, /** * Fires a roll event after determining the roll factor to apply. Mac OS Sierra (10.12+) * introduces a lot more wheel events fired from the trackpad, so the roll factor to be applied * has to be reduced in order to make the scrolling less sensitive. * * @param domEvent {Event} DOM event * @param type {String} The type of the dom event * @param target {Element} event target */ _fireRoll : function(domEvent, type, target) { var now; var detectionTimeout; if (domEvent.type === qx.core.Environment.get("event.mousewheel").type) { if (this._isTouchPadDetectionEnabled()) { now = new Date().getTime(); detectionTimeout = qx.event.handler.GestureCore.TOUCHPAD_WHEEL_EVENTS_TIMEOUT; if (this.__lastRollEventTime > 0 && now - this.__lastRollEventTime > detectionTimeout) { // The detection timeout was reached. A new detection step should occur. this.__touchPadDetectionPerformed = false; this.__rollEvents = []; this.__lastRollEventTime = 0; } if (!this.__touchPadDetectionPerformed) { // We are into a detection session. We count the events so that we can decide if // they were fired by a real mouse wheel or a touchpad. Just swallow them until the // detection period is over. if (this.__rollEvents.length === 0) { // detection starts this.__rollEventsCountStart = now; qx.event.Timer.once(function () { if (!this.__touchPadDetectionPerformed) { // There were not enough events during the TOUCHPAD_WHEEL_EVENTS_PERIOD to actually // trigger a scrolling. Trigger it manually. this.__endTouchPadDetection(); this.__performAdaptativeRollScrolling(target); } }, this, qx.event.handler.GestureCore.TOUCHPAD_WHEEL_EVENTS_PERIOD + 50) } this.__rollEvents.push(domEvent); this.__rollEventsCount++; if (now - this.__rollEventsCountStart > qx.event.handler.GestureCore.TOUCHPAD_WHEEL_EVENTS_PERIOD) { this.__endTouchPadDetection(); } } if (this.__touchPadDetectionPerformed) { if (this.__rollEvents.length === 0) { this.__rollEvents.push(domEvent); } // Detection is done. We can now decide the roll factor to apply to the delta. // Default to a real mouse wheel event as opposed to a touchpad one. this.__performAdaptativeRollScrolling(target); } } else { this.__fireRollEvent(domEvent, target, qx.event.handler.GestureCore.ROLL_FACTOR); } } else { var gesture = this.__gesture[domEvent.pointerId]; domEvent.delta = { x: -gesture.velocityX, y: -gesture.velocityY, axis : Math.abs(gesture.velocityX / gesture.velocityY) < 1 ? "y" : "x" }; this._fireEvent(domEvent, "roll", domEvent.target || target); } }, /** * Fires a rotate event. * * @param domEvent {Event} DOM event * @param target {Element} event target */ __fireRotate : function(domEvent, target) { if(!domEvent.isPrimary) { var angle = this._calcAngle(); domEvent.angle = Math.round((angle - this.__initialAngle) % 360); this._fireEvent(domEvent, "rotate", this.__primaryTarget); } }, /** * Fires a pinch event. * * @param domEvent {Event} DOM event * @param target {Element} event target */ __firePinch: function(domEvent, target) { if (!domEvent.isPrimary) { var distance = this._calcDistance(); var scale = distance / this.__initialDistance; domEvent.scale = (Math.round(scale * 100) / 100); this._fireEvent(domEvent, "pinch", this.__primaryTarget); } }, /** * Fires the long tap event. * * @param domEvent {Event} DOM event * @param target {Element} event target */ __fireLongTap : function(domEvent, target) { var gesture = this.__gesture[domEvent.pointerId]; if (gesture) { this._fireEvent(domEvent, "longtap", domEvent.target || target); gesture.longTapTimer = null; gesture.isTap = false; } }, /** * Stops the time for the long tap event. * @param gesture {Map} Data may representing the gesture. */ __stopLongTapTimer : function(gesture) { if (gesture.longTapTimer) { window.clearTimeout(gesture.longTapTimer); gesture.longTapTimer = null; } }, /** * Dispose the current instance */ dispose : function() { for(var gesture in this.__gesture) { this.__stopLongTapTimer(gesture); } this._stopObserver(); this.__defaultTarget = this.__emitter = null; } } });