Skip to content

Commit

Permalink
feat: dismiss keeping focus (#720)
Browse files Browse the repository at this point in the history
## 📜 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
  • Loading branch information
kirillzyusko authored Dec 10, 2024
1 parent 4886f74 commit 62b76d5
Show file tree
Hide file tree
Showing 27 changed files with 225 additions and 21 deletions.
28 changes: 27 additions & 1 deletion FabricExample/src/screens/Examples/Close/index.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,44 @@
import { useRef, useState } from "react";
import { Button, StyleSheet, TextInput, View } from "react-native";
import { KeyboardController } from "react-native-keyboard-controller";

function CloseScreen() {
const ref = useRef<TextInput>(null);
const [keepFocus, setKeepFocus] = useState(false);

return (
<View>
<Button
testID="keep_focus_button"
title={keepFocus ? "Keep focus" : "Don't keep focus"}
onPress={() => setKeepFocus(!keepFocus)}
/>
<Button
testID="set_focus_to_current"
title="KeyboardController.setFocusTo('current')"
onPress={() => KeyboardController.setFocusTo("current")}
/>
<Button
testID="focus_from_ref"
title="Focus from ref"
onPress={() => ref.current?.focus()}
/>
<Button
testID="blur_from_ref"
title="Blur from ref"
onPress={() => ref.current?.blur()}
/>
<Button
testID="close_keyboard_button"
title="Close keyboard"
onPress={KeyboardController.dismiss}
onPress={() => KeyboardController.dismiss({ keepFocus })}
/>
<TextInput
ref={ref}
placeholder="Touch to open the keyboard..."
placeholderTextColor="#7C7C7C"
style={styles.input}
testID="input"
onBlur={() => console.log("blur")}
onFocus={() => console.log("focus")}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ class KeyboardControllerModule(
module.setDefaultMode()
}

override fun dismiss() {
module.dismiss()
override fun dismiss(keepFocus: Boolean) {
module.dismiss(keepFocus)
}

override fun setFocusTo(direction: String) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,17 @@ class KeyboardControllerModuleImpl(
setSoftInputMode(mDefaultMode)
}

fun dismiss() {
fun dismiss(keepFocus: Boolean) {
val activity = mReactContext.currentActivity
val view: View? = FocusedInputHolder.get()

if (view != null) {
UiThreadUtil.runOnUiThread {
val imm = activity?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.hideSoftInputFromWindow(view.windowToken, 0)
view.clearFocus()
if (!keepFocus) {
view.clearFocus()
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ class KeyboardControllerModule(
}

@ReactMethod
fun dismiss() {
module.dismiss()
fun dismiss(keepFocus: Boolean) {
module.dismiss(keepFocus)
}

@ReactMethod
Expand Down
8 changes: 7 additions & 1 deletion docs/docs/api/keyboard-controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ KeyboardController.setDefaultMode();
### `dismiss`

```ts
static dismiss(): Promise<void>;
static dismiss(options?: DismissOptions): Promise<void>;
```

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):
Expand All @@ -64,6 +64,12 @@ This method is used to hide the keyboard. It triggers the dismissal of the keybo
await KeyboardController.dismiss();
```

If you want to hide a keyboard and keep focus then you can pass `keepFocus` option:

```ts
await KeyboardController.dismiss({ keepFocus: true });
```

:::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.

Expand Down
2 changes: 1 addition & 1 deletion e2e/.detoxrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ module.exports = {
type: "ios.simulator",
device: {
type: "iPhone 16 Pro",
os: "iOS 18.0",
os: "iOS 18.1",
},
},
attached: {
Expand Down
64 changes: 64 additions & 0 deletions e2e/kit/012-close-keyboard.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { expect } from "detox";

import { expectBitmapsToBeEqual } from "./asserts";
import {
scrollDownUntilElementIsVisible,
waitAndTap,
waitForExpect,
} from "./helpers";

describe("`KeyboardController.dismiss()` specification", () => {
it("should navigate to `CloseKeyboard` screen", async () => {
await scrollDownUntilElementIsVisible("main_scroll_view", "close");
await waitAndTap("close");
});

it("should show keyboard", async () => {
await waitAndTap("input");
await waitForExpect(async () => {
await expectBitmapsToBeEqual("CloseKeyboardOpened");
});
});

it("should dismiss keyboard loosing focus", async () => {
await waitAndTap("close_keyboard_button");
await expect(element(by.id("input"))).not.toBeFocused();
});

it("should show keyboard again when input tapped", async () => {
await waitAndTap("input");
await waitForExpect(async () => {
await expectBitmapsToBeEqual("CloseKeyboardOpened");
});
});

it("should dismiss keyboard keeping focus", async () => {
await waitAndTap("keep_focus_button");
await waitAndTap("close_keyboard_button");
await expect(element(by.id("input"))).toBeFocused();
});

it("should show keyboard again when input with focus tapped", async () => {
await waitAndTap("input");
await waitForExpect(async () => {
await expectBitmapsToBeEqual("CloseKeyboardOpenedKeepingFocus");
});
});

it("should dismiss keyboard", async () => {
await waitAndTap("close_keyboard_button");
await expect(element(by.id("input"))).toBeFocused();
});

it("should show keyboard when `KeyboardController.setFocusTo('current')` is called", async () => {
await waitAndTap("set_focus_to_current");
await waitForExpect(async () => {
await expectBitmapsToBeEqual("CloseKeyboardOpenedKeepingFocus");
});
});

it("should dismiss keyboard and blur input if `.blur()` is called", async () => {
await waitAndTap("blur_from_ref");
await expect(element(by.id("input"))).not.toBeFocused();
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 27 additions & 1 deletion example/src/screens/Examples/Close/index.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,44 @@
import { useRef, useState } from "react";
import { Button, StyleSheet, TextInput, View } from "react-native";
import { KeyboardController } from "react-native-keyboard-controller";

function CloseScreen() {
const ref = useRef<TextInput>(null);
const [keepFocus, setKeepFocus] = useState(false);

return (
<View>
<Button
testID="keep_focus_button"
title={keepFocus ? "Keep focus" : "Don't keep focus"}
onPress={() => setKeepFocus(!keepFocus)}
/>
<Button
testID="set_focus_to_current"
title="KeyboardController.setFocusTo('current')"
onPress={() => KeyboardController.setFocusTo("current")}
/>
<Button
testID="focus_from_ref"
title="Focus from ref"
onPress={() => ref.current?.focus()}
/>
<Button
testID="blur_from_ref"
title="Blur from ref"
onPress={() => ref.current?.blur()}
/>
<Button
testID="close_keyboard_button"
title="Close keyboard"
onPress={KeyboardController.dismiss}
onPress={() => KeyboardController.dismiss({ keepFocus })}
/>
<TextInput
ref={ref}
placeholder="Touch to open the keyboard..."
placeholderTextColor="#7C7C7C"
style={styles.input}
testID="input"
onBlur={() => console.log("blur")}
onFocus={() => console.log("focus")}
/>
Expand Down
6 changes: 3 additions & 3 deletions ios/KeyboardControllerModule.mm
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,13 @@ - (void)setInputMode:(double)mode
}

#ifdef RCT_NEW_ARCH_ENABLED
- (void)dismiss
- (void)dismiss:(BOOL)keepFocus
#else
RCT_EXPORT_METHOD(dismiss)
RCT_EXPORT_METHOD(dismiss : (BOOL)keepFocus)
#endif
{
dispatch_async(dispatch_get_main_queue(), ^{
[[UIResponder current] resignFirstResponder];
[KeyboardControllerModuleImpl dismiss:keepFocus];
});
}

Expand Down
66 changes: 66 additions & 0 deletions ios/KeyboardControllerModuleImpl.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//
// KeyboardControllerModuleImpl.swift
// Pods
//
// Created by Kiryl Ziusko on 19/11/2024.
//

import Foundation
import UIKit

@objc(KeyboardControllerModuleImpl)
public class KeyboardControllerModuleImpl: NSObject {
private static let keyboardRevealGestureName = "keyboardRevealGesture"

@objc
public static func dismiss(_ keepFocus: Bool) {
guard let input = UIResponder.current as? TextInput else { return }

if keepFocus {
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(onTextInputTapped(_:)))
tapGesture.name = keyboardRevealGestureName
input.addGestureRecognizer(tapGesture)

input.inputView = UIView()
input.reloadInputViews()

NotificationCenter.default.addObserver(
self,
selector: #selector(onResponderResigned(_:)),
name: UITextField.textDidEndEditingNotification,
object: input
)
} else {
input.resignFirstResponder()
}
}

@objc static func onTextInputTapped(_ gesture: UITapGestureRecognizer) {
if gesture.state == .ended {
guard let input = UIResponder.current as? TextInput else { return }

cleanup(input)

input.becomeFirstResponder()
}
}

@objc static func onResponderResigned(_ notification: Notification) {
guard let input = notification.object as? TextInput else { return }

cleanup(input)
}

static func cleanup(_ input: TextInput) {
input.inputView = nil
input.reloadInputViews()

if let gestures = input.gestureRecognizers {
for gesture in gestures where gesture.name == keyboardRevealGestureName {
input.removeGestureRecognizer(gesture)
}
}

NotificationCenter.default.removeObserver(self, name: UITextField.textDidEndEditingNotification, object: input)
}
}
4 changes: 3 additions & 1 deletion ios/protocols/TextInput.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
import Foundation
import UIKit

public protocol TextInput: AnyObject {
public protocol TextInput: UIView {
// default common methods/properties
var inputView: UIView? { get set }
var keyboardType: UIKeyboardType { get }
var keyboardAppearance: UIKeyboardAppearance { get }
func focus()
Expand Down
5 changes: 4 additions & 1 deletion ios/traversal/ViewHierarchyNavigator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ public class ViewHierarchyNavigator: NSObject {
@objc public static func setFocusTo(direction: String) {
DispatchQueue.main.async {
if direction == "current" {
FocusedInputHolder.shared.focus()
let input = FocusedInputHolder.shared.get()
input?.inputView = nil
input?.reloadInputViews()
input?.focus()
return
}

Expand Down
12 changes: 9 additions & 3 deletions src/module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { KeyboardControllerNative, KeyboardEvents } from "./bindings";

import type { KeyboardControllerModule, KeyboardEventData } from "./types";
import type {
DismissOptions,
KeyboardControllerModule,
KeyboardEventData,
} from "./types";

let isClosed = false;
let lastEvent: KeyboardEventData | null = null;
Expand All @@ -15,7 +19,9 @@ KeyboardEvents.addListener("keyboardDidShow", (e) => {
lastEvent = e;
});

const dismiss = async (): Promise<void> => {
const dismiss = async (
{ keepFocus }: DismissOptions = { keepFocus: false },
): Promise<void> => {
return new Promise((resolve) => {
if (isClosed) {
resolve();
Expand All @@ -28,7 +34,7 @@ const dismiss = async (): Promise<void> => {
subscription.remove();
});

KeyboardControllerNative.dismiss();
KeyboardControllerNative.dismiss(keepFocus);
});
};
const isVisible = () => !isClosed;
Expand Down
2 changes: 1 addition & 1 deletion src/specs/NativeKeyboardController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export interface Spec extends TurboModule {
// methods
setInputMode(mode: number): void;
setDefaultMode(): void;
dismiss(): void;
dismiss(keepFocus: boolean): void;
setFocusTo(direction: string): void;

// event emitter
Expand Down
Loading

0 comments on commit 62b76d5

Please sign in to comment.