diff --git a/packages/docs/src/components/Pressable/Pressable.stories.mdx b/packages/docs/src/components/Pressable/Pressable.stories.mdx
index 5ac540e8..e88b573c 100644
--- a/packages/docs/src/components/Pressable/Pressable.stories.mdx
+++ b/packages/docs/src/components/Pressable/Pressable.stories.mdx
@@ -64,3 +64,9 @@ Called when the pointer is released, but not if cancelled (e.g. by a scroll that
+
+
+
+
+
+
diff --git a/packages/docs/src/components/Pressable/examples/FeedbackEvents.js b/packages/docs/src/components/Pressable/examples/FeedbackEvents.js
index 883b726b..0e1714f6 100644
--- a/packages/docs/src/components/Pressable/examples/FeedbackEvents.js
+++ b/packages/docs/src/components/Pressable/examples/FeedbackEvents.js
@@ -56,13 +56,12 @@ export default function FeedbackEvents() {
}}
>
{
- console.log(focused);
let backgroundColor = 'white';
if (hovered) {
backgroundColor = 'lightgray';
diff --git a/packages/docs/src/components/Pressable/examples/PanAndPress.js b/packages/docs/src/components/Pressable/examples/PanAndPress.js
new file mode 100644
index 00000000..1b979104
--- /dev/null
+++ b/packages/docs/src/components/Pressable/examples/PanAndPress.js
@@ -0,0 +1,100 @@
+import React, { useRef, useState } from 'react';
+import { Animated, View, StyleSheet, PanResponder, Text, TouchableOpacity } from 'react-native';
+
+const App = () => {
+ const pan = useRef(new Animated.ValueXY()).current;
+ const [x, setX] = useState(0);
+
+ const panResponder = useRef(null);
+ if (panResponder.current == null) {
+ panResponder.current = PanResponder.create({
+ onMoveShouldSetPanResponder: () => true,
+ onPanResponderGrant: e => {
+ console.log('pan grant');
+ pan.setOffset({
+ x: pan.x._value,
+ y: pan.y._value
+ });
+ },
+ onPanResponderMove: Animated.event([null, { dx: pan.x, dy: pan.y }]),
+ onPanResponderRelease: () => {
+ console.log('pan release');
+ pan.flattenOffset();
+ },
+ onPanResponderTerminate() {
+ console.log('pan terminate');
+ pan.flattenOffset();
+ }
+ });
+ }
+
+ return (
+
+ Pressed: {x}
+
+
+ setX(x + 1)} style={styles.outerTouchable}>
+ {
+ console.log('press inner');
+ }}
+ style={styles.innerTouchable}
+ />
+
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ userSelect: 'none'
+ },
+ titleText: {
+ fontSize: 14,
+ lineHeight: 24,
+ fontWeight: 'bold'
+ },
+ box: {
+ height: 200,
+ width: 150,
+ backgroundColor: 'lightblue',
+ borderRadius: 5
+ },
+ outerTouchable: {
+ height: 150,
+ width: 100,
+ margin: 25,
+ backgroundColor: 'blue',
+ borderRadius: 5,
+ justifyContent: 'center'
+ },
+ innerTouchable: {
+ height: 20,
+ flex: 1,
+ marginVertical: 10,
+ marginHorizontal: 20,
+ backgroundColor: 'green',
+ borderRadius: 5
+ },
+ disabledButton: {
+ backgroundColor: 'red'
+ }
+});
+
+export default App;
diff --git a/packages/docs/src/components/Pressable/examples/index.js b/packages/docs/src/components/Pressable/examples/index.js
index 5e41fe04..1b523aff 100644
--- a/packages/docs/src/components/Pressable/examples/index.js
+++ b/packages/docs/src/components/Pressable/examples/index.js
@@ -1,3 +1,4 @@
export { default as delayEvents } from './DelayEvents';
export { default as disabled } from './Disabled';
export { default as feedbackEvents } from './FeedbackEvents';
+export { default as panAndPress } from './PanAndPress';
diff --git a/packages/react-native-web/src/modules/useResponderEvents/ResponderSystem.js b/packages/react-native-web/src/modules/useResponderEvents/ResponderSystem.js
index ffcfc90b..258fd3c2 100644
--- a/packages/react-native-web/src/modules/useResponderEvents/ResponderSystem.js
+++ b/packages/react-native-web/src/modules/useResponderEvents/ResponderSystem.js
@@ -374,12 +374,14 @@ function eventListener(domEvent: any) {
// Start
if (isStartEvent) {
if (onResponderStart != null) {
+ responderEvent.dispatchConfig.registrationName = 'onResponderStart';
onResponderStart(responderEvent);
}
}
// Move
else if (isMoveEvent) {
if (onResponderMove != null) {
+ responderEvent.dispatchConfig.registrationName = 'onResponderMove';
onResponderMove(responderEvent);
}
} else {
@@ -404,12 +406,14 @@ function eventListener(domEvent: any) {
// End
if (isEndEvent) {
if (onResponderEnd != null) {
+ responderEvent.dispatchConfig.registrationName = 'onResponderEnd';
onResponderEnd(responderEvent);
}
}
// Release
if (isReleaseEvent) {
if (onResponderRelease != null) {
+ responderEvent.dispatchConfig.registrationName = 'onResponderRelease';
onResponderRelease(responderEvent);
}
changeCurrentResponder(emptyResponder);
@@ -424,18 +428,20 @@ function eventListener(domEvent: any) {
eventType === 'scroll' ||
eventType === 'selectionchange'
) {
- if (
- wasNegotiated ||
- // Only call this function is it wasn't already called during negotiation.
- (onResponderTerminationRequest != null &&
- onResponderTerminationRequest(responderEvent) === false)
- ) {
+ // Only call this function is it wasn't already called during negotiation.
+ if (wasNegotiated) {
shouldTerminate = false;
+ } else if (onResponderTerminationRequest != null) {
+ responderEvent.dispatchConfig.registrationName = 'onResponderTerminationRequest';
+ if (onResponderTerminationRequest(responderEvent) === false) {
+ shouldTerminate = false;
+ }
}
}
if (shouldTerminate) {
if (onResponderTerminate != null) {
+ responderEvent.dispatchConfig.registrationName = 'onResponderTerminate';
onResponderTerminate(responderEvent);
}
changeCurrentResponder(emptyResponder);
@@ -466,8 +472,11 @@ function findWantsResponder(eventPaths, domEvent, responderEvent) {
const config = getResponderConfig(id);
const shouldSetCallback = config[callbackName];
if (shouldSetCallback != null) {
+ responderEvent.currentTarget = node;
if (shouldSetCallback(responderEvent) === true) {
- return { id, node, idPath };
+ // Start the path from the potential responder
+ const prunedIdPath = idPath.slice(idPath.indexOf(id));
+ return { id, node, idPath: prunedIdPath };
}
}
};
@@ -521,6 +530,7 @@ function attemptTransfer(responderEvent: ResponderEvent, wantsResponder: ActiveR
responderEvent.bubbles = false;
responderEvent.cancelable = false;
responderEvent.currentTarget = node;
+
// Set responder
if (currentId == null) {
if (onResponderGrant != null) {
@@ -533,22 +543,35 @@ function attemptTransfer(responderEvent: ResponderEvent, wantsResponder: ActiveR
// Negotiate with current responder
else {
const { onResponderTerminate, onResponderTerminationRequest } = getResponderConfig(currentId);
- const allowTransfer =
- onResponderTerminationRequest != null && onResponderTerminationRequest(responderEvent);
+
+ let allowTransfer = true;
+ if (onResponderTerminationRequest != null) {
+ responderEvent.currentTarget = currentNode;
+ responderEvent.dispatchConfig.registrationName = 'onResponderTerminationRequest';
+ if (onResponderTerminationRequest(responderEvent) === false) {
+ allowTransfer = false;
+ }
+ }
+
if (allowTransfer) {
// Terminate existing responder
if (onResponderTerminate != null) {
responderEvent.currentTarget = currentNode;
+ responderEvent.dispatchConfig.registrationName = 'onResponderTerminate';
onResponderTerminate(responderEvent);
}
// Grant next responder
if (onResponderGrant != null) {
+ responderEvent.currentTarget = node;
+ responderEvent.dispatchConfig.registrationName = 'onResponderGrant';
onResponderGrant(responderEvent);
}
changeCurrentResponder(wantsResponder);
} else {
// Reject responder request
if (onResponderReject != null) {
+ responderEvent.currentTarget = node;
+ responderEvent.dispatchConfig.registrationName = 'onResponderReject';
onResponderReject(responderEvent);
}
}
diff --git a/packages/react-native-web/src/modules/useResponderEvents/__tests__/index-test.js b/packages/react-native-web/src/modules/useResponderEvents/__tests__/index-test.js
index e9c5fb05..7d652c54 100644
--- a/packages/react-native-web/src/modules/useResponderEvents/__tests__/index-test.js
+++ b/packages/react-native-web/src/modules/useResponderEvents/__tests__/index-test.js
@@ -205,10 +205,9 @@ describe('useResponderEvents', () => {
});
testWithPointerType('start grants responder to grandParent', pointerType => {
- let grantCurrentTarget, shouldSetCurrentTarget;
+ let grantCurrentTarget;
const grandParentCallbacks = {
onStartShouldSetResponderCapture: jest.fn(e => {
- shouldSetCurrentTarget = e.currentTarget;
return true;
}),
onResponderGrant: jest.fn(e => {
@@ -246,7 +245,6 @@ describe('useResponderEvents', () => {
});
// responder set (capture phase)
expect(grandParentCallbacks.onStartShouldSetResponderCapture).toBeCalledTimes(1);
- expect(shouldSetCurrentTarget).toBe(null);
expect(parentCallbacks.onStartShouldSetResponderCapture).not.toBeCalled();
expect(targetCallbacks.onStartShouldSetResponderCapture).not.toBeCalled();
// responder grant
@@ -1637,11 +1635,185 @@ describe('useResponderEvents', () => {
});
/**
- * When there is an active responder, negotiation captures to and bubbles from
- * the ancestor registered with the system. The responder is transferred and
- * the relevant termination events are called.
+ * When there is an active responder, negotiation of the active pointer captures to
+ * and bubbles from the closest common ancestor registered with the system. The
+ * responder is transferred and maintained for subsequent events of the same type.
*/
- test('negotiates from first registered ancestor of responder and transfers', () => {
+ test('negotiates single-touch from first registered ancestor of responder and transfers', () => {
+ const pointerType = 'touch';
+ const eventLog = [];
+ const grandParentCallbacks = {
+ onStartShouldSetResponderCapture() {
+ eventLog.push('grandParent: onStartShouldSetResponderCapture');
+ return false;
+ },
+ onStartShouldSetResponder() {
+ eventLog.push('grandParent: onStartShouldSetResponder');
+ return false;
+ },
+ onMoveShouldSetResponderCapture() {
+ eventLog.push('grandParent: onMoveShouldSetResponderCapture');
+ return false;
+ },
+ onMoveShouldSetResponder() {
+ eventLog.push('grandParent: onMoveShouldSetResponder');
+ return true;
+ },
+ onResponderGrant() {
+ eventLog.push('grandParent: onResponderGrant');
+ },
+ onResponderStart() {
+ eventLog.push('grandParent: onResponderStart');
+ },
+ onResponderMove() {
+ eventLog.push('grandParent: onResponderMove');
+ },
+ onResponderEnd() {
+ eventLog.push('grandParent: onResponderEnd');
+ },
+ onResponderRelease() {
+ eventLog.push('grandParent: onResponderRelease');
+ },
+ onResponderTerminate() {
+ eventLog.push('grandParent: onResponderTerminate');
+ },
+ onResponderTerminationRequest() {
+ eventLog.push('grandParent: onResponderTerminationRequest');
+ return true;
+ }
+ };
+ const parentCallbacks = {
+ onStartShouldSetResponderCapture() {
+ eventLog.push('parent: onStartShouldSetResponderCapture');
+ return false;
+ },
+ onStartShouldSetResponder() {
+ eventLog.push('parent: onStartShouldSetResponder');
+ return false;
+ },
+ onMoveShouldSetResponderCapture() {
+ eventLog.push('parent: onMoveShouldSetResponderCapture');
+ return false;
+ },
+ onMoveShouldSetResponder() {
+ eventLog.push('parent: onMoveShouldSetResponder');
+ return false;
+ },
+ onResponderGrant() {
+ eventLog.push('parent: onResponderGrant');
+ },
+ onResponderStart() {
+ eventLog.push('parent: onResponderStart');
+ },
+ onResponderMove() {
+ eventLog.push('parent: onResponderMove');
+ },
+ onResponderEnd() {
+ eventLog.push('parent: onResponderEnd');
+ },
+ onResponderRelease() {
+ eventLog.push('parent: onResponderRelease');
+ },
+ onResponderTerminate() {
+ eventLog.push('parent: onResponderTerminate');
+ },
+ onResponderTerminationRequest() {
+ eventLog.push('parent: onResponderTerminationRequest');
+ return true;
+ }
+ };
+ const targetCallbacks = {
+ onStartShouldSetResponderCapture() {
+ eventLog.push('target: onStartShouldSetResponderCapture');
+ return false;
+ },
+ onStartShouldSetResponder() {
+ eventLog.push('target: onStartShouldSetResponder');
+ return true;
+ },
+ onMoveShouldSetResponderCapture() {
+ eventLog.push('target: onMoveShouldSetResponderCapture');
+ return false;
+ },
+ onMoveShouldSetResponder() {
+ eventLog.push('target: onMoveShouldSetResponder');
+ return false;
+ },
+ onResponderGrant() {
+ eventLog.push('target: onResponderGrant');
+ },
+ onResponderStart() {
+ eventLog.push('target: onResponderStart');
+ },
+ onResponderMove() {
+ eventLog.push('target: onResponderMove');
+ },
+ onResponderEnd() {
+ eventLog.push('target: onResponderEnd');
+ },
+ onResponderRelease() {
+ eventLog.push('target: onResponderRelease');
+ },
+ onResponderTerminate() {
+ eventLog.push('target: onResponderTerminate');
+ },
+ onResponderTerminationRequest() {
+ eventLog.push('target: onResponderTerminationRequest');
+ return true;
+ }
+ };
+
+ const Component = () => {
+ useResponderEvents(grandParentRef, grandParentCallbacks);
+ useResponderEvents(parentRef, parentCallbacks);
+ useResponderEvents(targetRef, targetCallbacks);
+ return (
+
+ );
+ };
+
+ // render
+ act(() => {
+ render();
+ });
+ const target = createEventTarget(targetRef.current);
+
+ // gesture start
+ act(() => {
+ target.pointerdown({ pointerType, pointerId: 1 });
+ target.pointermove({ pointerType, pointerId: 1 });
+ target.pointermove({ pointerType, pointerId: 1 });
+ });
+ expect(eventLog).toEqual([
+ 'grandParent: onStartShouldSetResponderCapture',
+ 'parent: onStartShouldSetResponderCapture',
+ 'target: onStartShouldSetResponderCapture',
+ 'target: onStartShouldSetResponder',
+ 'target: onResponderGrant',
+ 'target: onResponderStart',
+ 'grandParent: onMoveShouldSetResponderCapture',
+ 'parent: onMoveShouldSetResponderCapture',
+ 'parent: onMoveShouldSetResponder',
+ 'grandParent: onMoveShouldSetResponder',
+ 'target: onResponderTerminationRequest',
+ 'target: onResponderTerminate',
+ 'grandParent: onResponderGrant',
+ 'grandParent: onResponderMove',
+ // Continues calling 'move' rather than entering into negotiation again
+ 'grandParent: onResponderMove'
+ ]);
+ });
+
+ /**
+ * When there is an active responder, negotiation of a second pointer captures to
+ * and bubbles from the closest common ancestor registered with the system. The
+ * responder is transferred andvthe relevant termination events are called.
+ */
+ test('negotiates multi-touch from first registered ancestor of responder and transfers', () => {
const pointerType = 'touch';
let eventLog = [];
const grandParentCallbacks = {
@@ -1667,6 +1839,9 @@ describe('useResponderEvents', () => {
onResponderStart() {
eventLog.push('grandParent: onResponderStart');
},
+ onResponderMove() {
+ eventLog.push('grandParent: onResponderMove');
+ },
onResponderEnd() {
eventLog.push('grandParent: onResponderEnd');
},
@@ -1704,6 +1879,9 @@ describe('useResponderEvents', () => {
onResponderStart() {
eventLog.push('parent: onResponderStart');
},
+ onResponderMove() {
+ eventLog.push('parent: onResponderMove');
+ },
onResponderEnd() {
eventLog.push('parent: onResponderEnd');
},
@@ -1741,6 +1919,9 @@ describe('useResponderEvents', () => {
onResponderStart() {
eventLog.push('target: onResponderStart');
},
+ onResponderMove() {
+ eventLog.push('target: onResponderMove');
+ },
onResponderEnd() {
eventLog.push('target: onResponderEnd');
},
@@ -1816,21 +1997,25 @@ describe('useResponderEvents', () => {
'parent: onMoveShouldSetResponder',
'target: onResponderTerminationRequest',
'target: onResponderTerminate',
- 'parent: onResponderGrant'
+ 'parent: onResponderGrant',
+ 'parent: onResponderMove'
]);
eventLog = [];
// second move gesture
act(() => {
target.pointermove({ pointerType, pointerId: 1 });
+ target.pointermove({ pointerType, pointerId: 2 });
});
- // parent becomes responder, parent terminates
+ // grand parent becomes responder, parent terminates
expect(getResponderNode()).toBe(grandParentRef.current);
expect(eventLog).toEqual([
'grandParent: onMoveShouldSetResponderCapture',
'grandParent: onMoveShouldSetResponder',
'parent: onResponderTerminationRequest',
'parent: onResponderTerminate',
- 'grandParent: onResponderGrant'
+ 'grandParent: onResponderGrant',
+ 'grandParent: onResponderMove',
+ 'grandParent: onResponderMove'
]);
eventLog = [];
// end gestures