From 1651da45c61b8598c90e7a23557aff7b4df41c82 Mon Sep 17 00:00:00 2001 From: Kirill Zyusko <zyusko.kirik@gmail.com> Date: Sat, 23 Dec 2023 15:28:13 +0100 Subject: [PATCH] feat: dismiss keyboard (#306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📜 Description Added `KeyboardController.dismiss` method. ## 💡 Motivation and Context Actually there is quite a lot of motivation behind such functionality. Let's go one-by one ### 1️⃣ Community request In Algolia I constantly see that people are looking for `dismiss` method. |One week ago|Two weeks ago|One month ago| |--------------|---------------|----------------| |<img width="580" alt="image" src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/7a9de97e-cdcb-48a8-91f7-cb818338aaf5">|<img width="577" alt="image" src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/55f9760d-9a5b-43cc-b274-fb189dab60c9">|<img width="580" alt="image" src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/406fd6e8-91b6-494a-8a86-f9b7dc3d0655">| ### 2️⃣ `react-native` flaws implementation `react-native` implementation is based on the next code: ```ts class Keyboard { // ... dismiss(): void { dismissKeyboard(); } // ... } function dismissKeyboard() { TextInputState.blurTextInput(TextInputState.currentlyFocusedInput()); } ``` Where `currentlyFocusedInput` is set in: ```ts function focusInput(textField: ?ComponentRef): void { if (currentlyFocusedInputRef !== textField && textField != null) { currentlyFocusedInputRef = textField; } } ``` And the usage of this function: ```ts const _onFocus = (event: FocusEvent) => { TextInputState.focusInput(inputRef.current); if (props.onFocus) { props.onFocus(event); } }; ``` So theoretically if you use `TextInput` component that is not based on the implementation from `react-native` core (i. e. you decided to write your own component), then `Keyboard.dismiss` most likely will not work 😓 ### 3️⃣ Standalone module I'm going to continue the development of this library and in the future I may need to rely on the presence/implementation of my own methods. For example I'm thinking about `Toolbar` component implementation (the component above the keyboard with prev/next/done buttons). In my opinion it'll be strange when this component will fully depend on the methods from this package **AND** on a single function from `react-native` `Keyboard` module 🤔 So I think it's better to have own equivalent. ## 📢 Changelog <!-- High level overview of important changes --> <!-- For example: fixed status bar manipulation; added new types declarations; --> <!-- If your changes don't affect one of platform/language below - then remove this platform/language --> ### Docs - re-write docs about existing `setInputMode`/`setDefaultMode`; - added documentation about `dismiss` method; ### JS - added `dismiss` in `specs`, `types`; - added `mock` and unit-test; ### iOS - used with `resignFirstResponder` selector to dismiss a keyboard ([source](https://stackoverflow.com/a/11768282/9272042)); - added `dismiss` method to `KeyboardController`; ### Android - used `hideSoftInputFromWindow` to close a keyboard ([source](https://stackoverflow.com/a/1109108/9272042)); - added `dismiss` method to `KeyboardController`; ## 🤔 How Has This Been Tested? Tested manually on: - Pixel 3a (Android 13, emulator); - iPhone 15 Pro (iOS 17.2 simulator); ## 📸 Screenshots (if appropriate): |Android|iOS| |--------|----| |<video src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/39f47e5e-b15f-42bf-8af3-d1d7b4a76276">|<video src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/fbd1324d-d4de-44fe-a468-54b1f1d77a7e">| ## 📝 Checklist - [x] CI successfully passed --- .../__tests__/close-keyboard.spec.tsx | 25 ++++++++++ FabricExample/src/constants/screenNames.ts | 1 + .../src/navigation/ExamplesStack/index.tsx | 10 ++++ .../src/screens/Examples/Close/index.tsx | 30 ++++++++++++ .../src/screens/Examples/Main/constants.ts | 6 +++ .../KeyboardControllerModule.kt | 4 ++ .../modules/KeyboardControllerModuleImpl.kt | 13 +++++ .../KeyboardControllerModule.kt | 5 ++ docs/docs/api/keyboard-controller.md | 48 +++++++++++-------- example/__tests__/close-keyboard.spec.tsx | 25 ++++++++++ example/src/constants/screenNames.ts | 1 + .../src/navigation/ExamplesStack/index.tsx | 10 ++++ example/src/screens/Examples/Close/index.tsx | 30 ++++++++++++ .../src/screens/Examples/Main/constants.ts | 6 +++ ios/KeyboardControllerModule.mm | 14 ++++++ jest/index.js | 1 + package.json | 1 + src/bindings.ts | 1 + src/specs/NativeKeyboardController.ts | 1 + src/types.ts | 2 + 20 files changed, 215 insertions(+), 19 deletions(-) create mode 100644 FabricExample/__tests__/close-keyboard.spec.tsx create mode 100644 FabricExample/src/screens/Examples/Close/index.tsx create mode 100644 example/__tests__/close-keyboard.spec.tsx create mode 100644 example/src/screens/Examples/Close/index.tsx diff --git a/FabricExample/__tests__/close-keyboard.spec.tsx b/FabricExample/__tests__/close-keyboard.spec.tsx new file mode 100644 index 0000000000..0ad47bb855 --- /dev/null +++ b/FabricExample/__tests__/close-keyboard.spec.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Button } from 'react-native'; +import { fireEvent, render } from '@testing-library/react-native'; + +import { KeyboardController } from 'react-native-keyboard-controller'; + +function CloseKeyboard() { + return ( + <Button + title='Close keyboard' + testID='close_keyboard' + onPress={() => KeyboardController.dismiss()} + /> + ); +} + +describe('closing keyboard flow', () => { + it('should have a mock version of `KeyboardController.dismiss`', () => { + const { getByTestId } = render(<CloseKeyboard />); + + fireEvent.press(getByTestId('close_keyboard')); + + expect(KeyboardController.dismiss).toBeCalledTimes(1); + }); +}); diff --git a/FabricExample/src/constants/screenNames.ts b/FabricExample/src/constants/screenNames.ts index 911d71bbac..68b1e233e4 100644 --- a/FabricExample/src/constants/screenNames.ts +++ b/FabricExample/src/constants/screenNames.ts @@ -14,4 +14,5 @@ export enum ScreenNames { NATIVE_STACK = 'NATIVE_STACK', KEYBOARD_AVOIDING_VIEW = 'KEYBOARD_AVOIDING_VIEW', ENABLED_DISABLED = 'ENABLED_DISABLED', + CLOSE = 'CLOSE', } diff --git a/FabricExample/src/navigation/ExamplesStack/index.tsx b/FabricExample/src/navigation/ExamplesStack/index.tsx index 61d8f62bc6..a26340cf4e 100644 --- a/FabricExample/src/navigation/ExamplesStack/index.tsx +++ b/FabricExample/src/navigation/ExamplesStack/index.tsx @@ -16,6 +16,7 @@ import NativeStack from '../NestedStack'; import KeyboardAvoidingViewExample from '../../screens/Examples/KeyboardAvoidingView'; import EnabledDisabled from '../../screens/Examples/EnabledDisabled'; import AwareScrollViewStickyFooter from '../../screens/Examples/AwareScrollViewStickyFooter'; +import CloseScreen from '../../screens/Examples/Close'; export type ExamplesStackParamList = { [ScreenNames.ANIMATED_EXAMPLE]: undefined; @@ -31,6 +32,7 @@ export type ExamplesStackParamList = { [ScreenNames.NATIVE_STACK]: undefined; [ScreenNames.KEYBOARD_AVOIDING_VIEW]: undefined; [ScreenNames.ENABLED_DISABLED]: undefined; + [ScreenNames.CLOSE]: undefined; }; const Stack = createStackNavigator<ExamplesStackParamList>(); @@ -76,6 +78,9 @@ const options = { [ScreenNames.ENABLED_DISABLED]: { title: 'Enabled/disabled', }, + [ScreenNames.CLOSE]: { + title: 'Close keyboard', + }, }; const ExamplesStack = () => ( @@ -145,6 +150,11 @@ const ExamplesStack = () => ( component={EnabledDisabled} options={options[ScreenNames.ENABLED_DISABLED]} /> + <Stack.Screen + name={ScreenNames.CLOSE} + component={CloseScreen} + options={options[ScreenNames.CLOSE]} + /> </Stack.Navigator> ); diff --git a/FabricExample/src/screens/Examples/Close/index.tsx b/FabricExample/src/screens/Examples/Close/index.tsx new file mode 100644 index 0000000000..1d8150986f --- /dev/null +++ b/FabricExample/src/screens/Examples/Close/index.tsx @@ -0,0 +1,30 @@ +import { Button, StyleSheet, TextInput, View } from "react-native"; +import { KeyboardController } from "react-native-keyboard-controller"; + +function CloseScreen() { + return ( + <View> + <Button + title="Close keyboard" + onPress={() => KeyboardController.dismiss()} + testID="close_keyboard_button" + /> + <TextInput style={styles.input} placeholder="Touch to open the keyboard..." placeholderTextColor="#7C7C7C" /> + </View> + ); +}; + +const styles = StyleSheet.create({ + input: { + height: 50, + width: "84%", + borderWidth: 2, + borderColor: "#3C3C3C", + borderRadius: 8, + alignSelf: 'center', + paddingHorizontal: 8, + marginTop: 16, + }, +}); + +export default CloseScreen; \ No newline at end of file diff --git a/FabricExample/src/screens/Examples/Main/constants.ts b/FabricExample/src/screens/Examples/Main/constants.ts index e3dc5bdeae..2dc10816bc 100644 --- a/FabricExample/src/screens/Examples/Main/constants.ts +++ b/FabricExample/src/screens/Examples/Main/constants.ts @@ -80,4 +80,10 @@ export const examples: Example[] = [ info: ScreenNames.ENABLED_DISABLED, icons: '💡', }, + { + title: 'Close', + testID: 'close', + info: ScreenNames.CLOSE, + icons: '❌', + }, ]; \ No newline at end of file diff --git a/android/src/fabric/java/com/reactnativekeyboardcontroller/KeyboardControllerModule.kt b/android/src/fabric/java/com/reactnativekeyboardcontroller/KeyboardControllerModule.kt index 4423ac70db..8d9c0f3bce 100644 --- a/android/src/fabric/java/com/reactnativekeyboardcontroller/KeyboardControllerModule.kt +++ b/android/src/fabric/java/com/reactnativekeyboardcontroller/KeyboardControllerModule.kt @@ -16,6 +16,10 @@ class KeyboardControllerModule(mReactContext: ReactApplicationContext) : NativeK module.setDefaultMode() } + override fun dismiss() { + module.dismiss() + } + override fun addListener(eventName: String?) { /* Required for RN built-in Event Emitter Calls. */ } diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/modules/KeyboardControllerModuleImpl.kt b/android/src/main/java/com/reactnativekeyboardcontroller/modules/KeyboardControllerModuleImpl.kt index ae2596009c..642d8c9198 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/modules/KeyboardControllerModuleImpl.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/modules/KeyboardControllerModuleImpl.kt @@ -1,6 +1,9 @@ package com.reactnativekeyboardcontroller.modules +import android.content.Context +import android.view.View import android.view.WindowManager +import android.view.inputmethod.InputMethodManager import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.UiThreadUtil @@ -15,6 +18,16 @@ class KeyboardControllerModuleImpl(private val mReactContext: ReactApplicationCo setSoftInputMode(mDefaultMode) } + fun dismiss() { + val activity = mReactContext.currentActivity + val view: View? = activity?.currentFocus + + if (view != null) { + val imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.hideSoftInputFromWindow(view.windowToken, 0) + } + } + private fun setSoftInputMode(mode: Int) { UiThreadUtil.runOnUiThread { if (getCurrentMode() != mode) { diff --git a/android/src/paper/java/com/reactnativekeyboardcontroller/KeyboardControllerModule.kt b/android/src/paper/java/com/reactnativekeyboardcontroller/KeyboardControllerModule.kt index 6972b20770..54728a3996 100644 --- a/android/src/paper/java/com/reactnativekeyboardcontroller/KeyboardControllerModule.kt +++ b/android/src/paper/java/com/reactnativekeyboardcontroller/KeyboardControllerModule.kt @@ -20,6 +20,11 @@ class KeyboardControllerModule(mReactContext: ReactApplicationContext) : ReactCo module.setDefaultMode() } + @ReactMethod + fun dismiss() { + module.dismiss() + } + @Suppress("detekt:UnusedParameter") @ReactMethod fun addListener(eventName: String?) { diff --git a/docs/docs/api/keyboard-controller.md b/docs/docs/api/keyboard-controller.md index 2056685fe3..56f7253e5d 100644 --- a/docs/docs/api/keyboard-controller.md +++ b/docs/docs/api/keyboard-controller.md @@ -1,30 +1,40 @@ --- sidebar_position: 5 -keywords: [react-native-keyboard-controller, KeyboardController, module, windowSoftInputMode, adjustResize, adjustPan] +keywords: [react-native-keyboard-controller, react-native, KeyboardController, module, dismiss, dismiss keyboard, windowSoftInputMode, adjustResize, adjustPan] --- # KeyboardController -`KeyboardController` is an object which has two functions: +The `KeyboardController` module in React Native provides a convenient set of methods for managing the behavior of the keyboard. With seamless runtime adjustments, this module allows developers to dynamically change the `windowSoftInputMode` on Android and dismiss the keyboard on both platforms. -- `setInputMode` - used to change `windowSoftInputMode` in runtime; -- `setDefaultMode` - used to restore default `windowSoftInputMode` (which is declared in `AndroidManifest.xml`); +## Methods -## Example +### `setInputMode` + +This method is used to dynamically change the `windowSoftInputMode` during runtime in an Android application. It takes an argument that specifies the desired input mode. The example provided sets the input mode to `SOFT_INPUT_ADJUST_RESIZE`: ```ts -import { - KeyboardController, - AndroidSoftInputModes, -} from "react-native-keyboard-controller"; - -export const useResizeMode = () => { - useEffect(() => { - KeyboardController.setInputMode( - AndroidSoftInputModes.SOFT_INPUT_ADJUST_RESIZE - ); - - return () => KeyboardController.setDefaultMode(); - }, []); -}; +KeyboardController.setInputMode(AndroidSoftInputModes.SOFT_INPUT_ADJUST_RESIZE); ``` + +### `setDefaultMode` + +This method is used to restore the default `windowSoftInputMode` declared in the `AndroidManifest.xml`. It resets the input mode to the default value: + +```ts +KeyboardController.setDefaultMode(); +``` + +### `dismiss` + +This method is used to hide the keyboard. It triggers the dismissal of the keyboard: + +```ts +KeyboardController.dismiss(); +``` + +:::info What is the difference comparing to `react-native` implementation? +The equivalent method from `react-native` relies on specific internal components, such as `TextInput`, and may not work as intended if a custom input component is used. + +In contrast, the described method enables keyboard dismissal for any focused input, extending functionality beyond the limitations of the default implementation. +::: diff --git a/example/__tests__/close-keyboard.spec.tsx b/example/__tests__/close-keyboard.spec.tsx new file mode 100644 index 0000000000..0ad47bb855 --- /dev/null +++ b/example/__tests__/close-keyboard.spec.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Button } from 'react-native'; +import { fireEvent, render } from '@testing-library/react-native'; + +import { KeyboardController } from 'react-native-keyboard-controller'; + +function CloseKeyboard() { + return ( + <Button + title='Close keyboard' + testID='close_keyboard' + onPress={() => KeyboardController.dismiss()} + /> + ); +} + +describe('closing keyboard flow', () => { + it('should have a mock version of `KeyboardController.dismiss`', () => { + const { getByTestId } = render(<CloseKeyboard />); + + fireEvent.press(getByTestId('close_keyboard')); + + expect(KeyboardController.dismiss).toBeCalledTimes(1); + }); +}); diff --git a/example/src/constants/screenNames.ts b/example/src/constants/screenNames.ts index 3165c2d9e2..6ea05b530c 100644 --- a/example/src/constants/screenNames.ts +++ b/example/src/constants/screenNames.ts @@ -15,4 +15,5 @@ export enum ScreenNames { NATIVE_STACK = 'NATIVE_STACK', KEYBOARD_AVOIDING_VIEW = 'KEYBOARD_AVOIDING_VIEW', ENABLED_DISABLED = 'ENABLED_DISABLED', + CLOSE = 'CLOSE', } diff --git a/example/src/navigation/ExamplesStack/index.tsx b/example/src/navigation/ExamplesStack/index.tsx index 95d2e4566b..09fecc7f6c 100644 --- a/example/src/navigation/ExamplesStack/index.tsx +++ b/example/src/navigation/ExamplesStack/index.tsx @@ -17,6 +17,7 @@ import KeyboardAvoidingViewExample from '../../screens/Examples/KeyboardAvoiding import ReanimatedChatFlatlist from '../../screens/Examples/ReanimatedChatFlatlist'; import EnabledDisabled from '../../screens/Examples/EnabledDisabled'; import AwareScrollViewStickyFooter from '../../screens/Examples/AwareScrollViewStickyFooter'; +import CloseScreen from '../../screens/Examples/Close'; export type ExamplesStackParamList = { [ScreenNames.ANIMATED_EXAMPLE]: undefined; @@ -32,6 +33,7 @@ export type ExamplesStackParamList = { [ScreenNames.NATIVE_STACK]: undefined; [ScreenNames.KEYBOARD_AVOIDING_VIEW]: undefined; [ScreenNames.ENABLED_DISABLED]: undefined; + [ScreenNames.CLOSE]: undefined; }; const Stack = createStackNavigator<ExamplesStackParamList>(); @@ -80,6 +82,9 @@ const options = { [ScreenNames.ENABLED_DISABLED]: { title: 'Enabled/disabled', }, + [ScreenNames.CLOSE]: { + title: 'Close keyboard', + }, }; const ExamplesStack = () => ( @@ -154,6 +159,11 @@ const ExamplesStack = () => ( component={EnabledDisabled} options={options[ScreenNames.ENABLED_DISABLED]} /> + <Stack.Screen + name={ScreenNames.CLOSE} + component={CloseScreen} + options={options[ScreenNames.CLOSE]} + /> </Stack.Navigator> ); diff --git a/example/src/screens/Examples/Close/index.tsx b/example/src/screens/Examples/Close/index.tsx new file mode 100644 index 0000000000..1d8150986f --- /dev/null +++ b/example/src/screens/Examples/Close/index.tsx @@ -0,0 +1,30 @@ +import { Button, StyleSheet, TextInput, View } from "react-native"; +import { KeyboardController } from "react-native-keyboard-controller"; + +function CloseScreen() { + return ( + <View> + <Button + title="Close keyboard" + onPress={() => KeyboardController.dismiss()} + testID="close_keyboard_button" + /> + <TextInput style={styles.input} placeholder="Touch to open the keyboard..." placeholderTextColor="#7C7C7C" /> + </View> + ); +}; + +const styles = StyleSheet.create({ + input: { + height: 50, + width: "84%", + borderWidth: 2, + borderColor: "#3C3C3C", + borderRadius: 8, + alignSelf: 'center', + paddingHorizontal: 8, + marginTop: 16, + }, +}); + +export default CloseScreen; \ No newline at end of file diff --git a/example/src/screens/Examples/Main/constants.ts b/example/src/screens/Examples/Main/constants.ts index ce99a82e4d..9e142fc187 100644 --- a/example/src/screens/Examples/Main/constants.ts +++ b/example/src/screens/Examples/Main/constants.ts @@ -86,4 +86,10 @@ export const examples: Example[] = [ info: ScreenNames.ENABLED_DISABLED, icons: '💡', }, + { + title: 'Close', + testID: 'close', + info: ScreenNames.CLOSE, + icons: '❌', + }, ]; diff --git a/ios/KeyboardControllerModule.mm b/ios/KeyboardControllerModule.mm index 514ad88dbe..f5eeb9a779 100644 --- a/ios/KeyboardControllerModule.mm +++ b/ios/KeyboardControllerModule.mm @@ -61,6 +61,20 @@ - (void)setInputMode:(double)mode { } +#ifdef RCT_NEW_ARCH_ENABLED +- (void)dismiss +#else +RCT_EXPORT_METHOD(dismiss) +#endif +{ + dispatch_async(dispatch_get_main_queue(), ^{ + [[UIApplication sharedApplication] sendAction:@selector(resignFirstResponder) + to:nil + from:nil + forEvent:nil]; + }); +} + + (KeyboardController *)shared { return shared; diff --git a/jest/index.js b/jest/index.js index 1f01c759d0..b4af6b1d27 100644 --- a/jest/index.js +++ b/jest/index.js @@ -39,6 +39,7 @@ const mock = { KeyboardController: { setInputMode: jest.fn(), setDefaultMode: jest.fn(), + dismiss: jest.fn(), }, KeyboardEvents: { addListener: jest.fn(() => ({ remove: jest.fn() })), diff --git a/package.json b/package.json index 1310e733a5..4d2c7022a6 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "react-native", "keyboard", "animation", + "dismiss", "focused input", "text changed", "avoiding view", diff --git a/src/bindings.ts b/src/bindings.ts index 0ec4fd6dea..ca3890ffc1 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -12,6 +12,7 @@ const NOOP = () => {}; export const KeyboardController: KeyboardControllerModule = { setDefaultMode: NOOP, setInputMode: NOOP, + dismiss: NOOP, addListener: NOOP, removeListeners: NOOP, }; diff --git a/src/specs/NativeKeyboardController.ts b/src/specs/NativeKeyboardController.ts index 0da8d64ce9..489378a983 100644 --- a/src/specs/NativeKeyboardController.ts +++ b/src/specs/NativeKeyboardController.ts @@ -8,6 +8,7 @@ export interface Spec extends TurboModule { // methods setInputMode(mode: number): void; setDefaultMode(): void; + dismiss(): void; // event emitter addListener: (eventName: string) => void; diff --git a/src/types.ts b/src/types.ts index be6297de2f..8dba8d0573 100644 --- a/src/types.ts +++ b/src/types.ts @@ -85,6 +85,8 @@ export type KeyboardControllerModule = { // android only setDefaultMode: () => void; setInputMode: (mode: number) => void; + // all platforms + dismiss: () => void; // native event module stuff addListener: (eventName: string) => void; removeListeners: (count: number) => void;