diff --git a/packages/react-native-web/src/exports/PanResponder/Alternative.js b/packages/react-native-web/src/exports/PanResponder/Alternative.js new file mode 100644 index 00000000..0507abf9 --- /dev/null +++ b/packages/react-native-web/src/exports/PanResponder/Alternative.js @@ -0,0 +1,362 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * PAN RESPONDER + * + * `PanResponder` uses the Responder System to reconcile several touches into + * a single gesture. It makes single-touch gestures resilient to extra touches, + * and can be used to recognize simple multi-touch gestures. For each handler, + * it provides a `gestureState` object alongside the ResponderEvent object. + * + * By default, `PanResponder` holds an `InteractionManager` handle to block + * long-running JS events from interrupting active gestures. + * + * A graphical explanation of the touch data flow: + * + * +----------------------------+ +--------------------------------+ + * | ResponderTouchHistoryStore | |TouchHistoryMath | + * +----------------------------+ +----------+---------------------+ + * |Global store of touchHistory| |Allocation-less math util | + * |including activeness, start | |on touch history (centroids | + * |position, prev/cur position.| |and multitouch movement etc) | + * | | | | + * +----^-----------------------+ +----^---------------------------+ + * | | + * | (records relevant history | + * | of touches relevant for | + * | implementing higher level | + * | gestures) | + * | | + * +----+-----------------------+ +----|---------------------------+ + * | ResponderEventPlugin | | | Your App/Component | + * +----------------------------+ +----|---------------------------+ + * |Negotiates which view gets | Low level | | High level | + * |onResponderMove events. | events w/ | +-+-------+ events w/ | + * |Also records history into | touchHistory| | Pan | multitouch + | + * |ResponderTouchHistoryStore. +---------------->Responder+-----> accumulative| + * +----------------------------+ attached to | | | distance and | + * each event | +---------+ velocity. | + * | | + * | | + * +--------------------------------+ + */ + +'use strict'; + +import type { PressEvent } from '../../vendor/react-native/Types/CoreEventTypes'; + +import InteractionManager from '../InteractionManager'; +import TouchHistoryMath from '../../vendor/react-native/TouchHistoryMath'; + +export type GestureState = {| + // ID of the gestureState; persisted as long as there's a pointer on screen + stateID: number, + // The latest screen coordinates of the gesture + x: number, + // The latest screen coordinates of the gesture + y: number, + // The screen coordinates of the responder grant + initialX: number, + // The screen coordinates of the responder grant + initialY: number, + // Accumulated distance of the gesture since it started + deltaX: number, + // Accumulated distance of the gesture since it started + deltaY: number, + // Current velocity of the gesture + velocityX: number, + // Current velocity of the gesture + velocityY: number, + // Number of touches currently on screen + numberActiveTouches: number, + _accountsForMovesUpTo: number +|}; + +type ActiveCallback = (event: PressEvent, gestureState: GestureState) => boolean; +type PassiveCallback = (event: PressEvent, gestureState: GestureState) => void; + +type PanResponderConfig = $ReadOnly<{| + // Negotiate for the responder + onMoveShouldSetResponder?: ?ActiveCallback, + onMoveShouldSetResponderCapture?: ?ActiveCallback, + onStartShouldSetResponder?: ?ActiveCallback, + onStartShouldSetResponderCapture?: ?ActiveCallback, + onPanTerminationRequest?: ?ActiveCallback, + // Gesture started + onPanGrant?: ?PassiveCallback, + // Gesture rejected + onPanReject?: ?PassiveCallback, + // A pointer touched the screen + onPanStart?: ?PassiveCallback, + // A pointer moved + onPanMove?: ?PassiveCallback, + // A pointer was removed from the screen + onPanEnd?: ?PassiveCallback, + // All pointers removed, gesture successful + onPanRelease?: ?PassiveCallback, + // Gesture cancelled + onPanTerminate?: ?PassiveCallback +|}>; + +const { + currentCentroidX, + currentCentroidY, + currentCentroidXOfTouchesChangedAfter, + currentCentroidYOfTouchesChangedAfter, + previousCentroidXOfTouchesChangedAfter, + previousCentroidYOfTouchesChangedAfter +} = TouchHistoryMath; + +const PanResponder = { + _initializeGestureState(gestureState: GestureState) { + gestureState.x = 0; + gestureState.y = 0; + gestureState.initialX = 0; + gestureState.initialY = 0; + gestureState.deltaX = 0; + gestureState.deltaY = 0; + gestureState.velocityX = 0; + gestureState.velocityY = 0; + gestureState.numberActiveTouches = 0; + // All `gestureState` accounts for timeStamps up until: + gestureState._accountsForMovesUpTo = 0; + }, + + /** + * Take all recently moved touches, calculate how the centroid has changed just for those + * recently moved touches, and append that change to an accumulator. This is + * to (at least) handle the case where the user is moving three fingers, and + * then one of the fingers stops but the other two continue. + * + * This is very different than taking all of the recently moved touches and + * storing their centroid as `dx/dy`. For correctness, we must *accumulate + * changes* in the centroid of recently moved touches. + * + * There is also some nuance with how we handle multiple moved touches in a + * single event. Multiple touches generate two 'move' events, each of + * them triggering `onResponderMove`. But with the way `PanResponder` works, + * all of the gesture inference is performed on the first dispatch, since it + * looks at all of the touches. Therefore, `PanResponder` does not call + * `onResponderMove` passed the first dispatch. This diverges from the + * typical responder callback pattern (without using `PanResponder`), but + * avoids more dispatches than necessary. + * + * When moving two touches in opposite directions, the cumulative + * distance is zero in each dimension. When two touches move in parallel five + * pixels in the same direction, the cumulative distance is five, not ten. If + * two touches start, one moves five in a direction, then stops and the other + * touch moves fives in the same direction, the cumulative distance is ten. + * + * This logic requires a kind of processing of time "clusters" of touch events + * so that two touch moves that essentially occur in parallel but move every + * other frame respectively, are considered part of the same movement. + * + * x/y: If a move event has been observed, `(x, y)` is the centroid of the most + * recently moved "cluster" of active touches. + * deltaX/deltaY: Cumulative touch distance. Accounts for touch moves that are + * clustered together in time, moving the same direction. Only valid when + * currently responder (otherwise, it only represents the drag distance below + * the threshold). + */ + _updateGestureStateOnMove( + gestureState: GestureState, + touchHistory: $PropertyType + ) { + const movedAfter = gestureState._accountsForMovesUpTo; + const prevX = previousCentroidXOfTouchesChangedAfter(touchHistory, movedAfter); + const prevY = previousCentroidYOfTouchesChangedAfter(touchHistory, movedAfter); + const prevDeltaX = gestureState.deltaX; + const prevDeltaY = gestureState.deltaY; + + const x = currentCentroidXOfTouchesChangedAfter(touchHistory, movedAfter); + const y = currentCentroidYOfTouchesChangedAfter(touchHistory, movedAfter); + const deltaX = prevDeltaX + (x - prevX); + const deltaY = prevDeltaY + (y - prevY); + // TODO: This must be filtered intelligently. + const dt = touchHistory.mostRecentTimeStamp - gestureState._accountsForMovesUpTo; + + gestureState.deltaX = deltaX; + gestureState.deltaY = deltaY; + gestureState.numberActiveTouches = touchHistory.numberActiveTouches; + gestureState.velocityX = (deltaX - prevDeltaX) / dt; + gestureState.velocityY = (deltaY - prevDeltaY) / dt; + gestureState.x = x; + gestureState.y = y; + gestureState._accountsForMovesUpTo = touchHistory.mostRecentTimeStamp; + }, + + /** + * Enhanced versions of all of the responder callbacks that provide not only + * the `ResponderEvent`, but also the `PanResponder` gesture state. + * + * In general, for events that have capture equivalents, we update the + * gestureState once in the capture phase and can use it in the bubble phase + * as well. + */ + create(config: PanResponderConfig) { + const interactionState = { + handle: (null: ?number) + }; + const gestureState: GestureState = { + // Useful for debugging + stateID: Math.random(), + x: 0, + y: 0, + initialX: 0, + initialY: 0, + deltaX: 0, + deltaY: 0, + velocityX: 0, + velocityY: 0, + numberActiveTouches: 0, + _accountsForMovesUpTo: 0 + }; + + const { + onStartShouldSetResponder, + onStartShouldSetResponderCapture, + onMoveShouldSetResponder, + onMoveShouldSetResponderCapture, + onPanGrant, + onPanStart, + onPanMove, + onPanEnd, + onPanRelease, + onPanReject, + onPanTerminate, + onPanTerminationRequest + } = config; + + const panHandlers = { + onStartShouldSetResponder(event: PressEvent): boolean { + return onStartShouldSetResponder != null + ? onStartShouldSetResponder(event, gestureState) + : false; + }, + onMoveShouldSetResponder(event: PressEvent): boolean { + return onMoveShouldSetResponder != null + ? onMoveShouldSetResponder(event, gestureState) + : false; + }, + onStartShouldSetResponderCapture(event: PressEvent): boolean { + // TODO: Actually, we should reinitialize the state any time + // touches.length increases from 0 active to > 0 active. + if (event.nativeEvent.touches.length === 1) { + PanResponder._initializeGestureState(gestureState); + } + gestureState.numberActiveTouches = event.touchHistory.numberActiveTouches; + return onStartShouldSetResponderCapture != null + ? onStartShouldSetResponderCapture(event, gestureState) + : false; + }, + + onMoveShouldSetResponderCapture(event: PressEvent): boolean { + const touchHistory = event.touchHistory; + // Responder system incorrectly dispatches should* to current responder + // Filter out any touch moves past the first one - we would have + // already processed multi-touch geometry during the first event. + // NOTE: commented out because new responder system should get it right. + //if (gestureState._accountsForMovesUpTo === touchHistory.mostRecentTimeStamp) { + // return false; + //} + PanResponder._updateGestureStateOnMove(gestureState, touchHistory); + return onMoveShouldSetResponderCapture != null + ? onMoveShouldSetResponderCapture(event, gestureState) + : false; + }, + + onResponderGrant(event: PressEvent): void { + if (!interactionState.handle) { + interactionState.handle = InteractionManager.createInteractionHandle(); + } + gestureState.initialX = currentCentroidX(event.touchHistory); + gestureState.initialY = currentCentroidY(event.touchHistory); + gestureState.deltaX = 0; + gestureState.deltaY = 0; + if (onPanGrant != null) { + onPanGrant(event, gestureState); + } + }, + + onResponderReject(event: PressEvent): void { + clearInteractionHandle(interactionState, onPanReject, event, gestureState); + }, + + onResponderStart(event: PressEvent): void { + const { numberActiveTouches } = event.touchHistory; + gestureState.numberActiveTouches = numberActiveTouches; + if (onPanStart != null) { + onPanStart(event, gestureState); + } + }, + + onResponderMove(event: PressEvent): void { + const touchHistory = event.touchHistory; + // Guard against the dispatch of two touch moves when there are two + // simultaneously changed touches. + if (gestureState._accountsForMovesUpTo === touchHistory.mostRecentTimeStamp) { + return; + } + // Filter out any touch moves past the first one - we would have + // already processed multi-touch geometry during the first event. + PanResponder._updateGestureStateOnMove(gestureState, touchHistory); + if (onPanMove != null) { + onPanMove(event, gestureState); + } + }, + + onResponderEnd(event: PressEvent): void { + const { numberActiveTouches } = event.touchHistory; + gestureState.numberActiveTouches = numberActiveTouches; + clearInteractionHandle(interactionState, onPanEnd, event, gestureState); + }, + + onResponderRelease(event: PressEvent): void { + clearInteractionHandle(interactionState, onPanRelease, event, gestureState); + PanResponder._initializeGestureState(gestureState); + }, + + onResponderTerminate(event: PressEvent): void { + clearInteractionHandle(interactionState, onPanTerminate, event, gestureState); + PanResponder._initializeGestureState(gestureState); + }, + + onResponderTerminationRequest(event: PressEvent): boolean { + return onPanTerminationRequest != null + ? onPanTerminationRequest(event, gestureState) + : true; + } + }; + return { + panHandlers, + getInteractionHandle(): ?number { + return interactionState.handle; + } + }; + } +}; + +function clearInteractionHandle( + interactionState: { handle: ?number }, + callback: ?(ActiveCallback | PassiveCallback), + event: PressEvent, + gestureState: GestureState +) { + if (interactionState.handle) { + InteractionManager.clearInteractionHandle(interactionState.handle); + interactionState.handle = null; + } + if (callback) { + callback(event, gestureState); + } +} + +export default PanResponder;