From b805602ffa2df60f2febbee67b474111b652e043 Mon Sep 17 00:00:00 2001 From: "Manu Mtz.-Almeida" Date: Mon, 27 Jun 2016 02:04:25 +0200 Subject: [PATCH 1/3] fix(util): UIEventManager should handle touchcancel event --- src/util/ui-event-manager.ts | 38 +++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/src/util/ui-event-manager.ts b/src/util/ui-event-manager.ts index 457f7ebc980..fc698edc27f 100644 --- a/src/util/ui-event-manager.ts +++ b/src/util/ui-event-manager.ts @@ -1,8 +1,6 @@ import {ElementRef} from '@angular/core'; - - /** * @private */ @@ -10,6 +8,7 @@ export class PointerEvents { private rmTouchStart: Function = null; private rmTouchMove: Function = null; private rmTouchEnd: Function = null; + private rmTouchCancel: Function = null; private rmMouseStart: Function = null; private rmMouseMove: Function = null; @@ -26,8 +25,8 @@ export class PointerEvents { private zone: boolean, private option: any) { - this.rmTouchStart = listenEvent(ele, 'touchstart', zone, option, (ev: any) => this.handleTouchStart(ev)); - this.rmMouseStart = listenEvent(ele, 'mousedown', zone, option, (ev: any) => this.handleMouseDown(ev)); + this.rmTouchStart = listenEvent(ele, 'touchstart', zone, option, this.handleTouchStart.bind(this)); + this.rmMouseStart = listenEvent(ele, 'mousedown', zone, option, this.handleMouseDown.bind(this)); } private handleTouchStart(ev: any) { @@ -38,8 +37,12 @@ export class PointerEvents { if (!this.rmTouchMove) { this.rmTouchMove = listenEvent(this.ele, 'touchmove', this.zone, this.option, this.pointerMove); } + let handleTouchEnd = (ev: any) => this.handleTouchEnd(ev); if (!this.rmTouchEnd) { - this.rmTouchEnd = listenEvent(this.ele, 'touchend', this.zone, this.option, (ev: any) => this.handleTouchEnd(ev)); + this.rmTouchEnd = listenEvent(this.ele, 'touchend', this.zone, this.option, handleTouchEnd); + } + if (!this.rmTouchCancel) { + this.rmTouchCancel = listenEvent(this.ele, 'touchcancel', this.zone, this.option, handleTouchEnd); } } @@ -60,35 +63,38 @@ export class PointerEvents { } private handleTouchEnd(ev: any) { - this.rmTouchMove && this.rmTouchMove(); - this.rmTouchMove = null; - this.rmTouchEnd && this.rmTouchEnd(); - this.rmTouchEnd = null; - + this.stopTouch(); this.pointerUp(ev); } private handleMouseUp(ev: any) { - this.rmMouseMove && this.rmMouseMove(); - this.rmMouseMove = null; - this.rmMouseUp && this.rmMouseUp(); - this.rmMouseUp = null; - + this.stopMouse(); this.pointerUp(ev); } - stop() { + private stopTouch() { this.rmTouchMove && this.rmTouchMove(); this.rmTouchEnd && this.rmTouchEnd(); + this.rmTouchCancel && this.rmTouchCancel(); + this.rmTouchMove = null; this.rmTouchEnd = null; + this.rmTouchCancel = null; + } + private stopMouse() { this.rmMouseMove && this.rmMouseMove(); this.rmMouseUp && this.rmMouseUp(); + this.rmMouseMove = null; this.rmMouseUp = null; } + stop() { + this.stopTouch(); + this.stopMouse(); + } + destroy() { this.rmTouchStart && this.rmTouchStart(); this.rmTouchStart = null; From d6f62bcb60a2ce319e73b217f1e13a9e234d2d9b Mon Sep 17 00:00:00 2001 From: "Manu Mtz.-Almeida" Date: Sun, 3 Jul 2016 20:29:38 +0200 Subject: [PATCH 2/3] fix(sliding): much better UX + performance - sliding should behave exactly like a native one - much better performance references #7049 references #7116 closes #6913 closes #6958 --- src/components/item/item-reorder-gesture.ts | 10 +- src/components/item/item-sliding-gesture.ts | 205 +++++++++++++------- src/components/item/item-sliding.ts | 2 +- src/components/picker/picker-component.ts | 11 +- src/components/range/range.ts | 11 +- src/components/refresher/refresher.ts | 10 +- src/components/toggle/toggle.ts | 11 +- src/util/dom.ts | 6 +- src/util/ui-event-manager.ts | 50 +++-- 9 files changed, 209 insertions(+), 107 deletions(-) diff --git a/src/components/item/item-reorder-gesture.ts b/src/components/item/item-reorder-gesture.ts index c6bd82c0139..50052f07719 100644 --- a/src/components/item/item-reorder-gesture.ts +++ b/src/components/item/item-reorder-gesture.ts @@ -27,10 +27,12 @@ export class ItemReorderGesture { constructor(public list: ItemReorder) { let element = this.list.getNativeElement(); - this.events.pointerEvents(element, - this.onDragStart.bind(this), - this.onDragMove.bind(this), - this.onDragEnd.bind(this)); + this.events.pointerEvents({ + element: element, + pointerDown: this.onDragStart.bind(this), + pointerMove: this.onDragMove.bind(this), + pointerUp: this.onDragEnd.bind(this) + }); } private onDragStart(ev: any): boolean { diff --git a/src/components/item/item-sliding-gesture.ts b/src/components/item/item-sliding-gesture.ts index 7ddde117cd2..f746434fa8a 100644 --- a/src/components/item/item-sliding-gesture.ts +++ b/src/components/item/item-sliding-gesture.ts @@ -1,93 +1,105 @@ -import {DragGesture} from '../../gestures/drag-gesture'; -import {ItemSliding} from './item-sliding'; -import {List} from '../list/list'; +import { ItemSliding } from './item-sliding'; +import { List } from '../list/list'; -import {closest} from '../../util/dom'; +import { closest, Coordinates, pointerCoord } from '../../util/dom'; +import { PointerEvents, UIEventManager } from '../../util/ui-event-manager'; -const DRAG_THRESHOLD = 20; +const DRAG_THRESHOLD = 10; const MAX_ATTACK_ANGLE = 20; -export class ItemSlidingGesture extends DragGesture { - onTap: any; - selectedContainer: ItemSliding = null; - openContainer: ItemSliding = null; +export class ItemSlidingGesture { + private preSelectedContainer: ItemSliding = null; + private selectedContainer: ItemSliding = null; + private openContainer: ItemSliding = null; + private events: UIEventManager = new UIEventManager(false); + private panDetector: PanXRecognizer = new PanXRecognizer(DRAG_THRESHOLD, MAX_ATTACK_ANGLE); + private pointerEvents: PointerEvents; + private firstCoordX: number; + private firstTimestamp: number; constructor(public list: List) { - super(list.getNativeElement(), { - direction: 'x', - threshold: DRAG_THRESHOLD + this.pointerEvents = this.events.pointerEvents({ + element: list.getNativeElement(), + pointerDown: this.pointerStart.bind(this), + pointerMove: this.pointerMove.bind(this), + pointerUp: this.pointerEnd.bind(this), }); - this.listen(); } - onTapCallback(ev: any) { - if (isFromOptionButtons(ev)) { - return; + private pointerStart(ev: any): boolean { + if (this.selectedContainer) { + return false; + } + // Get swiped sliding container + let container = getContainer(ev); + if (!container) { + this.closeOpened(); + return false; } - let didClose = this.closeOpened(); - if (didClose) { - console.debug('tap close sliding item, preventDefault'); - ev.preventDefault(); + // Close open container if it is not the selected one. + if (container !== this.openContainer && this.closeOpened()) { + return false; } + + let coord = pointerCoord(ev); + this.preSelectedContainer = container; + this.panDetector.start(coord); + this.firstCoordX = coord.x; + this.firstTimestamp = Date.now(); + return true; } - onDragStart(ev: any): boolean { - let angle = Math.abs(ev.angle); - if (angle > MAX_ATTACK_ANGLE && Math.abs(angle - 180) > MAX_ATTACK_ANGLE) { - this.closeOpened(); - return false; + private pointerMove(ev: any) { + if (this.selectedContainer) { + this.onDragMove(ev); + return; } + let coord = pointerCoord(ev); + if (this.panDetector.detect(coord)) { + if (!this.panDetector.isPanX()) { + this.pointerEvents.stop(); + this.closeOpened(); + } else { + this.onDragStart(ev, coord); + } + } + } + private pointerEnd(ev: any) { if (this.selectedContainer) { - console.debug('onDragStart, another container is already selected'); - return false; + this.onDragEnd(ev); + } else { + this.closeOpened(); } + } + private onDragStart(ev: any, coord: Coordinates): boolean { let container = getContainer(ev); if (!container) { console.debug('onDragStart, no itemContainerEle'); return false; } + ev.preventDefault(); - // Close open container if it is not the selected one. - if (container !== this.openContainer) { - this.closeOpened(); - } - - this.selectedContainer = container; - this.openContainer = container; - container.startSliding(ev.center.x); - - return true; + this.selectedContainer = this.openContainer = this.preSelectedContainer; + container.startSliding(coord.x); } - onDrag(ev: any): boolean { - if (this.selectedContainer) { - this.selectedContainer.moveSliding(ev.center.x); - ev.preventDefault(); - } - return; + private onDragMove(ev: any) { + let coordX = pointerCoord(ev).x; + ev.preventDefault(); + this.selectedContainer.moveSliding(coordX); } - onDragEnd(ev: any) { - if (!this.selectedContainer) { - return; - } + private onDragEnd(ev: any) { ev.preventDefault(); + let coordX = pointerCoord(ev).x; + let deltaX = (coordX - this.firstCoordX); + let deltaT = (Date.now() - this.firstTimestamp); - let openAmount = this.selectedContainer.endSliding(ev.velocityX); + let openAmount = this.selectedContainer.endSliding(deltaX / deltaT); this.selectedContainer = null; - - // TODO: I am not sure listening for a tap event is the best idea - // we should try mousedown/touchstart - if (openAmount === 0) { - this.openContainer = null; - this.off('tap', this.onTap); - this.onTap = null; - } else if (!this.onTap) { - this.onTap = (event: any) => this.onTapCallback(event); - this.on('tap', this.onTap); - } + this.preSelectedContainer = null; } closeOpened(): boolean { @@ -97,15 +109,17 @@ export class ItemSlidingGesture extends DragGesture { this.openContainer.close(); this.openContainer = null; this.selectedContainer = null; - this.off('tap', this.onTap); - this.onTap = null; return true; } unlisten() { this.closeOpened(); - super.unlisten(); + this.events.unlistenAll(); + this.list = null; + this.preSelectedContainer = null; + this.selectedContainer = null; + this.openContainer = null; } } @@ -117,10 +131,69 @@ function getContainer(ev: any): ItemSliding { return null; } -function isFromOptionButtons(ev: any): boolean { - let button = closest(ev.target, '.button', true); - if (!button) { +class AngleRecognizer { + private startCoord: Coordinates; + private sumCoord: Coordinates; + private dirty: boolean; + private _angle: any = null; + private threshold: number; + + constructor(threshold: number) { + this.threshold = threshold ** 2; + } + + start(coord: Coordinates) { + this.startCoord = coord; + this._angle = 0; + this.dirty = true; + } + + angle(): any { + return this._angle; + } + + detect(coord: Coordinates): boolean { + if (!this.dirty) { + return false; + } + let deltaX = (coord.x - this.startCoord.x); + let deltaY = (coord.y - this.startCoord.y); + let distance = deltaX * deltaX + deltaY * deltaY; + if (distance >= this.threshold) { + this._angle = Math.atan2(deltaY, deltaX); + this.dirty = false; + return true; + } + return false; + } +} + +const degresToRadians = Math.PI / 180; + +class PanXRecognizer extends AngleRecognizer { + private _isPanX: boolean; + private maxAngle: number; + + constructor(threshold: number, maxAngle: number) { + super(threshold); + this.maxAngle = maxAngle * degresToRadians; + } + + start(coord: Coordinates) { + super.start(coord); + this._isPanX = false; + } + + isPanX(): boolean { + return this._isPanX; + } + + detect(coord: Coordinates): boolean { + if (super.detect(coord)) { + let angle = Math.abs(this.angle()); + this._isPanX = (angle < this.maxAngle || Math.abs(angle - Math.PI) < this.maxAngle); + return true; + } return false; } - return !!closest(button, 'ion-item-options', true); } diff --git a/src/components/item/item-sliding.ts b/src/components/item/item-sliding.ts index 652951b7bc1..833e79f4d92 100644 --- a/src/components/item/item-sliding.ts +++ b/src/components/item/item-sliding.ts @@ -5,7 +5,7 @@ import { Item } from './item'; import { isPresent } from '../../util/util'; import { List } from '../list/list'; -const SWIPE_MARGIN = 20; +const SWIPE_MARGIN = 30; const ELASTIC_FACTOR = 0.55; export const enum ItemSideFlags { diff --git a/src/components/picker/picker-component.ts b/src/components/picker/picker-component.ts index b3a2ad7da81..2c2259cbf4c 100644 --- a/src/components/picker/picker-component.ts +++ b/src/components/picker/picker-component.ts @@ -74,11 +74,12 @@ export class PickerColumnCmp { this.setSelected(this.col.selectedIndex, 0); // Listening for pointer events - this.events.pointerEventsRef(this.elementRef, - (ev: any) => this.pointerStart(ev), - (ev: any) => this.pointerMove(ev), - (ev: any) => this.pointerEnd(ev) - ); + this.events.pointerEvents({ + elementRef: this.elementRef, + pointerDown: this.pointerStart.bind(this), + pointerMove: this.pointerMove.bind(this), + pointerUp: this.pointerEnd.bind(this) + }); } ngOnDestroy() { diff --git a/src/components/range/range.ts b/src/components/range/range.ts index afc89f0abe5..14777f70898 100644 --- a/src/components/range/range.ts +++ b/src/components/range/range.ts @@ -364,11 +364,12 @@ export class Range implements AfterViewInit, ControlValueAccessor, OnDestroy { this._renderer.setElementStyle(this._bar.nativeElement, 'right', barR); // add touchstart/mousedown listeners - this._events.pointerEventsRef(this._slider, - this.pointerDown.bind(this), - this.pointerMove.bind(this), - this.pointerUp.bind(this)); - + this._events.pointerEvents({ + elementRef: this._slider, + pointerDown: this.pointerDown.bind(this), + pointerMove: this.pointerMove.bind(this), + pointerUp: this.pointerUp.bind(this) + }); this.createTicks(); } diff --git a/src/components/refresher/refresher.ts b/src/components/refresher/refresher.ts index eda4e1c4069..6c1f5d8b229 100644 --- a/src/components/refresher/refresher.ts +++ b/src/components/refresher/refresher.ts @@ -462,10 +462,12 @@ export class Refresher { this._events.unlistenAll(); this._pointerEvents = null; if (shouldListen) { - this._pointerEvents = this._events.pointerEvents(this._content.getScrollElement(), - this._onStart.bind(this), - this._onMove.bind(this), - this._onEnd.bind(this)); + this._pointerEvents = this._events.pointerEvents({ + element: this._content.getScrollElement(), + pointerDown: this._onStart.bind(this), + pointerMove: this._onMove.bind(this), + pointerUp: this._onEnd.bind(this) + }); } } diff --git a/src/components/toggle/toggle.ts b/src/components/toggle/toggle.ts index 90926cd57c8..30ceff83eda 100644 --- a/src/components/toggle/toggle.ts +++ b/src/components/toggle/toggle.ts @@ -242,11 +242,12 @@ export class Toggle implements AfterContentInit, ControlValueAccessor, OnDestroy */ ngAfterContentInit() { this._init = true; - this._events.pointerEventsRef(this._elementRef, - (ev: any) => this.pointerDown(ev), - (ev: any) => this.pointerMove(ev), - (ev: any) => this.pointerUp(ev) - ); + this._events.pointerEvents({ + elementRef: this._elementRef, + pointerDown: this.pointerDown.bind(this), + pointerMove: this.pointerMove.bind(this), + pointerUp: this.pointerUp.bind(this) + }); } /** diff --git a/src/util/dom.ts b/src/util/dom.ts index 5a6c61d396e..9a97f5cd504 100644 --- a/src/util/dom.ts +++ b/src/util/dom.ts @@ -190,8 +190,10 @@ export function pointerCoord(ev: any): Coordinates { } export function hasPointerMoved(threshold: number, startCoord: Coordinates, endCoord: Coordinates) { - return startCoord && endCoord && - (Math.abs(startCoord.x - endCoord.x) > threshold || Math.abs(startCoord.y - endCoord.y) > threshold); + let deltaX = (startCoord.x - endCoord.x); + let deltaY = (startCoord.y - endCoord.y); + let distance = deltaX * deltaX + deltaY * deltaY; + return distance > (threshold * threshold); } export function isActive(ele: HTMLElement) { diff --git a/src/util/ui-event-manager.ts b/src/util/ui-event-manager.ts index fc698edc27f..03116c78795 100644 --- a/src/util/ui-event-manager.ts +++ b/src/util/ui-event-manager.ts @@ -1,5 +1,14 @@ import {ElementRef} from '@angular/core'; +export interface PointerEventsConfig { + element?: HTMLElement; + elementRef?: ElementRef; + pointerDown: (ev: any) => boolean; + pointerMove: (ev: any) => void; + pointerUp: (ev: any) => void; + nativeOptions?: any; + zone?: boolean; +} /** * @private @@ -14,6 +23,9 @@ export class PointerEvents { private rmMouseMove: Function = null; private rmMouseUp: Function = null; + private bindTouchEnd: Function; + private bindMouseUp: Function; + private lastTouchEvent: number = 0; mouseWait: number = 2 * 1000; @@ -23,7 +35,11 @@ export class PointerEvents { private pointerMove: any, private pointerUp: any, private zone: boolean, - private option: any) { + private option: any + ) { + + this.bindTouchEnd = this.handleTouchEnd.bind(this); + this.bindMouseUp = this.handleMouseUp.bind(this); this.rmTouchStart = listenEvent(ele, 'touchstart', zone, option, this.handleTouchStart.bind(this)); this.rmMouseStart = listenEvent(ele, 'mousedown', zone, option, this.handleMouseDown.bind(this)); @@ -37,12 +53,11 @@ export class PointerEvents { if (!this.rmTouchMove) { this.rmTouchMove = listenEvent(this.ele, 'touchmove', this.zone, this.option, this.pointerMove); } - let handleTouchEnd = (ev: any) => this.handleTouchEnd(ev); if (!this.rmTouchEnd) { - this.rmTouchEnd = listenEvent(this.ele, 'touchend', this.zone, this.option, handleTouchEnd); + this.rmTouchEnd = listenEvent(this.ele, 'touchend', this.zone, this.option, this.bindTouchEnd); } if (!this.rmTouchCancel) { - this.rmTouchCancel = listenEvent(this.ele, 'touchcancel', this.zone, this.option, handleTouchEnd); + this.rmTouchCancel = listenEvent(this.ele, 'touchcancel', this.zone, this.option, this.bindTouchEnd); } } @@ -58,7 +73,7 @@ export class PointerEvents { this.rmMouseMove = listenEvent(window, 'mousemove', this.zone, this.option, this.pointerMove); } if (!this.rmMouseUp) { - this.rmMouseUp = listenEvent(window, 'mouseup', this.zone, this.option, (ev: any) => this.handleMouseUp(ev)); + this.rmMouseUp = listenEvent(window, 'mouseup', this.zone, this.option, this.bindMouseUp); } } @@ -126,21 +141,26 @@ export class UIEventManager { return this.listen(ref.nativeElement, eventName, callback, option); } - pointerEventsRef(ref: ElementRef, pointerStart: any, pointerMove: any, pointerEnd: any, option?: any): PointerEvents { - return this.pointerEvents(ref.nativeElement, pointerStart, pointerMove, pointerEnd, option); - } - - pointerEvents(element: any, pointerDown: any, pointerMove: any, pointerUp: any, option: any = false): PointerEvents { + pointerEvents(config: PointerEventsConfig): PointerEvents { + let element = config.element; if (!element) { + element = config.elementRef.nativeElement; + } + + if (!element || !config.pointerDown || !config.pointerMove || !config.pointerUp) { + console.error('PointerEvents config is invalid'); return; } + let zone = config.zone || this.zoneWrapped; + let options = config.nativeOptions || false; + let submanager = new PointerEvents( element, - pointerDown, - pointerMove, - pointerUp, - this.zoneWrapped, - option); + config.pointerDown, + config.pointerMove, + config.pointerUp, + zone, + options); let removeFunc = () => submanager.destroy(); this.events.push(removeFunc); From 9f19023cb918e5ab32fc0e2e22031780e981591a Mon Sep 17 00:00:00 2001 From: "Manu Mtz.-Almeida" Date: Wed, 6 Jul 2016 03:04:47 +0200 Subject: [PATCH 3/3] feat(gesture): Introducing new gesture controller --- .../app/test/gesture-collision/e2e.ts | 0 .../app/test/gesture-collision/index.ts | 70 ++++ .../app/test/gesture-collision/main.html | 159 +++++++++ .../app/test/gesture-collision/page1.html | 84 +++++ src/components/item/item-reorder-gesture.ts | 21 +- src/components/item/item-sliding-gesture.ts | 45 ++- src/components/list/list.ts | 10 +- src/components/menu/menu-gestures.ts | 79 +++-- src/components/menu/menu.ts | 4 +- src/components/menu/test/basic/main.html | 2 +- src/components/menu/test/basic/page1.html | 16 +- src/components/nav/nav-controller.ts | 6 +- src/components/nav/nav-portal.ts | 6 +- src/components/nav/nav.ts | 6 +- src/components/nav/swipe-back.ts | 45 ++- src/components/range/range.ts | 16 +- src/components/refresher/refresher.ts | 18 +- .../refresher/test/refresher.spec.ts | 12 +- src/components/slides/test/loop/index.ts | 12 +- src/components/tabs/tab.ts | 6 +- src/config/providers.ts | 2 + src/gestures/drag-gesture.ts | 4 +- src/gestures/gesture-controller.ts | 215 ++++++++++++ src/gestures/test/gesture-controller.spec.ts | 314 ++++++++++++++++++ src/index.ts | 1 + 25 files changed, 1032 insertions(+), 121 deletions(-) create mode 100644 src/components/app/test/gesture-collision/e2e.ts create mode 100644 src/components/app/test/gesture-collision/index.ts create mode 100644 src/components/app/test/gesture-collision/main.html create mode 100644 src/components/app/test/gesture-collision/page1.html create mode 100644 src/gestures/gesture-controller.ts create mode 100644 src/gestures/test/gesture-controller.spec.ts diff --git a/src/components/app/test/gesture-collision/e2e.ts b/src/components/app/test/gesture-collision/e2e.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/components/app/test/gesture-collision/index.ts b/src/components/app/test/gesture-collision/index.ts new file mode 100644 index 00000000000..9ca007dab0a --- /dev/null +++ b/src/components/app/test/gesture-collision/index.ts @@ -0,0 +1,70 @@ +import { Component, ViewChild } from '@angular/core'; +import { ionicBootstrap, MenuController, NavController, AlertController, Nav, Refresher } from '../../../../../src'; + + +@Component({ + templateUrl: 'page1.html' +}) +class Page1 { + constructor(private nav: NavController, private alertCtrl: AlertController) {} + + presentAlert() { + let alert = this.alertCtrl.create({ + title: 'New Friend!', + message: 'Your friend, Obi wan Kenobi, just accepted your friend request!', + cssClass: 'my-alert', + buttons: ['Ok'] + }); + alert.present(); + } + + goToPage1() { + this.nav.push(Page1); + } + + doRefresh(refresher: Refresher) { + setTimeout(() => { + refresher.complete(); + }, 1000); + } +} + + +@Component({ + templateUrl: 'main.html' +}) +class E2EPage { + rootPage: any; + changeDetectionCount: number = 0; + pages: Array<{title: string, component: any}>; + @ViewChild(Nav) nav: Nav; + + constructor(private menu: MenuController) { + this.rootPage = Page1; + + this.pages = [ + { title: 'Page 1', component: Page1 }, + { title: 'Page 2', component: Page1 }, + { title: 'Page 3', component: Page1 }, + ]; + } + + openPage(page: any) { + // Reset the content nav to have just this page + // we wouldn't want the back button to show in this scenario + this.nav.setRoot(page.component).then(() => { + // wait for the root page to be completely loaded + // then close the menu + this.menu.close(); + }); + } +} + +@Component({ + template: '' +}) +class E2EApp { + rootPage = E2EPage; +} + +ionicBootstrap(E2EApp); diff --git a/src/components/app/test/gesture-collision/main.html b/src/components/app/test/gesture-collision/main.html new file mode 100644 index 00000000000..98a7b6c4437 --- /dev/null +++ b/src/components/app/test/gesture-collision/main.html @@ -0,0 +1,159 @@ + + + + + Left Menu + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Footer + + + + + + + + + + + Right Menu + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/app/test/gesture-collision/page1.html b/src/components/app/test/gesture-collision/page1.html new file mode 100644 index 00000000000..d3a3e87585f --- /dev/null +++ b/src/components/app/test/gesture-collision/page1.html @@ -0,0 +1,84 @@ + + + + + + + + Menu + + + + + + + + + + + + + + + + + +

Page 1

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + RANGE + + + + + + SLIDING ITEM + RANGE + + + + + + + + + + + + + +
diff --git a/src/components/item/item-reorder-gesture.ts b/src/components/item/item-reorder-gesture.ts index 50052f07719..a580de81835 100644 --- a/src/components/item/item-reorder-gesture.ts +++ b/src/components/item/item-reorder-gesture.ts @@ -25,10 +25,9 @@ export class ItemReorderGesture { private events: UIEventManager = new UIEventManager(false); - constructor(public list: ItemReorder) { - let element = this.list.getNativeElement(); + constructor(public reorderList: ItemReorder) { this.events.pointerEvents({ - element: element, + element: this.reorderList.getNativeElement(), pointerDown: this.onDragStart.bind(this), pointerMove: this.onDragMove.bind(this), pointerUp: this.onDragEnd.bind(this) @@ -46,7 +45,7 @@ export class ItemReorderGesture { console.error('ion-reorder does not contain $ionComponent'); return false; } - this.list.reorderPrepare(); + this.reorderList.reorderPrepare(); let item = reorderMark.getReorderNode(); if (!item) { @@ -62,13 +61,13 @@ export class ItemReorderGesture { this.lastToIndex = indexForItem(item); this.windowHeight = window.innerHeight - AUTO_SCROLL_MARGIN; - this.lastScrollPosition = this.list.scrollContent(0); + this.lastScrollPosition = this.reorderList.scrollContent(0); this.offset = pointerCoord(ev); this.offset.y += this.lastScrollPosition; item.classList.add(ITEM_REORDER_ACTIVE); - this.list.reorderStart(); + this.reorderList.reorderStart(); return true; } @@ -96,7 +95,7 @@ export class ItemReorderGesture { this.lastToIndex = toIndex; this.lastYcoord = posY; this.emptyZone = false; - this.list.reorderMove(fromIndex, toIndex, this.selectedItemHeight); + this.reorderList.reorderMove(fromIndex, toIndex, this.selectedItemHeight); } } else { this.emptyZone = true; @@ -127,7 +126,7 @@ export class ItemReorderGesture { } else { reorderInactive(); } - this.list.reorderEmit(fromIndex, toIndex); + this.reorderList.reorderEmit(fromIndex, toIndex); } private itemForCoord(coord: Coordinates): HTMLElement { @@ -136,9 +135,9 @@ export class ItemReorderGesture { private scroll(posY: number): number { if (posY < AUTO_SCROLL_MARGIN) { - this.lastScrollPosition = this.list.scrollContent(-SCROLL_JUMP); + this.lastScrollPosition = this.reorderList.scrollContent(-SCROLL_JUMP); } else if (posY > this.windowHeight) { - this.lastScrollPosition = this.list.scrollContent(SCROLL_JUMP); + this.lastScrollPosition = this.reorderList.scrollContent(SCROLL_JUMP); } return this.lastScrollPosition; } @@ -150,7 +149,7 @@ export class ItemReorderGesture { this.onDragEnd(); this.events.unlistenAll(); this.events = null; - this.list = null; + this.reorderList = null; } } diff --git a/src/components/item/item-sliding-gesture.ts b/src/components/item/item-sliding-gesture.ts index f746434fa8a..b97a1c9c9af 100644 --- a/src/components/item/item-sliding-gesture.ts +++ b/src/components/item/item-sliding-gesture.ts @@ -3,6 +3,7 @@ import { List } from '../list/list'; import { closest, Coordinates, pointerCoord } from '../../util/dom'; import { PointerEvents, UIEventManager } from '../../util/ui-event-manager'; +import { GestureDelegate, GestureOptions, GesturePriority } from '../../gestures/gesture-controller'; const DRAG_THRESHOLD = 10; const MAX_ATTACK_ANGLE = 20; @@ -16,8 +17,13 @@ export class ItemSlidingGesture { private pointerEvents: PointerEvents; private firstCoordX: number; private firstTimestamp: number; + private gesture: GestureDelegate; constructor(public list: List) { + this.gesture = list.gestureCtrl.create('item-sliding', { + priority: GesturePriority.Interactive, + }); + this.pointerEvents = this.events.pointerEvents({ element: list.getNativeElement(), pointerDown: this.pointerStart.bind(this), @@ -36,11 +42,18 @@ export class ItemSlidingGesture { this.closeOpened(); return false; } + // Close open container if it is not the selected one. if (container !== this.openContainer && this.closeOpened()) { return false; } + // Try to start gesture + if (!this.gesture.start()) { + this.gesture.release(); + return false; + } + let coord = pointerCoord(ev); this.preSelectedContainer = container; this.panDetector.start(coord); @@ -56,16 +69,19 @@ export class ItemSlidingGesture { } let coord = pointerCoord(ev); if (this.panDetector.detect(coord)) { - if (!this.panDetector.isPanX()) { - this.pointerEvents.stop(); - this.closeOpened(); - } else { - this.onDragStart(ev, coord); + if (this.panDetector.isPanX() && this.gesture.capture()) { + this.onDragStart(ev, coord); + return; } + + // Detection/capturing was not successful, aborting! + this.closeOpened(); + this.pointerEvents.stop(); } } private pointerEnd(ev: any) { + this.gesture.release(); if (this.selectedContainer) { this.onDragEnd(ev); } else { @@ -103,18 +119,21 @@ export class ItemSlidingGesture { } closeOpened(): boolean { - if (!this.openContainer) { - return false; - } - this.openContainer.close(); - this.openContainer = null; this.selectedContainer = null; - return true; + this.gesture.release(); + + if (this.openContainer) { + this.openContainer.close(); + this.openContainer = null; + return true; + } + return false; } - unlisten() { - this.closeOpened(); + destroy() { + this.gesture.destroy(); this.events.unlistenAll(); + this.closeOpened(); this.list = null; this.preSelectedContainer = null; diff --git a/src/components/list/list.ts b/src/components/list/list.ts index 06cdd3d88ad..18a0eb6f862 100644 --- a/src/components/list/list.ts +++ b/src/components/list/list.ts @@ -4,6 +4,7 @@ import { Content } from '../content/content'; import { Ion } from '../ion'; import { isTrueProperty } from '../../util/util'; import { ItemSlidingGesture } from '../item/item-sliding-gesture'; +import { GestureController } from '../../gestures/gesture-controller'; /** * The List is a widely used interface element in almost any mobile app, @@ -29,7 +30,10 @@ export class List extends Ion { private _containsSlidingItems: boolean = false; private _slidingGesture: ItemSlidingGesture; - constructor(elementRef: ElementRef, private _rendered: Renderer) { + constructor( + elementRef: ElementRef, + private _rendered: Renderer, + public gestureCtrl: GestureController) { super(elementRef); } @@ -78,11 +82,11 @@ export class List extends Ion { this._updateSlidingState(); } - + private _updateSlidingState() { let shouldSlide = this._enableSliding && this._containsSlidingItems; if (!shouldSlide) { - this._slidingGesture && this._slidingGesture.unlisten(); + this._slidingGesture && this._slidingGesture.destroy(); this._slidingGesture = null; } else if (!this._slidingGesture) { diff --git a/src/components/menu/menu-gestures.ts b/src/components/menu/menu-gestures.ts index f8066d990fb..60c274b98e5 100644 --- a/src/components/menu/menu-gestures.ts +++ b/src/components/menu/menu-gestures.ts @@ -1,27 +1,39 @@ -import {Menu} from './menu'; -import {SlideEdgeGesture} from '../../gestures/slide-edge-gesture'; -import {SlideData} from '../../gestures/slide-gesture'; -import {assign} from '../../util/util'; +import { Menu } from './menu'; +import { SlideEdgeGesture } from '../../gestures/slide-edge-gesture'; +import { SlideData } from '../../gestures/slide-gesture'; +import { assign } from '../../util/util'; +import { GestureDelegate, GesturePriority } from '../../gestures/gesture-controller'; /** * Gesture attached to the content which the menu is assigned to */ export class MenuContentGesture extends SlideEdgeGesture { + gesture: GestureDelegate; constructor(public menu: Menu, contentEle: HTMLElement, options: any = {}) { - super(contentEle, assign({ direction: 'x', edge: menu.side, threshold: 0, maxEdgeStart: menu.maxEdgeStart || 75 }, options)); + + this.gesture = menu.gestureCtrl.create('menu-swipe', { + priority: GesturePriority.NavigationOptional, + }); } - canStart(ev: any) { - let menu = this.menu; + canStart(ev: any): boolean { + if (this.shouldStart(ev)) { + return this.gesture.capture(); + } + this.gesture.release(); + return false; + } + shouldStart(ev: any): boolean { + let menu = this.menu; if (!menu.enabled || !menu.swipeEnabled) { console.debug('menu can not start, isEnabled:', menu.enabled, 'isSwipeEnabled:', menu.swipeEnabled, 'side:', menu.side); return false; @@ -33,40 +45,23 @@ export class MenuContentGesture extends SlideEdgeGesture { return false; } - console.debug('menu canStart,', menu.side, 'isOpen', menu.isOpen, 'angle', ev.angle, 'distance', ev.distance); + console.debug('menu shouldCapture,', menu.side, 'isOpen', menu.isOpen, 'angle', ev.angle, 'distance', ev.distance); + + if (menu.isOpen) { + return true; + } if (menu.side === 'right') { - // right side - if (menu.isOpen) { - // right side, opened - return true; - - } else { - // right side, closed - if ((ev.angle > 140 && ev.angle <= 180) || (ev.angle > -140 && ev.angle <= -180)) { - return super.canStart(ev); - } + if ((ev.angle > 140 && ev.angle <= 180) || (ev.angle > -140 && ev.angle <= -180)) { + return super.canStart(ev); } - } else { - // left side - if (menu.isOpen) { - // left side, opened - return true; - - } else { - // left side, closed - if (ev.angle > -40 && ev.angle < 40) { - return super.canStart(ev); - } + if (ev.angle > -40 && ev.angle < 40) { + return super.canStart(ev); } - } - - // didn't pass the test, don't open this menu return false; } - // Set CSS, then wait one frame for it to apply before sliding starts onSlideBeforeStart(slide: SlideData, ev: any) { console.debug('menu gesture, onSlideBeforeStart', this.menu.side); @@ -83,16 +78,18 @@ export class MenuContentGesture extends SlideEdgeGesture { } onSlideEnd(slide: SlideData, ev: any) { + this.gesture.release(); + let z = (this.menu.side === 'right' ? slide.min : slide.max); let currentStepValue = (slide.distance / z); z = Math.abs(z * 0.5); let shouldCompleteRight = (ev.velocityX >= 0) && (ev.velocityX > 0.2 || slide.delta > z); - + let shouldCompleteLeft = (ev.velocityX <= 0) && (ev.velocityX < -0.2 || slide.delta < -z); - + console.debug( 'menu gesture, onSlide', this.menu.side, 'distance', slide.distance, @@ -103,7 +100,6 @@ export class MenuContentGesture extends SlideEdgeGesture { 'shouldCompleteLeft', shouldCompleteLeft, 'shouldCompleteRight', shouldCompleteRight, 'currentStepValue', currentStepValue); - this.menu.swipeEnd(shouldCompleteLeft, shouldCompleteRight, currentStepValue); } @@ -132,6 +128,16 @@ export class MenuContentGesture extends SlideEdgeGesture { max: this.menu.width() }; } + + unlisten() { + this.gesture.release(); + super.unlisten(); + } + + destroy() { + this.gesture.destroy(); + super.destroy(); + } } @@ -143,5 +149,6 @@ export class MenuTargetGesture extends MenuContentGesture { super(menu, menuEle, { maxEdgeStart: 0 }); + this.gesture.priority++; } } diff --git a/src/components/menu/menu.ts b/src/components/menu/menu.ts index 119eb100067..11f8e0e7b05 100644 --- a/src/components/menu/menu.ts +++ b/src/components/menu/menu.ts @@ -9,6 +9,7 @@ import { MenuContentGesture, MenuTargetGesture } from './menu-gestures'; import { MenuController } from './menu-controller'; import { MenuType } from './menu-types'; import { Platform } from '../../platform/platform'; +import { GestureController } from '../../gestures/gesture-controller'; /** @@ -302,7 +303,8 @@ export class Menu extends Ion { private _platform: Platform, private _renderer: Renderer, private _keyboard: Keyboard, - private _zone: NgZone + private _zone: NgZone, + public gestureCtrl: GestureController ) { super(_elementRef); } diff --git a/src/components/menu/test/basic/main.html b/src/components/menu/test/basic/main.html index 3ce5cb68384..e56fb8c3d07 100644 --- a/src/components/menu/test/basic/main.html +++ b/src/components/menu/test/basic/main.html @@ -148,6 +148,6 @@ - +
diff --git a/src/components/menu/test/basic/page1.html b/src/components/menu/test/basic/page1.html index 036d3d4b6a4..ae6905f8272 100644 --- a/src/components/menu/test/basic/page1.html +++ b/src/components/menu/test/basic/page1.html @@ -35,9 +35,19 @@

Page 1

-

- -

+ + + + + + + + + + + + +

diff --git a/src/components/nav/nav-controller.ts b/src/components/nav/nav-controller.ts index abe56111dca..9bba7c94825 100644 --- a/src/components/nav/nav-controller.ts +++ b/src/components/nav/nav-controller.ts @@ -3,10 +3,10 @@ import { ComponentResolver, ElementRef, EventEmitter, NgZone, provide, Reflectiv import { addSelector } from '../../config/bootstrap'; import { App } from '../app/app'; import { Config } from '../../config/config'; +import { GestureController } from '../../gestures/gesture-controller'; import { Ion } from '../ion'; import { isBlank, pascalCaseToDashCase } from '../../util/util'; import { Keyboard } from '../../util/keyboard'; -import { MenuController } from '../menu/menu-controller'; import { NavOptions } from './nav-interfaces'; import { NavParams } from './nav-params'; import { SwipeBackGesture } from './swipe-back'; @@ -247,7 +247,7 @@ export class NavController extends Ion { protected _zone: NgZone, protected _renderer: Renderer, protected _compiler: ComponentResolver, - protected _menuCtrl: MenuController + private _gestureCtrl: GestureController ) { super(elementRef); @@ -1379,7 +1379,7 @@ export class NavController extends Ion { edge: 'left', threshold: this._sbThreshold }; - this._sbGesture = new SwipeBackGesture(this.getNativeElement(), opts, this, this._menuCtrl); + this._sbGesture = new SwipeBackGesture(this.getNativeElement(), opts, this, this._gestureCtrl); } if (this.canSwipeBack()) { diff --git a/src/components/nav/nav-portal.ts b/src/components/nav/nav-portal.ts index d7bf91b1fd5..54b6e55c697 100644 --- a/src/components/nav/nav-portal.ts +++ b/src/components/nav/nav-portal.ts @@ -2,8 +2,8 @@ import { ComponentResolver, Directive, ElementRef, forwardRef, Inject, NgZone, O import { App } from '../app/app'; import { Config } from '../../config/config'; +import { GestureController } from '../../gestures/gesture-controller'; import { Keyboard } from '../../util/keyboard'; -import { MenuController } from '../menu/menu-controller'; import { NavController } from '../nav/nav-controller'; /** @@ -21,10 +21,10 @@ export class NavPortal extends NavController { zone: NgZone, renderer: Renderer, compiler: ComponentResolver, - menuCtrl: MenuController, + gestureCtrl: GestureController, viewPort: ViewContainerRef ) { - super(null, app, config, keyboard, elementRef, zone, renderer, compiler, menuCtrl); + super(null, app, config, keyboard, elementRef, zone, renderer, compiler, gestureCtrl); this.isPortal = true; this.setViewport(viewPort); app.setPortal(this); diff --git a/src/components/nav/nav.ts b/src/components/nav/nav.ts index c49123f595e..efe41d5d33e 100644 --- a/src/components/nav/nav.ts +++ b/src/components/nav/nav.ts @@ -3,8 +3,8 @@ import { AfterViewInit, Component, ComponentResolver, ElementRef, Input, Optiona import { App } from '../app/app'; import { Config } from '../../config/config'; import { Keyboard } from '../../util/keyboard'; +import { GestureController } from '../../gestures/gesture-controller'; import { isTrueProperty } from '../../util/util'; -import { MenuController } from '../menu/menu-controller'; import { NavController } from './nav-controller'; import { ViewController } from './view-controller'; @@ -128,9 +128,9 @@ export class Nav extends NavController implements AfterViewInit { zone: NgZone, renderer: Renderer, compiler: ComponentResolver, - menuCtrl: MenuController + gestureCtrl: GestureController ) { - super(parent, app, config, keyboard, elementRef, zone, renderer, compiler, menuCtrl); + super(parent, app, config, keyboard, elementRef, zone, renderer, compiler, gestureCtrl); if (viewCtrl) { // an ion-nav can also act as an ion-page within a parent ion-nav diff --git a/src/components/nav/swipe-back.ts b/src/components/nav/swipe-back.ts index 04ab0906279..8a162d7e2ad 100644 --- a/src/components/nav/swipe-back.ts +++ b/src/components/nav/swipe-back.ts @@ -1,4 +1,5 @@ import { assign } from '../../util/util'; +import { GestureController, GestureDelegate, GesturePriority } from '../../gestures/gesture-controller'; import { MenuController } from '../menu/menu-controller'; import { NavController } from './nav-controller'; import { SlideData } from '../../gestures/slide-gesture'; @@ -7,39 +8,43 @@ import { SlideEdgeGesture } from '../../gestures/slide-edge-gesture'; export class SwipeBackGesture extends SlideEdgeGesture { + private gesture: GestureDelegate; + constructor( element: HTMLElement, options: any, private _nav: NavController, - private _menuCtrl: MenuController + gestureCtlr: GestureController ) { super(element, assign({ direction: 'x', maxEdgeStart: 75 }, options)); + + this.gesture = gestureCtlr.create('goback-swipe', { + priority: GesturePriority.Navigation, + }); } - canStart(ev: any) { + canStart(ev: any): boolean { + this.gesture.release(); + // the gesture swipe angle must be mainly horizontal and the // gesture distance would be relatively short for a swipe back // and swipe back must be possible on this nav controller - if (ev.angle > -40 && - ev.angle < 40 && - ev.distance < 50 && - this._nav.canSwipeBack()) { - // passed the tests, now see if the super says it's cool or not - return super.canStart(ev); - } - - // nerp, not today - return false; + return ( + ev.angle > -40 && + ev.angle < 40 && + ev.distance < 50 && + this._nav.canSwipeBack() && + super.canStart(ev) && + this.gesture.capture() + ); } onSlideBeforeStart(slideData: SlideData, ev: any) { console.debug('swipeBack, onSlideBeforeStart', ev.srcEvent.type); this._nav.swipeBackStart(); - - this._menuCtrl.tempDisable(true); } onSlide(slide: SlideData) { @@ -57,7 +62,17 @@ export class SwipeBackGesture extends SlideEdgeGesture { this._nav.swipeBackEnd(shouldComplete, currentStepValue); - this._menuCtrl.tempDisable(false); + this.gesture.release(); + } + + unlisten() { + this.gesture.release(); + super.unlisten(); + } + + destroy() { + this.gesture.destroy(); + super.destroy(); } } diff --git a/src/components/range/range.ts b/src/components/range/range.ts index 14777f70898..ddb9e911691 100644 --- a/src/components/range/range.ts +++ b/src/components/range/range.ts @@ -431,18 +431,12 @@ export class Range implements AfterViewInit, ControlValueAccessor, OnDestroy { ev.preventDefault(); ev.stopPropagation(); - if (this._start !== null && this._active !== null) { - // only use pointer move if it's a valid pointer - // and we already have start coordinates - - // update the ratio for the active knob - this.updateKnob(pointerCoord(ev), this._rect); - - // update the active knob's position - this._active.position(); - this._pressed = this._active.pressed = true; + // update the ratio for the active knob + this.updateKnob(pointerCoord(ev), this._rect); - } + // update the active knob's position + this._active.position(); + this._pressed = this._active.pressed = true; } /** diff --git a/src/components/refresher/refresher.ts b/src/components/refresher/refresher.ts index 6c1f5d8b229..03081e14da3 100644 --- a/src/components/refresher/refresher.ts +++ b/src/components/refresher/refresher.ts @@ -2,6 +2,7 @@ import { Directive, EventEmitter, Host, Input, Output, NgZone } from '@angular/c import { Content } from '../content/content'; import { CSS, pointerCoord } from '../../util/dom'; +import { GestureController, GestureDelegate, GesturePriority } from '../../gestures/gesture-controller'; import { isTrueProperty } from '../../util/util'; import { PointerEvents, UIEventManager } from '../../util/ui-event-manager'; @@ -98,6 +99,7 @@ export class Refresher { private _didStart: boolean; private _lastCheck: number = 0; private _isEnabled: boolean = true; + private _gesture: GestureDelegate; private _events: UIEventManager = new UIEventManager(false); private _pointerEvents: PointerEvents; private _top: string = ''; @@ -196,8 +198,11 @@ export class Refresher { @Output() ionStart: EventEmitter = new EventEmitter(); - constructor(@Host() private _content: Content, private _zone: NgZone) { + constructor(@Host() private _content: Content, private _zone: NgZone, gestureCtrl: GestureController) { _content.addCssClass('has-refresher'); + this._gesture = gestureCtrl.create('refresher', { + priority: GesturePriority.Interactive, + }); } private _onStart(ev: TouchEvent): any { @@ -216,6 +221,10 @@ export class Refresher { return false; } + if (!this._gesture.canStart()) { + return false; + } + let coord = pointerCoord(ev); console.debug('Pull-to-refresh, onStart', ev.type, 'y:', coord.y); @@ -228,7 +237,7 @@ export class Refresher { this.startY = this.currentY = coord.y; this.progress = 0; - this.state = STATE_PULLING; + this.state = STATE_INACTIVE; return true; } @@ -242,6 +251,10 @@ export class Refresher { return 1; } + if (!this._gesture.canStart()) { + return 0; + } + // do nothing if it's actively refreshing // or it's in the process of closing // or this was never a startY @@ -484,6 +497,7 @@ export class Refresher { * @private */ ngOnDestroy() { + this._gesture.destroy(); this._setListeners(false); } diff --git a/src/components/refresher/test/refresher.spec.ts b/src/components/refresher/test/refresher.spec.ts index 6daf15f543f..8ddb21a1e84 100644 --- a/src/components/refresher/test/refresher.spec.ts +++ b/src/components/refresher/test/refresher.spec.ts @@ -1,4 +1,4 @@ -import {Refresher, Content, Config, Ion} from '../../../../src'; +import { Refresher, Content, Config, GestureController, Ion } from '../../../../src'; export function run() { @@ -218,17 +218,19 @@ describe('Refresher', () => { let refresher: Refresher; let content: Content; let contentElementRef; + let gestureController: GestureController; let zone = { - run: function(cb) {cb()}, - runOutsideAngular: function(cb) {cb()} + run: function (cb) { cb(); }, + runOutsideAngular: function (cb) { cb(); } }; beforeEach(() => { contentElementRef = mockElementRef(); - content = new Content(contentElementRef, config, null, null, null); + gestureController = new GestureController(); + content = new Content(contentElementRef, config, null, null, zone, null, null); content._scrollEle = document.createElement('scroll-content'); - refresher = new Refresher(content, zone, mockElementRef()); + refresher = new Refresher(content, zone, gestureController); }); function touchEv(y: number) { diff --git a/src/components/slides/test/loop/index.ts b/src/components/slides/test/loop/index.ts index ef3480cd2dd..8b95e2cd428 100644 --- a/src/components/slides/test/loop/index.ts +++ b/src/components/slides/test/loop/index.ts @@ -14,16 +14,16 @@ class E2EApp { constructor() { this.slides = [ { - name: "Slide 1", - class: "yellow" + name: 'Slide 1', + class: 'yellow' }, { - name: "Slide 2", - class: "red" + name: 'Slide 2', + class: 'red' }, { - name: "Slide 3", - class: "blue" + name: 'Slide 3', + class: 'blue' } ]; diff --git a/src/components/tabs/tab.ts b/src/components/tabs/tab.ts index a91aeb9166c..635d1270a98 100644 --- a/src/components/tabs/tab.ts +++ b/src/components/tabs/tab.ts @@ -2,9 +2,9 @@ import { ChangeDetectorRef, Component, ComponentResolver, ElementRef, EventEmitt import { App } from '../app/app'; import { Config } from '../../config/config'; +import { GestureController } from '../../gestures/gesture-controller'; import { isTrueProperty} from '../../util/util'; import { Keyboard} from '../../util/keyboard'; -import { MenuController } from '../menu/menu-controller'; import { NavController } from '../nav/nav-controller'; import { NavOptions} from '../nav/nav-interfaces'; import { TabButton} from './tab-button'; @@ -229,10 +229,10 @@ export class Tab extends NavController { renderer: Renderer, compiler: ComponentResolver, private _cd: ChangeDetectorRef, - menuCtrl: MenuController + gestureCtrl: GestureController ) { // A Tab is a NavController for its child pages - super(parent, app, config, keyboard, elementRef, zone, renderer, compiler, menuCtrl); + super(parent, app, config, keyboard, elementRef, zone, renderer, compiler, gestureCtrl); parent.add(this); diff --git a/src/config/providers.ts b/src/config/providers.ts index 9ed2c313035..434f9de509e 100644 --- a/src/config/providers.ts +++ b/src/config/providers.ts @@ -10,6 +10,7 @@ import { closest, nativeTimeout } from '../util/dom'; import { Events } from '../util/events'; import { FeatureDetect } from '../util/feature-detect'; import { Form } from '../util/form'; +import { GestureController } from '../gestures/gesture-controller'; import { IONIC_DIRECTIVES } from './directives'; import { isPresent } from '../util/util'; import { Keyboard } from '../util/keyboard'; @@ -77,6 +78,7 @@ export function ionicProviders(customProviders?: Array, config?: any): any[ TapClick, ToastController, Translate, + GestureController, ]; if (isPresent(customProviders)) { diff --git a/src/gestures/drag-gesture.ts b/src/gestures/drag-gesture.ts index d94d5b18d08..87dd7a2624f 100644 --- a/src/gestures/drag-gesture.ts +++ b/src/gestures/drag-gesture.ts @@ -1,5 +1,5 @@ -import {Gesture} from './gesture'; -import {defaults} from '../util'; +import { Gesture } from './gesture'; +import { defaults } from '../util'; /** * @private diff --git a/src/gestures/gesture-controller.ts b/src/gestures/gesture-controller.ts new file mode 100644 index 00000000000..715c757f8cc --- /dev/null +++ b/src/gestures/gesture-controller.ts @@ -0,0 +1,215 @@ + +import { Injectable } from '@angular/core'; + +import { App } from '../components/app/app'; + +export const enum GesturePriority { + Minimun = -10000, + NavigationOptional = -20, + Navigation = -10, + Normal = 0, + Interactive = 10, + Input = 20, +} + +export const enum DisableScroll { + Never, + DuringCapture, + Always, +} + +export interface GestureOptions { + disable?: string[]; + disableScroll?: DisableScroll; + priority?: number; +} + +@Injectable() +export class GestureController { + private id: number = 1; + private requestedStart: { [eventId: number]: number } = {}; + private disabledGestures: { [eventName: string]: Set } = {}; + private disabledScroll: Set = new Set(); + private appRoot: App; + private capturedID: number = null; + + create(name: string, opts: GestureOptions = {}): GestureDelegate { + let id = this.id; this.id++; + return new GestureDelegate(name, id, this, opts); + } + + start(gestureName: string, id: number, priority: number): boolean { + if (!this.canStart(gestureName)) { + delete this.requestedStart[id]; + return false; + } + + this.requestedStart[id] = priority; + return true; + } + + capture(gestureName: string, id: number, priority: number): boolean { + if (!this.start(gestureName, id, priority)) { + return false; + } + let requestedStart = this.requestedStart; + let maxPriority = GesturePriority.Minimun; + for (let gestureID in requestedStart) { + maxPriority = Math.max(maxPriority, requestedStart[gestureID]); + } + + if (maxPriority === priority) { + this.capturedID = id; + this.requestedStart = {}; + return true; + } + delete requestedStart[id]; + console.debug(`${gestureName} can not start because it is has lower priority`); + return false; + } + + release(id: number) { + delete this.requestedStart[id]; + if (this.capturedID && id === this.capturedID) { + this.capturedID = null; + } + } + + disableGesture(gestureName: string, id: number) { + let set = this.disabledGestures[gestureName]; + if (!set) { + set = new Set(); + this.disabledGestures[gestureName] = set; + } + set.add(id); + } + + enableGesture(gestureName: string, id: number) { + let set = this.disabledGestures[gestureName]; + if (set) { + set.delete(id); + } + } + + disableScroll(id: number) { + let isEnabled = !this.isScrollDisabled(); + this.disabledScroll.add(id); + if (isEnabled && this.isScrollDisabled()) { + // this.appRoot.disableScroll = true; + } + } + + enableScroll(id: number) { + let isDisabled = this.isScrollDisabled(); + this.disabledScroll.delete(id); + if (isDisabled && !this.isScrollDisabled()) { + // this.appRoot.disableScroll = false; + } + } + + canStart(gestureName: string): boolean { + if (this.capturedID) { + // a gesture already captured + return false; + } + + if (this.isDisabled(gestureName)) { + return false; + } + return true; + } + + isCaptured(): boolean { + return !!this.capturedID; + } + + isScrollDisabled(): boolean { + return this.disabledScroll.size > 0; + } + + isDisabled(gestureName: string): boolean { + let disabled = this.disabledGestures[gestureName]; + if (disabled && disabled.size > 0) { + return true; + } + return false; + } + +} + +export class GestureDelegate { + private disable: string[]; + private disableScroll: DisableScroll; + public priority: number = 0; + + constructor( + private name: string, + private id: number, + private controller: GestureController, + opts: GestureOptions + ) { + this.disable = opts.disable || []; + this.disableScroll = opts.disableScroll || DisableScroll.Never; + this.priority = opts.priority || 0; + + // Disable gestures + for (let gestureName of this.disable) { + controller.disableGesture(gestureName, id); + } + + // Disable scrolling (always) + if (this.disableScroll === DisableScroll.Always) { + controller.disableScroll(id); + } + } + + canStart(): boolean { + if (!this.controller) { + return false; + } + return this.controller.canStart(this.name); + } + + start(): boolean { + if (!this.controller) { + return false; + } + return this.controller.start(this.name, this.id, this.priority); + } + + capture(): boolean { + if (!this.controller) { + return false; + } + let captured = this.controller.capture(this.name, this.id, this.priority); + if (captured && this.disableScroll === DisableScroll.DuringCapture) { + this.controller.disableScroll(this.id); + } + return captured; + } + + release() { + if (!this.controller) { + return; + } + this.controller.release(this.id); + if (this.disableScroll === DisableScroll.DuringCapture) { + this.controller.enableScroll(this.id); + } + } + + destroy() { + if (!this.controller) { + return; + } + this.release(); + + for (let disabled of this.disable) { + this.controller.enableGesture(disabled, this.id); + } + if (this.disableScroll === DisableScroll.Always) { + this.controller.enableScroll(this.id); + } + this.controller = null; + } +} \ No newline at end of file diff --git a/src/gestures/test/gesture-controller.spec.ts b/src/gestures/test/gesture-controller.spec.ts new file mode 100644 index 00000000000..6456ea63648 --- /dev/null +++ b/src/gestures/test/gesture-controller.spec.ts @@ -0,0 +1,314 @@ +import { GestureController, DisableScroll } from '../../../src'; + +export function run() { + + it('should create an instance of GestureController', () => { + let c = new GestureController(); + expect(c.isCaptured()).toEqual(false); + expect(c.isScrollDisabled()).toEqual(false); + }); + + it('should test scrolling enable/disable stack', () => { + let c = new GestureController(); + c.enableScroll(1); + expect(c.isScrollDisabled()).toEqual(false); + + c.disableScroll(1); + expect(c.isScrollDisabled()).toEqual(true); + c.disableScroll(1); + c.disableScroll(1); + expect(c.isScrollDisabled()).toEqual(true); + + c.enableScroll(1); + expect(c.isScrollDisabled()).toEqual(false); + + for (var i = 0; i < 100; i++) { + for (var j = 0; j < 100; j++) { + c.disableScroll(j); + } + } + + for (var i = 0; i < 100; i++) { + expect(c.isScrollDisabled()).toEqual(true); + c.enableScroll(50 - i); + c.enableScroll(i); + } + expect(c.isScrollDisabled()).toEqual(false); + }); + + it('should test gesture enable/disable stack', () => { + let c = new GestureController(); + c.enableGesture('swipe', 1); + expect(c.isDisabled('swipe')).toEqual(false); + + c.disableGesture('swipe', 1); + expect(c.isDisabled('swipe')).toEqual(true); + c.disableGesture('swipe', 1); + c.disableGesture('swipe', 1); + expect(c.isDisabled('swipe')).toEqual(true); + + c.enableGesture('swipe', 1); + expect(c.isDisabled('swipe')).toEqual(false); + + // Disabling gestures multiple times + for (var gestureName = 0; gestureName < 10; gestureName++) { + for (var i = 0; i < 50; i++) { + for (var j = 0; j < 50; j++) { + c.disableGesture(gestureName.toString(), j); + } + } + } + + for (var gestureName = 0; gestureName < 10; gestureName++) { + for (var i = 0; i < 49; i++) { + c.enableGesture(gestureName.toString(), i); + } + expect(c.isDisabled(gestureName.toString())).toEqual(true); + c.enableGesture(gestureName.toString(), 49); + expect(c.isDisabled(gestureName.toString())).toEqual(false); + } + }); + + + it('should test if canStart', () => { + let c = new GestureController(); + expect(c.canStart('event')).toEqual(true); + expect(c.canStart('event1')).toEqual(true); + expect(c.canStart('event')).toEqual(true); + expect(c['requestedStart']).toEqual({}); + expect(c.isCaptured()).toEqual(false); + }); + + + + it('should initialize a delegate without options', () => { + let c = new GestureController(); + let g = c.create('event'); + expect(g['name']).toEqual('event'); + expect(g.priority).toEqual(0); + expect(g['disable']).toEqual([]); + expect(g['disableScroll']).toEqual(DisableScroll.Never); + expect(g['controller']).toEqual(c); + expect(g['id']).toEqual(1); + + let g2 = c.create('event2'); + expect(g2['id']).toEqual(2); + }); + + + it('should initialize a delegate with options', () => { + let c = new GestureController(); + let g = c.create('swipe', { + priority: -123, + disableScroll: DisableScroll.DuringCapture, + disable: ['event2'] + }); + expect(g['name']).toEqual('swipe'); + expect(g.priority).toEqual(-123); + expect(g['disable']).toEqual(['event2']); + expect(g['disableScroll']).toEqual(DisableScroll.DuringCapture); + expect(g['controller']).toEqual(c); + expect(g['id']).toEqual(1); + }); + + it('should test if several gestures can be started', () => { + let c = new GestureController(); + let g1 = c.create('swipe'); + let g2 = c.create('swipe1', {priority: 3}); + let g3 = c.create('swipe2', {priority: 4}); + + for (var i = 0; i < 10; i++) { + expect(g1.start()).toEqual(true); + expect(g2.start()).toEqual(true); + expect(g3.start()).toEqual(true); + } + expect(c['requestedStart']).toEqual({ + 1: 0, + 2: 3, + 3: 4 + }); + + g1.release(); + g1.release(); + + expect(c['requestedStart']).toEqual({ + 2: 3, + 3: 4 + }); + expect(g1.start()).toEqual(true); + expect(g2.start()).toEqual(true); + g3.destroy(); + + expect(c['requestedStart']).toEqual({ + 1: 0, + 2: 3, + }); + }); + + + it('should test if several gestures try to capture at the same time', () => { + let c = new GestureController(); + let g1 = c.create('swipe1'); + let g2 = c.create('swipe2', { priority: 2 }); + let g3 = c.create('swipe3', { priority: 3 }); + let g4 = c.create('swipe4', { priority: 4 }); + let g5 = c.create('swipe5', { priority: 5 }); + + // Low priority capture() returns false + expect(g2.start()).toEqual(true); + expect(g3.start()).toEqual(true); + expect(g1.capture()).toEqual(false); + expect(c['requestedStart']).toEqual({ + 2: 2, + 3: 3 + }); + + // Low priority start() + capture() returns false + expect(g2.capture()).toEqual(false); + expect(c['requestedStart']).toEqual({ + 3: 3 + }); + + // Higher priority capture() return true + expect(g4.capture()).toEqual(true); + expect(c.isScrollDisabled()).toEqual(false); + expect(c.isCaptured()).toEqual(true); + expect(c['requestedStart']).toEqual({}); + + // Higher priority can not capture because it is already capture + expect(g5.capture()).toEqual(false); + expect(g5.canStart()).toEqual(false); + expect(g5.start()).toEqual(false); + expect(c['requestedStart']).toEqual({}); + + // Only captured gesture can release + g1.release(); + g2.release(); + g3.release(); + g5.release(); + expect(c.isCaptured()).toEqual(true); + + // G4 releases + g4.release(); + expect(c.isCaptured()).toEqual(false); + + // Once it was release, any gesture can capture + expect(g1.start()).toEqual(true); + expect(g1.capture()).toEqual(true); + }); + + + it('should destroy correctly', () => { + let c = new GestureController(); + let g = c.create('swipe', { + priority: 123, + disableScroll: DisableScroll.Always, + disable: ['event2'] + }); + expect(c.isScrollDisabled()).toEqual(true); + + // Capturing + expect(g.capture()).toEqual(true); + expect(c.isCaptured()).toEqual(true); + expect(g.capture()).toEqual(false); + expect(c.isScrollDisabled()).toEqual(true); + + // Releasing + g.release(); + expect(c.isCaptured()).toEqual(false); + expect(c.isScrollDisabled()).toEqual(true); + expect(g.capture()).toEqual(true); + expect(c.isCaptured()).toEqual(true); + + // Destroying + g.destroy(); + expect(c.isCaptured()).toEqual(false); + expect(g['controller']).toBeNull(); + + // it should return false and not crash + expect(g.start()).toEqual(false); + expect(g.capture()).toEqual(false); + g.release(); + }); + + + it('should disable some events', () => { + let c = new GestureController(); + + let goback = c.create('goback'); + expect(goback.canStart()).toEqual(true); + + let g2 = c.create('goback2'); + expect(g2.canStart()).toEqual(true); + + let g3 = c.create('swipe', { + disable: ['range', 'goback', 'something'] + }); + + let g4 = c.create('swipe2', { + disable: ['range'] + }); + + // it should be noop + g3.release(); + + // goback is disabled + expect(c.isDisabled('range')).toEqual(true); + expect(c.isDisabled('goback')).toEqual(true); + expect(c.isDisabled('something')).toEqual(true); + expect(c.isDisabled('goback2')).toEqual(false); + expect(goback.canStart()).toEqual(false); + expect(goback.start()).toEqual(false); + expect(goback.capture()).toEqual(false); + expect(g3.canStart()).toEqual(true); + + // Once g3 is destroyed, goback and something should be enabled + g3.destroy(); + expect(c.isDisabled('range')).toEqual(true); + expect(c.isDisabled('goback')).toEqual(false); + expect(c.isDisabled('something')).toEqual(false); + expect(g3.canStart()).toEqual(false); + + // Once g4 is destroyed, range is also enabled + g4.destroy(); + expect(c.isDisabled('range')).toEqual(false); + expect(g4.canStart()).toEqual(false); + }); + + it('should disable scrolling on capture', () => { + let c = new GestureController(); + let g = c.create('goback', { + disableScroll: DisableScroll.DuringCapture, + }); + let g1 = c.create('swipe'); + + g.start(); + expect(c.isScrollDisabled()).toEqual(false); + + g1.capture(); + g.capture(); + expect(c.isScrollDisabled()).toEqual(false); + + g1.release(); + expect(c.isScrollDisabled()).toEqual(false); + + g.capture(); + expect(c.isScrollDisabled()).toEqual(true); + + let g2 = c.create('swipe2', { + disableScroll: DisableScroll.Always, + }); + g.release(); + expect(c.isScrollDisabled()).toEqual(true); + + g2.destroy(); + expect(c.isScrollDisabled()).toEqual(false); + + g.capture(); + expect(c.isScrollDisabled()).toEqual(true); + + g.destroy(); + expect(c.isScrollDisabled()).toEqual(false); + }); + +} diff --git a/src/index.ts b/src/index.ts index f0aa6922e30..5043478fb84 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ export * from './gestures/drag-gesture'; export * from './gestures/gesture'; export * from './gestures/slide-edge-gesture'; export * from './gestures/slide-gesture'; +export * from './gestures/gesture-controller'; export * from './platform/platform'; export * from './platform/storage';