Skip to content

Commit 62b76d5

Browse files
authored
feat: dismiss keeping focus (#720)
## 📜 Description Added an ability to dismiss keyboard while keeping focus. ## 💡 Motivation and Context <!-- Why is this change required? What problem does it solve? --> <!-- If it fixes an open issue, please link to the issue here. --> ## 📢 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 - added a note about using `keepFocus` flag; ### E2E - cover "Close" screen with e2e test; ### JS - added `DismissOptions` to `dismiss` method; - update spec and include `keepFocus` flag; - update examples app; ### iOS - added `KeyboardControllerModuleImpl` file; - attach resign listener + tap gesture if field was dismissed with `keepFocus`; - remove resign listener and tap gesture if keyboard opens again or if responder was resigned; - enhance `TextInput` protocol with new props; - remove `inputView` when we call `setFocusTo("current")`; ### Android - call `view.clearFocus()` conditionally depending on `keepFocus` flag; ## 🤔 How Has This Been Tested? Tested manually on: - Pixel 3a (API 33, emulator); - iPhone 15 Pro (iOS 17.5, simulator) ## 📸 Screenshots (if appropriate): |Keep focus|Android|iOS| |-----------|--------|----| |Yes|<video src="https://github.com/user-attachments/assets/a77bd617-22a3-4143-9adf-007de3c8e4e9">|<video src="https://github.com/user-attachments/assets/5b234a0f-fc65-4245-b1b9-21cb90c86a57">| |No|<video src="https://github.com/user-attachments/assets/3b06a0bf-534c-4ac3-aa93-03aa28d8dc34">|<video src="https://github.com/user-attachments/assets/9f809514-f23e-4f8e-a780-91a3f648bdf8">| ## 📝 Checklist - [x] CI successfully passed - [x] I added new mocks and corresponding unit-tests if library API was changed
1 parent 4886f74 commit 62b76d5

File tree

27 files changed

+225
-21
lines changed

27 files changed

+225
-21
lines changed

FabricExample/src/screens/Examples/Close/index.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,44 @@
1+
import { useRef, useState } from "react";
12
import { Button, StyleSheet, TextInput, View } from "react-native";
23
import { KeyboardController } from "react-native-keyboard-controller";
34

45
function CloseScreen() {
6+
const ref = useRef<TextInput>(null);
7+
const [keepFocus, setKeepFocus] = useState(false);
8+
59
return (
610
<View>
11+
<Button
12+
testID="keep_focus_button"
13+
title={keepFocus ? "Keep focus" : "Don't keep focus"}
14+
onPress={() => setKeepFocus(!keepFocus)}
15+
/>
16+
<Button
17+
testID="set_focus_to_current"
18+
title="KeyboardController.setFocusTo('current')"
19+
onPress={() => KeyboardController.setFocusTo("current")}
20+
/>
21+
<Button
22+
testID="focus_from_ref"
23+
title="Focus from ref"
24+
onPress={() => ref.current?.focus()}
25+
/>
26+
<Button
27+
testID="blur_from_ref"
28+
title="Blur from ref"
29+
onPress={() => ref.current?.blur()}
30+
/>
731
<Button
832
testID="close_keyboard_button"
933
title="Close keyboard"
10-
onPress={KeyboardController.dismiss}
34+
onPress={() => KeyboardController.dismiss({ keepFocus })}
1135
/>
1236
<TextInput
37+
ref={ref}
1338
placeholder="Touch to open the keyboard..."
1439
placeholderTextColor="#7C7C7C"
1540
style={styles.input}
41+
testID="input"
1642
onBlur={() => console.log("blur")}
1743
onFocus={() => console.log("focus")}
1844
/>

android/src/fabric/java/com/reactnativekeyboardcontroller/KeyboardControllerModule.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ class KeyboardControllerModule(
1818
module.setDefaultMode()
1919
}
2020

21-
override fun dismiss() {
22-
module.dismiss()
21+
override fun dismiss(keepFocus: Boolean) {
22+
module.dismiss(keepFocus)
2323
}
2424

2525
override fun setFocusTo(direction: String) {

android/src/main/java/com/reactnativekeyboardcontroller/modules/KeyboardControllerModuleImpl.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,17 @@ class KeyboardControllerModuleImpl(
2222
setSoftInputMode(mDefaultMode)
2323
}
2424

25-
fun dismiss() {
25+
fun dismiss(keepFocus: Boolean) {
2626
val activity = mReactContext.currentActivity
2727
val view: View? = FocusedInputHolder.get()
2828

2929
if (view != null) {
3030
UiThreadUtil.runOnUiThread {
3131
val imm = activity?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
3232
imm?.hideSoftInputFromWindow(view.windowToken, 0)
33-
view.clearFocus()
33+
if (!keepFocus) {
34+
view.clearFocus()
35+
}
3436
}
3537
}
3638
}

android/src/paper/java/com/reactnativekeyboardcontroller/KeyboardControllerModule.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ class KeyboardControllerModule(
2323
}
2424

2525
@ReactMethod
26-
fun dismiss() {
27-
module.dismiss()
26+
fun dismiss(keepFocus: Boolean) {
27+
module.dismiss(keepFocus)
2828
}
2929

3030
@ReactMethod

docs/docs/api/keyboard-controller.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ KeyboardController.setDefaultMode();
5555
### `dismiss`
5656

5757
```ts
58-
static dismiss(): Promise<void>;
58+
static dismiss(options?: DismissOptions): Promise<void>;
5959
```
6060

6161
This method is used to hide the keyboard. It triggers the dismissal of the keyboard. The method returns promise that will be resolved only when keyboard is fully hidden (if keyboard is already hidden it will resolve immediately):
@@ -64,6 +64,12 @@ This method is used to hide the keyboard. It triggers the dismissal of the keybo
6464
await KeyboardController.dismiss();
6565
```
6666

67+
If you want to hide a keyboard and keep focus then you can pass `keepFocus` option:
68+
69+
```ts
70+
await KeyboardController.dismiss({ keepFocus: true });
71+
```
72+
6773
:::info What is the difference comparing to `react-native` implementation?
6874
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.
6975

e2e/.detoxrc.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ module.exports = {
6666
type: "ios.simulator",
6767
device: {
6868
type: "iPhone 16 Pro",
69-
os: "iOS 18.0",
69+
os: "iOS 18.1",
7070
},
7171
},
7272
attached: {

e2e/kit/012-close-keyboard.e2e.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { expect } from "detox";
2+
3+
import { expectBitmapsToBeEqual } from "./asserts";
4+
import {
5+
scrollDownUntilElementIsVisible,
6+
waitAndTap,
7+
waitForExpect,
8+
} from "./helpers";
9+
10+
describe("`KeyboardController.dismiss()` specification", () => {
11+
it("should navigate to `CloseKeyboard` screen", async () => {
12+
await scrollDownUntilElementIsVisible("main_scroll_view", "close");
13+
await waitAndTap("close");
14+
});
15+
16+
it("should show keyboard", async () => {
17+
await waitAndTap("input");
18+
await waitForExpect(async () => {
19+
await expectBitmapsToBeEqual("CloseKeyboardOpened");
20+
});
21+
});
22+
23+
it("should dismiss keyboard loosing focus", async () => {
24+
await waitAndTap("close_keyboard_button");
25+
await expect(element(by.id("input"))).not.toBeFocused();
26+
});
27+
28+
it("should show keyboard again when input tapped", async () => {
29+
await waitAndTap("input");
30+
await waitForExpect(async () => {
31+
await expectBitmapsToBeEqual("CloseKeyboardOpened");
32+
});
33+
});
34+
35+
it("should dismiss keyboard keeping focus", async () => {
36+
await waitAndTap("keep_focus_button");
37+
await waitAndTap("close_keyboard_button");
38+
await expect(element(by.id("input"))).toBeFocused();
39+
});
40+
41+
it("should show keyboard again when input with focus tapped", async () => {
42+
await waitAndTap("input");
43+
await waitForExpect(async () => {
44+
await expectBitmapsToBeEqual("CloseKeyboardOpenedKeepingFocus");
45+
});
46+
});
47+
48+
it("should dismiss keyboard", async () => {
49+
await waitAndTap("close_keyboard_button");
50+
await expect(element(by.id("input"))).toBeFocused();
51+
});
52+
53+
it("should show keyboard when `KeyboardController.setFocusTo('current')` is called", async () => {
54+
await waitAndTap("set_focus_to_current");
55+
await waitForExpect(async () => {
56+
await expectBitmapsToBeEqual("CloseKeyboardOpenedKeepingFocus");
57+
});
58+
});
59+
60+
it("should dismiss keyboard and blur input if `.blur()` is called", async () => {
61+
await waitAndTap("blur_from_ref");
62+
await expect(element(by.id("input"))).not.toBeFocused();
63+
});
64+
});
Loading
Loading
Loading

0 commit comments

Comments
 (0)