diff --git a/src/components/gesture/gesture-controller.ts b/src/components/gesture/gesture-controller.ts new file mode 100644 index 0000000000..a41d3bb8ac --- /dev/null +++ b/src/components/gesture/gesture-controller.ts @@ -0,0 +1,173 @@ + + +export class GestureController { + private id: number = 0; + private requestedStart: { [eventId: number]: number } = {}; + private disabledGestures: { [eventName: string]: Set } = {}; + private disabledScroll: Set = new Set(); + 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(); + 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; + } + +} diff --git a/src/components/gesture/gesture.ts b/src/components/gesture/gesture.ts new file mode 100644 index 0000000000..4edb6cacca --- /dev/null +++ b/src/components/gesture/gesture.ts @@ -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 = (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(); +} + diff --git a/src/components/gesture/recognizers.ts b/src/components/gesture/recognizers.ts new file mode 100644 index 0000000000..230b65e289 --- /dev/null +++ b/src/components/gesture/recognizers.ts @@ -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; + } +} diff --git a/src/themes/ionic.globals.scss b/src/themes/ionic.globals.scss index e2c2644f6d..9b0234a81b 100644 --- a/src/themes/ionic.globals.scss +++ b/src/themes/ionic.globals.scss @@ -16,7 +16,7 @@ $include-rtl: true !default; // Global font path -$font-path: "../fonts" !default; +$font-path: "/dist/fonts" !default; // Ionicons font path