mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-11-09 16:16:41 +08:00
feat(): added gesture.
This commit is contained in:
173
src/components/gesture/gesture-controller.ts
Normal file
173
src/components/gesture/gesture-controller.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
372
src/components/gesture/gesture.ts
Normal file
372
src/components/gesture/gesture.ts
Normal 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();
|
||||
}
|
||||
|
||||
66
src/components/gesture/recognizers.ts
Normal file
66
src/components/gesture/recognizers.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -16,7 +16,7 @@ $include-rtl: true !default;
|
||||
|
||||
|
||||
// Global font path
|
||||
$font-path: "../fonts" !default;
|
||||
$font-path: "/dist/fonts" !default;
|
||||
|
||||
|
||||
// Ionicons font path
|
||||
|
||||
Reference in New Issue
Block a user