feat(): added gesture.

This commit is contained in:
Josh Thomas
2017-05-18 10:28:48 -05:00
parent 0f0cb8f01f
commit 38e7d3f8dc
4 changed files with 612 additions and 1 deletions

View File

@ -0,0 +1,173 @@
export class GestureController {
private id: number = 0;
private requestedStart: { [eventId: number]: number } = {};
private disabledGestures: { [eventName: string]: Set<number> } = {};
private disabledScroll: Set<number> = new Set<number>();
private capturedID: number = null;
createGesture(gestureName: string, gesturePriority: number, disableScroll: boolean): GestureDelegate {
return new GestureDelegate(this, ++this.id, gestureName, gesturePriority, disableScroll);
}
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 = -10000;
for (let gestureID in requestedStart) {
maxPriority = Math.max(maxPriority, requestedStart[gestureID]);
}
if (maxPriority === priority) {
this.capturedID = id;
this.requestedStart = {};
return true;
}
delete requestedStart[id];
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<number>();
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 (this._app && isEnabled && this.isScrollDisabled()) {
// console.debug('GestureController: Disabling scrolling');
// this._app._setDisableScroll(true);
// }
}
enableScroll(id: number) {
// let isDisabled = this.isScrollDisabled();
this.disabledScroll.delete(id);
// if (this._app && isDisabled && !this.isScrollDisabled()) {
// console.debug('GestureController: Enabling scrolling');
// this._app._setDisableScroll(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 {
constructor(
private ctrl: GestureController,
private id: number,
private name: string,
private priority: number,
private disableScroll: boolean
) { }
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;
}
let 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 = null;
}
}

View File

@ -0,0 +1,372 @@
import { applyStyles, getElementReference, pointerCoordX, pointerCoordY } from '../../util/helpers';
import { Component, Ionic, Listen, Prop } from '../index';
import { GestureCallback, GestureDetail } from '../../util/interfaces';
import { GestureController, GestureDelegate } from './gesture-controller';
import { PanRecognizer } from './recognizers';
@Component({
tag: 'ion-gesture',
shadow: false
})
export class Gesture {
private $el: HTMLElement;
private detail: GestureDetail = {};
private positions: number[] = [];
private gesture: GestureDelegate;
private lastTouch = 0;
private pan: PanRecognizer;
private hasCapturedPan = false;
private hasPress = false;
private hasStartedPan = false;
private requiresMove = false;
private isMoveQueued = false;
@Prop() direction: string = 'x';
@Prop() gestureName: string = '';
@Prop() gesturePriority: number = 0;
@Prop() attachTo: string = 'child';
@Prop() maxAngle: number = 40;
@Prop() threshold: number = 20;
@Prop() type: string = 'pan';
@Prop() canStart: GestureCallback;
@Prop() onStart: GestureCallback;
@Prop() onMove: GestureCallback;
@Prop() onEnd: GestureCallback;
@Prop() onPress: GestureCallback;
@Prop() notCaptured: GestureCallback;
ionViewDidLoad() {
Ionic.controllers.gesture = (Ionic.controllers.gesture || new GestureController());
this.gesture = (<GestureController>Ionic.controllers.gesture).createGesture(this.gestureName, this.gesturePriority, false);
const types = this.type.replace(/\s/g, '').toLowerCase().split(',');
if (types.indexOf('pan') > -1) {
this.pan = new PanRecognizer(this.direction, this.threshold, this.maxAngle);
this.requiresMove = true;
}
this.hasPress = (types.indexOf('press') > -1);
if (this.pan || this.hasPress) {
Ionic.listener.enable(this, 'touchstart', true, this.attachTo);
Ionic.listener.enable(this, 'mousedown', true, this.attachTo);
Ionic.dom.write(() => {
applyStyles(getElementReference(this.$el, this.attachTo), GESTURE_INLINE_STYLES);
});
}
}
// DOWN *************************
@Listen('touchstart', { passive: true, enabled: false })
onTouchStart(ev: TouchEvent) {
this.lastTouch = now(ev);
this.enableMouse(false);
this.enableTouch(true);
this.pointerDown(ev, this.lastTouch);
}
@Listen('mousedown', { passive: true, enabled: false })
onMouseDown(ev: MouseEvent) {
const timeStamp = now(ev);
if (this.lastTouch === 0 || (this.lastTouch + MOUSE_WAIT < timeStamp)) {
this.enableMouse(true);
this.enableTouch(false);
this.pointerDown(ev, timeStamp);
}
}
private pointerDown(ev: UIEvent, timeStamp: number): boolean {
if (!this.gesture || this.hasStartedPan) {
return false;
}
const detail = this.detail;
detail.startX = detail.currentX = pointerCoordX(ev);
detail.startY = detail.currentY = pointerCoordY(ev);
detail.startTimeStamp = detail.timeStamp = timeStamp;
detail.velocityX = detail.velocityY = detail.deltaX = detail.deltaY = 0;
detail.directionX = detail.directionY = detail.velocityDirectionX = detail.velocityDirectionY = null;
detail.event = ev;
this.positions.length = 0;
if (this.canStart && this.canStart(detail) === false) {
return false;
}
this.positions.push(detail.currentX, detail.currentY, timeStamp);
// Release fallback
this.gesture.release();
// Start gesture
if (!this.gesture.start()) {
return false;
}
if (this.pan) {
this.hasStartedPan = true;
this.hasCapturedPan = false;
this.pan.start(detail.startX, detail.startY);
}
return true;
}
// MOVE *************************
@Listen('touchmove', { passive: true, enabled: false })
onTouchMove(ev: TouchEvent) {
this.lastTouch = this.detail.timeStamp = now(ev);
this.pointerMove(ev);
}
@Listen('document:mousemove', { passive: true, enabled: false })
onMoveMove(ev: TouchEvent) {
const timeStamp = now(ev);
if (this.lastTouch === 0 || (this.lastTouch + MOUSE_WAIT < timeStamp)) {
this.detail.timeStamp = timeStamp;
this.pointerMove(ev);
}
}
private pointerMove(ev: UIEvent) {
const detail = this.detail;
this.calcGestureData(ev);
if (this.pan) {
if (this.hasCapturedPan) {
if (!this.isMoveQueued) {
this.isMoveQueued = true;
Ionic.dom.write(() => {
this.isMoveQueued = false;
detail.type = 'pan';
if (this.onMove) {
this.onMove(detail);
} else {
Ionic.emit(this, 'ionGestureMove', { detail: this.detail });
}
});
}
} else if (this.pan.detect(detail.currentX, detail.currentY)) {
if (this.pan.isGesture() !== 0) {
if (!this.tryToCapturePan(ev)) {
this.abortGesture();
}
}
}
}
}
private calcGestureData(ev: UIEvent) {
const detail = this.detail;
detail.currentX = pointerCoordX(ev);
detail.currentY = pointerCoordY(ev);
detail.deltaX = (detail.currentX - detail.startX);
detail.deltaY = (detail.currentY - detail.startY);
detail.event = ev;
// figure out which direction we're movin'
detail.directionX = detail.velocityDirectionX = (detail.deltaX > 0 ? 'left' : (detail.deltaX < 0 ? 'right' : null));
detail.directionY = detail.velocityDirectionY = (detail.deltaY > 0 ? 'up' : (detail.deltaY < 0 ? 'down' : null));
const positions = this.positions;
positions.push(detail.currentX, detail.currentY, detail.timeStamp);
var endPos = (positions.length - 1);
var startPos = endPos;
var timeRange = (detail.timeStamp - 100);
// move pointer to position measured 100ms ago
for (var i = endPos; i > 0 && positions[i] > timeRange; i -= 3) {
startPos = i;
}
if (startPos !== endPos) {
// compute relative movement between these two points
var movedX = (positions[startPos - 2] - positions[endPos - 2]);
var movedY = (positions[startPos - 1] - positions[endPos - 1]);
var factor = 16.67 / (positions[endPos] - positions[startPos]);
// based on XXms compute the movement to apply for each render step
detail.velocityX = movedX * factor;
detail.velocityY = movedY * factor;
detail.velocityDirectionX = (movedX > 0 ? 'left' : (movedX < 0 ? 'right' : null));
detail.velocityDirectionY = (movedY > 0 ? 'up' : (movedY < 0 ? 'down' : null));
}
}
private tryToCapturePan(ev: UIEvent): boolean {
if (this.gesture && !this.gesture.capture()) {
return false;
}
this.detail.event = ev;
if (this.onStart) {
this.onStart(this.detail);
} else {
Ionic.emit(this, 'ionGestureStart', { detail: this.detail });
}
this.hasCapturedPan = true;
return true;
}
private abortGesture() {
this.hasStartedPan = false;
this.hasCapturedPan = false;
this.gesture.release();
this.enable(false);
this.notCaptured(this.detail);
}
// END *************************
@Listen('touchend', { passive: true, enabled: false })
onTouchEnd(ev: TouchEvent) {
this.lastTouch = this.detail.timeStamp = now(ev);
this.pointerUp(ev);
this.enableTouch(false);
}
@Listen('document:mouseup', { passive: true, enabled: false })
onMouseUp(ev: TouchEvent) {
const timeStamp = now(ev);
if (this.lastTouch === 0 || (this.lastTouch + MOUSE_WAIT < timeStamp)) {
this.detail.timeStamp = timeStamp;
this.pointerUp(ev);
this.enableMouse(false);
}
}
private pointerUp(ev: UIEvent) {
const detail = this.detail;
this.gesture && this.gesture.release();
detail.event = ev;
this.calcGestureData(ev);
if (this.pan) {
if (this.hasCapturedPan) {
detail.type = 'pan';
if (this.onEnd) {
this.onEnd(detail);
} else {
Ionic.emit(this, 'ionGestureEnd', { detail: detail });
}
} else if (this.hasPress) {
this.detectPress();
} else {
if (this.notCaptured) {
this.notCaptured(detail);
} else {
Ionic.emit(this, 'ionGestureNotCaptured', { detail: detail });
}
}
} else if (this.hasPress) {
this.detectPress();
}
this.hasCapturedPan = false;
this.hasStartedPan = false;
}
private detectPress() {
const detail = this.detail;
if (Math.abs(detail.startX - detail.currentX) < 10 && Math.abs(detail.startY - detail.currentY) < 10) {
detail.type = 'press';
if (this.onPress) {
this.onPress(detail);
} else {
Ionic.emit(this, 'ionPress', { detail: detail });
}
}
}
// ENABLE LISTENERS *************************
private enableMouse(shouldEnable: boolean) {
if (this.requiresMove) {
Ionic.listener.enable(this, 'document:mousemove', shouldEnable);
}
Ionic.listener.enable(this, 'document:mouseup', shouldEnable);
}
private enableTouch(shouldEnable: boolean) {
if (this.requiresMove) {
Ionic.listener.enable(this, 'touchmove', shouldEnable);
}
Ionic.listener.enable(this, 'touchend', shouldEnable);
}
private enable(shouldEnable: boolean) {
this.enableMouse(shouldEnable);
this.enableTouch(shouldEnable);
}
ionViewDidUnload() {
this.gesture && this.gesture.destroy();
this.gesture = this.pan = this.detail = this.detail.event = null;
}
}
const GESTURE_INLINE_STYLES = {
'touch-action': 'none',
'user-select': 'none',
'-webkit-user-drag': 'none',
'-webkit-tap-highlight-color': 'rgba(0,0,0,0)'
};
const MOUSE_WAIT = 2500;
function now(ev: UIEvent) {
return ev.timeStamp || Date.now();
}

View File

@ -0,0 +1,66 @@
export class PanRecognizer {
private startX: number;
private startY: number;
private dirty: boolean = false;
private threshold: number;
private maxCosine: number;
private angle = 0;
private isPan = 0;
constructor(private direction: string, threshold: number, maxAngle: number) {
const radians = maxAngle * (Math.PI / 180);
this.maxCosine = Math.cos(radians);
this.threshold = threshold * threshold;
}
start(x: number, y: number) {
this.startX = x;
this.startY = y;
this.angle = 0;
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) {
var angle = Math.atan2(deltaY, deltaX);
var cosine = (this.direction === 'y')
? Math.sin(angle)
: Math.cos(angle);
this.angle = angle;
if (cosine > this.maxCosine) {
this.isPan = 1;
} else if (cosine < -this.maxCosine) {
this.isPan = -1;
} else {
this.isPan = 0;
}
this.dirty = false;
return true;
}
return false;
}
isGesture(): number {
return this.isPan;
}
}

View File

@ -16,7 +16,7 @@ $include-rtl: true !default;
// Global font path
$font-path: "../fonts" !default;
$font-path: "/dist/fonts" !default;
// Ionicons font path