fix(tapclick): several improvements

- refactors code using UIEventManager
- improved performance by using passive event listeners
- fixes isScrolling()
- click tolerance has been increased to match native behavior
- click is immediately prevented if the content is scrolled.
This commit is contained in:
Manu Mtz.-Almeida
2016-10-19 20:23:53 +02:00
parent 272acfc893
commit 35d12efe0b
7 changed files with 154 additions and 149 deletions

View File

@ -22,6 +22,7 @@ export class App {
private _title: string = ''; private _title: string = '';
private _titleSrv: Title = new Title(); private _titleSrv: Title = new Title();
private _rootNav: NavController = null; private _rootNav: NavController = null;
private _canDisableScroll: boolean;
/** /**
* @private * @private
@ -70,6 +71,7 @@ export class App {
// listen for hardware back button events // listen for hardware back button events
// register this back button action with a default priority // register this back button action with a default priority
_platform.registerBackButtonAction(this.navPop.bind(this)); _platform.registerBackButtonAction(this.navPop.bind(this));
this._canDisableScroll = this._config.get('canDisableScroll', true);
} }
/** /**
@ -122,7 +124,7 @@ export class App {
* scrolling is enabled. When set to `true`, scrolling is disabled. * scrolling is enabled. When set to `true`, scrolling is disabled.
*/ */
setScrollDisabled(disableScroll: boolean) { setScrollDisabled(disableScroll: boolean) {
if (this._config.get('canDisableScroll', true)) { if (this._canDisableScroll) {
this._appRoot._disableScroll(disableScroll); this._appRoot._disableScroll(disableScroll);
} }
} }
@ -148,7 +150,7 @@ export class App {
* @return {boolean} returns true or false * @return {boolean} returns true or false
*/ */
isScrolling(): boolean { isScrolling(): boolean {
return (this._scrollTime + 48 > Date.now()); return ((this._scrollTime + ACTIVE_SCROLLING_TIME) > Date.now());
} }
/** /**
@ -275,4 +277,5 @@ export class App {
} }
const ACTIVE_SCROLLING_TIME = 100;
const CLICK_BLOCK_BUFFER_IN_MILLIS = 64; const CLICK_BLOCK_BUFFER_IN_MILLIS = 64;

View File

@ -52,8 +52,7 @@ ion-content.js-scroll > .scroll-content {
} }
.disable-scroll .ion-page .scroll-content { .disable-scroll .ion-page .scroll-content {
overflow-y: hidden; pointer-events: none;
overflow-x: hidden;
} }

View File

@ -190,7 +190,7 @@ export class Content extends Ion {
this._zone.runOutsideAngular(() => { this._zone.runOutsideAngular(() => {
this._scroll = new ScrollView(this._scrollEle); this._scroll = new ScrollView(this._scrollEle);
this._scLsn = this.addScrollListener(this._app.setScrolling); this._scLsn = this.addScrollListener(this._app.setScrolling.bind(this._app));
}); });
} }
@ -252,6 +252,9 @@ export class Content extends Ion {
return this._addListener('mousemove', handler); return this._addListener('mousemove', handler);
} }
/**
* @private
*/
_addListener(type: string, handler: any): Function { _addListener(type: string, handler: any): Function {
assert(handler, 'handler must be valid'); assert(handler, 'handler must be valid');
assert(this._scrollEle, '_scrollEle must be valid'); assert(this._scrollEle, '_scrollEle must be valid');

View File

@ -21,7 +21,7 @@ export class Activator {
// queue to have this element activated // queue to have this element activated
this._queue.push(activatableEle); this._queue.push(activatableEle);
rafFrames(2, () => { rafFrames(6, () => {
let activatableEle: HTMLElement; let activatableEle: HTMLElement;
for (let i = 0; i < this._queue.length; i++) { for (let i = 0; i < this._queue.length; i++) {
activatableEle = this._queue[i]; activatableEle = this._queue[i];
@ -30,7 +30,7 @@ export class Activator {
activatableEle.classList.add(this._css); activatableEle.classList.add(this._css);
} }
} }
this._queue = []; this._queue.length = 0;
}); });
} }
@ -59,7 +59,7 @@ export class Activator {
deactivate() { deactivate() {
// remove the active class from all active elements // remove the active class from all active elements
this._queue = []; this._queue.length = 0;
rafFrames(2, () => { rafFrames(2, () => {
for (var i = 0; i < this._active.length; i++) { for (var i = 0; i < this._active.length; i++) {

View File

@ -5,156 +5,89 @@ import { App } from '../app/app';
import { Config } from '../../config/config'; import { Config } from '../../config/config';
import { hasPointerMoved, pointerCoord } from '../../util/dom'; import { hasPointerMoved, pointerCoord } from '../../util/dom';
import { RippleActivator } from './ripple'; import { RippleActivator } from './ripple';
import { UIEventManager, PointerEvents, PointerEventType } from '../../util/ui-event-manager';
/** /**
* @private * @private
*/ */
@Injectable() @Injectable()
export class TapClick { export class TapClick {
private lastTouch: number = 0;
private disableClick: number = 0; private disableClick: number = 0;
private lastActivated: number = 0;
private usePolyfill: boolean; private usePolyfill: boolean;
private activator: Activator; private activator: Activator;
private startCoord: any; private startCoord: any;
private pointerMove: any; private events: UIEventManager = new UIEventManager(false);
private pointerEvents: PointerEvents;
constructor( constructor(
config: Config, config: Config,
private app: App, private app: App,
zone: NgZone zone: NgZone
) { ) {
if (config.get('activator') === 'ripple') { let activator = config.get('activator');
if (activator === 'ripple') {
this.activator = new RippleActivator(app, config); this.activator = new RippleActivator(app, config);
} else if (config.get('activator') === 'highlight') { } else if (activator === 'highlight') {
this.activator = new Activator(app, config); this.activator = new Activator(app, config);
} }
this.usePolyfill = (config.get('tapPolyfill') === true); this.usePolyfill = (config.get('tapPolyfill') === true);
zone.runOutsideAngular(() => { this.events.listen(document, 'click', this.click.bind(this), true);
addListener('click', this.click.bind(this), true); this.pointerEvents = this.events.pointerEvents({
element: <any>document,
addListener('touchstart', this.touchStart.bind(this)); pointerDown: this.pointerStart.bind(this),
addListener('touchend', this.touchEnd.bind(this)); pointerMove: this.pointerMove.bind(this),
addListener('touchcancel', this.pointerCancel.bind(this)); pointerUp: this.pointerEnd.bind(this),
passive: true
addListener('mousedown', this.mouseDown.bind(this), true);
addListener('mouseup', this.mouseUp.bind(this), true);
}); });
this.pointerEvents.mouseWait = DISABLE_NATIVE_CLICK_AMOUNT;
this.pointerMove = (ev: UIEvent) => {
if (!this.startCoord || hasPointerMoved(POINTER_MOVE_UNTIL_CANCEL, this.startCoord, pointerCoord(ev)) ) {
this.pointerCancel(ev);
}
};
} }
touchStart(ev: UIEvent) { pointerStart(ev: any): boolean {
this.lastTouch = Date.now(); if (this.startCoord) {
this.pointerStart(ev); return false;
}
touchEnd(ev: UIEvent) {
this.lastTouch = Date.now();
if (this.usePolyfill && this.startCoord && this.app.isEnabled()) {
// only dispatch mouse click events from a touchend event
// when tapPolyfill config is true, and the startCoordand endCoord
// are not too far off from each other
let endCoord = pointerCoord(ev);
if (!hasPointerMoved(POINTER_TOLERANCE, this.startCoord, endCoord)) {
// prevent native mouse click events for XX amount of time
this.disableClick = this.lastTouch + DISABLE_NATIVE_CLICK_AMOUNT;
if (this.app.isScrolling()) {
// do not fire off a click event while the app was scrolling
console.debug('click from touch prevented by scrolling ' + Date.now());
} else {
// dispatch a mouse click event
console.debug('create click from touch ' + Date.now());
let clickEvent: any = document.createEvent('MouseEvents');
clickEvent.initMouseEvent('click', true, true, window, 1, 0, 0, endCoord.x, endCoord.y, false, false, false, false, 0, null);
clickEvent.isIonicTap = true;
ev.target.dispatchEvent(clickEvent);
}
}
} }
this.pointerEnd(ev);
}
mouseDown(ev: any) {
if (this.isDisabledNativeClick()) {
console.debug('mouseDown prevent ' + ev.target.tagName + ' ' + Date.now());
// does not prevent default on purpose
// so native blur events from inputs can happen
ev.stopPropagation();
} else if (this.lastTouch + DISABLE_NATIVE_CLICK_AMOUNT < Date.now()) {
this.pointerStart(ev);
}
}
mouseUp(ev: any) {
if (this.isDisabledNativeClick()) {
console.debug('mouseUp prevent ' + ev.target.tagName + ' ' + Date.now());
ev.preventDefault();
ev.stopPropagation();
}
if (this.lastTouch + DISABLE_NATIVE_CLICK_AMOUNT < Date.now()) {
this.pointerEnd(ev);
}
}
pointerStart(ev: any) {
let activatableEle = getActivatableTarget(ev.target); let activatableEle = getActivatableTarget(ev.target);
if (!activatableEle) {
if (activatableEle) {
this.startCoord = pointerCoord(ev);
let now = Date.now();
if (this.lastActivated + 150 < now && !this.app.isScrolling()) {
this.activator && this.activator.downAction(ev, activatableEle, this.startCoord);
this.lastActivated = now;
}
this.moveListeners(true);
} else {
this.startCoord = null; this.startCoord = null;
return false;
}
this.startCoord = pointerCoord(ev);
this.activator && this.activator.downAction(ev, activatableEle, this.startCoord);
return true;
}
pointerMove(ev: UIEvent) {
if (!this.startCoord ||
hasPointerMoved(POINTER_TOLERANCE, this.startCoord, pointerCoord(ev)) ||
this.app.isScrolling()) {
this.pointerCancel(ev);
} }
} }
pointerEnd(ev: any) { pointerEnd(ev: any, type: PointerEventType) {
if (this.startCoord && this.activator) { if (!this.startCoord) {
return;
}
if (type === PointerEventType.TOUCH && this.usePolyfill && this.app.isEnabled()) {
this.handleTapPolyfill(ev);
}
if (this.activator) {
let activatableEle = getActivatableTarget(ev.target); let activatableEle = getActivatableTarget(ev.target);
if (activatableEle) { if (activatableEle) {
this.activator.upAction(ev, activatableEle, this.startCoord); this.activator.upAction(ev, activatableEle, this.startCoord);
} }
} }
this.startCoord = null;
this.moveListeners(false);
} }
pointerCancel(ev: UIEvent) { pointerCancel(ev: UIEvent) {
console.debug('pointerCancel from ' + ev.type + ' ' + Date.now()); console.debug('pointerCancel from ' + ev.type + ' ' + Date.now());
this.startCoord = null;
this.activator && this.activator.clearState(); this.activator && this.activator.clearState();
this.moveListeners(false); this.pointerEvents.stop();
}
moveListeners(shouldAdd: boolean) {
removeListener(this.usePolyfill ? 'touchmove' : 'mousemove', this.pointerMove);
if (shouldAdd) {
addListener(this.usePolyfill ? 'touchmove' : 'mousemove', this.pointerMove);
}
} }
click(ev: any) { click(ev: any) {
@ -174,6 +107,34 @@ export class TapClick {
} }
} }
handleTapPolyfill(ev: any) {
// only dispatch mouse click events from a touchend event
// when tapPolyfill config is true, and the startCoordand endCoord
// are not too far off from each other
let endCoord = pointerCoord(ev);
if (hasPointerMoved(POINTER_TOLERANCE, this.startCoord, endCoord)) {
console.debug('click from touch prevented by pointer moved');
return;
}
// prevent native mouse click events for XX amount of time
this.disableClick = Date.now() + DISABLE_NATIVE_CLICK_AMOUNT;
if (this.app.isScrolling()) {
// do not fire off a click event while the app was scrolling
console.debug('click from touch prevented by scrolling ' + Date.now());
} else {
// dispatch a mouse click event
console.debug('create click from touch ' + Date.now());
let clickEvent: any = document.createEvent('MouseEvents');
clickEvent.initMouseEvent('click', true, true, window, 1, 0, 0, endCoord.x, endCoord.y, false, false, false, false, 0, null);
clickEvent.isIonicTap = true;
ev.target.dispatchEvent(clickEvent);
}
}
isDisabledNativeClick() { isDisabledNativeClick() {
return this.disableClick > Date.now(); return this.disableClick > Date.now();
} }
@ -194,33 +155,23 @@ function getActivatableTarget(ele: HTMLElement) {
/** /**
* @private * @private
*/ */
export const isActivatable = function(ele: HTMLElement) { export const isActivatable = function (ele: HTMLElement) {
if (ACTIVATABLE_ELEMENTS.test(ele.tagName)) { if (ACTIVATABLE_ELEMENTS.indexOf(ele.tagName) > -1) {
return true; return true;
} }
let attributes = ele.attributes; let attributes = ele.attributes;
for (let i = 0, l = attributes.length; i < l; i++) { for (let i = 0, l = attributes.length; i < l; i++) {
if (ACTIVATABLE_ATTRIBUTES.test(attributes[i].name)) { if (ACTIVATABLE_ATTRIBUTES.indexOf(attributes[i].name) > -1) {
return true; return true;
} }
} }
return false; return false;
}; };
function addListener(type: string, listener: any, useCapture?: boolean) { const ACTIVATABLE_ELEMENTS = ['A', 'BUTTON'];
document.addEventListener(type, listener, useCapture); const ACTIVATABLE_ATTRIBUTES = ['tappable', 'button'];
} const POINTER_TOLERANCE = 60;
function removeListener(type: string, listener: any) {
document.removeEventListener(type, listener);
}
const ACTIVATABLE_ELEMENTS = /^(A|BUTTON)$/;
const ACTIVATABLE_ATTRIBUTES = /tappable|button/i;
const POINTER_TOLERANCE = 4;
const POINTER_MOVE_UNTIL_CANCEL = 10;
const DISABLE_NATIVE_CLICK_AMOUNT = 2500; const DISABLE_NATIVE_CLICK_AMOUNT = 2500;
export function setupTapClick(config: Config, app: App, zone: NgZone) { export function setupTapClick(config: Config, app: App, zone: NgZone) {

View File

@ -110,7 +110,7 @@ export const PLATFORM_CONFIGS: {[key: string]: PlatformConfig} = {
swipeBackThreshold: 40, swipeBackThreshold: 40,
tapPolyfill: isIOSDevice, tapPolyfill: isIOSDevice,
virtualScrollEventAssist: !(window.indexedDB), virtualScrollEventAssist: !(window.indexedDB),
canDisableScroll: !!(window.indexedDB), canDisableScroll: isIOSDevice,
}, },
isMatch(p: Platform) { isMatch(p: Platform) {
return p.isPlatformMatch('ios', ['iphone', 'ipad', 'ipod'], ['windows phone']); return p.isPlatformMatch('ios', ['iphone', 'ipad', 'ipod'], ['windows phone']);

View File

@ -1,4 +1,5 @@
import { ElementRef } from '@angular/core'; import { ElementRef } from '@angular/core';
import { assert } from './util';
export interface PointerEventsConfig { export interface PointerEventsConfig {
element?: HTMLElement; element?: HTMLElement;
@ -6,10 +7,29 @@ export interface PointerEventsConfig {
pointerDown: (ev: any) => boolean; pointerDown: (ev: any) => boolean;
pointerMove?: (ev: any) => void; pointerMove?: (ev: any) => void;
pointerUp?: (ev: any) => void; pointerUp?: (ev: any) => void;
nativeOptions?: any;
zone?: boolean; zone?: boolean;
capture?: boolean;
passive?: boolean;
} }
export const enum PointerEventType {
UNDEFINED,
MOUSE,
TOUCH
}
// Test via a getter in the options object to see if the passive property is accessed
var supportsPassive = false;
try {
var opts = Object.defineProperty({}, 'passive', {
get: function() {
supportsPassive = true;
}
});
window.addEventListener('test', null, opts);
} catch (e) { }
/** /**
* @private * @private
*/ */
@ -29,6 +49,7 @@ export class PointerEvents {
private lastTouchEvent: number = 0; private lastTouchEvent: number = 0;
mouseWait: number = 2 * 1000; mouseWait: number = 2 * 1000;
lastEventType: PointerEventType = PointerEventType.UNDEFINED;
constructor(private ele: any, constructor(private ele: any,
private pointerDown: any, private pointerDown: any,
@ -37,6 +58,9 @@ export class PointerEvents {
private zone: boolean, private zone: boolean,
private option: any private option: any
) { ) {
assert(ele, 'element can not be null');
assert(pointerDown, 'pointerDown can not be null');
this.bindTouchEnd = this.handleTouchEnd.bind(this); this.bindTouchEnd = this.handleTouchEnd.bind(this);
this.bindMouseUp = this.handleMouseUp.bind(this); this.bindMouseUp = this.handleMouseUp.bind(this);
@ -45,8 +69,12 @@ export class PointerEvents {
} }
private handleTouchStart(ev: any) { private handleTouchStart(ev: any) {
assert(this.ele, 'element can not be null');
assert(this.pointerDown, 'pointerDown can not be null');
this.lastTouchEvent = Date.now() + this.mouseWait; this.lastTouchEvent = Date.now() + this.mouseWait;
if (!this.pointerDown(ev)) { this.lastEventType = PointerEventType.TOUCH;
if (!this.pointerDown(ev, PointerEventType.TOUCH)) {
return; return;
} }
if (!this.rmTouchMove && this.pointerMove) { if (!this.rmTouchMove && this.pointerMove) {
@ -61,11 +89,15 @@ export class PointerEvents {
} }
private handleMouseDown(ev: any) { private handleMouseDown(ev: any) {
assert(this.ele, 'element can not be null');
assert(this.pointerDown, 'pointerDown can not be null');
if (this.lastTouchEvent > Date.now()) { if (this.lastTouchEvent > Date.now()) {
console.debug('mousedown event dropped because of previous touch'); console.debug('mousedown event dropped because of previous touch');
return; return;
} }
if (!this.pointerDown(ev)) { this.lastEventType = PointerEventType.MOUSE;
if (!this.pointerDown(ev, PointerEventType.MOUSE)) {
return; return;
} }
if (!this.rmMouseMove && this.pointerMove) { if (!this.rmMouseMove && this.pointerMove) {
@ -78,12 +110,12 @@ export class PointerEvents {
private handleTouchEnd(ev: any) { private handleTouchEnd(ev: any) {
this.stopTouch(); this.stopTouch();
this.pointerUp && this.pointerUp(ev); this.pointerUp && this.pointerUp(ev, PointerEventType.TOUCH);
} }
private handleMouseUp(ev: any) { private handleMouseUp(ev: any) {
this.stopMouse(); this.stopMouse();
this.pointerUp && this.pointerUp(ev); this.pointerUp && this.pointerUp(ev, PointerEventType.MOUSE);
} }
private stopTouch() { private stopTouch() {
@ -136,10 +168,6 @@ export class UIEventManager {
constructor(public zoneWrapped: boolean = true) {} constructor(public zoneWrapped: boolean = true) {}
listenRef(ref: ElementRef, eventName: string, callback: any, option?: any): Function {
return this.listen(ref.nativeElement, eventName, callback, option);
}
pointerEvents(config: PointerEventsConfig): PointerEvents { pointerEvents(config: PointerEventsConfig): PointerEvents {
let element = config.element; let element = config.element;
if (!element) { if (!element) {
@ -151,19 +179,39 @@ export class UIEventManager {
return; return;
} }
let zone = config.zone || this.zoneWrapped; let zone = config.zone || this.zoneWrapped;
let options = config.nativeOptions || false; let opts;
if (supportsPassive) {
opts = {};
if (config.passive === true) {
opts['passive'] = true;
}
if (config.capture === true) {
opts['capture'] = true;
}
} else {
if (config.passive === true) {
console.debug('passive event listeners are not supported by this browser');
}
if (config.capture === true) {
opts = true;
}
}
let submanager = new PointerEvents( let pointerEvents = new PointerEvents(
element, element,
config.pointerDown, config.pointerDown,
config.pointerMove, config.pointerMove,
config.pointerUp, config.pointerUp,
zone, zone,
options); opts);
let removeFunc = () => submanager.destroy(); let removeFunc = () => pointerEvents.destroy();
this.events.push(removeFunc); this.events.push(removeFunc);
return submanager; return pointerEvents;
}
listenRef(ref: ElementRef, eventName: string, callback: any, option?: any): Function {
return this.listen(ref.nativeElement, eventName, callback, option);
} }
listen(element: any, eventName: string, callback: any, option: any = false): Function { listen(element: any, eventName: string, callback: any, option: any = false): Function {
@ -187,9 +235,10 @@ function listenEvent(ele: any, eventName: string, zoneWrapped: boolean, option:
let rawEvent = (!zoneWrapped && '__zone_symbol__addEventListener' in ele); let rawEvent = (!zoneWrapped && '__zone_symbol__addEventListener' in ele);
if (rawEvent) { if (rawEvent) {
ele.__zone_symbol__addEventListener(eventName, callback, option); ele.__zone_symbol__addEventListener(eventName, callback, option);
return () => ele.__zone_symbol__removeEventListener(eventName, callback); assert('__zone_symbol__removeEventListener' in ele, 'native removeEventListener does not exist');
return () => ele.__zone_symbol__removeEventListener(eventName, callback, option);
} else { } else {
ele.addEventListener(eventName, callback, option); ele.addEventListener(eventName, callback, option);
return () => ele.removeEventListener(eventName, callback); return () => ele.removeEventListener(eventName, callback, option);
} }
} }