Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: dismiss keeping focus #720

Merged
merged 9 commits into from
Dec 10, 2024
Merged
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
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 }
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
Loading