perf(gesture): lazy loaded dynamic ES module

This commit is contained in:
Manu Mtz.-Almeida
2018-08-01 01:38:52 +02:00
parent 1b5bb67959
commit 49cac8beec
34 changed files with 939 additions and 1709 deletions

View File

@@ -1,5 +1,5 @@
import { Animation } from '../../interface';
import { TransitionOptions } from '../../utils/transition';
import { TransitionOptions } from '../transition';
const DURATION = 500;
const EASING = 'cubic-bezier(0.36,0.66,0.04,1)';

View File

@@ -1,5 +1,5 @@
import { Animation } from '../../interface';
import { TransitionOptions } from '../../utils/transition';
import { TransitionOptions } from '../transition';
const TRANSLATEY = 'translateY';
const OFF_BOTTOM = '40px';

View File

@@ -0,0 +1,256 @@
export class GestureController {
private gestureId = 0;
private requestedStart = new Map<number, number>();
private disabledGestures = new Map<string, Set<number>>();
private disabledScroll = new Set<number>();
private capturedId: number | null = null;
constructor(
private doc: Document
) {}
/**
* Creates a gesture delegate based on the GestureConfig passed
*/
createGesture(config: GestureConfig): GestureDelegate {
return new GestureDelegate(
this,
this.newID(),
config.name,
config.priority ? config.priority : 0,
!!config.disableScroll
);
}
/**
* Creates a blocker that will block any other gesture events from firing. Set in the ion-gesture component.
*/
createBlocker(opts: BlockerConfig = {}): BlockerDelegate {
return new BlockerDelegate(
this.newID(),
this,
opts.disable,
!!opts.disableScroll
);
}
start(gestureName: string, id: number, priority: number): boolean {
if (!this.canStart(gestureName)) {
this.requestedStart.delete(id);
return false;
}
this.requestedStart.set(id, priority);
return true;
}
capture(gestureName: string, id: number, priority: number): boolean {
if (!this.start(gestureName, id, priority)) {
return false;
}
const requestedStart = this.requestedStart;
let maxPriority = -10000;
requestedStart.forEach(value => {
maxPriority = Math.max(maxPriority, value);
});
if (maxPriority === priority) {
this.capturedId = id;
requestedStart.clear();
const event = new CustomEvent('ionGestureCaptured', { detail: gestureName });
this.doc.body.dispatchEvent(event);
return true;
}
requestedStart.delete(id);
return false;
}
release(id: number) {
this.requestedStart.delete(id);
if (this.capturedId && id === this.capturedId) {
this.capturedId = null;
}
}
disableGesture(gestureName: string, id: number) {
let set = this.disabledGestures.get(gestureName);
if (!set) {
set = new Set<number>();
this.disabledGestures.set(gestureName, set);
}
set.add(id);
}
enableGesture(gestureName: string, id: number) {
const set = this.disabledGestures.get(gestureName);
if (set) {
set.delete(id);
}
}
disableScroll(id: number) {
this.disabledScroll.add(id);
}
enableScroll(id: number) {
this.disabledScroll.delete(id);
}
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 {
const disabled = this.disabledGestures.get(gestureName);
if (disabled && disabled.size > 0) {
return true;
}
return false;
}
private newID(): number {
this.gestureId++;
return this.gestureId;
}
}
export class GestureDelegate {
private ctrl?: GestureController;
constructor(
ctrl: any,
private id: number,
private name: string,
private priority: number,
private disableScroll: boolean
) {
this.ctrl = ctrl;
}
canStart(): boolean {
if (!this.ctrl) {
return false;
}
return this.ctrl.canStart(this.name);
}
start(): boolean {
if (!this.ctrl) {
return false;
}
return this.ctrl.start(this.name, this.id, this.priority);
}
capture(): boolean {
if (!this.ctrl) {
return false;
}
const captured = this.ctrl.capture(this.name, this.id, this.priority);
if (captured && this.disableScroll) {
this.ctrl.disableScroll(this.id);
}
return captured;
}
release() {
if (this.ctrl) {
this.ctrl.release(this.id);
if (this.disableScroll) {
this.ctrl.enableScroll(this.id);
}
}
}
destroy() {
this.release();
this.ctrl = undefined;
}
}
export class BlockerDelegate {
private ctrl?: GestureController;
constructor(
private id: number,
ctrl: any,
private disable: string[] | undefined,
private disableScroll: boolean
) {
this.ctrl = ctrl;
}
block() {
if (!this.ctrl) {
return;
}
if (this.disable) {
for (const gesture of this.disable) {
this.ctrl.disableGesture(gesture, this.id);
}
}
if (this.disableScroll) {
this.ctrl.disableScroll(this.id);
}
}
unblock() {
if (!this.ctrl) {
return;
}
if (this.disable) {
for (const gesture of this.disable) {
this.ctrl.enableGesture(gesture, this.id);
}
}
if (this.disableScroll) {
this.ctrl.enableScroll(this.id);
}
}
destroy() {
this.unblock();
this.ctrl = undefined;
}
}
export interface GestureConfig {
name: string;
priority?: number;
disableScroll?: boolean;
}
export interface BlockerConfig {
disable?: string[];
disableScroll?: boolean;
}
export const gestureController = new GestureController(document);

View File

@@ -0,0 +1,346 @@
import { QueueApi } from '@stencil/core';
import { PanRecognizer } from './recognizers';
import { GestureDelegate, gestureController } from './gesture-controller';
import { PointerEvents } from './pointer-events';
export interface GestureDetail {
type: string;
startX: number;
startY: number;
startTimeStamp: number;
currentX: number;
currentY: number;
velocityX: number;
velocityY: number;
deltaX: number;
deltaY: number;
timeStamp: number;
event: UIEvent;
data?: any;
}
export type GestureCallback = (detail?: GestureDetail) => boolean | void;
export interface GestureConfig {
el: Node;
disableScroll?: boolean;
queue: QueueApi;
direction?: 'x' | 'y';
gestureName: string;
gesturePriority?: number;
passive?: boolean;
maxAngle?: number;
threshold?: number;
canStart?: GestureCallback;
onWillStart?: (_: GestureDetail) => Promise<void>;
onStart?: GestureCallback;
onMove?: GestureCallback;
onEnd?: GestureCallback;
notCaptured?: GestureCallback;
}
export function create(config: GestureConfig) {
return new Gesture(config);
}
export class Gesture {
private detail: GestureDetail;
private positions: number[] = [];
private gesture: GestureDelegate;
private pan: PanRecognizer;
private hasCapturedPan = false;
private hasStartedPan = false;
private hasFiredStart = true;
private isMoveQueued = false;
private pointerEvents: PointerEvents;
private canStart?: GestureCallback;
private onWillStart?: (_: GestureDetail) => Promise<void>;
private onStart?: GestureCallback;
private onMove?: GestureCallback;
private onEnd?: GestureCallback;
private notCaptured?: GestureCallback;
private threshold: number;
private queue: QueueApi;
constructor(config: GestureConfig) {
const finalConfig = {
disableScroll: false,
direction: 'x',
gesturePriority: 0,
passive: true,
maxAngle: 40,
threshold: 10,
...config
};
this.canStart = finalConfig.canStart;
this.onWillStart = finalConfig.onWillStart;
this.onStart = finalConfig.onStart;
this.onEnd = finalConfig.onEnd;
this.onMove = finalConfig.onMove;
this.threshold = finalConfig.threshold;
this.queue = finalConfig.queue;
this.detail = {
type: 'pan',
startX: 0,
startY: 0,
startTimeStamp: 0,
currentX: 0,
currentY: 0,
velocityX: 0,
velocityY: 0,
deltaX: 0,
deltaY: 0,
timeStamp: 0,
event: undefined as any,
data: undefined
};
this.pointerEvents = new PointerEvents(
finalConfig.el,
this.pointerDown.bind(this),
this.pointerMove.bind(this),
this.pointerUp.bind(this),
{
capture: false,
}
);
this.pan = new PanRecognizer(finalConfig.direction, finalConfig.threshold, finalConfig.maxAngle);
this.gesture = gestureController.createGesture({
name: config.gestureName,
priority: config.gesturePriority,
disableScroll: config.disableScroll
});
}
set disabled(disabled: boolean) {
this.pointerEvents.disabled = disabled;
}
destroy() {
this.gesture.destroy();
this.pointerEvents.destroy();
}
private pointerDown(ev: UIEvent): boolean {
const timeStamp = now(ev);
if (this.hasStartedPan || !this.hasFiredStart) {
return false;
}
const detail = this.detail;
updateDetail(ev, detail);
detail.startX = detail.currentX;
detail.startY = detail.currentY;
detail.startTimeStamp = detail.timeStamp = timeStamp;
detail.velocityX = detail.velocityY = detail.deltaX = detail.deltaY = 0;
detail.event = ev;
this.positions.length = 0;
// Check if gesture can start
if (this.canStart && this.canStart(detail) === false) {
return false;
}
// Release fallback
this.gesture.release();
// Start gesture
if (!this.gesture.start()) {
return false;
}
this.positions.push(detail.currentX, detail.currentY, timeStamp);
this.hasStartedPan = true;
if (this.threshold === 0) {
return this.tryToCapturePan();
}
this.pan.start(detail.startX, detail.startY);
return true;
}
private pointerMove(ev: UIEvent) {
// fast path, if gesture is currently captured
// do minimun job to get user-land even dispatched
if (this.hasCapturedPan) {
if (!this.isMoveQueued && this.hasFiredStart) {
this.isMoveQueued = true;
this.calcGestureData(ev);
this.queue.write(this.fireOnMove.bind(this));
}
return;
}
// gesture is currently being detected
const detail = this.detail;
this.calcGestureData(ev);
if (this.pan.detect(detail.currentX, detail.currentY)) {
if (this.pan.isGesture()) {
if (!this.tryToCapturePan()) {
this.abortGesture();
}
}
}
}
private fireOnMove() {
// Since fireOnMove is called inside a RAF, onEnd() might be called,
// we must double check hasCapturedPan
if (!this.hasCapturedPan) {
return;
}
const detail = this.detail;
this.isMoveQueued = false;
if (this.onMove) {
this.onMove(detail);
}
}
private calcGestureData(ev: UIEvent) {
const detail = this.detail;
updateDetail(ev, detail);
const currentX = detail.currentX;
const currentY = detail.currentY;
const timestamp = detail.timeStamp = now(ev);
detail.deltaX = currentX - detail.startX;
detail.deltaY = currentY - detail.startY;
detail.event = ev;
const timeRange = timestamp - 100;
const positions = this.positions;
let startPos = positions.length - 1;
// move pointer to position measured 100ms ago
while (startPos > 0 && positions[startPos] > timeRange) {
startPos -= 3;
}
if (startPos > 1) {
// compute relative movement between these two points
const frequency = 1 / (positions[startPos] - timestamp);
const movedY = positions[startPos - 1] - currentY;
const movedX = positions[startPos - 2] - currentX;
// based on XXms compute the movement to apply for each render step
// velocity = space/time = s*(1/t) = s*frequency
detail.velocityX = movedX * frequency;
detail.velocityY = movedY * frequency;
} else {
detail.velocityX = 0;
detail.velocityY = 0;
}
positions.push(currentX, currentY, timestamp);
}
private tryToCapturePan(): boolean {
if (this.gesture && !this.gesture.capture()) {
return false;
}
this.hasCapturedPan = true;
this.hasFiredStart = false;
// reset start position since the real user-land event starts here
// If the pan detector threshold is big, not reseting the start position
// will cause a jump in the animation equal to the detector threshold.
// the array of positions used to calculate the gesture velocity does not
// need to be cleaned, more points in the positions array always results in a
// more acurate value of the velocity.
const detail = this.detail;
detail.startX = detail.currentX;
detail.startY = detail.currentY;
detail.startTimeStamp = detail.timeStamp;
if (this.onWillStart) {
this.onWillStart(this.detail).then(this.fireOnStart.bind(this));
} else {
this.fireOnStart();
}
return true;
}
private fireOnStart() {
if (this.onStart) {
this.onStart(this.detail);
}
this.hasFiredStart = true;
}
private abortGesture() {
this.reset();
this.pointerEvents.stop();
if (this.notCaptured) {
this.notCaptured(this.detail);
}
}
private reset() {
this.hasCapturedPan = false;
this.hasStartedPan = false;
this.isMoveQueued = false;
this.hasFiredStart = true;
if (this.gesture) {
this.gesture.release();
}
}
// END *************************
private pointerUp(ev: UIEvent) {
const hasCaptured = this.hasCapturedPan;
const hasFiredStart = this.hasFiredStart;
this.reset();
if (!hasFiredStart) {
return;
}
this.calcGestureData(ev);
const detail = this.detail;
// Try to capture press
if (hasCaptured) {
if (this.onEnd) {
this.onEnd(detail);
}
return;
}
// Not captured any event
if (this.notCaptured) {
this.notCaptured(detail);
}
}
}
function updateDetail(ev: any, detail: GestureDetail) {
// get X coordinates for either a mouse click
// or a touch depending on the given event
let x = 0;
let y = 0;
if (ev) {
const changedTouches = ev.changedTouches;
if (changedTouches && changedTouches.length > 0) {
const touch = changedTouches[0];
x = touch.clientX;
y = touch.clientY;
} else if (ev.pageX !== undefined) {
x = ev.pageX;
y = ev.pageY;
}
}
detail.currentX = x;
detail.currentY = y;
}
function now(ev: UIEvent) {
return ev.timeStamp || Date.now();
}

View File

@@ -0,0 +1,50 @@
let _sPassive: boolean | undefined;
function supportsPassive(node: Node) {
if (_sPassive === undefined) {
try {
const opts = Object.defineProperty({}, 'passive', {
get: () => {
_sPassive = true;
}
});
node.addEventListener('optsTest', () => { return; }, opts);
} catch {
_sPassive = false;
}
}
return !!_sPassive;
}
export function addEventListener(
el: any,
eventName: string,
callback: EventListenerOrEventListenerObject,
opts: {
passive?: boolean;
capture?: boolean
}
): () => void {
// use event listener options when supported
// otherwise it's just a boolean for the "capture" arg
const listenerOpts = supportsPassive(el) ? {
'capture': !!opts.capture,
'passive': !!opts.passive,
} : !!opts.capture;
let add: string;
let remove: string;
if (el['__zone_symbol__addEventListener']) {
add = '__zone_symbol__addEventListener';
remove = '__zone_symbol__removeEventListener';
} else {
add = 'addEventListener';
remove = 'removeEventListener';
}
el[add](eventName, callback, listenerOpts);
return () => {
el[remove](eventName, callback, listenerOpts);
};
}

View File

@@ -0,0 +1,144 @@
import { addEventListener } from './listener';
const MOUSE_WAIT = 2000;
export class PointerEvents {
private rmTouchStart?: () => void;
private rmTouchMove?: () => void;
private rmTouchEnd?: () => void;
private rmTouchCancel?: () => void;
private rmMouseStart?: () => void;
private rmMouseMove?: () => void;
private rmMouseUp?: () => void;
private bindTouchEnd: any;
private bindMouseUp: any;
private lastTouchEvent = 0;
constructor(
private el: Node,
private pointerDown: any,
private pointerMove: any,
private pointerUp: any,
private options: EventListenerOptions
) {
this.bindTouchEnd = this.handleTouchEnd.bind(this);
this.bindMouseUp = this.handleMouseUp.bind(this);
}
set disabled(disabled: boolean) {
if (disabled) {
if (this.rmTouchStart) {
this.rmTouchStart();
}
if (this.rmMouseStart) {
this.rmMouseStart();
}
this.rmTouchStart = this.rmMouseStart = undefined;
this.stop();
} else {
if (!this.rmTouchStart) {
this.rmTouchStart = addEventListener(this.el, 'touchstart', this.handleTouchStart.bind(this), this.options);
}
if (!this.rmMouseStart) {
this.rmMouseStart = addEventListener(this.el, 'mousedown', this.handleMouseDown.bind(this), this.options);
}
}
}
stop() {
this.stopTouch();
this.stopMouse();
}
destroy() {
this.disabled = true;
this.pointerUp = this.pointerMove = this.pointerDown = undefined;
}
private handleTouchStart(ev: any) {
this.lastTouchEvent = Date.now() + MOUSE_WAIT;
if (!this.pointerDown(ev, POINTER_EVENT_TYPE_TOUCH)) {
return;
}
if (!this.rmTouchMove && this.pointerMove) {
this.rmTouchMove = addEventListener(this.el, 'touchmove', this.pointerMove, this.options);
}
if (!this.rmTouchEnd) {
this.rmTouchEnd = addEventListener(this.el, 'touchend', this.bindTouchEnd, this.options);
}
if (!this.rmTouchCancel) {
this.rmTouchCancel = addEventListener(this.el, 'touchcancel', this.bindTouchEnd, this.options);
}
}
private handleMouseDown(ev: any) {
if (this.lastTouchEvent > Date.now()) {
console.debug('mousedown event dropped because of previous touch');
return;
}
if (!this.pointerDown(ev, POINTER_EVENT_TYPE_MOUSE)) {
return;
}
if (!this.rmMouseMove && this.pointerMove) {
this.rmMouseMove = addEventListener(this.el.ownerDocument, 'mousemove', this.pointerMove, this.options);
}
if (!this.rmMouseUp) {
this.rmMouseUp = addEventListener(this.el.ownerDocument, 'mouseup', this.bindMouseUp, this.options);
}
}
private handleTouchEnd(ev: any) {
this.stopTouch();
if (this.pointerUp) {
this.pointerUp(ev, POINTER_EVENT_TYPE_TOUCH);
}
}
private handleMouseUp(ev: any) {
this.stopMouse();
if (this.pointerUp) {
this.pointerUp(ev, POINTER_EVENT_TYPE_MOUSE);
}
}
private stopTouch() {
if (this.rmTouchMove) {
this.rmTouchMove();
}
if (this.rmTouchEnd) {
this.rmTouchEnd();
}
if (this.rmTouchCancel) {
this.rmTouchCancel();
}
this.rmTouchMove = this.rmTouchEnd = this.rmTouchCancel = undefined;
}
private stopMouse() {
if (this.rmMouseMove) {
this.rmMouseMove();
}
if (this.rmMouseUp) {
this.rmMouseUp();
}
this.rmMouseMove = this.rmMouseUp = undefined;
}
}
export const POINTER_EVENT_TYPE_MOUSE = 1;
export const POINTER_EVENT_TYPE_TOUCH = 2;
export interface PointerEventsConfig {
element?: HTMLElement;
pointerDown: (ev: any) => boolean;
pointerMove?: (ev: any) => void;
pointerUp?: (ev: any) => void;
zone?: boolean;
capture?: boolean;
passive?: boolean;
}

View File

@@ -0,0 +1,63 @@
export class PanRecognizer {
private startX!: number;
private startY!: number;
private dirty = false;
private threshold: number;
private maxCosine: number;
private isDirX: boolean;
private isPan = 0;
constructor(direction: string, threshold: number, maxAngle: number) {
const radians = maxAngle * (Math.PI / 180);
this.isDirX = direction === 'x';
this.maxCosine = Math.cos(radians);
this.threshold = threshold * threshold;
}
start(x: number, y: number) {
this.startX = x;
this.startY = y;
this.isPan = 0;
this.dirty = true;
}
detect(x: number, y: number): boolean {
if (!this.dirty) {
return false;
}
const deltaX = (x - this.startX);
const deltaY = (y - this.startY);
const distance = deltaX * deltaX + deltaY * deltaY;
if (distance < this.threshold) {
return false;
}
const hypotenuse = Math.sqrt(distance);
const cosine = (this.isDirX ? deltaX : deltaY) / hypotenuse;
if (cosine > this.maxCosine) {
this.isPan = 1;
} else if (cosine < -this.maxCosine) {
this.isPan = -1;
} else {
this.isPan = 0;
}
this.dirty = false;
return true;
}
isGesture(): boolean {
return this.isPan !== 0;
}
getDirection(): number {
return this.isPan;
}
}

View File

@@ -1,4 +1,4 @@
import { pointerCoord } from '../../../utils/helpers';
import { pointerCoord } from '../../helpers';
import { isFocused, relocateInput } from './common';
import { getScrollData } from './scroll-data';