Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {HostInstance} from '../../../types/public/ReactNativeTypes';
import {ColorValue, StyleProp} from '../../StyleSheet/StyleSheet';
import {ViewStyle} from '../../StyleSheet/StyleSheetTypes';
import {
GestureResponderEvent,
NativeSyntheticEvent,
NativeTouchEvent,
} from '../../Types/CoreEventTypes';
Expand Down Expand Up @@ -665,6 +666,15 @@ export interface ScrollViewProps
*/
keyboardShouldPersistTaps?: 'always' | 'never' | 'handled' | undefined;

/**
* Called when a tap would dismiss the keyboard in
* `keyboardShouldPersistTaps="handled"` mode. Components that handle
* touches outside of the responder system can return `false` to mark the
* tap as handled and keep the keyboard up.
*/
shouldDismissKeyboardOnTap?:
((event: GestureResponderEvent) => boolean) | undefined;

/**
* Called when scrollable content view of the ScrollView changes.
* Handler function is passed the content width and content height as parameters: (contentWidth, contentHeight)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,13 @@ type ScrollViewBaseProps = Readonly<{
* @default `'never'`
*/
keyboardShouldPersistTaps?: ?('always' | 'never' | 'handled'),
/**
* Called when a tap would dismiss the keyboard in
* `keyboardShouldPersistTaps="handled"` mode. Components that handle
* touches outside of the responder system can return `false` to mark the
* tap as handled and keep the keyboard up.
*/
shouldDismissKeyboardOnTap?: ?(event: GestureResponderEvent) => boolean,
/**
* When set, the scroll view will adjust the scroll position so that the first child that is
* partially or fully visible and at or beyond `minIndexForVisible` will not change position.
Expand Down Expand Up @@ -1545,7 +1552,8 @@ class ScrollView extends React.Component<ScrollViewProps, ScrollViewState> {
if (
this.props.keyboardShouldPersistTaps === 'handled' &&
this._keyboardIsDismissible() &&
e.target !== currentlyFocusedInput
e.target !== currentlyFocusedInput &&
this.props.shouldDismissKeyboardOnTap?.(e) !== false
) {
return true;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/

import type {HostInstance} from '../../../../src/private/types/HostInstance';
import type {KeyboardEvent} from '../../Keyboard/Keyboard';
import type {ScrollViewProps} from '../ScrollView';

import TextInputState from '../../TextInput/TextInputState';
import * as React from 'react';
import ReactTestRenderer from 'react-test-renderer';

// The jest preset replaces ScrollView with a mock component — these tests
// exercise the real implementation's responder negotiation.
const ScrollView = (jest.requireActual('../ScrollView') as $FlowFixMe).default;

const fakeTextInput: HostInstance = {} as $FlowFixMe;
const fakeKeyboardEvent: KeyboardEvent = {
duration: 250,
easing: 'keyboard',
endCoordinates: {height: 336, screenX: 0, screenY: 400, width: 400},
startCoordinates: {height: 0, screenX: 0, screenY: 736, width: 400},
isEventFromThisApp: true,
};

function fakeTapEvent(target: unknown) {
return {target, nativeEvent: {touches: []}} as $FlowFixMe;
}

let testRenderer: $FlowFixMe = null;

async function renderScrollView(props: ScrollViewProps) {
await ReactTestRenderer.act(() => {
testRenderer = ReactTestRenderer.create(<ScrollView {...props} />);
});

const instance = testRenderer.root.find(
node => node.instance?._handleStartShouldSetResponder != null,
).instance as $FlowFixMe;

return instance;
}

describe('shouldDismissKeyboardOnTap', () => {
beforeEach(() => {
// Simulate a focused text input with an open soft keyboard.
TextInputState.registerInput(fakeTextInput);
TextInputState.focusInput(fakeTextInput);
});

afterEach(async () => {
if (testRenderer != null) {
await ReactTestRenderer.act(() => {
testRenderer.unmount();
});
testRenderer = null;
}

TextInputState.blurInput(fakeTextInput);
TextInputState.unregisterInput(fakeTextInput);
});

it('by default, a tap outside the focused input claims the responder in "handled" mode', async () => {
const instance = await renderScrollView({
keyboardShouldPersistTaps: 'handled',
});
instance.scrollResponderKeyboardWillShow(fakeKeyboardEvent);

expect(instance._handleStartShouldSetResponder(fakeTapEvent({}))).toBe(
true,
);
});

it('returning false vetoes the responder claim', async () => {
const shouldDismissKeyboardOnTap = jest.fn().mockReturnValue(false);
const instance = await renderScrollView({
keyboardShouldPersistTaps: 'handled',
shouldDismissKeyboardOnTap,
});
instance.scrollResponderKeyboardWillShow(fakeKeyboardEvent);

const tapEvent = fakeTapEvent({});
expect(instance._handleStartShouldSetResponder(tapEvent)).toBe(false);
expect(shouldDismissKeyboardOnTap).toHaveBeenCalledWith(tapEvent);
});

it('returning true keeps the responder claim', async () => {
const shouldDismissKeyboardOnTap = jest.fn().mockReturnValue(true);
const instance = await renderScrollView({
keyboardShouldPersistTaps: 'handled',
shouldDismissKeyboardOnTap,
});
instance.scrollResponderKeyboardWillShow(fakeKeyboardEvent);

const tapEvent = fakeTapEvent({});
expect(instance._handleStartShouldSetResponder(tapEvent)).toBe(true);
expect(shouldDismissKeyboardOnTap).toHaveBeenCalledWith(tapEvent);
});

it('is not consulted when there is no dismissible keyboard', async () => {
const shouldDismissKeyboardOnTap = jest.fn().mockReturnValue(false);
const instance = await renderScrollView({
keyboardShouldPersistTaps: 'handled',
shouldDismissKeyboardOnTap,
});
// No keyboard event was received, so there is no keyboard to dismiss.

expect(instance._handleStartShouldSetResponder(fakeTapEvent({}))).toBe(
false,
);
expect(shouldDismissKeyboardOnTap).not.toHaveBeenCalled();
});

it('is not consulted when the tap lands on the focused input', async () => {
const shouldDismissKeyboardOnTap = jest.fn().mockReturnValue(false);
const instance = await renderScrollView({
keyboardShouldPersistTaps: 'handled',
shouldDismissKeyboardOnTap,
});
instance.scrollResponderKeyboardWillShow(fakeKeyboardEvent);

expect(
instance._handleStartShouldSetResponder(fakeTapEvent(fakeTextInput)),
).toBe(false);
expect(shouldDismissKeyboardOnTap).not.toHaveBeenCalled();
});

it.each(['never', 'always'])(
'is not consulted when keyboardShouldPersistTaps is %s',
async keyboardShouldPersistTaps => {
const shouldDismissKeyboardOnTap = jest.fn().mockReturnValue(false);
const instance = await renderScrollView({
keyboardShouldPersistTaps,
shouldDismissKeyboardOnTap,
});
instance.scrollResponderKeyboardWillShow(fakeKeyboardEvent);

expect(instance._handleStartShouldSetResponder(fakeTapEvent({}))).toBe(
false,
);
expect(shouldDismissKeyboardOnTap).not.toHaveBeenCalled();
},
);
});
27 changes: 15 additions & 12 deletions packages/react-native/ReactNativeApi.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<040a8eb579cea089295c7137db40e0d6>>
* @generated SignedSource<<72f33a1c654fc86a6cc2e8b917ba5f8f>>
*
* This file was generated by scripts/js-api/build-types/index.js.
*/
Expand Down Expand Up @@ -4256,6 +4256,9 @@ declare type ScrollViewBaseProps = {
readonly scrollEnabled?: boolean
readonly scrollEventThrottle?: number
readonly scrollViewRef?: React.Ref<ScrollViewInstance>
readonly shouldDismissKeyboardOnTap?: (
event: GestureResponderEvent,
) => boolean
readonly showsVerticalScrollIndicator?: boolean
readonly snapToAlignment?: "center" | "end" | "start"
readonly snapToEnd?: boolean
Expand Down Expand Up @@ -5722,7 +5725,7 @@ export {
AlertOptions, // 8a116d2a
AlertType, // 5ab91217
AndroidKeyboardEvent, // e03becc8
Animated, // 4bf77b70
Animated, // 75c41a48
AppConfig, // 35c0ca70
AppRegistry, // 1e8c5a00
AppState, // 12012be5
Expand Down Expand Up @@ -5774,9 +5777,9 @@ export {
EventSubscription, // b8d084aa
ExtendedExceptionData, // 5a6ccf5a
FilterFunction, // bf24c0e3
FlatList, // e1b005c7
FlatListInstance, // 2d1d8e45
FlatListProps, // 94fa2dc7
FlatList, // f9c45fc6
FlatListInstance, // b3da8d34
FlatListProps, // 52d81a96
FocusEvent, // 850f1517
FontVariant, // 7c7558bb
GestureResponderEvent, // 14d3e77a
Expand Down Expand Up @@ -5917,18 +5920,18 @@ export {
ScrollEvent, // d7abdd0a
ScrollResponderType, // ba188eae
ScrollToLocationParamsType, // d7ecdad1
ScrollView, // 066a8597
ScrollView, // 181e1e41
ScrollViewImperativeMethods, // 904c66fd
ScrollViewInstance, // ccf4f341
ScrollViewProps, // b62913d1
ScrollViewProps, // 2bcb6875
ScrollViewPropsAndroid, // 02f3df2e
ScrollViewPropsIOS, // 807cb4f6
ScrollViewScrollToOptions, // 3313411e
SectionBase, // 9f13db00
SectionList, // ee3e7972
SectionList, // 728d3fe6
SectionListData, // 1a4de01a
SectionListInstance, // c9b991fe
SectionListProps, // 0e933318
SectionListInstance, // 10da5c15
SectionListProps, // 4f5adad4
SectionListRenderItem, // 715b2086
SectionListRenderItemInfo, // 4a48a922
Separators, // 6a45f7e3
Expand Down Expand Up @@ -6001,10 +6004,10 @@ export {
VirtualViewMode, // 6be59722
VirtualizedList, // 68c7345e
VirtualizedListInstance, // 423ee7c0
VirtualizedListProps, // f51f4a42
VirtualizedListProps, // 3e8bcf6f
VirtualizedSectionList, // 9fd9cd61
VirtualizedSectionListInstance, // 12b706d5
VirtualizedSectionListProps, // 8210f30a
VirtualizedSectionListProps, // d51515e0
WrapperComponentProvider, // 9ef54e61
codegenNativeCommands, // 628a7c0a
codegenNativeComponent, // 32a1bca6
Expand Down