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,21 @@
|
|
|
1
|
+
export interface ScrollViewport {
|
|
2
|
+
y: number;
|
|
3
|
+
height: number;
|
|
4
|
+
}
|
|
5
|
+
export interface ScrollInput {
|
|
6
|
+
y: number;
|
|
7
|
+
height: number;
|
|
8
|
+
}
|
|
9
|
+
export interface ScrollCalculation {
|
|
10
|
+
offset: number;
|
|
11
|
+
delta: number;
|
|
12
|
+
}
|
|
13
|
+
/** Return how far a bottom edge must move to clear the keyboard and spacing. */
|
|
14
|
+
export declare function calculateKeyboardDisplacement(viewport: ScrollViewport, keyboardTop: number, spacing: number): number;
|
|
15
|
+
/** Return the part of the measured view actually covered by the keyboard. */
|
|
16
|
+
export declare function calculateKeyboardOverlap(viewport: ScrollViewport, keyboardHeight: number, keyboardTop?: number): number;
|
|
17
|
+
/**
|
|
18
|
+
* Calculate the smallest scroll adjustment needed to bring an input into the
|
|
19
|
+
* visible viewport. `keyboardTop` avoids double-counting Android adjustResize.
|
|
20
|
+
*/
|
|
21
|
+
export declare function calculateScrollOffset(input: ScrollInput, viewport: ScrollViewport, keyboardHeight: number, spacing: number, currentOffset: number, keyboardTop?: number): ScrollCalculation;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.calculateKeyboardDisplacement = calculateKeyboardDisplacement;
|
|
4
|
+
exports.calculateKeyboardOverlap = calculateKeyboardOverlap;
|
|
5
|
+
exports.calculateScrollOffset = calculateScrollOffset;
|
|
6
|
+
/** Return how far a bottom edge must move to clear the keyboard and spacing. */
|
|
7
|
+
function calculateKeyboardDisplacement(viewport, keyboardTop, spacing) {
|
|
8
|
+
return Math.max(0, viewport.y + viewport.height - keyboardTop + Math.max(0, spacing));
|
|
9
|
+
}
|
|
10
|
+
/** Return the part of the measured view actually covered by the keyboard. */
|
|
11
|
+
function calculateKeyboardOverlap(viewport, keyboardHeight, keyboardTop) {
|
|
12
|
+
const viewportBottom = viewport.y + viewport.height;
|
|
13
|
+
const obscuredTop = typeof keyboardTop === 'number'
|
|
14
|
+
? Math.max(viewport.y, Math.min(viewportBottom, keyboardTop))
|
|
15
|
+
: Math.max(viewport.y, viewportBottom - Math.max(0, keyboardHeight));
|
|
16
|
+
return Math.max(0, viewportBottom - obscuredTop);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Calculate the smallest scroll adjustment needed to bring an input into the
|
|
20
|
+
* visible viewport. `keyboardTop` avoids double-counting Android adjustResize.
|
|
21
|
+
*/
|
|
22
|
+
function calculateScrollOffset(input, viewport, keyboardHeight, spacing, currentOffset, keyboardTop) {
|
|
23
|
+
const viewportBottom = viewport.y + viewport.height;
|
|
24
|
+
const unobscuredBottom = viewportBottom -
|
|
25
|
+
calculateKeyboardOverlap(viewport, keyboardHeight, keyboardTop) -
|
|
26
|
+
spacing;
|
|
27
|
+
const unobscuredTop = viewport.y + spacing;
|
|
28
|
+
const inputBottom = input.y + input.height;
|
|
29
|
+
let delta = 0;
|
|
30
|
+
if (inputBottom > unobscuredBottom) {
|
|
31
|
+
delta = inputBottom - unobscuredBottom;
|
|
32
|
+
}
|
|
33
|
+
else if (input.y < unobscuredTop) {
|
|
34
|
+
delta = input.y - unobscuredTop;
|
|
35
|
+
}
|
|
36
|
+
return { delta, offset: Math.max(0, currentOffset + delta) };
|
|
37
|
+
}
|
package/docs/android.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Android testing and setup
|
|
2
|
+
|
|
3
|
+
The library listens to `keyboardDidShow` / `keyboardDidHide`, measures native
|
|
4
|
+
input and scroll-view frames, and reads the reported keyboard screen coordinate
|
|
5
|
+
when available. It does not edit `MainActivity`, `MainApplication`, or the
|
|
6
|
+
manifest.
|
|
7
|
+
|
|
8
|
+
For common form behavior, configure the activity to use `adjustResize`. The
|
|
9
|
+
repository app already uses this setting. The components intersect the measured
|
|
10
|
+
viewport with the keyboard's reported top frame; if `adjustResize` has already
|
|
11
|
+
ended the viewport at that frame, the measured overlap is zero and no second
|
|
12
|
+
keyboard-height inset is added. OEM keyboards and edge-to-edge configurations
|
|
13
|
+
can still report coordinates relative to different window origins.
|
|
14
|
+
|
|
15
|
+
For a sticky submit action, put an absolutely positioned
|
|
16
|
+
`KeyboardAwareView` outside the scroll view. With `adjustResize`, Android may
|
|
17
|
+
already move the footer as the window changes size; the measured keyboard frame
|
|
18
|
+
is used to apply only any remaining displacement and configured spacing.
|
|
19
|
+
|
|
20
|
+
## Device checklist
|
|
21
|
+
|
|
22
|
+
- Test Android 10+ devices/emulators and the app's minimum supported OS.
|
|
23
|
+
- Try both gesture and three-button navigation.
|
|
24
|
+
- Test `adjustResize` and, if the app supports it, `adjustPan`.
|
|
25
|
+
- Test status/navigation bars, edge-to-edge, and any translucent system bars.
|
|
26
|
+
- Test multiline inputs, modal screens, nested scroll views, and input focus
|
|
27
|
+
during navigation transitions.
|
|
28
|
+
- Try at least one hardware keyboard and one alternate software keyboard.
|
|
29
|
+
|
|
30
|
+
This project has not been validated on physical Android devices. Do not treat
|
|
31
|
+
these notes as device compatibility claims.
|
package/docs/api.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# API reference
|
|
2
|
+
|
|
3
|
+
## `KeyboardAwareScrollView`
|
|
4
|
+
|
|
5
|
+
Extends React Native `ScrollViewProps`.
|
|
6
|
+
|
|
7
|
+
- `behavior?: 'smart' | 'scroll' | 'resize' | 'padding' | 'none'` (default
|
|
8
|
+
`'smart'`): smart and scroll behaviors minimally scroll a covered focused
|
|
9
|
+
input; padding extends the scroll range without focus scrolling; resize uses
|
|
10
|
+
automatic keyboard insets on iOS and relies on window resizing on Android;
|
|
11
|
+
none disables keyboard adjustment.
|
|
12
|
+
- `keyboardSpacing?: number` (default `16`): spacing in points.
|
|
13
|
+
- `enabled?: boolean` (default `true`): disable keyboard adjustments.
|
|
14
|
+
|
|
15
|
+
The component measures its viewport and the focused native input. It adds
|
|
16
|
+
scrollable bottom space based on measured viewport/keyboard-frame overlap,
|
|
17
|
+
avoiding blindly adding the full keyboard height when the OS has resized the
|
|
18
|
+
window. Existing numeric bottom padding is preserved. Its ref points to the
|
|
19
|
+
underlying React Native `ScrollView`.
|
|
20
|
+
|
|
21
|
+
## `KeyboardAwareView`
|
|
22
|
+
|
|
23
|
+
Extends `ViewProps`. Supports `behavior`, `keyboardSpacing`, and `enabled`.
|
|
24
|
+
Smart, scroll, and padding add bottom padding for the measured overlap. Resize
|
|
25
|
+
and none do not adjust the layout. When `style.position` is `absolute`, the
|
|
26
|
+
component animates the view upward by the distance from its measured bottom
|
|
27
|
+
edge to the keyboard frame, plus `keyboardSpacing`. It returns to its original
|
|
28
|
+
position when the keyboard hides. This view does not scroll to reveal a hidden
|
|
29
|
+
child. Its ref points to the underlying React Native `View`.
|
|
30
|
+
|
|
31
|
+
## `KeyboardProvider`
|
|
32
|
+
|
|
33
|
+
`<KeyboardProvider debug={false}>children</KeyboardProvider>`. It is optional;
|
|
34
|
+
set `debug` to log state changes in development.
|
|
35
|
+
|
|
36
|
+
## `KeyboardToolbar`
|
|
37
|
+
|
|
38
|
+
Extends `ViewProps` and accepts:
|
|
39
|
+
|
|
40
|
+
- `inputRefs?: readonly RefObject<TextInputRef | null>[]`: refs ordered for
|
|
41
|
+
Previous/Next navigation. The currently focused ref is detected
|
|
42
|
+
automatically.
|
|
43
|
+
- `onFocusedInputChange?: (index: number) => void`: called when a supplied ref
|
|
44
|
+
becomes or ceases to be focused; `-1` means no supplied ref is focused.
|
|
45
|
+
- `onDone?: () => void`, `doneText?: string`, and `alwaysVisible?: boolean`.
|
|
46
|
+
- `children?: ReactNode`: replaces the default controls.
|
|
47
|
+
|
|
48
|
+
Previous and Next skip null/unmounted refs and are disabled when no mounted
|
|
49
|
+
input exists in that direction. Done blurs the focused native input when
|
|
50
|
+
available, calls `Keyboard.dismiss()`, then invokes `onDone`. The deprecated
|
|
51
|
+
`focusedInputIndex` prop remains supported for compatibility; it is not needed
|
|
52
|
+
for automatic focus tracking.
|
|
53
|
+
|
|
54
|
+
## `KeyboardDismissButton`
|
|
55
|
+
|
|
56
|
+
Accepts `children`, `onPress`, `dismissKeyboard` (default `true`),
|
|
57
|
+
`accessibilityLabel`, and React Native `ViewProps`. The optional callback runs
|
|
58
|
+
before keyboard dismissal.
|
|
59
|
+
|
|
60
|
+
## Hooks
|
|
61
|
+
|
|
62
|
+
- `useKeyboardState()` and its alias `useKeyboard()` return a stable
|
|
63
|
+
`KeyboardState`: `{ isVisible, height, progress, animationDuration, screenY? }`.
|
|
64
|
+
- `useKeyboardAnimation()` returns `{ progress: Animated.Value,
|
|
65
|
+
animationDuration }`.
|
|
66
|
+
- `useFocusedInput()` returns `{ focusedInput, refresh }`; the layout is
|
|
67
|
+
window-relative or `null` when the focused input cannot be measured.
|
|
68
|
+
|
|
69
|
+
The package entry point also exports `calculateKeyboardOverlap`,
|
|
70
|
+
`calculateScrollOffset`, `focusInput`, `getFocusedInput`, `isAndroid`, `isIOS`,
|
|
71
|
+
`measureFocusedInput`, and the `TextInputRef` type used by toolbar refs. The
|
|
72
|
+
`KeyboardAwareScrollViewRef` and `KeyboardAwareViewRef` types describe the
|
|
73
|
+
corresponding component refs.
|
|
74
|
+
|
|
75
|
+
`calculateKeyboardOverlap(viewport, keyboardHeight, keyboardTop?)` returns the
|
|
76
|
+
measured overlap. `calculateScrollOffset(input, viewport, keyboardHeight,
|
|
77
|
+
spacing, currentOffset, keyboardTop?)` returns `{ offset, delta }`. The
|
|
78
|
+
coordinate values must use the same window-coordinate system.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540" role="img" aria-labelledby="title description">
|
|
2
|
+
<title id="title">Keyboard Shepherd illustrative demo preview</title>
|
|
3
|
+
<desc id="description">An illustration of a keyboard-aware profile form above a software keyboard.</desc>
|
|
4
|
+
<rect width="960" height="540" fill="#f4f6fb"/>
|
|
5
|
+
<text x="54" y="58" fill="#101828" font-family="Arial, sans-serif" font-size="26" font-weight="700">Keyboard Shepherd</text>
|
|
6
|
+
<text x="54" y="86" fill="#667085" font-family="Arial, sans-serif" font-size="15">Illustrative form layout — keyboard-aware scrolling</text>
|
|
7
|
+
<rect x="250" y="110" width="460" height="290" rx="18" fill="#ffffff" stroke="#d0d5dd"/>
|
|
8
|
+
<text x="282" y="151" fill="#101828" font-family="Arial, sans-serif" font-size="21" font-weight="700">Create your profile</text>
|
|
9
|
+
<text x="282" y="181" fill="#475467" font-family="Arial, sans-serif" font-size="13">Email</text>
|
|
10
|
+
<rect x="282" y="190" width="396" height="42" rx="8" fill="#ffffff" stroke="#98a2b3"/>
|
|
11
|
+
<text x="296" y="216" fill="#667085" font-family="Arial, sans-serif" font-size="14">name@example.com</text>
|
|
12
|
+
<text x="282" y="258" fill="#475467" font-family="Arial, sans-serif" font-size="13">Description</text>
|
|
13
|
+
<rect x="282" y="267" width="396" height="62" rx="8" fill="#ffffff" stroke="#98a2b3"/>
|
|
14
|
+
<text x="296" y="293" fill="#667085" font-family="Arial, sans-serif" font-size="14">Input remains visible while typing</text>
|
|
15
|
+
<rect x="282" y="345" width="396" height="38" rx="8" fill="#155eef"/>
|
|
16
|
+
<text x="480" y="370" fill="#ffffff" font-family="Arial, sans-serif" font-size="14" font-weight="700" text-anchor="middle">Submit form</text>
|
|
17
|
+
<rect x="0" y="420" width="960" height="120" fill="#e4e7ec"/>
|
|
18
|
+
<rect x="24" y="434" width="912" height="30" rx="7" fill="#ffffff"/>
|
|
19
|
+
<text x="480" y="454" fill="#475467" font-family="Arial, sans-serif" font-size="13" text-anchor="middle">Keyboard toolbar: Previous Next Done</text>
|
|
20
|
+
<g fill="#ffffff">
|
|
21
|
+
<rect x="38" y="475" width="84" height="25" rx="5"/><rect x="132" y="475" width="84" height="25" rx="5"/><rect x="226" y="475" width="84" height="25" rx="5"/><rect x="320" y="475" width="84" height="25" rx="5"/><rect x="414" y="475" width="84" height="25" rx="5"/><rect x="508" y="475" width="84" height="25" rx="5"/><rect x="602" y="475" width="84" height="25" rx="5"/><rect x="696" y="475" width="84" height="25" rx="5"/><rect x="790" y="475" width="84" height="25" rx="5"/>
|
|
22
|
+
<rect x="82" y="507" width="84" height="25" rx="5"/><rect x="176" y="507" width="84" height="25" rx="5"/><rect x="270" y="507" width="84" height="25" rx="5"/><rect x="364" y="507" width="84" height="25" rx="5"/><rect x="458" y="507" width="84" height="25" rx="5"/><rect x="552" y="507" width="84" height="25" rx="5"/><rect x="646" y="507" width="84" height="25" rx="5"/><rect x="740" y="507" width="84" height="25" rx="5"/>
|
|
23
|
+
</g>
|
|
24
|
+
</svg>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Installation
|
|
2
|
+
|
|
3
|
+
```sh
|
|
4
|
+
npm install react-native-keyboard-toolkit
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
React and React Native are peer dependencies. The package uses only React Native
|
|
8
|
+
JavaScript APIs, so it does not require native linking, a Pod, a config plugin,
|
|
9
|
+
or a custom Expo development client.
|
|
10
|
+
|
|
11
|
+
## React Native CLI
|
|
12
|
+
|
|
13
|
+
1. Install the package in the app.
|
|
14
|
+
2. Use `KeyboardAwareScrollView` around the form content.
|
|
15
|
+
3. On Android, keep the activity's `windowSoftInputMode` at `adjustResize` for
|
|
16
|
+
the conventional resizable-window behavior.
|
|
17
|
+
|
|
18
|
+
## Expo
|
|
19
|
+
|
|
20
|
+
Use the package from an Expo app as you would any JavaScript-only dependency.
|
|
21
|
+
The keyboard APIs are included in Expo Go. The development demo lives in the
|
|
22
|
+
repository's React Native host app; it is not a generated Expo project.
|
package/docs/ios.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# iOS testing
|
|
2
|
+
|
|
3
|
+
The library consumes iOS keyboard frame-change events and uses
|
|
4
|
+
`measureInWindow` for the focused field and its scroll viewport. The intersection
|
|
5
|
+
of those measured window coordinates with the keyboard frame determines the
|
|
6
|
+
temporary scroll range, so safe-area-constrained viewports do not blindly add
|
|
7
|
+
the full keyboard height. The library does not set safe-area, navigation, or
|
|
8
|
+
modal presentation configuration.
|
|
9
|
+
|
|
10
|
+
An absolutely positioned `KeyboardAwareView` uses the same keyboard frame and
|
|
11
|
+
event duration to animate a sticky footer above the keyboard. Place it outside
|
|
12
|
+
the scroll view and account for its height in the scroll view's content padding
|
|
13
|
+
and `keyboardSpacing`.
|
|
14
|
+
|
|
15
|
+
## Device checklist
|
|
16
|
+
|
|
17
|
+
- Test iPhone and iPad layouts with safe areas and the home indicator.
|
|
18
|
+
- Verify navigation headers and bottom tab bars are outside the measured form
|
|
19
|
+
region or included in its layout.
|
|
20
|
+
- Test multiline inputs, interactive keyboard dismissal, and modal screens.
|
|
21
|
+
- On iPad, test floating and split keyboards where available.
|
|
22
|
+
- Test hardware keyboard attachment and focus transitions.
|
|
23
|
+
- Check input behavior during navigation animations.
|
|
24
|
+
|
|
25
|
+
Floating/split keyboards and transition-time measurements may not describe one
|
|
26
|
+
contiguous obscured area. Automated unit tests cannot validate native keyboard
|
|
27
|
+
geometry; test on actual simulators/devices before release.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Troubleshooting
|
|
2
|
+
|
|
3
|
+
## The focused field does not scroll
|
|
4
|
+
|
|
5
|
+
- Confirm the input is a native React Native `TextInput`, not an unmeasurable
|
|
6
|
+
third-party wrapper.
|
|
7
|
+
- Ensure the focused input belongs to the scroll view and has completed layout.
|
|
8
|
+
- Check that `enabled` is true and the behavior is `smart` or `scroll`.
|
|
9
|
+
- If a custom input wrapper delays focus, call `refresh()` from
|
|
10
|
+
`useFocusedInput` after its layout settles.
|
|
11
|
+
|
|
12
|
+
## A sticky submit button is behind the keyboard
|
|
13
|
+
|
|
14
|
+
Wrap the footer in `KeyboardAwareView` and set `position: 'absolute'` on that
|
|
15
|
+
component's style. The view then measures its bottom edge and moves above the
|
|
16
|
+
keyboard by the covered distance plus `keyboardSpacing`. Keep it outside the
|
|
17
|
+
scroll view and give the scroll content enough bottom padding to scroll above
|
|
18
|
+
the footer.
|
|
19
|
+
|
|
20
|
+
## Android content is moved twice
|
|
21
|
+
|
|
22
|
+
Use `adjustResize` as the standard window mode and avoid adding a second
|
|
23
|
+
keyboard-height spacer around the same content. The scroll view uses the event
|
|
24
|
+
screen coordinate where available, but unusual edge-to-edge or OEM keyboard
|
|
25
|
+
configurations may need a custom layout.
|
|
26
|
+
|
|
27
|
+
## Keyboard height is zero or unexpected
|
|
28
|
+
|
|
29
|
+
Hardware keyboards may not open a software keyboard. Floating and split
|
|
30
|
+
keyboards can occupy only part of the screen and do not have a useful full-width
|
|
31
|
+
height. Keyboard-reported heights can differ with navigation bars and OS
|
|
32
|
+
versions.
|
|
33
|
+
|
|
34
|
+
## Nested views add too much space
|
|
35
|
+
|
|
36
|
+
Use one keyboard-padding component per layout region. A nested aware view and
|
|
37
|
+
scroll view both respond to the same keyboard state; disable one with
|
|
38
|
+
`behavior="resize"` or `enabled={false}` when only one should adjust.
|
|
39
|
+
|
|
40
|
+
## The keyboard hides a modal field
|
|
41
|
+
|
|
42
|
+
Place the scroll view inside the modal content and verify the modal's measured
|
|
43
|
+
window frame. Presentation styles and window insets vary; test on the target
|
|
44
|
+
platform rather than assuming the main screen's coordinates apply to a modal.
|
package/package.json
CHANGED
|
@@ -1,6 +1,92 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-keyboard-toolkit",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Keyboard-aware scrolling, views, sticky form actions, and keyboard hooks for React Native on iOS and Android.",
|
|
5
|
+
"author": "Denish Dabhi <dabhidenish57@gmail.com>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://github.com/DenishDabhi/react-native-keyboard-toolkit#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/DenishDabhi/react-native-keyboard-toolkit.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/DenishDabhi/react-native-keyboard-toolkit/issues"
|
|
14
|
+
},
|
|
15
|
+
"main": "dist/index.js",
|
|
16
|
+
"react-native": "src/index.ts",
|
|
17
|
+
"types": "dist/index.d.ts",
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src",
|
|
21
|
+
"docs",
|
|
22
|
+
"README.md",
|
|
23
|
+
"CHANGELOG.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"react-native": "./src/index.ts",
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"sideEffects": false,
|
|
34
|
+
"keywords": [
|
|
35
|
+
"react-native",
|
|
36
|
+
"keyboard",
|
|
37
|
+
"keyboard-aware",
|
|
38
|
+
"keyboard-management",
|
|
39
|
+
"keyboard-avoiding",
|
|
40
|
+
"scrollview",
|
|
41
|
+
"textinput",
|
|
42
|
+
"expo",
|
|
43
|
+
"ios",
|
|
44
|
+
"android",
|
|
45
|
+
"typescript",
|
|
46
|
+
"keyboard-dismiss",
|
|
47
|
+
"keyboard-handling",
|
|
48
|
+
"sticky-button",
|
|
49
|
+
"sticky-footer"
|
|
50
|
+
],
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"android": "react-native run-android",
|
|
56
|
+
"ios": "react-native run-ios",
|
|
57
|
+
"build": "tsc -p tsconfig.build.json",
|
|
58
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
59
|
+
"lint": "eslint src tests demo App.tsx --ext .ts,.tsx",
|
|
60
|
+
"format:check": "prettier --check src tests demo App.tsx package.json README.md CHANGELOG.md docs",
|
|
61
|
+
"start": "react-native start",
|
|
62
|
+
"test": "jest --runInBand",
|
|
63
|
+
"validate": "npm run typecheck && npm run lint && npm run format:check && npm test && npm run build && npm pack --dry-run",
|
|
64
|
+
"prepublishOnly": "npm run validate"
|
|
65
|
+
},
|
|
66
|
+
"peerDependencies": {
|
|
67
|
+
"react": ">=18.2.0",
|
|
68
|
+
"react-native": ">=0.71.0"
|
|
69
|
+
},
|
|
70
|
+
"devDependencies": {
|
|
71
|
+
"@babel/core": "^7.25.2",
|
|
72
|
+
"@react-native-community/cli": "20.2.0",
|
|
73
|
+
"@react-native-community/cli-platform-android": "20.2.0",
|
|
74
|
+
"@react-native-community/cli-platform-ios": "20.2.0",
|
|
75
|
+
"@react-native/babel-preset": "0.88.1",
|
|
76
|
+
"@react-native/eslint-config": "0.88.1",
|
|
77
|
+
"@react-native/jest-preset": "0.88.1",
|
|
78
|
+
"@react-native/metro-config": "0.88.1",
|
|
79
|
+
"@react-native/typescript-config": "0.88.1",
|
|
80
|
+
"@types/jest": "^29.5.13",
|
|
81
|
+
"@types/react": "^19.2.0",
|
|
82
|
+
"@types/react-test-renderer": "^19.1.0",
|
|
83
|
+
"eslint": "^8.19.0",
|
|
84
|
+
"jest": "^29.6.3",
|
|
85
|
+
"prettier": "2.8.8",
|
|
86
|
+
"react": "19.3.0",
|
|
87
|
+
"react-native": "0.88.1",
|
|
88
|
+
"react-native-safe-area-context": "^5.5.2",
|
|
89
|
+
"react-test-renderer": "19.3.0",
|
|
90
|
+
"typescript": "^6.0.3"
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
forwardRef,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useImperativeHandle,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import { ScrollView, StyleSheet, type LayoutChangeEvent } from 'react-native';
|
|
10
|
+
import type { KeyboardAwareScrollViewProps } from '../types';
|
|
11
|
+
import type { KeyboardAwareScrollViewRef } from '../types';
|
|
12
|
+
import { getFocusedInput, measureFocusedInput } from '../utils/focusManager';
|
|
13
|
+
import {
|
|
14
|
+
calculateKeyboardOverlap,
|
|
15
|
+
calculateScrollOffset,
|
|
16
|
+
} from '../utils/scrollManager';
|
|
17
|
+
import { useKeyboardDebug } from '../context/KeyboardContext';
|
|
18
|
+
import { useKeyboardState } from '../hooks/useKeyboardState';
|
|
19
|
+
import { DEFAULT_KEYBOARD_SPACING } from '../utils/constants';
|
|
20
|
+
import { isIOS } from '../utils/platform';
|
|
21
|
+
|
|
22
|
+
/** A ScrollView that adds keyboard space and scrolls a covered focused input into view. */
|
|
23
|
+
export const KeyboardAwareScrollView: React.ForwardRefExoticComponent<
|
|
24
|
+
KeyboardAwareScrollViewProps & React.RefAttributes<KeyboardAwareScrollViewRef>
|
|
25
|
+
> = forwardRef<KeyboardAwareScrollViewRef, KeyboardAwareScrollViewProps>(
|
|
26
|
+
function KeyboardAwareScrollViewComponent(
|
|
27
|
+
{
|
|
28
|
+
behavior = 'smart',
|
|
29
|
+
keyboardSpacing = DEFAULT_KEYBOARD_SPACING,
|
|
30
|
+
enabled = true,
|
|
31
|
+
contentContainerStyle,
|
|
32
|
+
onScroll,
|
|
33
|
+
onScrollBeginDrag,
|
|
34
|
+
onContentSizeChange,
|
|
35
|
+
onLayout,
|
|
36
|
+
onFocusCapture,
|
|
37
|
+
scrollEventThrottle = 16,
|
|
38
|
+
...props
|
|
39
|
+
},
|
|
40
|
+
forwardedRef,
|
|
41
|
+
): React.JSX.Element {
|
|
42
|
+
const keyboard = useKeyboardState();
|
|
43
|
+
const debug = useKeyboardDebug();
|
|
44
|
+
const scrollRef = useRef<KeyboardAwareScrollViewRef>(null);
|
|
45
|
+
useImperativeHandle(forwardedRef, () => scrollRef.current!, []);
|
|
46
|
+
const scrollOffset = useRef(0);
|
|
47
|
+
const lastScrollTarget = useRef<number | null>(null);
|
|
48
|
+
const measurementSequence = useRef(0);
|
|
49
|
+
const [keyboardOverlap, setKeyboardOverlap] = useState(0);
|
|
50
|
+
const spacing = Math.max(0, keyboardSpacing);
|
|
51
|
+
|
|
52
|
+
const scrollFocusedInput = useCallback(async () => {
|
|
53
|
+
const sequence = ++measurementSequence.current;
|
|
54
|
+
if (!enabled || !keyboard.isVisible || behavior === 'resize') {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const scrollView = scrollRef.current;
|
|
59
|
+
const input = getFocusedInput();
|
|
60
|
+
if (!scrollView?.measureInWindow) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const viewport = await new Promise<{ y: number; height: number }>(
|
|
65
|
+
resolve => {
|
|
66
|
+
scrollView.measureInWindow((_x, y, _width, height) =>
|
|
67
|
+
resolve({ y, height }),
|
|
68
|
+
);
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
if (sequence !== measurementSequence.current || !keyboard.isVisible) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const overlap = calculateKeyboardOverlap(
|
|
77
|
+
viewport,
|
|
78
|
+
keyboard.height,
|
|
79
|
+
keyboard.screenY,
|
|
80
|
+
);
|
|
81
|
+
if (Math.abs(overlap - keyboardOverlap) > 1) {
|
|
82
|
+
setKeyboardOverlap(overlap);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (
|
|
86
|
+
!input?.measureInWindow ||
|
|
87
|
+
behavior === 'none' ||
|
|
88
|
+
behavior === 'padding'
|
|
89
|
+
) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const inputLayout = await measureFocusedInput(input);
|
|
94
|
+
if (sequence !== measurementSequence.current || !inputLayout) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const calculation = calculateScrollOffset(
|
|
99
|
+
{ y: inputLayout.y, height: inputLayout.height },
|
|
100
|
+
viewport,
|
|
101
|
+
keyboard.height,
|
|
102
|
+
spacing,
|
|
103
|
+
scrollOffset.current,
|
|
104
|
+
keyboard.screenY,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
if (debug && __DEV__) {
|
|
108
|
+
console.debug('[keyboard-toolkit] scroll calculation', calculation);
|
|
109
|
+
}
|
|
110
|
+
if (
|
|
111
|
+
Math.abs(calculation.delta) > 1 &&
|
|
112
|
+
(lastScrollTarget.current === null ||
|
|
113
|
+
Math.abs(lastScrollTarget.current - calculation.offset) > 1)
|
|
114
|
+
) {
|
|
115
|
+
lastScrollTarget.current = calculation.offset;
|
|
116
|
+
scrollView.scrollTo({ y: calculation.offset, animated: true });
|
|
117
|
+
}
|
|
118
|
+
}, [
|
|
119
|
+
behavior,
|
|
120
|
+
debug,
|
|
121
|
+
enabled,
|
|
122
|
+
keyboard.height,
|
|
123
|
+
keyboard.isVisible,
|
|
124
|
+
keyboard.screenY,
|
|
125
|
+
keyboardOverlap,
|
|
126
|
+
spacing,
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (!keyboard.isVisible) {
|
|
131
|
+
lastScrollTarget.current = null;
|
|
132
|
+
measurementSequence.current += 1;
|
|
133
|
+
setKeyboardOverlap(0);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const frame = requestAnimationFrame(() => {
|
|
137
|
+
scrollFocusedInput();
|
|
138
|
+
});
|
|
139
|
+
return () => cancelAnimationFrame(frame);
|
|
140
|
+
}, [keyboard.height, keyboard.isVisible, scrollFocusedInput]);
|
|
141
|
+
|
|
142
|
+
const handleScroll = useCallback(
|
|
143
|
+
(event: Parameters<NonNullable<typeof onScroll>>[0]) => {
|
|
144
|
+
scrollOffset.current = event.nativeEvent.contentOffset.y;
|
|
145
|
+
if (
|
|
146
|
+
lastScrollTarget.current !== null &&
|
|
147
|
+
Math.abs(scrollOffset.current - lastScrollTarget.current) <= 1
|
|
148
|
+
) {
|
|
149
|
+
lastScrollTarget.current = null;
|
|
150
|
+
}
|
|
151
|
+
onScroll?.(event);
|
|
152
|
+
},
|
|
153
|
+
[onScroll],
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const handleScrollBeginDrag = useCallback(
|
|
157
|
+
(event: Parameters<NonNullable<typeof onScrollBeginDrag>>[0]) => {
|
|
158
|
+
lastScrollTarget.current = null;
|
|
159
|
+
onScrollBeginDrag?.(event);
|
|
160
|
+
},
|
|
161
|
+
[onScrollBeginDrag],
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const handleContentSizeChange = useCallback(
|
|
165
|
+
(width: number, height: number) => {
|
|
166
|
+
onContentSizeChange?.(width, height);
|
|
167
|
+
if (keyboard.isVisible) {
|
|
168
|
+
requestAnimationFrame(() => scrollFocusedInput());
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
[keyboard.isVisible, onContentSizeChange, scrollFocusedInput],
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const handleLayout = useCallback(
|
|
175
|
+
(event: LayoutChangeEvent) => {
|
|
176
|
+
onLayout?.(event);
|
|
177
|
+
if (keyboard.isVisible) {
|
|
178
|
+
requestAnimationFrame(() => scrollFocusedInput());
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
[keyboard.isVisible, onLayout, scrollFocusedInput],
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const handleFocusCapture = useCallback(
|
|
185
|
+
(event: Parameters<NonNullable<typeof onFocusCapture>>[0]) => {
|
|
186
|
+
onFocusCapture?.(event);
|
|
187
|
+
if (keyboard.isVisible) {
|
|
188
|
+
requestAnimationFrame(() => scrollFocusedInput());
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
[keyboard.isVisible, onFocusCapture, scrollFocusedInput],
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const flattenedContentStyle = StyleSheet.flatten(contentContainerStyle);
|
|
195
|
+
const existingBottomPadding =
|
|
196
|
+
typeof flattenedContentStyle?.paddingBottom === 'number'
|
|
197
|
+
? flattenedContentStyle.paddingBottom
|
|
198
|
+
: 0;
|
|
199
|
+
const shouldPad =
|
|
200
|
+
enabled &&
|
|
201
|
+
keyboard.isVisible &&
|
|
202
|
+
behavior !== 'none' &&
|
|
203
|
+
behavior !== 'resize' &&
|
|
204
|
+
keyboardOverlap > 0;
|
|
205
|
+
const adjustedContentStyle =
|
|
206
|
+
shouldPad && typeof flattenedContentStyle?.paddingBottom !== 'string'
|
|
207
|
+
? [
|
|
208
|
+
contentContainerStyle,
|
|
209
|
+
{
|
|
210
|
+
paddingBottom: Math.max(
|
|
211
|
+
existingBottomPadding,
|
|
212
|
+
keyboardOverlap + spacing,
|
|
213
|
+
),
|
|
214
|
+
},
|
|
215
|
+
]
|
|
216
|
+
: contentContainerStyle;
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<ScrollView
|
|
220
|
+
{...props}
|
|
221
|
+
automaticallyAdjustKeyboardInsets={
|
|
222
|
+
props.automaticallyAdjustKeyboardInsets ??
|
|
223
|
+
(behavior === 'resize' && isIOS)
|
|
224
|
+
}
|
|
225
|
+
ref={scrollRef}
|
|
226
|
+
contentContainerStyle={adjustedContentStyle}
|
|
227
|
+
onContentSizeChange={handleContentSizeChange}
|
|
228
|
+
onLayout={handleLayout}
|
|
229
|
+
onFocusCapture={handleFocusCapture}
|
|
230
|
+
onScroll={handleScroll}
|
|
231
|
+
onScrollBeginDrag={handleScrollBeginDrag}
|
|
232
|
+
scrollEventThrottle={scrollEventThrottle}
|
|
233
|
+
/>
|
|
234
|
+
);
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
KeyboardAwareScrollView.displayName = 'KeyboardAwareScrollView';
|
|
239
|
+
|
|
240
|
+
export default KeyboardAwareScrollView;
|