Skip to content

Commit 7fc91f1

Browse files
authored
React events: add onPressMove and pressRetentionOffset to Press (#15374)
This implementation differs from equivalents in React Native in the following ways: 1. A move during a press will not cancel onLongPress. 2. A move to outside the retention target will cancel the press and not reactivate when moved back within the retention target.
1 parent dd9cef9 commit 7fc91f1

File tree

3 files changed

+381
-44
lines changed

3 files changed

+381
-44
lines changed

packages/react-events/README.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
events API that is not available in open source builds.*
55

66
Event components do not render a host node. They listen to native browser events
7-
dispatched on the host node of their child and transform those events into
7+
dispatched on the host node of their child and transform those events into
88
high-level events for applications.
99

1010

@@ -176,7 +176,8 @@ Disables all `Press` events.
176176

177177
### onLongPress: (e: PressEvent) => void
178178

179-
Called once the element has been pressed for the length of `delayLongPress`.
179+
Called once the element has been pressed for the length of `delayLongPress`. If
180+
the press point moves more than 10px `onLongPress` is cancelled.
180181

181182
### onLongPressChange: boolean => void
182183

@@ -202,17 +203,23 @@ Called when the element changes press state (i.e., after `onPressStart` and
202203

203204
### onPressEnd: (e: PressEvent) => void
204205

205-
Called once the element is no longer pressed. If the press starts again before
206-
the `delayPressEnd` threshold is exceeded then the delay is reset to prevent
207-
`onPressEnd` being called during a press.
206+
Called once the element is no longer pressed (because it was released, or moved
207+
beyond the hit bounds). If the press starts again before the `delayPressEnd`
208+
threshold is exceeded then the delay is reset to prevent `onPressEnd` being
209+
called during a press.
210+
211+
### onPressMove: (e: PressEvent) => void
212+
213+
Called when an active press moves within the hit bounds of the element. Never
214+
called for keyboard-initiated press events.
208215

209216
### onPressStart: (e: PressEvent) => void
210217

211218
Called once the element is pressed down. If the press is released before the
212219
`delayPressStart` threshold is exceeded then the delay is cut short and
213220
`onPressStart` is called immediately.
214221

215-
### pressRententionOffset: PressOffset
222+
### pressRetentionOffset: PressOffset
216223

217224
Defines how far the pointer (while held down) may move outside the bounds of the
218225
element before it is deactivated. Once deactivated, the pointer (still held

packages/react-events/src/Press.js

Lines changed: 125 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,14 @@ type PressProps = {
2424
onPress: (e: PressEvent) => void,
2525
onPressChange: boolean => void,
2626
onPressEnd: (e: PressEvent) => void,
27+
onPressMove: (e: PressEvent) => void,
2728
onPressStart: (e: PressEvent) => void,
28-
pressRententionOffset: Object,
29+
pressRetentionOffset: {
30+
top: number,
31+
right: number,
32+
bottom: number,
33+
left: number,
34+
},
2935
};
3036

3137
type PressState = {
@@ -35,15 +41,23 @@ type PressState = {
3541
isAnchorTouched: boolean,
3642
isLongPressed: boolean,
3743
isPressed: boolean,
44+
isPressWithinResponderRegion: boolean,
3845
longPressTimeout: null | TimeoutID,
3946
pressTarget: null | Element | Document,
4047
pressEndTimeout: null | TimeoutID,
4148
pressStartTimeout: null | TimeoutID,
49+
responderRegion: null | $ReadOnly<{|
50+
bottom: number,
51+
left: number,
52+
right: number,
53+
top: number,
54+
|}>,
4255
shouldSkipMouseAfterTouch: boolean,
4356
};
4457

4558
type PressEventType =
4659
| 'press'
60+
| 'pressmove'
4761
| 'pressstart'
4862
| 'pressend'
4963
| 'presschange'
@@ -59,6 +73,12 @@ type PressEvent = {|
5973
const DEFAULT_PRESS_END_DELAY_MS = 0;
6074
const DEFAULT_PRESS_START_DELAY_MS = 0;
6175
const DEFAULT_LONG_PRESS_DELAY_MS = 500;
76+
const DEFAULT_PRESS_RETENTION_OFFSET = {
77+
bottom: 20,
78+
top: 20,
79+
left: 20,
80+
right: 20,
81+
};
6282

6383
const targetEventTypes = [
6484
{name: 'click', passive: false},
@@ -70,13 +90,18 @@ const targetEventTypes = [
7090
const rootEventTypes = [
7191
{name: 'keyup', passive: false},
7292
{name: 'pointerup', passive: false},
93+
'pointermove',
7394
'scroll',
7495
];
7596

7697
// If PointerEvents is not supported (e.g., Safari), also listen to touch and mouse events.
7798
if (typeof window !== 'undefined' && window.PointerEvent === undefined) {
78-
targetEventTypes.push('touchstart', 'touchend', 'mousedown', 'touchcancel');
79-
rootEventTypes.push({name: 'mouseup', passive: false});
99+
targetEventTypes.push('touchstart', 'touchend', 'touchcancel', 'mousedown');
100+
rootEventTypes.push(
101+
{name: 'mouseup', passive: false},
102+
'touchmove',
103+
'mousemove',
104+
);
80105
}
81106

82107
function createPressEvent(
@@ -232,8 +257,11 @@ function dispatchPressEndEvents(
232257
if (!wasActivePressStart && state.pressStartTimeout !== null) {
233258
clearTimeout(state.pressStartTimeout);
234259
state.pressStartTimeout = null;
235-
// if we haven't yet activated (due to delays), activate now
236-
activate(context, props, state);
260+
// don't activate if a press has moved beyond the responder region
261+
if (state.isPressWithinResponderRegion) {
262+
// if we haven't yet activated (due to delays), activate now
263+
activate(context, props, state);
264+
}
237265
}
238266

239267
if (state.isActivePressed) {
@@ -267,6 +295,59 @@ function calculateDelayMS(delay: ?number, min = 0, fallback = 0) {
267295
return Math.max(min, maybeNumber != null ? maybeNumber : fallback);
268296
}
269297

298+
// TODO: account for touch hit slop
299+
function calculateResponderRegion(target, props) {
300+
const pressRetentionOffset = {
301+
...DEFAULT_PRESS_RETENTION_OFFSET,
302+
...props.pressRetentionOffset,
303+
};
304+
305+
const clientRect = target.getBoundingClientRect();
306+
307+
let bottom = clientRect.bottom;
308+
let left = clientRect.left;
309+
let right = clientRect.right;
310+
let top = clientRect.top;
311+
312+
if (pressRetentionOffset) {
313+
if (pressRetentionOffset.bottom != null) {
314+
bottom += pressRetentionOffset.bottom;
315+
}
316+
if (pressRetentionOffset.left != null) {
317+
left -= pressRetentionOffset.left;
318+
}
319+
if (pressRetentionOffset.right != null) {
320+
right += pressRetentionOffset.right;
321+
}
322+
if (pressRetentionOffset.top != null) {
323+
top -= pressRetentionOffset.top;
324+
}
325+
}
326+
327+
return {
328+
bottom,
329+
top,
330+
left,
331+
right,
332+
};
333+
}
334+
335+
function isPressWithinResponderRegion(
336+
nativeEvent: $PropertyType<ResponderEvent, 'nativeEvent'>,
337+
state: PressState,
338+
): boolean {
339+
const {responderRegion} = state;
340+
const event = (nativeEvent: any);
341+
342+
return (
343+
responderRegion != null &&
344+
(event.pageX >= responderRegion.left &&
345+
event.pageX <= responderRegion.right &&
346+
event.pageY >= responderRegion.top &&
347+
event.pageY <= responderRegion.bottom)
348+
);
349+
}
350+
270351
function unmountResponder(
271352
context: ReactResponderContext,
272353
props: PressProps,
@@ -288,10 +369,12 @@ const PressResponder = {
288369
isAnchorTouched: false,
289370
isLongPressed: false,
290371
isPressed: false,
372+
isPressWithinResponderRegion: true,
291373
longPressTimeout: null,
292374
pressEndTimeout: null,
293375
pressStartTimeout: null,
294376
pressTarget: null,
377+
responderRegion: null,
295378
shouldSkipMouseAfterTouch: false,
296379
};
297380
},
@@ -333,11 +416,46 @@ const PressResponder = {
333416
}
334417
}
335418
state.pressTarget = target;
419+
state.isPressWithinResponderRegion = true;
336420
dispatchPressStartEvents(context, props, state);
337421
context.addRootEventTypes(target.ownerDocument, rootEventTypes);
338422
}
339423
break;
340424
}
425+
case 'pointermove':
426+
case 'mousemove':
427+
case 'touchmove': {
428+
if (state.isPressed) {
429+
if (state.shouldSkipMouseAfterTouch) {
430+
return;
431+
}
432+
433+
if (state.responderRegion == null) {
434+
let currentTarget = (target: any);
435+
while (
436+
currentTarget.parentNode &&
437+
context.isTargetWithinEventComponent(currentTarget.parentNode)
438+
) {
439+
currentTarget = currentTarget.parentNode;
440+
}
441+
state.responderRegion = calculateResponderRegion(
442+
currentTarget,
443+
props,
444+
);
445+
}
446+
447+
if (isPressWithinResponderRegion(nativeEvent, state)) {
448+
state.isPressWithinResponderRegion = true;
449+
if (props.onPressMove) {
450+
dispatchEvent(context, state, 'pressmove', props.onPressMove);
451+
}
452+
} else {
453+
state.isPressWithinResponderRegion = false;
454+
dispatchPressEndEvents(context, props, state);
455+
}
456+
}
457+
break;
458+
}
341459
case 'pointerup':
342460
case 'mouseup': {
343461
if (state.isPressed) {
@@ -373,6 +491,7 @@ const PressResponder = {
373491
context.removeRootEventTypes(rootEventTypes);
374492
}
375493
state.isAnchorTouched = false;
494+
state.shouldSkipMouseAfterTouch = false;
376495
break;
377496
}
378497

@@ -389,6 +508,7 @@ const PressResponder = {
389508
return;
390509
}
391510
state.pressTarget = target;
511+
state.isPressWithinResponderRegion = true;
392512
dispatchPressStartEvents(context, props, state);
393513
context.addRootEventTypes(target.ownerDocument, rootEventTypes);
394514
}

0 commit comments

Comments
 (0)