@samitouri / QOS-React / commits / 7fc91f17c9

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.

Nicolas Gallagher committed Apr 10, 2019 at 10:52 UTC 7fc91f17c99fbbd1497737fb8cbd8f45cbfd6d48
3 files changed +381 -44
packages/react-events/README.md
+13 -6
@@ -4,7 +4,7 @@
4 events API that is not available in open source builds.*
5
6 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
8 high-level events for applications.
9
10
@@ -176,7 +176,8 @@ Disables all `Press` events.
176
177 ### onLongPress: (e: PressEvent) => void
178
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.
181
182 ### onLongPressChange: boolean => void
183
@@ -202,9 +203,15 @@ Called when the element changes press state (i.e., after `onPressStart` and
203
204 ### onPressEnd: (e: PressEvent) => void
205
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.
215
216 ### onPressStart: (e: PressEvent) => void
217
@@ -212,7 +219,7 @@ Called once the element is pressed down. If the press is released before the
219 `delayPressStart` threshold is exceeded then the delay is cut short and
220 `onPressStart` is called immediately.
221
215 -### pressRententionOffset: PressOffset
222 +### pressRetentionOffset: PressOffset
223
224 Defines how far the pointer (while held down) may move outside the bounds of the
225 element before it is deactivated. Once deactivated, the pointer (still held
packages/react-events/src/Press.js
+125 -5
@@ -24,8 +24,14 @@ type PressProps = {
24 onPress: (e: PressEvent) => void,
25 onPressChange: boolean => void,
26 onPressEnd: (e: PressEvent) => void,
27 + onPressMove: (e: PressEvent) => void,
28 onPressStart: (e: PressEvent) => void,
28 - pressRententionOffset: Object,
29 + pressRetentionOffset: {
30 + top: number,
31 + right: number,
32 + bottom: number,
33 + left: number,
34 + },
35 };
36
37 type PressState = {
@@ -35,15 +41,23 @@ type PressState = {
41 isAnchorTouched: boolean,
42 isLongPressed: boolean,
43 isPressed: boolean,
44 + isPressWithinResponderRegion: boolean,
45 longPressTimeout: null | TimeoutID,
46 pressTarget: null | Element | Document,
47 pressEndTimeout: null | TimeoutID,
48 pressStartTimeout: null | TimeoutID,
49 + responderRegion: null | $ReadOnly<{|
50 + bottom: number,
51 + left: number,
52 + right: number,
53 + top: number,
54 + |}>,
55 shouldSkipMouseAfterTouch: boolean,
56 };
57
58 type PressEventType =
59 | 'press'
60 + | 'pressmove'
61 | 'pressstart'
62 | 'pressend'
63 | 'presschange'
@@ -59,6 +73,12 @@ type PressEvent = {|
73 const DEFAULT_PRESS_END_DELAY_MS = 0;
74 const DEFAULT_PRESS_START_DELAY_MS = 0;
75 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 +};
82
83 const targetEventTypes = [
84 {name: 'click', passive: false},
@@ -70,13 +90,18 @@ const targetEventTypes = [
90 const rootEventTypes = [
91 {name: 'keyup', passive: false},
92 {name: 'pointerup', passive: false},
93 + 'pointermove',
94 'scroll',
95 ];
96
97 // If PointerEvents is not supported (e.g., Safari), also listen to touch and mouse events.
98 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 + );
105 }
106
107 function createPressEvent(
@@ -232,8 +257,11 @@ function dispatchPressEndEvents(
257 if (!wasActivePressStart && state.pressStartTimeout !== null) {
258 clearTimeout(state.pressStartTimeout);
259 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 + }
265 }
266
267 if (state.isActivePressed) {
@@ -267,6 +295,59 @@ function calculateDelayMS(delay: ?number, min = 0, fallback = 0) {
295 return Math.max(min, maybeNumber != null ? maybeNumber : fallback);
296 }
297
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 +
351 function unmountResponder(
352 context: ReactResponderContext,
353 props: PressProps,
@@ -288,10 +369,12 @@ const PressResponder = {
369 isAnchorTouched: false,
370 isLongPressed: false,
371 isPressed: false,
372 + isPressWithinResponderRegion: true,
373 longPressTimeout: null,
374 pressEndTimeout: null,
375 pressStartTimeout: null,
376 pressTarget: null,
377 + responderRegion: null,
378 shouldSkipMouseAfterTouch: false,
379 };
380 },
@@ -333,11 +416,46 @@ const PressResponder = {
416 }
417 }
418 state.pressTarget = target;
419 + state.isPressWithinResponderRegion = true;
420 dispatchPressStartEvents(context, props, state);
421 context.addRootEventTypes(target.ownerDocument, rootEventTypes);
422 }
423 break;
424 }
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 + }
459 case 'pointerup':
460 case 'mouseup': {
461 if (state.isPressed) {
@@ -373,6 +491,7 @@ const PressResponder = {
491 context.removeRootEventTypes(rootEventTypes);
492 }
493 state.isAnchorTouched = false;
494 + state.shouldSkipMouseAfterTouch = false;
495 break;
496 }
497
@@ -389,6 +508,7 @@ const PressResponder = {
508 return;
509 }
510 state.pressTarget = target;
511 + state.isPressWithinResponderRegion = true;
512 dispatchPressStartEvents(context, props, state);
513 context.addRootEventTypes(target.ownerDocument, rootEventTypes);
514 }
packages/react-events/src/__tests__/Press-test.internal.js
+243 -33
@@ -16,9 +16,14 @@ let Press;
16
17 const DEFAULT_LONG_PRESS_DELAY = 500;
18
19 -const createPointerEvent = type => {
20 - const event = document.createEvent('Event');
21 - event.initEvent(type, true, true);
19 +const createPointerEvent = (type, data) => {
20 + const event = document.createEvent('CustomEvent');
21 + event.initCustomEvent(type, true, true);
22 + if (data != null) {
23 + Object.entries(data).forEach(([key, value]) => {
24 + event[key] = value;
25 + });
26 + }
27 return event;
28 };
29
@@ -592,36 +597,241 @@ describe('Event responder: Press', () => {
597 });
598 });
599
595 - // TODO
596 - //describe('`onPress*` with movement', () => {
597 - //describe('within bounds of hit rect', () => {
598 - /** ┌──────────────────┐
599 - * │ ┌────────────┐ │
600 - * │ │ VisualRect │ │
601 - * │ └────────────┘ │
602 - * │ HitRect X │ <= Move to X and release
603 - * └──────────────────┘
604 - */
605 -
606 - //it('"onPress*" events are called when no delay', () => {});
607 - //it('"onPress*" events are called after a delay', () => {});
608 - //});
609 -
610 - //describe('beyond bounds of hit rect', () => {
611 - /** ┌──────────────────┐
612 - * │ ┌────────────┐ │
613 - * │ │ VisualRect │ │
614 - * │ └────────────┘ │
615 - * │ HitRect │
616 - * └──────────────────┘
617 - * X <= Move to X and release
618 - */
619 -
620 - //it('"onPress" only is not called when no delay', () => {});
621 - //it('"onPress*" events are not called after a delay', () => {});
622 - //it('"onPress*" events are called when press is released before measure completes', () => {});
623 - //});
624 - //});
600 + describe('press with movement', () => {
601 + const rectMock = {
602 + width: 100,
603 + height: 100,
604 + top: 50,
605 + left: 50,
606 + right: 500,
607 + bottom: 500,
608 + };
609 + const pressRectOffset = 20;
610 + const getBoundingClientRectMock = () => rectMock;
611 + const coordinatesInside = {
612 + pageX: rectMock.left - pressRectOffset,
613 + pageY: rectMock.top - pressRectOffset,
614 + };
615 + const coordinatesOutside = {
616 + pageX: rectMock.left - pressRectOffset - 1,
617 + pageY: rectMock.top - pressRectOffset - 1,
618 + };
619 +
620 + describe('within bounds of hit rect', () => {
621 + /** ┌──────────────────┐
622 + * │ ┌────────────┐ │
623 + * │ │ VisualRect │ │
624 + * │ └────────────┘ │
625 + * │ HitRect X │ <= Move to X and release
626 + * └──────────────────┘
627 + */
628 + it('no delay and "onPress*" events are called immediately', () => {
629 + let events = [];
630 + const ref = React.createRef();
631 + const createEventHandler = msg => () => {
632 + events.push(msg);
633 + };
634 +
635 + const element = (
636 + <Press
637 + onPress={createEventHandler('onPress')}
638 + onPressChange={createEventHandler('onPressChange')}
639 + onPressMove={createEventHandler('onPressMove')}
640 + onPressStart={createEventHandler('onPressStart')}
641 + onPressEnd={createEventHandler('onPressEnd')}>
642 + <div ref={ref} />
643 + </Press>
644 + );
645 +
646 + ReactDOM.render(element, container);
647 +
648 + ref.current.getBoundingClientRect = getBoundingClientRectMock;
649 + ref.current.dispatchEvent(createPointerEvent('pointerdown'));
650 + ref.current.dispatchEvent(
651 + createPointerEvent('pointermove', coordinatesInside),
652 + );
653 + ref.current.dispatchEvent(createPointerEvent('pointerup'));
654 + jest.runAllTimers();
655 +
656 + expect(events).toEqual([
657 + 'onPressStart',
658 + 'onPressChange',
659 + 'onPressMove',
660 + 'onPressEnd',
661 + 'onPressChange',
662 + 'onPress',
663 + ]);
664 + });
665 +
666 + it('delay and "onPressMove" is called before "onPress*" events', () => {
667 + let events = [];
668 + const ref = React.createRef();
669 + const createEventHandler = msg => () => {
670 + events.push(msg);
671 + };
672 +
673 + const element = (
674 + <Press
675 + delayPressStart={500}
676 + onPress={createEventHandler('onPress')}
677 + onPressChange={createEventHandler('onPressChange')}
678 + onPressMove={createEventHandler('onPressMove')}
679 + onPressStart={createEventHandler('onPressStart')}
680 + onPressEnd={createEventHandler('onPressEnd')}>
681 + <div ref={ref} />
682 + </Press>
683 + );
684 +
685 + ReactDOM.render(element, container);
686 +
687 + ref.current.getBoundingClientRect = getBoundingClientRectMock;
688 + ref.current.dispatchEvent(createPointerEvent('pointerdown'));
689 + ref.current.dispatchEvent(
690 + createPointerEvent('pointermove', coordinatesInside),
691 + );
692 + jest.advanceTimersByTime(499);
693 + expect(events).toEqual(['onPressMove']);
694 + events = [];
695 +
696 + jest.advanceTimersByTime(1);
697 + expect(events).toEqual(['onPressStart', 'onPressChange']);
698 + events = [];
699 +
700 + ref.current.dispatchEvent(createPointerEvent('pointerup'));
701 + expect(events).toEqual(['onPressEnd', 'onPressChange', 'onPress']);
702 + });
703 +
704 + it('press retention offset can be configured', () => {
705 + let events = [];
706 + const ref = React.createRef();
707 + const createEventHandler = msg => () => {
708 + events.push(msg);
709 + };
710 + const pressRetentionOffset = {top: 40, bottom: 40, left: 40, right: 40};
711 +
712 + const element = (
713 + <Press
714 + pressRetentionOffset={pressRetentionOffset}
715 + onPress={createEventHandler('onPress')}
716 + onPressChange={createEventHandler('onPressChange')}
717 + onPressMove={createEventHandler('onPressMove')}
718 + onPressStart={createEventHandler('onPressStart')}
719 + onPressEnd={createEventHandler('onPressEnd')}>
720 + <div ref={ref} />
721 + </Press>
722 + );
723 +
724 + ReactDOM.render(element, container);
725 + ref.current.getBoundingClientRect = getBoundingClientRectMock;
726 + ref.current.dispatchEvent(createPointerEvent('pointerdown'));
727 + ref.current.dispatchEvent(
728 + createPointerEvent('pointermove', {
729 + pageX: rectMock.left - pressRetentionOffset.left,
730 + pageY: rectMock.top - pressRetentionOffset.top,
731 + }),
732 + );
733 + ref.current.dispatchEvent(createPointerEvent('pointerup'));
734 + expect(events).toEqual([
735 + 'onPressStart',
736 + 'onPressChange',
737 + 'onPressMove',
738 + 'onPressEnd',
739 + 'onPressChange',
740 + 'onPress',
741 + ]);
742 + });
743 + });
744 +
745 + describe('beyond bounds of hit rect', () => {
746 + /** ┌──────────────────┐
747 + * │ ┌────────────┐ │
748 + * │ │ VisualRect │ │
749 + * │ └────────────┘ │
750 + * │ HitRect │
751 + * └──────────────────┘
752 + * X <= Move to X and release
753 + */
754 +
755 + it('"onPress" is not called on release', () => {
756 + let events = [];
757 + const ref = React.createRef();
758 + const createEventHandler = msg => () => {
759 + events.push(msg);
760 + };
761 +
762 + const element = (
763 + <Press
764 + onPress={createEventHandler('onPress')}
765 + onPressChange={createEventHandler('onPressChange')}
766 + onPressMove={createEventHandler('onPressMove')}
767 + onPressStart={createEventHandler('onPressStart')}
768 + onPressEnd={createEventHandler('onPressEnd')}>
769 + <div ref={ref} />
770 + </Press>
771 + );
772 +
773 + ReactDOM.render(element, container);
774 +
775 + ref.current.getBoundingClientRect = getBoundingClientRectMock;
776 + ref.current.dispatchEvent(createPointerEvent('pointerdown'));
777 + ref.current.dispatchEvent(
778 + createPointerEvent('pointermove', coordinatesInside),
779 + );
780 + ref.current.dispatchEvent(
781 + createPointerEvent('pointermove', coordinatesOutside),
782 + );
783 + ref.current.dispatchEvent(createPointerEvent('pointerup'));
784 + jest.runAllTimers();
785 +
786 + expect(events).toEqual([
787 + 'onPressStart',
788 + 'onPressChange',
789 + 'onPressMove',
790 + 'onPressEnd',
791 + 'onPressChange',
792 + ]);
793 + });
794 +
795 + it('"onPress*" events are not called after delay expires', () => {
796 + let events = [];
797 + const ref = React.createRef();
798 + const createEventHandler = msg => () => {
799 + events.push(msg);
800 + };
801 +
802 + const element = (
803 + <Press
804 + delayPressStart={500}
805 + delayPressEnd={500}
806 + onLongPress={createEventHandler('onLongPress')}
807 + onPress={createEventHandler('onPress')}
808 + onPressChange={createEventHandler('onPressChange')}
809 + onPressMove={createEventHandler('onPressMove')}
810 + onPressStart={createEventHandler('onPressStart')}
811 + onPressEnd={createEventHandler('onPressEnd')}>
812 + <div ref={ref} />
813 + </Press>
814 + );
815 +
816 + ReactDOM.render(element, container);
817 +
818 + ref.current.getBoundingClientRect = getBoundingClientRectMock;
819 + ref.current.dispatchEvent(createPointerEvent('pointerdown'));
820 + ref.current.dispatchEvent(
821 + createPointerEvent('pointermove', coordinatesInside),
822 + );
823 + ref.current.dispatchEvent(
824 + createPointerEvent('pointermove', coordinatesOutside),
825 + );
826 + jest.runAllTimers();
827 + expect(events).toEqual(['onPressMove']);
828 + events = [];
829 + ref.current.dispatchEvent(createPointerEvent('pointerup'));
830 + jest.runAllTimers();
831 + expect(events).toEqual([]);
832 + });
833 + });
834 + });
835
836 describe('delayed and multiple events', () => {
837 it('dispatches in the correct order', () => {