react-native-keyboard-toolkit 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +256 -2
- package/dist/components/KeyboardAwareScrollView.d.ts +6 -0
- package/dist/components/KeyboardAwareScrollView.js +137 -0
- package/dist/components/KeyboardAwareView.d.ts +6 -0
- package/dist/components/KeyboardAwareView.js +112 -0
- package/dist/components/KeyboardDismissButton.d.ts +5 -0
- package/dist/components/KeyboardDismissButton.js +16 -0
- package/dist/components/KeyboardToolbar.d.ts +5 -0
- package/dist/components/KeyboardToolbar.js +109 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.js +11 -0
- package/dist/context/KeyboardContext.d.ts +6 -0
- package/dist/context/KeyboardContext.js +27 -0
- package/dist/context/index.d.ts +1 -0
- package/dist/context/index.js +5 -0
- package/dist/hooks/index.d.ts +4 -0
- package/dist/hooks/index.js +11 -0
- package/dist/hooks/useFocusedInput.d.ts +6 -0
- package/dist/hooks/useFocusedInput.js +42 -0
- package/dist/hooks/useKeyboard.d.ts +1 -0
- package/dist/hooks/useKeyboard.js +5 -0
- package/dist/hooks/useKeyboardAnimation.d.ts +6 -0
- package/dist/hooks/useKeyboardAnimation.js +43 -0
- package/dist/hooks/useKeyboardState.d.ts +2 -0
- package/dist/hooks/useKeyboardState.js +9 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +23 -0
- package/dist/types/index.d.ts +69 -0
- package/dist/types/index.js +2 -0
- package/dist/utils/constants.d.ts +2 -0
- package/dist/utils/constants.js +5 -0
- package/dist/utils/focusManager.d.ts +10 -0
- package/dist/utils/focusManager.js +34 -0
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.js +13 -0
- package/dist/utils/keyboardEvents.d.ts +7 -0
- package/dist/utils/keyboardEvents.js +88 -0
- package/dist/utils/platform.d.ts +11 -0
- package/dist/utils/platform.js +19 -0
- package/dist/utils/scrollManager.d.ts +21 -0
- package/dist/utils/scrollManager.js +37 -0
- package/docs/android.md +31 -0
- package/docs/api.md +78 -0
- package/docs/assets/demo-preview.svg +24 -0
- package/docs/installation.md +22 -0
- package/docs/ios.md +27 -0
- package/docs/troubleshooting.md +44 -0
- package/package.json +90 -4
- package/src/components/KeyboardAwareScrollView.tsx +240 -0
- package/src/components/KeyboardAwareView.tsx +172 -0
- package/src/components/KeyboardDismissButton.tsx +32 -0
- package/src/components/KeyboardToolbar.tsx +200 -0
- package/src/components/index.ts +4 -0
- package/src/context/KeyboardContext.tsx +35 -0
- package/src/context/index.ts +1 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/useFocusedInput.ts +52 -0
- package/src/hooks/useKeyboard.ts +1 -0
- package/src/hooks/useKeyboardAnimation.ts +50 -0
- package/src/hooks/useKeyboardState.ts +14 -0
- package/src/index.ts +35 -0
- package/src/types/index.ts +101 -0
- package/src/utils/constants.ts +2 -0
- package/src/utils/focusManager.ts +40 -0
- package/src/utils/index.ts +10 -0
- package/src/utils/keyboardEvents.ts +110 -0
- package/src/utils/platform.ts +23 -0
- package/src/utils/scrollManager.ts +71 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
forwardRef,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useImperativeHandle,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import { Animated, StyleSheet } from 'react-native';
|
|
10
|
+
import type { KeyboardAwareViewProps } from '../types';
|
|
11
|
+
import type { KeyboardAwareViewRef } from '../types';
|
|
12
|
+
import { DEFAULT_KEYBOARD_SPACING } from '../utils/constants';
|
|
13
|
+
import { useKeyboardState } from '../hooks/useKeyboardState';
|
|
14
|
+
import {
|
|
15
|
+
calculateKeyboardDisplacement,
|
|
16
|
+
calculateKeyboardOverlap,
|
|
17
|
+
} from '../utils/scrollManager';
|
|
18
|
+
|
|
19
|
+
/** Adds keyboard spacing to in-flow content and keeps absolute footers visible. */
|
|
20
|
+
export const KeyboardAwareView: React.ForwardRefExoticComponent<
|
|
21
|
+
KeyboardAwareViewProps & React.RefAttributes<KeyboardAwareViewRef>
|
|
22
|
+
> = forwardRef<KeyboardAwareViewRef, KeyboardAwareViewProps>(
|
|
23
|
+
function KeyboardAwareViewComponent(
|
|
24
|
+
{
|
|
25
|
+
behavior = 'smart',
|
|
26
|
+
keyboardSpacing = DEFAULT_KEYBOARD_SPACING,
|
|
27
|
+
enabled = true,
|
|
28
|
+
style,
|
|
29
|
+
onLayout,
|
|
30
|
+
...props
|
|
31
|
+
},
|
|
32
|
+
forwardedRef,
|
|
33
|
+
): React.JSX.Element {
|
|
34
|
+
const keyboard = useKeyboardState();
|
|
35
|
+
const viewRef = useRef<KeyboardAwareViewRef>(null);
|
|
36
|
+
useImperativeHandle(forwardedRef, () => viewRef.current!, []);
|
|
37
|
+
const measurementSequence = useRef(0);
|
|
38
|
+
const [keyboardOverlap, setKeyboardOverlap] = useState(0);
|
|
39
|
+
const [keyboardDisplacement, setKeyboardDisplacement] = useState(0);
|
|
40
|
+
const animatedDisplacement = useRef(new Animated.Value(0)).current;
|
|
41
|
+
const flattenedStyle = StyleSheet.flatten(style);
|
|
42
|
+
const isAbsolutelyPositioned = flattenedStyle?.position === 'absolute';
|
|
43
|
+
const spacing = Number.isFinite(keyboardSpacing)
|
|
44
|
+
? Math.max(0, keyboardSpacing)
|
|
45
|
+
: 0;
|
|
46
|
+
const basePadding =
|
|
47
|
+
typeof flattenedStyle?.paddingBottom === 'number'
|
|
48
|
+
? flattenedStyle.paddingBottom
|
|
49
|
+
: 0;
|
|
50
|
+
const measureOverlap = useCallback(() => {
|
|
51
|
+
const view = viewRef.current;
|
|
52
|
+
if (!view?.measureInWindow || !keyboard.isVisible) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const sequence = ++measurementSequence.current;
|
|
57
|
+
view.measureInWindow((_x, y, _width, height) => {
|
|
58
|
+
if (sequence !== measurementSequence.current || !keyboard.isVisible) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (!isAbsolutelyPositioned) {
|
|
62
|
+
setKeyboardOverlap(
|
|
63
|
+
calculateKeyboardOverlap(
|
|
64
|
+
{ y, height },
|
|
65
|
+
keyboard.height,
|
|
66
|
+
keyboard.screenY,
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
setKeyboardDisplacement(
|
|
71
|
+
isAbsolutelyPositioned && typeof keyboard.screenY === 'number'
|
|
72
|
+
? calculateKeyboardDisplacement(
|
|
73
|
+
{ y, height },
|
|
74
|
+
keyboard.screenY,
|
|
75
|
+
spacing,
|
|
76
|
+
)
|
|
77
|
+
: 0,
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
}, [
|
|
81
|
+
isAbsolutelyPositioned,
|
|
82
|
+
keyboard.height,
|
|
83
|
+
keyboard.isVisible,
|
|
84
|
+
keyboard.screenY,
|
|
85
|
+
spacing,
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!keyboard.isVisible) {
|
|
90
|
+
measurementSequence.current += 1;
|
|
91
|
+
setKeyboardOverlap(0);
|
|
92
|
+
setKeyboardDisplacement(0);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const frame = requestAnimationFrame(measureOverlap);
|
|
96
|
+
return () => cancelAnimationFrame(frame);
|
|
97
|
+
}, [keyboard.isVisible, keyboard.height, keyboard.screenY, measureOverlap]);
|
|
98
|
+
|
|
99
|
+
const shouldAdjust =
|
|
100
|
+
enabled &&
|
|
101
|
+
keyboard.isVisible &&
|
|
102
|
+
behavior !== 'none' &&
|
|
103
|
+
behavior !== 'resize';
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!isAbsolutelyPositioned) {
|
|
106
|
+
animatedDisplacement.setValue(0);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const animation = Animated.timing(animatedDisplacement, {
|
|
110
|
+
toValue: shouldAdjust ? -keyboardDisplacement : 0,
|
|
111
|
+
duration: keyboard.animationDuration,
|
|
112
|
+
useNativeDriver: true,
|
|
113
|
+
});
|
|
114
|
+
animation.start();
|
|
115
|
+
return () => animation.stop();
|
|
116
|
+
}, [
|
|
117
|
+
animatedDisplacement,
|
|
118
|
+
isAbsolutelyPositioned,
|
|
119
|
+
keyboard.animationDuration,
|
|
120
|
+
keyboardDisplacement,
|
|
121
|
+
shouldAdjust,
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
const handleLayout = useCallback(
|
|
125
|
+
(event: Parameters<NonNullable<typeof onLayout>>[0]) => {
|
|
126
|
+
onLayout?.(event);
|
|
127
|
+
measureOverlap();
|
|
128
|
+
},
|
|
129
|
+
[measureOverlap, onLayout],
|
|
130
|
+
);
|
|
131
|
+
const adjustPadding =
|
|
132
|
+
shouldAdjust &&
|
|
133
|
+
!isAbsolutelyPositioned &&
|
|
134
|
+
keyboardOverlap > 0 &&
|
|
135
|
+
typeof flattenedStyle?.paddingBottom !== 'string';
|
|
136
|
+
const stickyTransform = isAbsolutelyPositioned
|
|
137
|
+
? [
|
|
138
|
+
...(Array.isArray(flattenedStyle?.transform)
|
|
139
|
+
? flattenedStyle.transform
|
|
140
|
+
: []),
|
|
141
|
+
{ translateY: animatedDisplacement },
|
|
142
|
+
]
|
|
143
|
+
: undefined;
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<Animated.View
|
|
147
|
+
{...props}
|
|
148
|
+
ref={viewRef}
|
|
149
|
+
onLayout={handleLayout}
|
|
150
|
+
style={
|
|
151
|
+
adjustPadding
|
|
152
|
+
? [
|
|
153
|
+
style,
|
|
154
|
+
{
|
|
155
|
+
paddingBottom: Math.max(
|
|
156
|
+
basePadding,
|
|
157
|
+
keyboardOverlap + spacing,
|
|
158
|
+
),
|
|
159
|
+
},
|
|
160
|
+
]
|
|
161
|
+
: isAbsolutelyPositioned
|
|
162
|
+
? [style, { transform: stickyTransform }]
|
|
163
|
+
: style
|
|
164
|
+
}
|
|
165
|
+
/>
|
|
166
|
+
);
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
KeyboardAwareView.displayName = 'KeyboardAwareView';
|
|
171
|
+
|
|
172
|
+
export default KeyboardAwareView;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Keyboard, Pressable } from 'react-native';
|
|
3
|
+
import type { KeyboardDismissButtonProps } from '../types';
|
|
4
|
+
|
|
5
|
+
/** A pressable that dismisses the software keyboard by default. */
|
|
6
|
+
export function KeyboardDismissButton({
|
|
7
|
+
children,
|
|
8
|
+
onPress,
|
|
9
|
+
dismissKeyboard = true,
|
|
10
|
+
accessibilityLabel = 'Dismiss keyboard',
|
|
11
|
+
...props
|
|
12
|
+
}: KeyboardDismissButtonProps): React.JSX.Element {
|
|
13
|
+
const handlePress = () => {
|
|
14
|
+
onPress?.();
|
|
15
|
+
if (dismissKeyboard) {
|
|
16
|
+
Keyboard.dismiss();
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<Pressable
|
|
22
|
+
{...props}
|
|
23
|
+
accessibilityLabel={accessibilityLabel}
|
|
24
|
+
accessibilityRole="button"
|
|
25
|
+
onPress={handlePress}
|
|
26
|
+
>
|
|
27
|
+
{children}
|
|
28
|
+
</Pressable>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default KeyboardDismissButton;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Keyboard,
|
|
4
|
+
Pressable,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
Text,
|
|
7
|
+
TextInput,
|
|
8
|
+
View,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
import type { KeyboardToolbarProps } from '../types';
|
|
11
|
+
import { useKeyboardState } from '../hooks/useKeyboardState';
|
|
12
|
+
|
|
13
|
+
function findFocusedInputIndex(
|
|
14
|
+
inputRefs: NonNullable<KeyboardToolbarProps['inputRefs']>,
|
|
15
|
+
): number {
|
|
16
|
+
return inputRefs.findIndex(ref => ref.current?.isFocused() === true);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isMountedInputAt(
|
|
20
|
+
inputRefs: NonNullable<KeyboardToolbarProps['inputRefs']>,
|
|
21
|
+
index: number,
|
|
22
|
+
): boolean {
|
|
23
|
+
return (
|
|
24
|
+
index >= 0 && index < inputRefs.length && inputRefs[index]?.current != null
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function hasMountedInputBefore(
|
|
29
|
+
inputRefs: NonNullable<KeyboardToolbarProps['inputRefs']>,
|
|
30
|
+
index: number,
|
|
31
|
+
): boolean {
|
|
32
|
+
return inputRefs.some(
|
|
33
|
+
(input, inputIndex) => inputIndex < index && input.current != null,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hasMountedInputAfter(
|
|
38
|
+
inputRefs: NonNullable<KeyboardToolbarProps['inputRefs']>,
|
|
39
|
+
index: number,
|
|
40
|
+
): boolean {
|
|
41
|
+
return inputRefs.some(
|
|
42
|
+
(input, inputIndex) => inputIndex > index && input.current != null,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** An optional in-layout toolbar for input navigation and keyboard dismissal. */
|
|
47
|
+
export function KeyboardToolbar({
|
|
48
|
+
inputRefs = [],
|
|
49
|
+
focusedInputIndex,
|
|
50
|
+
onFocusedInputChange,
|
|
51
|
+
onDone,
|
|
52
|
+
doneText = 'Done',
|
|
53
|
+
children,
|
|
54
|
+
alwaysVisible = false,
|
|
55
|
+
style,
|
|
56
|
+
...props
|
|
57
|
+
}: KeyboardToolbarProps): React.JSX.Element | null {
|
|
58
|
+
const keyboard = useKeyboardState();
|
|
59
|
+
const [trackedIndex, setTrackedIndex] = useState(-1);
|
|
60
|
+
const lastReportedIndex = useRef(-1);
|
|
61
|
+
const trackFocus = useCallback(() => {
|
|
62
|
+
const index = findFocusedInputIndex(inputRefs);
|
|
63
|
+
setTrackedIndex(current => (current === index ? current : index));
|
|
64
|
+
if (lastReportedIndex.current !== index) {
|
|
65
|
+
lastReportedIndex.current = index;
|
|
66
|
+
onFocusedInputChange?.(index);
|
|
67
|
+
}
|
|
68
|
+
}, [inputRefs, onFocusedInputChange]);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
trackFocus();
|
|
72
|
+
if (!keyboard.isVisible && !alwaysVisible) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const interval = setInterval(trackFocus, 100);
|
|
77
|
+
return () => clearInterval(interval);
|
|
78
|
+
}, [alwaysVisible, keyboard.isVisible, trackFocus]);
|
|
79
|
+
|
|
80
|
+
if (!alwaysVisible && !keyboard.isVisible) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const actualFocusedIndex = findFocusedInputIndex(inputRefs);
|
|
85
|
+
const currentIndex =
|
|
86
|
+
actualFocusedIndex >= 0
|
|
87
|
+
? actualFocusedIndex
|
|
88
|
+
: isMountedInputAt(inputRefs, trackedIndex)
|
|
89
|
+
? trackedIndex
|
|
90
|
+
: isMountedInputAt(inputRefs, focusedInputIndex ?? -1)
|
|
91
|
+
? focusedInputIndex!
|
|
92
|
+
: -1;
|
|
93
|
+
const canNavigateBackward =
|
|
94
|
+
currentIndex >= 0 && hasMountedInputBefore(inputRefs, currentIndex);
|
|
95
|
+
const canNavigateForward =
|
|
96
|
+
currentIndex >= 0 && hasMountedInputAfter(inputRefs, currentIndex);
|
|
97
|
+
|
|
98
|
+
const focusInDirection = (direction: -1 | 1) => {
|
|
99
|
+
const actualIndex = findFocusedInputIndex(inputRefs);
|
|
100
|
+
const origin = actualIndex >= 0 ? actualIndex : currentIndex;
|
|
101
|
+
if (origin < 0) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (
|
|
106
|
+
let index = origin + direction;
|
|
107
|
+
index >= 0 && index < inputRefs.length;
|
|
108
|
+
index += direction
|
|
109
|
+
) {
|
|
110
|
+
const target = inputRefs[index]?.current;
|
|
111
|
+
if (target) {
|
|
112
|
+
target.focus();
|
|
113
|
+
lastReportedIndex.current = index;
|
|
114
|
+
setTrackedIndex(index);
|
|
115
|
+
onFocusedInputChange?.(index);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const done = () => {
|
|
122
|
+
const focusedInput = TextInput.State.currentlyFocusedInput?.();
|
|
123
|
+
if (focusedInput) {
|
|
124
|
+
TextInput.State.blurTextInput(focusedInput);
|
|
125
|
+
}
|
|
126
|
+
Keyboard.dismiss();
|
|
127
|
+
onDone?.();
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<View {...props} style={[styles.toolbar, style]}>
|
|
132
|
+
{children ?? (
|
|
133
|
+
<>
|
|
134
|
+
<Pressable
|
|
135
|
+
accessibilityRole="button"
|
|
136
|
+
accessibilityLabel="Previous input"
|
|
137
|
+
disabled={!canNavigateBackward}
|
|
138
|
+
onPress={() => focusInDirection(-1)}
|
|
139
|
+
style={styles.action}
|
|
140
|
+
>
|
|
141
|
+
<Text style={canNavigateBackward ? styles.label : styles.disabled}>
|
|
142
|
+
Previous
|
|
143
|
+
</Text>
|
|
144
|
+
</Pressable>
|
|
145
|
+
<Pressable
|
|
146
|
+
accessibilityRole="button"
|
|
147
|
+
accessibilityLabel="Next input"
|
|
148
|
+
disabled={!canNavigateForward}
|
|
149
|
+
onPress={() => focusInDirection(1)}
|
|
150
|
+
style={styles.action}
|
|
151
|
+
>
|
|
152
|
+
<Text style={canNavigateForward ? styles.label : styles.disabled}>
|
|
153
|
+
Next
|
|
154
|
+
</Text>
|
|
155
|
+
</Pressable>
|
|
156
|
+
<View style={styles.spacer} />
|
|
157
|
+
<Pressable
|
|
158
|
+
accessibilityRole="button"
|
|
159
|
+
accessibilityLabel="Done"
|
|
160
|
+
onPress={done}
|
|
161
|
+
style={styles.action}
|
|
162
|
+
>
|
|
163
|
+
<Text style={styles.label}>{doneText}</Text>
|
|
164
|
+
</Pressable>
|
|
165
|
+
</>
|
|
166
|
+
)}
|
|
167
|
+
</View>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const styles = StyleSheet.create({
|
|
172
|
+
toolbar: {
|
|
173
|
+
alignItems: 'center',
|
|
174
|
+
backgroundColor: '#f6f7f9',
|
|
175
|
+
borderTopColor: '#d8dce2',
|
|
176
|
+
borderTopWidth: StyleSheet.hairlineWidth,
|
|
177
|
+
flexDirection: 'row',
|
|
178
|
+
minHeight: 48,
|
|
179
|
+
paddingHorizontal: 12,
|
|
180
|
+
},
|
|
181
|
+
action: {
|
|
182
|
+
minHeight: 44,
|
|
183
|
+
justifyContent: 'center',
|
|
184
|
+
paddingHorizontal: 10,
|
|
185
|
+
},
|
|
186
|
+
label: {
|
|
187
|
+
color: '#155eef',
|
|
188
|
+
fontSize: 15,
|
|
189
|
+
fontWeight: '600',
|
|
190
|
+
},
|
|
191
|
+
disabled: {
|
|
192
|
+
color: '#98a2b3',
|
|
193
|
+
fontSize: 15,
|
|
194
|
+
},
|
|
195
|
+
spacer: {
|
|
196
|
+
flex: 1,
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
export default KeyboardToolbar;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import React, { createContext, useContext, useEffect, useRef } from 'react';
|
|
2
|
+
import { useKeyboardState } from '../hooks/useKeyboardState';
|
|
3
|
+
import type { KeyboardProviderProps } from '../types';
|
|
4
|
+
|
|
5
|
+
const DebugContext = createContext(false);
|
|
6
|
+
|
|
7
|
+
/** Optional provider for development diagnostics; keyboard subscriptions are shared globally. */
|
|
8
|
+
export function KeyboardProvider({
|
|
9
|
+
children,
|
|
10
|
+
debug = false,
|
|
11
|
+
}: KeyboardProviderProps): React.JSX.Element {
|
|
12
|
+
const keyboard = useKeyboardState();
|
|
13
|
+
const previous = useRef(keyboard);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (
|
|
17
|
+
debug &&
|
|
18
|
+
__DEV__ &&
|
|
19
|
+
(previous.current.isVisible !== keyboard.isVisible ||
|
|
20
|
+
previous.current.height !== keyboard.height)
|
|
21
|
+
) {
|
|
22
|
+
console.debug('[keyboard-toolkit]', keyboard);
|
|
23
|
+
}
|
|
24
|
+
previous.current = keyboard;
|
|
25
|
+
}, [debug, keyboard]);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<DebugContext.Provider value={debug}>{children}</DebugContext.Provider>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Read whether development diagnostics are enabled on the nearest provider. */
|
|
33
|
+
export function useKeyboardDebug(): boolean {
|
|
34
|
+
return useContext(DebugContext);
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { KeyboardProvider } from './KeyboardContext';
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { FocusedInputLayout } from '../types';
|
|
3
|
+
import { getFocusedInput, measureFocusedInput } from '../utils/focusManager';
|
|
4
|
+
import { useKeyboardState } from './useKeyboardState';
|
|
5
|
+
|
|
6
|
+
/** Measure the focused TextInput when the keyboard opens; call `refresh` after layout changes. */
|
|
7
|
+
export function useFocusedInput(): {
|
|
8
|
+
focusedInput: FocusedInputLayout | null;
|
|
9
|
+
refresh: () => Promise<FocusedInputLayout | null>;
|
|
10
|
+
} {
|
|
11
|
+
const keyboard = useKeyboardState();
|
|
12
|
+
const [focusedInput, setFocusedInput] = useState<FocusedInputLayout | null>(
|
|
13
|
+
null,
|
|
14
|
+
);
|
|
15
|
+
const measureSequence = useRef(0);
|
|
16
|
+
|
|
17
|
+
const refresh = useCallback(async () => {
|
|
18
|
+
const sequence = ++measureSequence.current;
|
|
19
|
+
const layout = await measureFocusedInput(getFocusedInput());
|
|
20
|
+
if (sequence === measureSequence.current) {
|
|
21
|
+
setFocusedInput(current => {
|
|
22
|
+
if (
|
|
23
|
+
current?.x === layout?.x &&
|
|
24
|
+
current?.y === layout?.y &&
|
|
25
|
+
current?.width === layout?.width &&
|
|
26
|
+
current?.height === layout?.height
|
|
27
|
+
) {
|
|
28
|
+
return current;
|
|
29
|
+
}
|
|
30
|
+
return layout;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return layout;
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!keyboard.isVisible) {
|
|
38
|
+
measureSequence.current += 1;
|
|
39
|
+
setFocusedInput(null);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
refresh();
|
|
44
|
+
const interval = setInterval(refresh, 120);
|
|
45
|
+
return () => {
|
|
46
|
+
measureSequence.current += 1;
|
|
47
|
+
clearInterval(interval);
|
|
48
|
+
};
|
|
49
|
+
}, [keyboard.isVisible, keyboard.height, refresh]);
|
|
50
|
+
|
|
51
|
+
return { focusedInput, refresh };
|
|
52
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useKeyboardState as useKeyboard } from './useKeyboardState';
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { AccessibilityInfo, Animated } from 'react-native';
|
|
3
|
+
import { useKeyboardState } from './useKeyboardState';
|
|
4
|
+
|
|
5
|
+
/** Provides an animated 0-to-1 visibility value and the native event duration. */
|
|
6
|
+
export function useKeyboardAnimation(): {
|
|
7
|
+
progress: Animated.Value;
|
|
8
|
+
animationDuration: number;
|
|
9
|
+
} {
|
|
10
|
+
const keyboard = useKeyboardState();
|
|
11
|
+
const progressRef = useRef<Animated.Value | null>(null);
|
|
12
|
+
if (!progressRef.current) {
|
|
13
|
+
progressRef.current = new Animated.Value(keyboard.progress);
|
|
14
|
+
}
|
|
15
|
+
const progress = progressRef.current;
|
|
16
|
+
const [reduceMotion, setReduceMotion] = useState(false);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
let active = true;
|
|
20
|
+
AccessibilityInfo.isReduceMotionEnabled()
|
|
21
|
+
.then(enabled => {
|
|
22
|
+
if (active) {
|
|
23
|
+
setReduceMotion(enabled);
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
.catch(() => {
|
|
27
|
+
// Keep animations enabled when the platform preference cannot be read.
|
|
28
|
+
});
|
|
29
|
+
const subscription = AccessibilityInfo.addEventListener(
|
|
30
|
+
'reduceMotionChanged',
|
|
31
|
+
setReduceMotion,
|
|
32
|
+
);
|
|
33
|
+
return () => {
|
|
34
|
+
active = false;
|
|
35
|
+
subscription.remove();
|
|
36
|
+
};
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const animation = Animated.timing(progress, {
|
|
41
|
+
toValue: keyboard.progress,
|
|
42
|
+
duration: reduceMotion ? 0 : keyboard.animationDuration,
|
|
43
|
+
useNativeDriver: true,
|
|
44
|
+
});
|
|
45
|
+
animation.start();
|
|
46
|
+
return () => animation.stop();
|
|
47
|
+
}, [keyboard.animationDuration, keyboard.progress, progress, reduceMotion]);
|
|
48
|
+
|
|
49
|
+
return { progress, animationDuration: keyboard.animationDuration };
|
|
50
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
getKeyboardState,
|
|
4
|
+
subscribeKeyboardState,
|
|
5
|
+
} from '../utils/keyboardEvents';
|
|
6
|
+
|
|
7
|
+
/** Subscribe to the shared, normalized keyboard state. A provider is optional. */
|
|
8
|
+
export function useKeyboardState() {
|
|
9
|
+
return useSyncExternalStore(
|
|
10
|
+
subscribeKeyboardState,
|
|
11
|
+
getKeyboardState,
|
|
12
|
+
getKeyboardState,
|
|
13
|
+
);
|
|
14
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export {
|
|
2
|
+
KeyboardAwareScrollView,
|
|
3
|
+
KeyboardAwareView,
|
|
4
|
+
KeyboardDismissButton,
|
|
5
|
+
KeyboardToolbar,
|
|
6
|
+
} from './components';
|
|
7
|
+
export { KeyboardProvider } from './context';
|
|
8
|
+
export {
|
|
9
|
+
useFocusedInput,
|
|
10
|
+
useKeyboard,
|
|
11
|
+
useKeyboardAnimation,
|
|
12
|
+
useKeyboardState,
|
|
13
|
+
} from './hooks';
|
|
14
|
+
export {
|
|
15
|
+
calculateScrollOffset,
|
|
16
|
+
calculateKeyboardOverlap,
|
|
17
|
+
focusInput,
|
|
18
|
+
getFocusedInput,
|
|
19
|
+
isAndroid,
|
|
20
|
+
isIOS,
|
|
21
|
+
measureFocusedInput,
|
|
22
|
+
} from './utils';
|
|
23
|
+
export type {
|
|
24
|
+
FocusedInputLayout,
|
|
25
|
+
KeyboardAwareScrollViewProps,
|
|
26
|
+
KeyboardAwareScrollViewRef,
|
|
27
|
+
KeyboardAwareViewProps,
|
|
28
|
+
KeyboardAwareViewRef,
|
|
29
|
+
KeyboardBehavior,
|
|
30
|
+
KeyboardDismissButtonProps,
|
|
31
|
+
KeyboardProviderProps,
|
|
32
|
+
KeyboardState,
|
|
33
|
+
KeyboardToolbarProps,
|
|
34
|
+
TextInputRef,
|
|
35
|
+
} from './types';
|