diff --git a/src/components/app/app.ts b/src/components/app/app.ts index 1a8658b6b3f..895b94ae22c 100644 --- a/src/components/app/app.ts +++ b/src/components/app/app.ts @@ -22,6 +22,7 @@ export class App { private _title: string = ''; private _titleSrv: Title = new Title(); private _rootNav: NavController = null; + private _canDisableScroll: boolean; /** * @private @@ -70,6 +71,7 @@ export class App { // listen for hardware back button events // register this back button action with a default priority _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. */ setScrollDisabled(disableScroll: boolean) { - if (this._config.get('canDisableScroll', true)) { + if (this._canDisableScroll) { this._appRoot._disableScroll(disableScroll); } } @@ -148,7 +150,7 @@ export class App { * @return {boolean} returns true or false */ 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; diff --git a/src/components/content/content.scss b/src/components/content/content.scss index dc7f5040d23..7acd661b7ed 100644 --- a/src/components/content/content.scss +++ b/src/components/content/content.scss @@ -52,8 +52,7 @@ ion-content.js-scroll > .scroll-content { } .disable-scroll .ion-page .scroll-content { - overflow-y: hidden; - overflow-x: hidden; + pointer-events: none; } diff --git a/src/components/content/content.ts b/src/components/content/content.ts index f26103c89dd..d65cc5176b1 100644 --- a/src/components/content/content.ts +++ b/src/components/content/content.ts @@ -190,7 +190,7 @@ export class Content extends Ion { this._zone.runOutsideAngular(() => { 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); } + /** + * @private + */ _addListener(type: string, handler: any): Function { assert(handler, 'handler must be valid'); assert(this._scrollEle, '_scrollEle must be valid'); diff --git a/src/components/tap-click/activator.ts b/src/components/tap-click/activator.ts index 93950404dd9..99fb82d31e3 100644 --- a/src/components/tap-click/activator.ts +++ b/src/components/tap-click/activator.ts @@ -21,7 +21,7 @@ export class Activator { // queue to have this element activated this._queue.push(activatableEle); - rafFrames(2, () => { + rafFrames(6, () => { let activatableEle: HTMLElement; for (let i = 0; i < this._queue.length; i++) { activatableEle = this._queue[i]; @@ -30,7 +30,7 @@ export class Activator { activatableEle.classList.add(this._css); } } - this._queue = []; + this._queue.length = 0; }); } @@ -59,7 +59,7 @@ export class Activator { deactivate() { // remove the active class from all active elements - this._queue = []; + this._queue.length = 0; rafFrames(2, () => { for (var i = 0; i < this._active.length; i++) { diff --git a/src/components/tap-click/tap-click.ts b/src/components/tap-click/tap-click.ts index a5d4c7ccf80..a73af6f0abd 100644 --- a/src/components/tap-click/tap-click.ts +++ b/src/components/tap-click/tap-click.ts @@ -5,156 +5,89 @@ import { App } from '../app/app'; import { Config } from '../../config/config'; import { hasPointerMoved, pointerCoord } from '../../util/dom'; import { RippleActivator } from './ripple'; - +import { UIEventManager, PointerEvents, PointerEventType } from '../../util/ui-event-manager'; /** * @private */ @Injectable() export class TapClick { - private lastTouch: number = 0; private disableClick: number = 0; - private lastActivated: number = 0; private usePolyfill: boolean; private activator: Activator; private startCoord: any; - private pointerMove: any; + private events: UIEventManager = new UIEventManager(false); + private pointerEvents: PointerEvents; constructor( config: Config, private app: App, zone: NgZone ) { - if (config.get('activator') === 'ripple') { + let activator = config.get('activator'); + if (activator === 'ripple') { this.activator = new RippleActivator(app, config); - } else if (config.get('activator') === 'highlight') { + } else if (activator === 'highlight') { this.activator = new Activator(app, config); } this.usePolyfill = (config.get('tapPolyfill') === true); - zone.runOutsideAngular(() => { - addListener('click', this.click.bind(this), true); - - addListener('touchstart', this.touchStart.bind(this)); - addListener('touchend', this.touchEnd.bind(this)); - addListener('touchcancel', this.pointerCancel.bind(this)); - - addListener('mousedown', this.mouseDown.bind(this), true); - addListener('mouseup', this.mouseUp.bind(this), true); + this.events.listen(document, 'click', this.click.bind(this), true); + this.pointerEvents = this.events.pointerEvents({ + element: document, + pointerDown: this.pointerStart.bind(this), + pointerMove: this.pointerMove.bind(this), + pointerUp: this.pointerEnd.bind(this), + passive: true }); - - this.pointerMove = (ev: UIEvent) => { - if (!this.startCoord || hasPointerMoved(POINTER_MOVE_UNTIL_CANCEL, this.startCoord, pointerCoord(ev)) ) { - this.pointerCancel(ev); - } - }; - } - - touchStart(ev: UIEvent) { - this.lastTouch = Date.now(); - this.pointerStart(ev); + this.pointerEvents.mouseWait = DISABLE_NATIVE_CLICK_AMOUNT; } - 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); - } - } + pointerStart(ev: any): boolean { + if (this.startCoord) { + return false; } - - 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); + let activatableEle = getActivatableTarget(ev.target); + if (!activatableEle) { + this.startCoord = null; + return false; } + this.startCoord = pointerCoord(ev); + this.activator && this.activator.downAction(ev, activatableEle, this.startCoord); + return true; } - 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); + pointerMove(ev: UIEvent) { + if (!this.startCoord || + hasPointerMoved(POINTER_TOLERANCE, this.startCoord, pointerCoord(ev)) || + this.app.isScrolling()) { + this.pointerCancel(ev); } } - pointerStart(ev: any) { - let activatableEle = getActivatableTarget(ev.target); - - 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; + pointerEnd(ev: any, type: PointerEventType) { + if (!this.startCoord) { + return; } - } - - pointerEnd(ev: any) { - if (this.startCoord && this.activator) { + if (type === PointerEventType.TOUCH && this.usePolyfill && this.app.isEnabled()) { + this.handleTapPolyfill(ev); + } + if (this.activator) { let activatableEle = getActivatableTarget(ev.target); if (activatableEle) { this.activator.upAction(ev, activatableEle, this.startCoord); } } - - this.moveListeners(false); + this.startCoord = null; } pointerCancel(ev: UIEvent) { console.debug('pointerCancel from ' + ev.type + ' ' + Date.now()); + this.startCoord = null; this.activator && this.activator.clearState(); - this.moveListeners(false); - } - - moveListeners(shouldAdd: boolean) { - removeListener(this.usePolyfill ? 'touchmove' : 'mousemove', this.pointerMove); - - if (shouldAdd) { - addListener(this.usePolyfill ? 'touchmove' : 'mousemove', this.pointerMove); - } + this.pointerEvents.stop(); } 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() { return this.disableClick > Date.now(); } @@ -194,33 +155,23 @@ function getActivatableTarget(ele: HTMLElement) { /** * @private */ -export const isActivatable = function(ele: HTMLElement) { - if (ACTIVATABLE_ELEMENTS.test(ele.tagName)) { +export const isActivatable = function (ele: HTMLElement) { + if (ACTIVATABLE_ELEMENTS.indexOf(ele.tagName) > -1) { return true; } let attributes = ele.attributes; 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 false; }; -function addListener(type: string, listener: any, useCapture?: boolean) { - document.addEventListener(type, listener, useCapture); -} - -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 ACTIVATABLE_ELEMENTS = ['A', 'BUTTON']; +const ACTIVATABLE_ATTRIBUTES = ['tappable', 'button']; +const POINTER_TOLERANCE = 60; const DISABLE_NATIVE_CLICK_AMOUNT = 2500; export function setupTapClick(config: Config, app: App, zone: NgZone) { diff --git a/src/platform/platform-registry.ts b/src/platform/platform-registry.ts index 74da3fd4afd..809100d347d 100644 --- a/src/platform/platform-registry.ts +++ b/src/platform/platform-registry.ts @@ -110,7 +110,7 @@ export const PLATFORM_CONFIGS: {[key: string]: PlatformConfig} = { swipeBackThreshold: 40, tapPolyfill: isIOSDevice, virtualScrollEventAssist: !(window.indexedDB), - canDisableScroll: !!(window.indexedDB), + canDisableScroll: isIOSDevice, }, isMatch(p: Platform) { return p.isPlatformMatch('ios', ['iphone', 'ipad', 'ipod'], ['windows phone']); diff --git a/src/util/ui-event-manager.ts b/src/util/ui-event-manager.ts index 0722e206dbf..eb80323a4ef 100644 --- a/src/util/ui-event-manager.ts +++ b/src/util/ui-event-manager.ts @@ -1,4 +1,5 @@ import { ElementRef } from '@angular/core'; +import { assert } from './util'; export interface PointerEventsConfig { element?: HTMLElement; @@ -6,10 +7,29 @@ export interface PointerEventsConfig { pointerDown: (ev: any) => boolean; pointerMove?: (ev: any) => void; pointerUp?: (ev: any) => void; - nativeOptions?: any; 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 */ @@ -29,6 +49,7 @@ export class PointerEvents { private lastTouchEvent: number = 0; mouseWait: number = 2 * 1000; + lastEventType: PointerEventType = PointerEventType.UNDEFINED; constructor(private ele: any, private pointerDown: any, @@ -37,6 +58,9 @@ export class PointerEvents { private zone: boolean, private option: any ) { + assert(ele, 'element can not be null'); + assert(pointerDown, 'pointerDown can not be null'); + this.bindTouchEnd = this.handleTouchEnd.bind(this); this.bindMouseUp = this.handleMouseUp.bind(this); @@ -45,8 +69,12 @@ export class PointerEvents { } 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; - if (!this.pointerDown(ev)) { + this.lastEventType = PointerEventType.TOUCH; + if (!this.pointerDown(ev, PointerEventType.TOUCH)) { return; } if (!this.rmTouchMove && this.pointerMove) { @@ -61,11 +89,15 @@ export class PointerEvents { } 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()) { console.debug('mousedown event dropped because of previous touch'); return; } - if (!this.pointerDown(ev)) { + this.lastEventType = PointerEventType.MOUSE; + if (!this.pointerDown(ev, PointerEventType.MOUSE)) { return; } if (!this.rmMouseMove && this.pointerMove) { @@ -78,12 +110,12 @@ export class PointerEvents { private handleTouchEnd(ev: any) { this.stopTouch(); - this.pointerUp && this.pointerUp(ev); + this.pointerUp && this.pointerUp(ev, PointerEventType.TOUCH); } private handleMouseUp(ev: any) { this.stopMouse(); - this.pointerUp && this.pointerUp(ev); + this.pointerUp && this.pointerUp(ev, PointerEventType.MOUSE); } private stopTouch() { @@ -136,10 +168,6 @@ export class UIEventManager { 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 { let element = config.element; if (!element) { @@ -151,19 +179,39 @@ export class UIEventManager { return; } 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, config.pointerDown, config.pointerMove, config.pointerUp, zone, - options); + opts); - let removeFunc = () => submanager.destroy(); + let removeFunc = () => pointerEvents.destroy(); 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 { @@ -187,9 +235,10 @@ function listenEvent(ele: any, eventName: string, zoneWrapped: boolean, option: let rawEvent = (!zoneWrapped && '__zone_symbol__addEventListener' in ele); if (rawEvent) { 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 { ele.addEventListener(eventName, callback, option); - return () => ele.removeEventListener(eventName, callback); + return () => ele.removeEventListener(eventName, callback, option); } }