Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React events: add onPressMove and pressRetentionOffset to Press #15374

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions packages/react-events/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
events API that is not available in open source builds.*

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


Expand Down Expand Up @@ -176,7 +176,8 @@ Disables all `Press` events.

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

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

### onLongPressChange: boolean => void

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

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

Called once the element is no longer pressed. If the press starts again before
the `delayPressEnd` threshold is exceeded then the delay is reset to prevent
`onPressEnd` being called during a press.
Called once the element is no longer pressed (because it was released, or moved
beyond the hit bounds). If the press starts again before the `delayPressEnd`
threshold is exceeded then the delay is reset to prevent `onPressEnd` being
called during a press.

### onPressMove: (e: PressEvent) => void

Called when an active press moves within the hit bounds of the element. Never
called for keyboard-initiated press events.

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

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

### pressRententionOffset: PressOffset
### pressRetentionOffset: PressOffset

Defines how far the pointer (while held down) may move outside the bounds of the
element before it is deactivated. Once deactivated, the pointer (still held
Expand Down
130 changes: 125 additions & 5 deletions packages/react-events/src/Press.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,14 @@ type PressProps = {
onPress: (e: PressEvent) => void,
onPressChange: boolean => void,
onPressEnd: (e: PressEvent) => void,
onPressMove: (e: PressEvent) => void,
onPressStart: (e: PressEvent) => void,
pressRententionOffset: Object,
pressRetentionOffset: {
top: number,
right: number,
bottom: number,
left: number,
},
};

type PressState = {
Expand All @@ -32,15 +38,23 @@ type PressState = {
isAnchorTouched: boolean,
isLongPressed: boolean,
isPressed: boolean,
isPressWithinResponderRegion: boolean,
longPressTimeout: null | TimeoutID,
pressTarget: null | Element | Document,
pressEndTimeout: null | TimeoutID,
pressStartTimeout: null | TimeoutID,
responderRegion: null | $ReadOnly<{|
bottom: number,
left: number,
right: number,
top: number,
|}>,
shouldSkipMouseAfterTouch: boolean,
};

type PressEventType =
| 'press'
| 'pressmove'
| 'pressstart'
| 'pressend'
| 'presschange'
Expand All @@ -56,6 +70,12 @@ type PressEvent = {|
const DEFAULT_PRESS_END_DELAY_MS = 0;
const DEFAULT_PRESS_START_DELAY_MS = 0;
const DEFAULT_LONG_PRESS_DELAY_MS = 500;
const DEFAULT_PRESS_RETENTION_OFFSET = {
bottom: 20,
top: 20,
left: 20,
right: 20,
};

const targetEventTypes = [
{name: 'click', passive: false},
Expand All @@ -67,13 +87,18 @@ const targetEventTypes = [
const rootEventTypes = [
{name: 'keyup', passive: false},
{name: 'pointerup', passive: false},
'pointermove',
trueadm marked this conversation as resolved.
Show resolved Hide resolved
'scroll',
];

// If PointerEvents is not supported (e.g., Safari), also listen to touch and mouse events.
if (typeof window !== 'undefined' && window.PointerEvent === undefined) {
targetEventTypes.push('touchstart', 'touchend', 'mousedown', 'touchcancel');
rootEventTypes.push({name: 'mouseup', passive: false});
targetEventTypes.push('touchstart', 'touchend', 'touchcancel', 'mousedown');
rootEventTypes.push(
{name: 'mouseup', passive: false},
'touchmove',
'mousemove',
trueadm marked this conversation as resolved.
Show resolved Hide resolved
);
}

function createPressEvent(
Expand Down Expand Up @@ -229,8 +254,11 @@ function dispatchPressEndEvents(
if (!wasActivePressStart && state.pressStartTimeout !== null) {
clearTimeout(state.pressStartTimeout);
state.pressStartTimeout = null;
// if we haven't yet activated (due to delays), activate now
activate(context, props, state);
// don't activate if a press has moved beyond the responder region
if (state.isPressWithinResponderRegion) {
// if we haven't yet activated (due to delays), activate now
activate(context, props, state);
}
}

if (state.isActivePressed) {
Expand Down Expand Up @@ -264,6 +292,59 @@ function calculateDelayMS(delay: ?number, min = 0, fallback = 0) {
return Math.max(min, maybeNumber != null ? maybeNumber : fallback);
}

// TODO: account for touch hit slop
function calculateResponderRegion(target, props) {
const pressRetentionOffset = {
...DEFAULT_PRESS_RETENTION_OFFSET,
...props.pressRetentionOffset,
};

const clientRect = target.getBoundingClientRect();

let bottom = clientRect.bottom;
let left = clientRect.left;
let right = clientRect.right;
let top = clientRect.top;

if (pressRetentionOffset) {
if (pressRetentionOffset.bottom != null) {
bottom += pressRetentionOffset.bottom;
}
if (pressRetentionOffset.left != null) {
left -= pressRetentionOffset.left;
}
if (pressRetentionOffset.right != null) {
right += pressRetentionOffset.right;
}
if (pressRetentionOffset.top != null) {
top -= pressRetentionOffset.top;
}
}

return {
bottom,
top,
left,
right,
};
}

function isPressWithinResponderRegion(
nativeEvent: $PropertyType<ResponderEvent, 'nativeEvent'>,
state: PressState,
): boolean {
const {responderRegion} = state;
const event = (nativeEvent: any);

return (
responderRegion != null &&
(event.pageX >= responderRegion.left &&
event.pageX <= responderRegion.right &&
event.pageY >= responderRegion.top &&
event.pageY <= responderRegion.bottom)
);
}

function unmountResponder(
context: ResponderContext,
props: PressProps,
Expand All @@ -285,10 +366,12 @@ const PressResponder = {
isAnchorTouched: false,
isLongPressed: false,
isPressed: false,
isPressWithinResponderRegion: true,
longPressTimeout: null,
pressEndTimeout: null,
pressStartTimeout: null,
pressTarget: null,
responderRegion: null,
shouldSkipMouseAfterTouch: false,
};
},
Expand Down Expand Up @@ -330,11 +413,46 @@ const PressResponder = {
}
}
state.pressTarget = target;
state.isPressWithinResponderRegion = true;
dispatchPressStartEvents(context, props, state);
context.addRootEventTypes(target.ownerDocument, rootEventTypes);
}
break;
}
case 'pointermove':
case 'mousemove':
case 'touchmove': {
if (state.isPressed) {
if (state.shouldSkipMouseAfterTouch) {
return;
}

if (state.responderRegion == null) {
let currentTarget = (target: any);
while (
currentTarget.parentNode &&
context.isTargetWithinEventComponent(currentTarget.parentNode)
) {
currentTarget = currentTarget.parentNode;
}
state.responderRegion = calculateResponderRegion(
currentTarget,
props,
);
}

if (isPressWithinResponderRegion(nativeEvent, state)) {
state.isPressWithinResponderRegion = true;
if (props.onPressMove) {
dispatchEvent(context, state, 'pressmove', props.onPressMove);
}
} else {
state.isPressWithinResponderRegion = false;
dispatchPressEndEvents(context, props, state);
}
}
break;
}
case 'pointerup':
case 'mouseup': {
if (state.isPressed) {
Expand Down Expand Up @@ -370,6 +488,7 @@ const PressResponder = {
context.removeRootEventTypes(rootEventTypes);
}
state.isAnchorTouched = false;
state.shouldSkipMouseAfterTouch = false;
break;
}

Expand All @@ -386,6 +505,7 @@ const PressResponder = {
return;
}
state.pressTarget = target;
state.isPressWithinResponderRegion = true;
dispatchPressStartEvents(context, props, state);
context.addRootEventTypes(target.ownerDocument, rootEventTypes);
}
Expand Down
Loading