Skip to content

Commit 4e65942

Browse files
authored
feat: focused input (#257)
## 📜 Description Added new `useReanimatedFocusedInput` hook that returns an information about `TextInput` that currently is in focus. ## 💡 Motivation and Context Before we could use a `target` property to measure `TextInput`. However such approach had several disadvantages: - on Fabric you need to have an access to `ShadowNode` and it adds more complexity to the code (and you need to use `findNodeHandle` which is deprecated in StrictMode); - if view layout was changed - you are still not aware about it, because `useKeyboardHandler` is not reporting such cases (and obviously it shouldn't); - it seems like measure on Fabric works differently and you need to take into consideration other UI elements (header height, etc.) With the introduction of this hook all these disadvantages are gone 😎 In this hook can be enhanced to cover more cases (for example add method `update` which can synchronously update layout before reading it, or update layout when certain lifecycle events occurs (for example screen rotation)). Closes #249 #222 ## 📢 Changelog ### Docs - added a new group `Input` in API section; - added a new page for new `useReanimatedFocusedInput` hook; - added new key feature in README; - added new keywords to `package.json`. ### JS - update KeyboardAwareScrollView UI (don't generate colors randomly anymore); - detect growth of `TextInputs`; - unified codebase across RN architectures (fabric, paper); - added new unit tests for new hook; ### iOS - added `KVO` to focused input; ### Android - all listeners (callbacks, observers) now live in `listeners` package; - removed zombie-view (on Fabric) - listener clean up after setEnabled call ## 🤔 How Has This Been Tested? Tested manually on (both paper and fabric): - iPhone 6s (iOS 15.6); - iPhone 11 (iOS 17.0); - Pixel 7 Pro (Android 14); - Xiaomi Redmi Note 5 Pro (Android 9); - iPhone 15 (iOS 17.0, simulator); - Pixel 3a (Android 13, emulator). ## 📸 Screenshots (if appropriate): https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/a82f8dcc-41d3-475f-925e-c4bf574f7967 ## 📝 Checklist - [x] CI successfully passed
1 parent 2cd92a2 commit 4e65942

39 files changed

+1016
-293
lines changed
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import '@testing-library/jest-native/extend-expect';
2+
import React from 'react';
3+
import Reanimated, { useAnimatedStyle } from 'react-native-reanimated';
4+
import { render } from '@testing-library/react-native';
5+
6+
import { useReanimatedFocusedInput } from 'react-native-keyboard-controller';
7+
8+
function RectangleWithFocusedInputLayout() {
9+
const { input } = useReanimatedFocusedInput();
10+
const style = useAnimatedStyle(
11+
() => {
12+
const layout = input.value?.layout;
13+
14+
return {
15+
top: layout?.y,
16+
left: layout?.x,
17+
height: layout?.height,
18+
width: layout?.width,
19+
};
20+
},
21+
[]
22+
);
23+
24+
return <Reanimated.View testID="view" style={style} />;
25+
}
26+
27+
describe('`useReanimatedFocusedInput` mocking', () => {
28+
it('should have different styles depends on `useReanimatedFocusedInput`', () => {
29+
const { getByTestId, update } = render(<RectangleWithFocusedInputLayout />);
30+
31+
expect(getByTestId('view')).toHaveStyle({
32+
top: 0,
33+
left: 0,
34+
width: 200,
35+
height: 40,
36+
});
37+
38+
(useReanimatedFocusedInput as jest.Mock).mockReturnValue({
39+
input: {
40+
value: {
41+
target: 2,
42+
layout: {
43+
x: 10,
44+
y: 100,
45+
width: 190,
46+
height: 80,
47+
absoluteX: 100,
48+
absoluteY: 200,
49+
},
50+
},
51+
}
52+
});
53+
update(<RectangleWithFocusedInputLayout />);
54+
55+
expect(getByTestId('view')).toHaveStyle({
56+
top: 100,
57+
left: 10,
58+
width: 190,
59+
height: 80,
60+
});
61+
});
62+
});

FabricExample/src/screens/Examples/AwareScrollView/KeyboardAwareScrollView.tsx

Lines changed: 41 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,20 @@
11
import React, { FC } from 'react';
22
import { ScrollViewProps, useWindowDimensions } from 'react-native';
3+
import { FocusedInputLayoutChangedEvent, useReanimatedFocusedInput } from 'react-native-keyboard-controller';
34
import Reanimated, {
4-
MeasuredDimensions,
55
interpolate,
66
scrollTo,
7+
useAnimatedReaction,
78
useAnimatedRef,
89
useAnimatedScrollHandler,
910
useAnimatedStyle,
1011
useSharedValue,
1112
useWorkletCallback,
1213
} from 'react-native-reanimated';
1314
import { useSmoothKeyboardHandler } from './useSmoothKeyboardHandler';
14-
import { AwareScrollViewProvider, useAwareScrollView } from './context';
1515

1616
const BOTTOM_OFFSET = 50;
1717

18-
type KeyboardAwareScrollViewProps = ScrollViewProps;
19-
2018
/**
2119
* Everything begins from `onStart` handler. This handler is called every time,
2220
* when keyboard changes its size or when focused `TextInput` was changed. In
@@ -55,22 +53,21 @@ type KeyboardAwareScrollViewProps = ScrollViewProps;
5553
* +============================+ +============================+ +=====================================+
5654
*
5755
*/
58-
const KeyboardAwareScrollView: FC<KeyboardAwareScrollViewProps> = ({
56+
const KeyboardAwareScrollView: FC<ScrollViewProps> = ({
5957
children,
6058
...rest
6159
}) => {
6260
const scrollViewAnimatedRef = useAnimatedRef<Reanimated.ScrollView>();
6361
const scrollPosition = useSharedValue(0);
6462
const position = useSharedValue(0);
65-
const layout = useSharedValue<MeasuredDimensions | null>(null);
66-
const fakeViewHeight = useSharedValue(0);
6763
const keyboardHeight = useSharedValue(0);
6864
const tag = useSharedValue(-1);
6965
const initialKeyboardSize = useSharedValue(0);
7066
const scrollBeforeKeyboardMovement = useSharedValue(0);
67+
const { input } = useReanimatedFocusedInput();
68+
const layout = useSharedValue<FocusedInputLayoutChangedEvent | null>(null);
7169

7270
const { height } = useWindowDimensions();
73-
const { measure } = useAwareScrollView();
7471

7572
const onScroll = useAnimatedScrollHandler(
7673
{
@@ -84,10 +81,9 @@ const KeyboardAwareScrollView: FC<KeyboardAwareScrollViewProps> = ({
8481
/**
8582
* Function that will scroll a ScrollView as keyboard gets moving
8683
*/
87-
const maybeScroll = useWorkletCallback((e: number, animated = false) => {
88-
fakeViewHeight.value = e;
84+
const maybeScroll = useWorkletCallback((e: number, animated: boolean = false) => {
8985
const visibleRect = height - keyboardHeight.value;
90-
const point = (layout.value?.pageY || 0) + (layout.value?.height || 0);
86+
const point = (layout.value?.layout.absoluteY || 0) + (layout.value?.layout.height || 0);
9187

9288
if (visibleRect - point <= BOTTOM_OFFSET) {
9389
const interpolatedScrollTo = interpolate(
@@ -98,7 +94,11 @@ const KeyboardAwareScrollView: FC<KeyboardAwareScrollViewProps> = ({
9894
const targetScrollY =
9995
Math.max(interpolatedScrollTo, 0) + scrollPosition.value;
10096
scrollTo(scrollViewAnimatedRef, 0, targetScrollY, animated);
97+
98+
return interpolatedScrollTo;
10199
}
100+
101+
return 0;
102102
}, []);
103103

104104
useSmoothKeyboardHandler(
@@ -110,6 +110,8 @@ const KeyboardAwareScrollView: FC<KeyboardAwareScrollViewProps> = ({
110110
keyboardHeight.value !== e.height && e.height > 0;
111111
const keyboardWillAppear = e.height > 0 && keyboardHeight.value === 0;
112112
const keyboardWillHide = e.height === 0;
113+
const focusWasChanged = (tag.value !== e.target && e.target !== -1) || keyboardWillChangeSize;
114+
113115
if (keyboardWillChangeSize) {
114116
initialKeyboardSize.value = keyboardHeight.value;
115117
}
@@ -120,24 +122,28 @@ const KeyboardAwareScrollView: FC<KeyboardAwareScrollViewProps> = ({
120122
scrollPosition.value = scrollBeforeKeyboardMovement.value;
121123
}
122124

123-
if (keyboardWillAppear || keyboardWillChangeSize) {
125+
if (keyboardWillAppear || keyboardWillChangeSize || focusWasChanged) {
124126
// persist scroll value
125127
scrollPosition.value = position.value;
126128
// just persist height - later will be used in interpolation
127129
keyboardHeight.value = e.height;
128130
}
129131

130132
// focus was changed
131-
if (tag.value !== e.target || keyboardWillChangeSize) {
133+
if (focusWasChanged) {
132134
tag.value = e.target;
133135

134-
if (tag.value !== -1) {
135-
// save position of focused text input when keyboard starts to move
136-
layout.value = measure(e.target);
137-
// save current scroll position - when keyboard will hide we'll reuse
138-
// this value to achieve smooth hide effect
139-
scrollBeforeKeyboardMovement.value = position.value;
140-
}
136+
// save position of focused text input when keyboard starts to move
137+
layout.value = input.value;
138+
// save current scroll position - when keyboard will hide we'll reuse
139+
// this value to achieve smooth hide effect
140+
scrollBeforeKeyboardMovement.value = position.value;
141+
}
142+
143+
if (focusWasChanged && !keyboardWillAppear) {
144+
// update position on scroll value, so `onEnd` handler
145+
// will pick up correct values
146+
position.value += maybeScroll(e.height, true);
141147
}
142148
},
143149
onMove: (e) => {
@@ -150,24 +156,24 @@ const KeyboardAwareScrollView: FC<KeyboardAwareScrollViewProps> = ({
150156

151157
keyboardHeight.value = e.height;
152158
scrollPosition.value = position.value;
153-
154-
if (e.target !== -1 && e.height !== 0) {
155-
const prevLayout = layout.value;
156-
// just be sure, that view is no overlapped (i.e. focus changed)
157-
layout.value = measure(e.target);
158-
maybeScroll(e.height, true);
159-
// do layout substitution back to assure there will be correct
160-
// back transition when keyboard hides
161-
layout.value = prevLayout;
162-
}
163159
},
164160
},
165161
[height]
166162
);
167163

164+
useAnimatedReaction(() => input.value, (current, previous) => {
165+
if (current?.target === previous?.target && current?.layout.height !== previous?.layout.height) {
166+
const prevLayout = layout.value;
167+
168+
layout.value = input.value;
169+
scrollPosition.value += maybeScroll(keyboardHeight.value, true);
170+
layout.value = prevLayout;
171+
}
172+
}, []);
173+
168174
const view = useAnimatedStyle(
169175
() => ({
170-
height: fakeViewHeight.value,
176+
paddingBottom: keyboardHeight.value,
171177
}),
172178
[]
173179
);
@@ -179,16 +185,11 @@ const KeyboardAwareScrollView: FC<KeyboardAwareScrollViewProps> = ({
179185
onScroll={onScroll}
180186
scrollEventThrottle={16}
181187
>
182-
{children}
183-
<Reanimated.View style={view} />
188+
<Reanimated.View style={view}>
189+
{children}
190+
</Reanimated.View>
184191
</Reanimated.ScrollView>
185192
);
186193
};
187194

188-
export default function (props: KeyboardAwareScrollViewProps) {
189-
return (
190-
<AwareScrollViewProvider>
191-
<KeyboardAwareScrollView {...props} />
192-
</AwareScrollViewProvider>
193-
);
194-
}
195+
export default KeyboardAwareScrollView;
Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,32 @@
11
import React from 'react';
2-
import { TextInputProps, TextInput as TextInputRN } from 'react-native';
3-
import { randomColor } from '../../../utils';
4-
import { useAwareScrollView } from './context';
5-
6-
const TextInput = React.forwardRef((props: TextInputProps, forwardRef) => {
7-
const { onRef } = useAwareScrollView();
2+
import { StyleSheet, TextInputProps, TextInput as TextInputRN } from 'react-native';
83

4+
const TextInput = (props: TextInputProps) => {
95
return (
106
<TextInputRN
11-
ref={(ref) => {
12-
onRef(ref);
13-
if (typeof forwardRef === 'function') {
14-
forwardRef(ref);
15-
}
16-
}}
17-
placeholderTextColor="black"
18-
style={{
19-
width: '100%',
20-
height: 50,
21-
backgroundColor: randomColor(),
22-
marginTop: 50,
23-
}}
7+
placeholderTextColor="#6c6c6c"
8+
style={styles.container}
9+
multiline
10+
numberOfLines={10}
2411
{...props}
12+
placeholder={`${props.placeholder} (${props.keyboardType === 'default' ? 'text' : 'numeric'})`}
2513
/>
2614
);
15+
};
16+
17+
const styles = StyleSheet.create({
18+
container: {
19+
width: '100%',
20+
minHeight: 50,
21+
maxHeight: 200,
22+
marginBottom: 50,
23+
borderColor: 'black',
24+
borderWidth: 2,
25+
marginRight: 160,
26+
borderRadius: 10,
27+
color: 'black',
28+
paddingHorizontal: 12,
29+
},
2730
});
2831

2932
export default TextInput;

FabricExample/src/screens/Examples/AwareScrollView/context.tsx

Lines changed: 0 additions & 102 deletions
This file was deleted.

0 commit comments

Comments
 (0)