Skip to content

Commit 60ec0ce

Browse files
authored
feat: keyboard state selector (#998)
## 📜 Description Added ability to pass selector to `useKeyboardState` hook to query only necessary updates. ## 💡 Motivation and Context The concept of selectors should be very familiar to many RN developers, since it's used in `redux` and `zustand` packages. I was also thinking about optimizing the amount of re-renders for this hook, but initially I've been thinking of using proxy (similar to what `react-hook-form` is doing). But such approach will introduce a lot of complexity, so I decided to keep the codebase simple and efficient, so added a concept of selectors. Additionally I re-worked documnetation mainly focusing on: - adding a sample how `useKeyboardAnimation` can be used as alternative; - making warnings/tips shorter with corresponding references; - adding the usage for selectors concept; - use a real-world example with `useKeyboardState` (conditional rendering instead of changing styles). ## 📢 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 --> ### JS - allow to pass selectors to `useKeyboardState`; ### Docs - make warning/tips shorter with corresponding links; - added `useKeyboardState` vs `useKeyboardAnimation` code reference; - mention how to use selectors; - re-work example app to match real-world scenarios usage. ## 🤔 How Has This Been Tested? Tested manually in example project. ## 📸 Screenshots (if appropriate): |Dark theme|Light theme| |------------|------------| |![image](https://github.com/user-attachments/assets/11eb0c85-7d6d-43a5-9d81-0bfc072f9042)|![image](https://github.com/user-attachments/assets/693c7580-5780-4ab6-8799-43503684e075)| ## 📝 Checklist - [x] CI successfully passed - [x] I added new mocks and corresponding unit-tests if library API was changed
1 parent c299547 commit 60ec0ce

File tree

7 files changed

+116
-33
lines changed

7 files changed

+116
-33
lines changed
Loading

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import React, { useEffect } from "react";
2-
import { Image, StyleSheet, TextInput } from "react-native";
2+
import { Image, StyleSheet, TextInput, useColorScheme } from "react-native";
33
import {
44
KeyboardBackgroundView,
55
KeyboardEvents,
66
KeyboardStickyView,
7+
useKeyboardState,
78
} from "react-native-keyboard-controller";
89
import Reanimated, {
910
interpolate,
@@ -16,13 +17,19 @@ import {
1617
useSafeAreaInsets,
1718
} from "react-native-safe-area-context";
1819

20+
import DarkIcon from "./ai-dark.png";
21+
import LightIcon from "./ai.png";
22+
1923
const ReanimatedBackgroundView = Reanimated.createAnimatedComponent(
2024
KeyboardBackgroundView,
2125
);
2226

2327
const LiquidKeyboardExample = () => {
28+
const scheme = useColorScheme();
29+
const appearance = useKeyboardState((state) => state.appearance);
2430
const progress = useSharedValue(0);
2531
const { bottom } = useSafeAreaInsets();
32+
const color = appearance === "default" ? scheme : appearance;
2633

2734
useEffect(() => {
2835
progress.set(0);
@@ -98,7 +105,7 @@ const LiquidKeyboardExample = () => {
98105
]}
99106
>
100107
<Image
101-
source={require("./ai.png")}
108+
source={color === "dark" ? LightIcon : DarkIcon}
102109
style={{ transform: [{ rotate: "-45deg" }], width: 20, height: 20 }}
103110
/>
104111
</ReanimatedBackgroundView>

docs/docs/api/hooks/keyboard/use-keyboard-state.mdx

Lines changed: 80 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,42 @@ sidebar_position: 4
2020
`useKeyboardState` is a hook which gives an access to current keyboard state. This hook combines data from `KeyboardController.state()` and `KeyboardController.isVisible()` methods and makes it reactive (i. e. triggers a re-render when keyboard state/visibility has changed).
2121

2222
:::warning
23-
Use this hook only when you need to control `props` of views returned in JSX-markup. If you need to access the keyboard `state` in callbacks or event handlers then consider to use [KeyboardController.state()](../../keyboard-controller.md#state) or [KeyboardController.isVisible()](../../keyboard-controller.md#isvisible) methods instead. This allows you to retrieve values as needed without triggering unnecessary re-renders.
23+
Don’t use `state` from `useKeyboardState` inside event handlers. It will cause unnecessary re-renders. See [common pitfalls](#-common-pitfalls) section for more details and alternatives.
24+
:::
25+
26+
:::tip
27+
Make sure that if you want to animate something based on keyboard presence then you've seen [optimize animation](#%EF%B8%8F-optimize-animations-with-native-threads) section.
28+
:::
29+
30+
`useKeyboardState` allows you to pass a **selector** function to pick only the necessary part of the keyboard state data. This is a powerful technique to prevent unnecessary re-renders of your component when only a specific property of the keyboard state changes.
31+
32+
```ts
33+
const appearance = useKeyboardState((state) => state.appearance);
34+
```
35+
36+
In this example, your component will only re-render when the `appearance` property of the `KeyboardState` changes, rather than for any change in the entire state object.
37+
38+
## Data structure
39+
40+
The `KeyboardState` is represented by following structure:
41+
42+
```ts
43+
type KeyboardState = {
44+
isVisible: boolean;
45+
height: number;
46+
duration: number; // duration of the animation
47+
timestamp: number; // timestamp of the event from native thread
48+
target: number; // tag of the focused `TextInput`
49+
type: string; // `keyboardType` property from focused `TextInput`
50+
appearance: string; // `keyboardAppearance` property from focused `TextInput`
51+
};
52+
```
53+
54+
## 🚫 Common Pitfalls
55+
56+
### ⚠️ Avoid Unnecessary Re-renders
57+
58+
If you need to access the keyboard `state` in callbacks or event handlers then consider to use [KeyboardController.state()](../../keyboard-controller.md#state) or [KeyboardController.isVisible()](../../keyboard-controller.md#isvisible) methods instead. This allows you to retrieve values as needed without triggering unnecessary re-renders.
2459

2560
<div className="code-grid">
2661
<div className="code-block">
@@ -30,7 +65,7 @@ Use this hook only when you need to control `props` of views returned in JSX-mar
3065

3166
<Button
3267
onPress={() => {
33-
// read value on demand
68+
// read value on demand
3469
if (KeyboardController.isVisible()) {
3570
// ...
3671
}
@@ -48,7 +83,7 @@ const { isVisible } = useKeyboardState();
4883

4984
<Button
5085
onPress={() => {
51-
// don't consume state from hook
86+
// don't consume state from hook
5287
if (isVisible) {
5388
// ...
5489
}
@@ -61,42 +96,64 @@ const { isVisible } = useKeyboardState();
6196
</div>
6297
</div>
6398

64-
:::
99+
### ⚡️ Optimize Animations with Native Threads
65100

66-
:::tip
67-
Also make sure that if you need to change style based on keyboard presence then you are using corresponding [animated](./use-keyboard-animation) hooks to offload animation to a native thread and free up resources for JS thread.
68-
:::
101+
Don't use `useKeyboardState` for controlling styles, because:
69102

70-
## Data structure
103+
- applying it directly to styles can lead to choppy animations;
104+
- it changes its values frequently making excessive re-renders on each keyboard state change.
71105

72-
The `KeyboardState` is represented by following structure:
106+
If you need to change styles then you can use "animated" hooks such as [useKeyboardAnimation](./use-keyboard-animation), [useReanimatedKeyboardAnimation](./use-reanimated-keyboard-animation) or even [useKeyboardHandler](./use-keyboard-handler) to offload animation to a native thread and free up resources for JS thread.
73107

74-
```ts
75-
type KeyboardState = {
76-
isVisible: boolean;
77-
height: number;
78-
duration: number; // duration of the animation
79-
timestamp: number; // timestamp of the event from native thread
80-
target: number; // tag of the focused `TextInput`
81-
type: string; // `keyboardType` property from focused `TextInput`
82-
appearance: string; // `keyboardAppearance` property from focused `TextInput`
83-
};
108+
<div className="code-grid">
109+
<div className="code-block">
110+
111+
```tsx title="✅ Recommended 👍"
112+
const { height } = useKeyboardAnimation();
113+
114+
<Animated.View
115+
style={{
116+
width: "100%",
117+
transform: [{ translateY: height }],
118+
}}
119+
>
120+
...
121+
</Animated.View>;
84122
```
85123

124+
</div>
125+
<div className="code-block">
126+
127+
```tsx title="❌ Not recommended 🙅‍♂️"
128+
const { height } = useKeyboardState();
129+
130+
<View
131+
style={{
132+
width: "100%",
133+
transform: [{ translateY: height }],
134+
}}
135+
>
136+
...
137+
</View>;
138+
```
139+
140+
</div>
141+
</div>
142+
86143
## Example
87144

88145
```tsx
89146
import { View, Text, StyleSheet } from "react-native";
90147
import { useKeyboardState } from "react-native-keyboard-controller";
91148

92149
const ShowcaseComponent = () => {
93-
const { isVisible } = useKeyboardState();
150+
const isVisible = useKeyboardState((state) => state.isVisible);
94151

95-
return (
96-
<View style={isVisible ? styles.highlighted : null}>
152+
return isVisible ? (
153+
<View style={styles.highlighted}>
97154
<Text>Address form</Text>
98155
</View>
99-
);
156+
) : null;
100157
};
101158

102159
const styles = StyleSheet.create({
Loading

example/src/screens/Examples/LiquidKeyboard/index.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import React, { useEffect } from "react";
2-
import { Image, StyleSheet, TextInput } from "react-native";
2+
import { Image, StyleSheet, TextInput, useColorScheme } from "react-native";
33
import {
44
KeyboardBackgroundView,
55
KeyboardEvents,
66
KeyboardStickyView,
7+
useKeyboardState,
78
} from "react-native-keyboard-controller";
89
import Reanimated, {
910
interpolate,
@@ -16,13 +17,19 @@ import {
1617
useSafeAreaInsets,
1718
} from "react-native-safe-area-context";
1819

20+
import DarkIcon from "./ai-dark.png";
21+
import LightIcon from "./ai.png";
22+
1923
const ReanimatedBackgroundView = Reanimated.createAnimatedComponent(
2024
KeyboardBackgroundView,
2125
);
2226

2327
const LiquidKeyboardExample = () => {
28+
const scheme = useColorScheme();
29+
const appearance = useKeyboardState((state) => state.appearance);
2430
const progress = useSharedValue(0);
2531
const { bottom } = useSafeAreaInsets();
32+
const color = appearance === "default" ? scheme : appearance;
2633

2734
useEffect(() => {
2835
progress.set(0);
@@ -98,7 +105,7 @@ const LiquidKeyboardExample = () => {
98105
]}
99106
>
100107
<Image
101-
source={require("./ai.png")}
108+
source={color === "dark" ? LightIcon : DarkIcon}
102109
style={{ transform: [{ rotate: "-45deg" }], width: 20, height: 20 }}
103110
/>
104111
</ReanimatedBackgroundView>

jest/index.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ const mock = {
5151
useGenericKeyboardHandler: jest.fn(),
5252
useKeyboardHandler: jest.fn(),
5353
useKeyboardContext: jest.fn().mockReturnValue(values),
54-
useKeyboardState: jest.fn().mockReturnValue(state),
54+
useKeyboardState: jest
55+
.fn()
56+
.mockImplementation((selector) => (selector ? selector(state) : state)),
5557
/// input
5658
useReanimatedFocusedInput: jest.fn().mockReturnValue(focusedInput),
5759
useFocusedInputHandler: jest.fn(),

src/hooks/useKeyboardState/index.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,17 @@ const getLatestState = () => ({
1212
isVisible: KeyboardController.isVisible(),
1313
});
1414

15+
type KeyboardStateSelector<T> = (state: KeyboardState) => T;
16+
17+
const defaultSelector: KeyboardStateSelector<KeyboardState> = (state) => state;
18+
1519
/**
1620
* React Hook that represents the current keyboard state on iOS and Android.
1721
* It tracks keyboard visibility, height, appearance, type and other properties.
1822
* This hook subscribes to keyboard events and updates the state reactively.
1923
*
24+
* @template T - A type of the returned object from the `selector`.
25+
* @param selector - A function that receives the current keyboard state and picks only necessary properties to avoid frequent re-renders.
2026
* @returns Object {@link KeyboardState|containing} keyboard state information.
2127
* @see {@link https://kirillzyusko.github.io/react-native-keyboard-controller/docs/api/hooks/keyboard/use-keyboard-state|Documentation} page for more details.
2228
* @example
@@ -32,27 +38,31 @@ const getLatestState = () => ({
3238
* }
3339
* ```
3440
*/
35-
export const useKeyboardState = (): KeyboardState => {
36-
const [state, setState] = useState(getLatestState);
41+
function useKeyboardState<T = KeyboardState>(
42+
selector: KeyboardStateSelector<T> = defaultSelector as KeyboardStateSelector<T>,
43+
): T {
44+
const [state, setState] = useState<T>(() => selector(getLatestState()));
3745

3846
useEffect(() => {
3947
const subscriptions = EVENTS.map((event) =>
4048
KeyboardEvents.addListener(event, () =>
4149
// state will be updated by global listener first,
4250
// so we simply read it and don't derive data from the event
43-
setState(getLatestState),
51+
setState(selector(getLatestState())),
4452
),
4553
);
4654

4755
// we might have missed an update between reading a value in render and
4856
// `addListener` in this handler, so we set it here. If there was
4957
// no change, React will filter out this update as a no-op.
50-
setState(getLatestState);
58+
setState(selector(getLatestState()));
5159

5260
return () => {
5361
subscriptions.forEach((subscription) => subscription.remove());
5462
};
5563
}, []);
5664

5765
return state;
58-
};
66+
}
67+
68+
export { useKeyboardState };

0 commit comments

Comments
 (0)