/** * GestureView.tsx * * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT license. * * Cross-platform parts of the implementation of the GestureView component. * It provides much of the standard work necessary to support combinations of * pinch-and-zoom, panning, single tap and double tap gestures. */ import * as React from 'react'; import assert from '../common/assert'; import { Types } from '../common/Interfaces'; import Timers from '../common/utils/Timers'; export enum GestureType { None, MultiTouch, Pan, PanVertical, PanHorizontal } // These threshold values were chosen empirically. const _pinchZoomPixelThreshold = 3; const _panPixelThreshold = 10; const _tapDurationThreshold = 500; const _longPressDurationThreshold = 750; const _tapPixelThreshold = 4; const _doubleTapDurationThreshold = 250; const _doubleTapPixelThreshold = 20; export interface GestureStatePoint { /** * accumulated distance of the gesture since the touch started */ dx: number; /** * accumulated distance of the gesture since the touch started */ dy: number; } export interface GestureStatePointVelocity extends GestureStatePoint { /** * current velocity of the gesture */ vx: number; /** * current velocity of the gesture */ vy: number; } // We need a method-less really-basic touch event basic type for allowing cross-platform adapting of // web(React)-based touch events into RX touch events, since they're based on the RN type to allow // for zero-work casting and manipulating. export interface TouchListBasic { [index: number]: Types.Touch; length: number; } export interface TouchEventBasic extends Types.SyntheticEvent { // We override this definition because the public // type excludes location and page fields. altKey: boolean; changedTouches: TouchListBasic; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean; targetTouches: TouchListBasic; locationX?: number; locationY?: number; pageX?: number; pageY?: number; touches: TouchListBasic; } export abstract class GestureView extends React.Component { private _doubleTapTimer: number | undefined; private _pendingLongPressEvent: Types.TapGestureState | undefined; private _longPressTimer: number | undefined; // State for tracking move gestures (pinch/zoom or pan) private _pendingGestureType: GestureType = GestureType.None; private _pendingGestureState: Types.MultiTouchGestureState | Types.PanGestureState | Types.TapGestureState | undefined; // State for tracking double taps private _lastTapEvent: Types.TapGestureState | undefined; // Skip ability for next tap to work around some event issues private _shouldSkipNextTap = false; // State for tracking single taps private _lastGestureStartEvent: TouchEventBasic | undefined; componentWillUnmount() { // Dispose of timer before the component goes away. this._cancelDoubleTapTimer(); } // Returns true if we care about trapping/tracking the event protected _onTouchSeriesStart(event: TouchEventBasic): boolean { this._lastGestureStartEvent = event; // If we're trying to detect a tap, set this as the responder immediately. if (this.props.onTap || this.props.onDoubleTap || this.props.onLongPress || this.props.onContextMenu) { if (this.props.onLongPress) { const gsState = this._touchEventToTapGestureState(event); this._startLongPressTimer(gsState); } return true; } return false; } // Returns true if we care about trapping/tracking the event protected _onTouchChange(event: TouchEventBasic, gestureState: GestureStatePointVelocity): boolean { if (!this._lastGestureStartEvent) { this._lastGestureStartEvent = event; } // If this is the first movement we've seen, try to match it against // the various move gestures that we're looking for. let initializeFromEvent = false; if (this._pendingGestureType === GestureType.None) { this._pendingGestureType = this._detectMoveGesture(event, gestureState); initializeFromEvent = true; } if (this._pendingGestureType === GestureType.MultiTouch) { this._setPendingGestureState(this._sendMultiTouchEvents(event, gestureState, initializeFromEvent, false)); return true; } else if (this._pendingGestureType === GestureType.Pan || this._pendingGestureType === GestureType.PanVertical || this._pendingGestureType === GestureType.PanHorizontal) { const spEvent = this._touchEventToTapGestureState(event); this._setPendingGestureState(this._sendPanEvent(spEvent, gestureState, this._pendingGestureType, initializeFromEvent, false)); return true; } return false; } protected _onTouchSeriesFinished(touchEvent: TouchEventBasic, gestureState: GestureStatePointVelocity) { // Can't possibly be a long press if the touch ended. this._cancelLongPressTimer(); // Close out any of the pending move gestures. if (this._pendingGestureType === GestureType.MultiTouch) { this._sendMultiTouchEvents(touchEvent, gestureState, false, true); this._pendingGestureState = undefined; this._pendingGestureType = GestureType.None; } else if (this._pendingGestureType === GestureType.Pan || this._pendingGestureType === GestureType.PanVertical || this._pendingGestureType === GestureType.PanHorizontal) { const spEvent = this._touchEventToTapGestureState(touchEvent); this._sendPanEvent(spEvent, gestureState, this._pendingGestureType, false, true); this._pendingGestureState = undefined; this._pendingGestureType = GestureType.None; } else if (this._isTap(touchEvent)) { const tapGestureState = this._touchEventToTapGestureState(touchEvent); if (!this.props.onDoubleTap) { // If there is no double-tap handler, we can invoke the tap handler immediately. this._sendTapEvent(tapGestureState); } else if (this._isDoubleTap(tapGestureState)) { // This is a double-tap, so swallow the previous single tap. this._cancelDoubleTapTimer(); this._sendDoubleTapEvent(tapGestureState); } else { // This wasn't a double-tap. Report any previous single tap and start the double-tap // timer so we can determine whether the current tap is a single or double. this._reportDelayedTap(); this._startDoubleTapTimer(tapGestureState); } } else { this._reportDelayedTap(); this._cancelDoubleTapTimer(); } } // Get preferred pan ratio for platform. protected abstract _getPreferredPanRatio(): number; // Returns the timestamp for the touch event in milliseconds. protected abstract _getEventTimestamp(e: TouchEventBasic | Types.MouseEvent): number; protected _skipNextTap() { this._shouldSkipNextTap = true; } private _setPendingGestureState(gestureState: Types.MultiTouchGestureState | Types.PanGestureState | Types.TapGestureState) { this._reportDelayedTap(); this._cancelDoubleTapTimer(); this._cancelLongPressTimer(); this._pendingGestureState = gestureState; } private _detectMoveGesture(e: TouchEventBasic, gestureState: GestureStatePoint): GestureType { if (this._shouldRespondToPinchZoom(e) || this._shouldRespondToRotate(e)) { return GestureType.MultiTouch; } else if (this._shouldRespondToPan(gestureState)) { return GestureType.Pan; } else if (this._shouldRespondToPanVertical(gestureState)) { return GestureType.PanVertical; } else if (this._shouldRespondToPanHorizontal(gestureState)) { return GestureType.PanHorizontal; } return GestureType.None; } // Determines whether a touch event constitutes a tap. The "finger up" // event must be within a certain distance and within a certain time // from where the "finger down" event occurred. private _isTap(e: TouchEventBasic): boolean { if (!this._lastGestureStartEvent) { return false; } const initialTimeStamp = this._getEventTimestamp(this._lastGestureStartEvent); const initialPageX = this._lastGestureStartEvent.pageX!; const initialPageY = this._lastGestureStartEvent.pageY!; const timeStamp = this._getEventTimestamp(e); return (timeStamp - initialTimeStamp <= _tapDurationThreshold && this._calcDistance(initialPageX - e.pageX!, initialPageY - e.pageY!) <= _tapPixelThreshold); } // This method assumes that the caller has already determined that two // taps have been detected in a row with no intervening gestures. It // is responsible for determining if they occurred within close proximity // and within a certain threshold of time. protected _isDoubleTap(e: Types.TapGestureState) { if (!this._lastTapEvent) { return false; } return (e.timeStamp - this._lastTapEvent.timeStamp <= _doubleTapDurationThreshold && this._calcDistance(this._lastTapEvent.pageX - e.pageX, this._lastTapEvent.pageY - e.pageY) <= _doubleTapPixelThreshold); } // Starts a timer that reports a previous tap if it's not canceled by a subsequent gesture. protected _startDoubleTapTimer(e: Types.TapGestureState) { this._lastTapEvent = e; this._doubleTapTimer = Timers.setTimeout(() => { this._reportDelayedTap(); this._doubleTapTimer = undefined; }, _doubleTapDurationThreshold); } // Cancels any pending double-tap timer. protected _cancelDoubleTapTimer() { if (this._doubleTapTimer) { Timers.clearTimeout(this._doubleTapTimer); this._doubleTapTimer = undefined; } } protected _startLongPressTimer(gsState: Types.TapGestureState, isDefinitelyMouse = false) { if (this._pendingLongPressEvent) { return; } this._pendingLongPressEvent = gsState; this._longPressTimer = Timers.setTimeout(() => { this._reportLongPress(); this._longPressTimer = undefined; }, _longPressDurationThreshold); } private _reportLongPress() { if (!this._pendingLongPressEvent) { return; } if (this.props.onLongPress) { this.props.onLongPress(this._pendingLongPressEvent); } this._pendingLongPressEvent = undefined; } protected _cancelLongPressTimer() { if (this._longPressTimer) { Timers.clearTimeout(this._longPressTimer); this._longPressTimer = undefined; } this._pendingLongPressEvent = undefined; } // If there was a previous tap recorded but we haven't yet reported it because we were // waiting for a potential second tap, report it now. protected _reportDelayedTap() { if (this._lastTapEvent && this.props.onTap) { this._sendTapEvent(this._lastTapEvent); this._lastTapEvent = undefined; } } protected _clearLastTap() { this._lastTapEvent = undefined; } private static _isActuallyMouseEvent(e: TouchEventBasic | undefined): boolean { if (!e) { return false; } const nativeEvent = e as any; if (nativeEvent.button !== undefined) { return true; } else if (nativeEvent.isRightButton || nativeEvent.IsRightButton) { return true; } else if (nativeEvent.isMiddleButton || nativeEvent.IsMiddleButton) { return true; } return false; } private _shouldRespondToPinchZoom(e: TouchEventBasic) { if (!this.props.onPinchZoom) { return false; } // Do we see two touches? if (!e.touches || e.touches.length !== 2) { return false; } // Has the user started to pinch or zoom? if (this._calcDistance(e.touches[0].pageX - e.touches[1].pageX, e.touches[0].pageY - e.touches[1].pageY) >= _pinchZoomPixelThreshold) { return true; } return false; } private _shouldRespondToRotate(e: TouchEventBasic) { if (!this.props.onRotate) { return false; } // Do we see two touches? if (!e.touches || e.touches.length !== 2) { return false; } return true; } protected _shouldRespondToPan(gestureState: GestureStatePoint) { if (!this.props.onPan) { return false; } // Has the user started to pan? const panThreshold = (this.props.panPixelThreshold !== undefined && this.props.panPixelThreshold > 0) ? this.props.panPixelThreshold : _panPixelThreshold; return (this._calcDistance(gestureState.dx, gestureState.dy) >= panThreshold); } protected _shouldRespondToPanVertical(gestureState: GestureStatePoint) { if (!this.props.onPanVertical) { return false; } // Has the user started to pan? const panThreshold = (this.props.panPixelThreshold !== undefined && this.props.panPixelThreshold > 0) ? this.props.panPixelThreshold : _panPixelThreshold; const isPan = Math.abs(gestureState.dy) >= panThreshold; if (isPan && this.props.preferredPan === Types.PreferredPanGesture.Horizontal) { return Math.abs(gestureState.dy) > Math.abs(gestureState.dx * this._getPreferredPanRatio()); } return isPan; } protected _shouldRespondToPanHorizontal(gestureState: GestureStatePoint) { if (!this.props.onPanHorizontal) { return false; } // Has the user started to pan? const panThreshold = (this.props.panPixelThreshold !== undefined && this.props.panPixelThreshold > 0) ? this.props.panPixelThreshold : _panPixelThreshold; const isPan = Math.abs(gestureState.dx) >= panThreshold; if (isPan && this.props.preferredPan === Types.PreferredPanGesture.Vertical) { return Math.abs(gestureState.dx) > Math.abs(gestureState.dy * this._getPreferredPanRatio()); } return isPan; } private _calcDistance(dx: number, dy: number) { return Math.sqrt(dx * dx + dy * dy); } private _calcAngle(touches: TouchListBasic): number { const a = touches[0]; const b = touches[1]; let degrees = this._radiansToDegrees(Math.atan2(b.pageY - a.pageY, b.pageX - a.pageX)); if (degrees < 0) { degrees += 360; } return degrees; } private _radiansToDegrees(rad: number): number { return rad * 180 / Math.PI; } private _sendMultiTouchEvents(e: TouchEventBasic, gestureState: GestureStatePointVelocity, initializeFromEvent: boolean, isComplete: boolean) { const p = this._pendingGestureState as Types.MultiTouchGestureState; let multiTouchEvent: Types.MultiTouchGestureState; // If the user lifted up one or both fingers, the multitouch gesture // is halted. Just return the existing gesture state. if (!e.touches || e.touches.length !== 2) { multiTouchEvent = p; p.isComplete = isComplete; } else { const centerPageX = (e.touches[0].pageX + e.touches[1].pageX) / 2; const centerPageY = (e.touches[0].pageY + e.touches[1].pageY) / 2; const centerClientX = (e.touches[0].locationX + e.touches[1].locationX) / 2; const centerClientY = (e.touches[0].locationY + e.touches[1].locationY) / 2; const width = Math.abs(e.touches[0].pageX - e.touches[1].pageX); const height = Math.abs(e.touches[0].pageY - e.touches[1].pageY); const distance = this._calcDistance(width, height); const angle = this._calcAngle(e.touches); const initialCenterPageX = initializeFromEvent ? centerPageX : p.initialCenterPageX; const initialCenterPageY = initializeFromEvent ? centerPageY : p.initialCenterPageY; const initialCenterClientX = initializeFromEvent ? centerClientX : p.initialCenterClientX; const initialCenterClientY = initializeFromEvent ? centerClientY : p.initialCenterClientY; const initialWidth = initializeFromEvent ? width : p.initialWidth; const initialHeight = initializeFromEvent ? height : p.initialHeight; const initialDistance = initializeFromEvent ? distance : p.initialDistance; const initialAngle = initializeFromEvent ? angle : p.initialAngle; const velocityX = initializeFromEvent ? 0 : gestureState.vx; const velocityY = initializeFromEvent ? 0 : gestureState.vy; multiTouchEvent = { initialCenterPageX: initialCenterPageX, initialCenterPageY: initialCenterPageY, initialCenterClientX: initialCenterClientX, initialCenterClientY: initialCenterClientY, initialWidth: initialWidth, initialHeight: initialHeight, initialDistance: initialDistance, initialAngle: initialAngle, centerPageX: centerPageX, centerPageY: centerPageY, centerClientX: centerClientX, centerClientY: centerClientY, velocityX: velocityX, velocityY: velocityY, width: width, height: height, distance: distance, angle: angle, isComplete: isComplete, timeStamp: e.timeStamp, isTouch: !GestureView._isActuallyMouseEvent(e) }; } if (this.props.onPinchZoom) { this.props.onPinchZoom(multiTouchEvent); } if (this.props.onRotate) { this.props.onRotate(multiTouchEvent); } return multiTouchEvent; } protected _touchEventToTapGestureState(e: TouchEventBasic): Types.TapGestureState { let pageX = e.pageX!; let pageY = e.pageY!; let clientX = e.locationX!; let clientY = e.locationY!; // Grab the first touch. If the user adds additional touch events, // we will ignore them. If we use e.pageX/Y, we will be using the average // of the touches, so we'll see a discontinuity. if (e.touches && e.touches.length > 0) { pageX = e.touches[0].pageX; pageY = e.touches[0].pageY; clientX = e.touches[0].locationX; clientY = e.touches[0].locationY; } return { timeStamp: this._getEventTimestamp(e), clientX, clientY, pageX, pageY, isTouch: !GestureView._isActuallyMouseEvent(e) }; } protected _mouseEventToTapGestureState(e: Types.MouseEvent): Types.TapGestureState { const xyOffset = this._getClientXYOffset(); return { timeStamp: this._getEventTimestamp(e), clientX: e.clientX - xyOffset.x, clientY: e.clientY - xyOffset.y, pageX: e.pageX || 0, pageY: e.pageY || 0, isTouch: false }; } protected _getClientXYOffset(): { x: number; y: number } { return { x: 0, y: 0 }; } private _sendPanEvent(e: Types.TapGestureState, gestureState: GestureStatePointVelocity, gestureType: GestureType, initializeFromEvent: boolean, isComplete: boolean) { const state = this._pendingGestureState as Types.PanGestureState; assert(this._lastGestureStartEvent, 'Gesture start event must not be null.'); const initialPageX = this._lastGestureStartEvent ? this._lastGestureStartEvent.pageX! : initializeFromEvent ? e.pageX : state.initialPageX; const initialPageY = this._lastGestureStartEvent ? this._lastGestureStartEvent.pageY! : initializeFromEvent ? e.pageY : state.initialPageY; const initialClientX = this._lastGestureStartEvent ? this._lastGestureStartEvent.locationX! : initializeFromEvent ? e.clientX : state.initialClientX; const initialClientY = this._lastGestureStartEvent ? this._lastGestureStartEvent.locationY! : initializeFromEvent ? e.clientY : state.initialClientY; const velocityX = initializeFromEvent ? 0 : gestureState.vx; const velocityY = initializeFromEvent ? 0 : gestureState.vy; const panEvent: Types.PanGestureState = { initialPageX: initialPageX, initialPageY: initialPageY, initialClientX: initialClientX, initialClientY: initialClientY, pageX: e.pageX, pageY: e.pageY, clientX: e.clientX, clientY: e.clientY, velocityX: velocityX, velocityY: velocityY, isComplete: isComplete, timeStamp: e.timeStamp, isTouch: !GestureView._isActuallyMouseEvent(this._lastGestureStartEvent) }; switch (gestureType) { case GestureType.Pan: if (this.props.onPan) { this.props.onPan(panEvent); } break; case GestureType.PanVertical: if (this.props.onPanVertical) { this.props.onPanVertical(panEvent); } break; case GestureType.PanHorizontal: if (this.props.onPanHorizontal) { this.props.onPanHorizontal(panEvent); } break; default: // do nothing; } return panEvent; } private static _toMouseButton(nativeEvent: any): number { if (nativeEvent.button !== undefined) { return nativeEvent.button; } else if (nativeEvent.isRightButton || nativeEvent.IsRightButton) { return 2; } else if (nativeEvent.isMiddleButton || nativeEvent.IsMiddleButton) { return 1; } return 0; } // Protected only as a hack for supporting keyboard nav clicking from native-common/GestureView protected _sendTapEvent = (tapEvent: Types.TapGestureState) => { // we need to skip tap after succesfull pan event // mouse up would otherwise trigger both pan & tap if (this._shouldSkipNextTap) { this._shouldSkipNextTap = false; return; } const button = GestureView._toMouseButton(tapEvent); if (button === 2) { // Always handle secondary button, even if context menu is not set - it shouldn't trigger onTap. if (this.props.onContextMenu) { this.props.onContextMenu(tapEvent); } } else if (this.props.onTap) { this.props.onTap(tapEvent); } } protected _sendDoubleTapEvent(e: Types.TapGestureState) { // If user did a double click with different mouse buttons, eg. left (50ms) right // both clicks need to be registered as separate events. const lastButton = GestureView._toMouseButton(this._lastTapEvent!); const button = GestureView._toMouseButton(e); if (lastButton !== button || button === 2) { this._sendTapEvent(this._lastTapEvent!); return; } if (this.props.onDoubleTap) { this.props.onDoubleTap(e); } this._lastTapEvent = undefined; } } export default GestureView;