Skip to content

Commit

Permalink
feat: dismiss keyboard (#306)
Browse files Browse the repository at this point in the history
## 📜 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
  • Loading branch information
kirillzyusko authored Dec 23, 2023
1 parent b4ca627 commit 1651da4
Show file tree
Hide file tree
Showing 20 changed files with 215 additions and 19 deletions.
25 changes: 25 additions & 0 deletions FabricExample/__tests__/close-keyboard.spec.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
1 change: 1 addition & 0 deletions FabricExample/src/constants/screenNames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ export enum ScreenNames {
NATIVE_STACK = 'NATIVE_STACK',
KEYBOARD_AVOIDING_VIEW = 'KEYBOARD_AVOIDING_VIEW',
ENABLED_DISABLED = 'ENABLED_DISABLED',
CLOSE = 'CLOSE',
}
10 changes: 10 additions & 0 deletions FabricExample/src/navigation/ExamplesStack/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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>();
Expand Down Expand Up @@ -76,6 +78,9 @@ const options = {
[ScreenNames.ENABLED_DISABLED]: {
title: 'Enabled/disabled',
},
[ScreenNames.CLOSE]: {
title: 'Close keyboard',
},
};

const ExamplesStack = () => (
Expand Down Expand Up @@ -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>
);

Expand Down
30 changes: 30 additions & 0 deletions FabricExample/src/screens/Examples/Close/index.tsx
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 6 additions & 0 deletions FabricExample/src/screens/Examples/Main/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,10 @@ export const examples: Example[] = [
info: ScreenNames.ENABLED_DISABLED,
icons: '💡',
},
{
title: 'Close',
testID: 'close',
info: ScreenNames.CLOSE,
icons: '❌',
},
];
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ class KeyboardControllerModule(mReactContext: ReactApplicationContext) : ReactCo
module.setDefaultMode()
}

@ReactMethod
fun dismiss() {
module.dismiss()
}

@Suppress("detekt:UnusedParameter")
@ReactMethod
fun addListener(eventName: String?) {
Expand Down
48 changes: 29 additions & 19 deletions docs/docs/api/keyboard-controller.md
Original file line number Diff line number Diff line change
@@ -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.
:::
25 changes: 25 additions & 0 deletions example/__tests__/close-keyboard.spec.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
1 change: 1 addition & 0 deletions example/src/constants/screenNames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ export enum ScreenNames {
NATIVE_STACK = 'NATIVE_STACK',
KEYBOARD_AVOIDING_VIEW = 'KEYBOARD_AVOIDING_VIEW',
ENABLED_DISABLED = 'ENABLED_DISABLED',
CLOSE = 'CLOSE',
}
10 changes: 10 additions & 0 deletions example/src/navigation/ExamplesStack/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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>();
Expand Down Expand Up @@ -80,6 +82,9 @@ const options = {
[ScreenNames.ENABLED_DISABLED]: {
title: 'Enabled/disabled',
},
[ScreenNames.CLOSE]: {
title: 'Close keyboard',
},
};

const ExamplesStack = () => (
Expand Down Expand Up @@ -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>
);

Expand Down
30 changes: 30 additions & 0 deletions example/src/screens/Examples/Close/index.tsx
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 6 additions & 0 deletions example/src/screens/Examples/Main/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,10 @@ export const examples: Example[] = [
info: ScreenNames.ENABLED_DISABLED,
icons: '💡',
},
{
title: 'Close',
testID: 'close',
info: ScreenNames.CLOSE,
icons: '❌',
},
];
14 changes: 14 additions & 0 deletions ios/KeyboardControllerModule.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions jest/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const mock = {
KeyboardController: {
setInputMode: jest.fn(),
setDefaultMode: jest.fn(),
dismiss: jest.fn(),
},
KeyboardEvents: {
addListener: jest.fn(() => ({ remove: jest.fn() })),
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"react-native",
"keyboard",
"animation",
"dismiss",
"focused input",
"text changed",
"avoiding view",
Expand Down
1 change: 1 addition & 0 deletions src/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const NOOP = () => {};
export const KeyboardController: KeyboardControllerModule = {
setDefaultMode: NOOP,
setInputMode: NOOP,
dismiss: NOOP,
addListener: NOOP,
removeListeners: NOOP,
};
Expand Down
1 change: 1 addition & 0 deletions src/specs/NativeKeyboardController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface Spec extends TurboModule {
// methods
setInputMode(mode: number): void;
setDefaultMode(): void;
dismiss(): void;

// event emitter
addListener: (eventName: string) => void;
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down

0 comments on commit 1651da4

Please sign in to comment.