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)