Skip to content

Commit 30ecc6f

Browse files
Add onLongPress to TextInput
TextInput already handles its presses through Pressability and exposes onPress, onPressIn and onPressOut, but not onLongPress, so a held press on a TextInput cannot be told apart from a tap. TextInput now hands onLongPress to Pressability. Once a touch is held past the long press delay, onLongPress is called, and the release that follows does not call onPress, as on Pressable. Without onLongPress a held press is still a press.
1 parent 32131bf commit 30ecc6f

5 files changed

Lines changed: 172 additions & 3 deletions

File tree

‎packages/react-native/Libraries/Components/TextInput/TextInput.flow.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -854,6 +854,12 @@ type TextInputBaseProps = Readonly<{
854854
*/
855855
onKeyPress?: ?(e: TextInputKeyPressEvent) => unknown,
856856

857+
/**
858+
* Called when a touch is held for 500ms. The release that follows does not
859+
* call `onPress`.
860+
*/
861+
onLongPress?: ?(event: GestureResponderEvent) => unknown,
862+
857863
/**
858864
* Called when a single tap gesture is detected.
859865
*/

‎packages/react-native/Libraries/Components/TextInput/TextInput.js‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,7 @@ function InternalTextInput(props: TextInputProps): React.Node {
468468
const {
469469
editable,
470470
hitSlop,
471+
onLongPress,
471472
onPress,
472473
onPressIn,
473474
onPressOut,
@@ -478,6 +479,7 @@ function InternalTextInput(props: TextInputProps): React.Node {
478479
() => ({
479480
cancelable: Platform.OS === 'ios' ? !rejectResponderTermination : null,
480481
hitSlop,
482+
onLongPress,
481483
onPress: (event: GestureResponderEvent) => {
482484
onPress?.(event);
483485
if (editable !== false) {
@@ -492,6 +494,7 @@ function InternalTextInput(props: TextInputProps): React.Node {
492494
[
493495
editable,
494496
hitSlop,
497+
onLongPress,
495498
onPress,
496499
onPressIn,
497500
onPressOut,

‎packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-itest.js‎

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,30 @@ import * as React from 'react';
1818
import {createRef, useEffect, useLayoutEffect, useRef} from 'react';
1919
import {TextInput} from 'react-native';
2020

21+
function touchStart(element: HostInstance): void {
22+
Fantom.dispatchNativeEvent(
23+
element,
24+
'onTouchStart',
25+
{
26+
touches: [{identifier: 0, pageX: 5, pageY: 5, timestamp: 0}],
27+
changedTouches: [{identifier: 0, pageX: 5, pageY: 5, timestamp: 0}],
28+
},
29+
{category: Fantom.NativeEventCategory.Discrete},
30+
);
31+
}
32+
33+
function touchEnd(element: HostInstance): void {
34+
Fantom.dispatchNativeEvent(
35+
element,
36+
'onTouchEnd',
37+
{
38+
touches: [],
39+
changedTouches: [{identifier: 0, pageX: 5, pageY: 5, timestamp: 200}],
40+
},
41+
{category: Fantom.NativeEventCategory.Discrete},
42+
);
43+
}
44+
2145
describe('<TextInput>', () => {
2246
describe('props', () => {
2347
describe('selection', () => {
@@ -255,6 +279,134 @@ describe('<TextInput>', () => {
255279
});
256280
});
257281

282+
describe('onLongPress', () => {
283+
let timers: Fantom.TimerMock;
284+
285+
beforeEach(() => {
286+
timers = Fantom.installTimerMock();
287+
});
288+
289+
afterEach(() => {
290+
timers.uninstall();
291+
});
292+
293+
it('is called once a touch is held past the long press delay', () => {
294+
const root = Fantom.createRoot();
295+
const nodeRef = createRef<HostInstance>();
296+
const onLongPress = jest.fn();
297+
298+
Fantom.runTask(() => {
299+
root.render(
300+
<TextInput
301+
onLongPress={onLongPress}
302+
ref={nodeRef}
303+
style={{height: 100, width: 100}}
304+
/>,
305+
);
306+
});
307+
308+
const element = nullthrows(nodeRef.current);
309+
touchStart(element);
310+
timers.advanceTimersByTime(499);
311+
expect(onLongPress).toHaveBeenCalledTimes(0);
312+
313+
timers.advanceTimersByTime(1);
314+
expect(onLongPress).toHaveBeenCalledTimes(1);
315+
});
316+
317+
it('takes the place of the press, so the release calls no onPress and sends no focus command', () => {
318+
const root = Fantom.createRoot();
319+
const nodeRef = createRef<HostInstance>();
320+
const onPress = jest.fn();
321+
322+
Fantom.runTask(() => {
323+
root.render(
324+
<TextInput
325+
nativeID="text-input"
326+
onLongPress={() => {}}
327+
onPress={onPress}
328+
ref={nodeRef}
329+
style={{height: 100, width: 100}}
330+
/>,
331+
);
332+
});
333+
334+
root.takeMountingManagerLogs();
335+
336+
const element = nullthrows(nodeRef.current);
337+
touchStart(element);
338+
timers.advanceTimersByTime(500);
339+
touchEnd(element);
340+
timers.runAllTimers();
341+
342+
expect(onPress).toHaveBeenCalledTimes(0);
343+
expect(root.takeMountingManagerLogs()).toEqual([]);
344+
});
345+
346+
it('leaves a press released before the delay a press that focuses', () => {
347+
const root = Fantom.createRoot();
348+
const nodeRef = createRef<HostInstance>();
349+
const onLongPress = jest.fn();
350+
const onPress = jest.fn();
351+
352+
Fantom.runTask(() => {
353+
root.render(
354+
<TextInput
355+
nativeID="text-input"
356+
onLongPress={onLongPress}
357+
onPress={onPress}
358+
ref={nodeRef}
359+
style={{height: 100, width: 100}}
360+
/>,
361+
);
362+
});
363+
364+
root.takeMountingManagerLogs();
365+
366+
const element = nullthrows(nodeRef.current);
367+
touchStart(element);
368+
timers.advanceTimersByTime(100);
369+
touchEnd(element);
370+
timers.runAllTimers();
371+
372+
expect(onLongPress).toHaveBeenCalledTimes(0);
373+
expect(onPress).toHaveBeenCalledTimes(1);
374+
expect(root.takeMountingManagerLogs()).toEqual([
375+
'Command {type: "AndroidTextInput", nativeID: "text-input", name: "focus"}',
376+
]);
377+
});
378+
379+
it('leaves a held press a press that focuses when it is not set', () => {
380+
const root = Fantom.createRoot();
381+
const nodeRef = createRef<HostInstance>();
382+
const onPress = jest.fn();
383+
384+
Fantom.runTask(() => {
385+
root.render(
386+
<TextInput
387+
nativeID="text-input"
388+
onPress={onPress}
389+
ref={nodeRef}
390+
style={{height: 100, width: 100}}
391+
/>,
392+
);
393+
});
394+
395+
root.takeMountingManagerLogs();
396+
397+
const element = nullthrows(nodeRef.current);
398+
touchStart(element);
399+
timers.advanceTimersByTime(500);
400+
touchEnd(element);
401+
timers.runAllTimers();
402+
403+
expect(onPress).toHaveBeenCalledTimes(1);
404+
expect(root.takeMountingManagerLogs()).toEqual([
405+
'Command {type: "AndroidTextInput", nativeID: "text-input", name: "focus"}',
406+
]);
407+
});
408+
});
409+
258410
describe('id and nativeID', () => {
259411
it(`has 'id' propagated as 'nativeID' to the mounting layer`, () => {
260412
const root = Fantom.createRoot();

‎packages/react-native/ReactNativeApi.d.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<b0c8c41e65758fde472ddf1c5b106a27>>
7+
* @generated SignedSource<<1b73a40ce1edc05ec461e82434a82f45>>
88
*
99
* This file was generated by scripts/js-api/build-types/index.js.
1010
*/
@@ -5048,6 +5048,7 @@ declare type TextInputBaseProps = {
50485048
readonly onEndEditing?: (e: TextInputEndEditingEvent) => unknown
50495049
readonly onFocus?: (e: TextInputFocusEvent) => unknown
50505050
readonly onKeyPress?: (e: TextInputKeyPressEvent) => unknown
5051+
readonly onLongPress?: (event: GestureResponderEvent) => unknown
50515052
readonly onPress?: (event: GestureResponderEvent) => unknown
50525053
readonly onPressIn?: (event: GestureResponderEvent) => unknown
50535054
readonly onPressOut?: (event: GestureResponderEvent) => unknown
@@ -6022,7 +6023,7 @@ export {
60226023
TaskProvider, // 266dedf2
60236024
Text, // 6f8269c1
60246025
TextContentType, // 239b3ecc
6025-
TextInput, // cf6467dc
6026+
TextInput, // 33a0ccfa
60266027
TextInputAndroidProps, // 9ebbc103
60276028
TextInputBlurEvent, // b4c7a58c
60286029
TextInputChangeEvent, // 92363e10
@@ -6032,7 +6033,7 @@ export {
60326033
TextInputIOSProps, // fb3c9327
60336034
TextInputInstance, // 53fe7b73
60346035
TextInputKeyPressEvent, // cbbc322a
6035-
TextInputProps, // bb087c92
6036+
TextInputProps, // 3ea65957
60366037
TextInputSelectionChangeEvent, // 4a8f10c2
60376038
TextInputSubmitEditingEvent, // e811fdbd
60386039
TextInstance, // 7c7e2069

‎packages/react-native/types_DEPRECATED/Libraries/Components/TextInput/TextInput.d.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,13 @@ export interface TextInputProps
828828
*/
829829
onEndEditing?: ((e: TextInputEndEditingEvent) => void) | undefined;
830830

831+
/**
832+
* Called when a touch is held for 500ms. The release that follows does not
833+
* call `onPress`.
834+
*/
835+
onLongPress?:
836+
((e: NativeSyntheticEvent<NativeTouchEvent>) => void) | undefined;
837+
831838
/**
832839
* Called when a single tap gesture is detected.
833840
*/

0 commit comments

Comments
 (0)