Skip to content

Commit 4aaf629

Browse files
yungstersfacebook-github-bot
authored andcommitted
Pressable: Minimum Press Duration
Summary: When a `Pressable` has a configured (or the default) `delayPressIn` and no (or the default) `delayPressOut`, tapping very quickly can lead to intantaneous invocation of `onPressIn` and `onPressOut`. The end result is that users may never experience any intended visual press feedback. This changes `Pressable` to accept (and be preconfigured with a default) **minimum press duration**. The minimum press duration ensures that even if the press is released before `delayPressIn` has elapsed, `onPressOut` will still wait the remaining time up to `minPressDuration` before firing. Note that setting a non-zero `delayPressOut` is insufficient because if a user holds down on a `Pressable` for longer than `delayPressIn`, we still want `onPressOut` to fire immediately when the press is released. Changelog: [General][Changed] - Added `minPressDuration` to `Pressable`. Reviewed By: TheSavior Differential Revision: D21614708 fbshipit-source-id: 502f3d8ad6a40e7762435b6df16809c8798dd92c
1 parent c8ed2db commit 4aaf629

File tree

2 files changed

+137
-2
lines changed

2 files changed

+137
-2
lines changed

Libraries/Pressability/Pressability.js

+19-1
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ export type PressabilityConfig = $ReadOnly<{|
7878
*/
7979
delayPressOut?: ?number,
8080

81+
/**
82+
* Minimum duration to wait between calling `onPressIn` and `onPressOut`.
83+
*/
84+
minPressDuration?: ?number,
85+
8186
/**
8287
* Called after the element loses focus.
8388
*/
@@ -279,6 +284,7 @@ const DEFAULT_PRESS_RECT_OFFSETS = {
279284
right: 20,
280285
top: 20,
281286
};
287+
const DEFAULT_MIN_PRESS_DURATION = 130;
282288

283289
/**
284290
* Pressability implements press handling capabilities.
@@ -393,6 +399,7 @@ export default class Pressability {
393399
pageX: number,
394400
pageY: number,
395401
|}>;
402+
_touchActivateTime: ?number;
396403
_touchState: TouchState = 'NOT_RESPONDER';
397404

398405
constructor(config: PressabilityConfig) {
@@ -702,6 +709,7 @@ export default class Pressability {
702709
pageX: touch.pageX,
703710
pageY: touch.pageY,
704711
};
712+
this._touchActivateTime = Date.now();
705713
if (onPressIn != null) {
706714
onPressIn(event);
707715
}
@@ -710,7 +718,16 @@ export default class Pressability {
710718
_deactivate(event: PressEvent): void {
711719
const {onPressOut} = this._config;
712720
if (onPressOut != null) {
713-
const delayPressOut = normalizeDelay(this._config.delayPressOut);
721+
const minPressDuration = normalizeDelay(
722+
this._config.minPressDuration,
723+
0,
724+
DEFAULT_MIN_PRESS_DURATION,
725+
);
726+
const pressDuration = Date.now() - (this._touchActivateTime ?? 0);
727+
const delayPressOut = Math.max(
728+
minPressDuration - pressDuration,
729+
normalizeDelay(this._config.delayPressOut),
730+
);
714731
if (delayPressOut > 0) {
715732
this._pressOutDelayTimeout = setTimeout(() => {
716733
onPressOut(event);
@@ -719,6 +736,7 @@ export default class Pressability {
719736
onPressOut(event);
720737
}
721738
}
739+
this._touchActivateTime = null;
722740
}
723741

724742
_measureResponderRegion(): void {

Libraries/Pressability/__tests__/Pressability-test.js

+118-1
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ const createMockPressEvent = (
232232
describe('Pressability', () => {
233233
beforeEach(() => {
234234
jest.resetModules();
235+
jest.spyOn(Date, 'now');
235236
jest.spyOn(HoverState, 'isHoverEnabled');
236237
});
237238

@@ -505,6 +506,7 @@ describe('Pressability', () => {
505506
handlers.onResponderRelease(createMockPressEvent('onResponderRelease'));
506507

507508
expect(config.onPress).toBeCalled();
509+
jest.runOnlyPendingTimers();
508510
expect(config.onPressOut).toBeCalled();
509511
});
510512
});
@@ -578,7 +580,118 @@ describe('Pressability', () => {
578580
});
579581
});
580582

581-
// TODO: onPressOut tests
583+
describe('onPressOut', () => {
584+
it('is called after `onResponderRelease` before `delayPressIn`', () => {
585+
const {config, handlers} = createMockPressability();
586+
587+
handlers.onStartShouldSetResponder();
588+
handlers.onResponderGrant(createMockPressEvent('onResponderGrant'));
589+
handlers.onResponderMove(createMockPressEvent('onResponderMove'));
590+
expect(config.onPressIn).not.toBeCalled();
591+
handlers.onResponderRelease(createMockPressEvent('onResponderRelease'));
592+
593+
expect(config.onPressOut).not.toBeCalled();
594+
jest.runOnlyPendingTimers();
595+
expect(config.onPressOut).toBeCalled();
596+
});
597+
598+
it('is called after `onResponderRelease` after `delayPressIn`', () => {
599+
const {config, handlers} = createMockPressability();
600+
601+
handlers.onStartShouldSetResponder();
602+
handlers.onResponderGrant(createMockPressEvent('onResponderGrant'));
603+
handlers.onResponderMove(createMockPressEvent('onResponderMove'));
604+
jest.runOnlyPendingTimers();
605+
expect(config.onPressIn).toBeCalled();
606+
handlers.onResponderRelease(createMockPressEvent('onResponderRelease'));
607+
608+
expect(config.onPressOut).not.toBeCalled();
609+
jest.runOnlyPendingTimers();
610+
expect(config.onPressOut).toBeCalled();
611+
});
612+
613+
it('is not called after `onResponderTerminate` before `delayPressIn`', () => {
614+
const {config, handlers} = createMockPressability();
615+
616+
handlers.onStartShouldSetResponder();
617+
handlers.onResponderGrant(createMockPressEvent('onResponderGrant'));
618+
handlers.onResponderMove(createMockPressEvent('onResponderMove'));
619+
handlers.onResponderTerminate(
620+
createMockPressEvent('onResponderTerminate'),
621+
);
622+
623+
expect(config.onPressOut).not.toBeCalled();
624+
jest.runOnlyPendingTimers();
625+
expect(config.onPressOut).not.toBeCalled();
626+
});
627+
628+
it('is not called after `onResponderTerminate` after `delayPressIn`', () => {
629+
const {config, handlers} = createMockPressability();
630+
631+
handlers.onStartShouldSetResponder();
632+
handlers.onResponderGrant(createMockPressEvent('onResponderGrant'));
633+
handlers.onResponderMove(createMockPressEvent('onResponderMove'));
634+
jest.runOnlyPendingTimers();
635+
expect(config.onPressIn).toBeCalled();
636+
handlers.onResponderTerminate(
637+
createMockPressEvent('onResponderTerminate'),
638+
);
639+
640+
expect(config.onPressOut).not.toBeCalled();
641+
jest.runOnlyPendingTimers();
642+
expect(config.onPressOut).toBeCalled();
643+
});
644+
645+
it('is called after the minimum press duration by default', () => {
646+
const {config, handlers} = createMockPressability();
647+
648+
handlers.onStartShouldSetResponder();
649+
handlers.onResponderGrant(createMockPressEvent('onResponderGrant'));
650+
handlers.onResponderMove(createMockPressEvent('onResponderMove'));
651+
jest.runOnlyPendingTimers();
652+
expect(config.onPressIn).toBeCalled();
653+
handlers.onResponderRelease(createMockPressEvent('onResponderRelease'));
654+
655+
jest.advanceTimersByTime(120);
656+
expect(config.onPressOut).not.toBeCalled();
657+
jest.advanceTimersByTime(10);
658+
expect(config.onPressOut).toBeCalled();
659+
});
660+
661+
it('is called after only after the remaining minimum press duration', () => {
662+
const {config, handlers} = createMockPressability();
663+
664+
handlers.onStartShouldSetResponder();
665+
handlers.onResponderGrant(createMockPressEvent('onResponderGrant'));
666+
handlers.onResponderMove(createMockPressEvent('onResponderMove'));
667+
jest.runOnlyPendingTimers();
668+
expect(config.onPressIn).toBeCalled();
669+
// WORKAROUND: Jest does not advance `Date.now()`.
670+
const touchActivateTime = Date.now();
671+
jest.advanceTimersByTime(120);
672+
Date.now.mockReturnValue(touchActivateTime + 120);
673+
handlers.onResponderRelease(createMockPressEvent('onResponderRelease'));
674+
675+
expect(config.onPressOut).not.toBeCalled();
676+
jest.advanceTimersByTime(10);
677+
expect(config.onPressOut).toBeCalled();
678+
});
679+
680+
it('is called synchronously if minimum press duration is 0ms', () => {
681+
const {config, handlers} = createMockPressability({
682+
minPressDuration: 0,
683+
});
684+
685+
handlers.onStartShouldSetResponder();
686+
handlers.onResponderGrant(createMockPressEvent('onResponderGrant'));
687+
handlers.onResponderMove(createMockPressEvent('onResponderMove'));
688+
jest.runOnlyPendingTimers();
689+
expect(config.onPressIn).toBeCalled();
690+
handlers.onResponderRelease(createMockPressEvent('onResponderRelease'));
691+
692+
expect(config.onPressOut).toBeCalled();
693+
});
694+
});
582695

583696
describe('`onPress*` with movement', () => {
584697
describe('within bounds of hit rect', () => {
@@ -611,6 +724,7 @@ describe('Pressability', () => {
611724

612725
expect(config.onPressIn).toBeCalled();
613726
expect(config.onPress).toBeCalled();
727+
jest.runOnlyPendingTimers();
614728
expect(config.onPressOut).toBeCalled();
615729
});
616730

@@ -648,6 +762,7 @@ describe('Pressability', () => {
648762
handlers.onResponderRelease(createMockPressEvent('onResponderRelease'));
649763

650764
expect(config.onPress).toBeCalled();
765+
jest.runOnlyPendingTimers();
651766
expect(config.onPressOut).toBeCalled();
652767
});
653768
});
@@ -676,6 +791,7 @@ describe('Pressability', () => {
676791

677792
handlers.onResponderRelease(createMockPressEvent('onResponderRelease'));
678793
expect(config.onPress).not.toBeCalled();
794+
jest.runOnlyPendingTimers();
679795
expect(config.onPressOut).toBeCalled();
680796
});
681797

@@ -733,6 +849,7 @@ describe('Pressability', () => {
733849
handlers.onResponderRelease(createMockPressEvent('onResponderRelease'));
734850

735851
expect(config.onPress).toBeCalled();
852+
jest.runOnlyPendingTimers();
736853
expect(config.onPressOut).toBeCalled();
737854
});
738855
});

0 commit comments

Comments
 (0)