From e1a97b2560cc9446141277ea3b24c6fdfa64a427 Mon Sep 17 00:00:00 2001 From: "Manu Mtz.-Almeida" Date: Sun, 3 Jul 2016 20:29:38 +0200 Subject: [PATCH] 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);