react-native-keyboard-controller 1.21.0-beta.1 → 1.21.0-beta.3
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/android/src/fabric/java/com/reactnativekeyboardcontroller/ClippingScrollViewDecoratorViewManager.kt +7 -0
- package/android/src/main/java/com/reactnativekeyboardcontroller/views/ClippingScrollViewDecoratorView.kt +28 -1
- package/android/src/paper/java/com/reactnativekeyboardcontroller/ClippingScrollViewDecoratorViewManager.kt +8 -0
- package/jest/index.js +1 -0
- package/lib/commonjs/components/KeyboardAwareScrollView/index.js +7 -4
- package/lib/commonjs/components/KeyboardAwareScrollView/index.js.map +1 -1
- package/lib/commonjs/components/KeyboardChatScrollView/hooks.js +13 -0
- package/lib/commonjs/components/KeyboardChatScrollView/hooks.js.map +1 -0
- package/lib/commonjs/components/KeyboardChatScrollView/index.js +80 -0
- package/lib/commonjs/components/KeyboardChatScrollView/index.js.map +1 -0
- package/lib/commonjs/components/KeyboardChatScrollView/types.js +6 -0
- package/lib/commonjs/components/KeyboardChatScrollView/types.js.map +1 -0
- package/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js +135 -0
- package/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js.map +1 -0
- package/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +121 -0
- package/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js.map +1 -0
- package/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.js +205 -0
- package/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.js.map +1 -0
- package/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/types.js +6 -0
- package/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/types.js.map +1 -0
- package/lib/commonjs/components/KeyboardStickyView/index.js +9 -5
- package/lib/commonjs/components/KeyboardStickyView/index.js.map +1 -1
- package/lib/commonjs/components/ScrollViewWithBottomPadding/index.js +46 -13
- package/lib/commonjs/components/ScrollViewWithBottomPadding/index.js.map +1 -1
- package/lib/commonjs/components/hooks/useScrollState.js +20 -1
- package/lib/commonjs/components/hooks/useScrollState.js.map +1 -1
- package/lib/commonjs/components/index.js +7 -0
- package/lib/commonjs/components/index.js.map +1 -1
- package/lib/commonjs/index.js +7 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/specs/ClippingScrollViewDecoratorViewNativeComponent.js.map +1 -1
- package/lib/commonjs/types/views.js.map +1 -1
- package/lib/module/components/KeyboardAwareScrollView/index.js +7 -4
- package/lib/module/components/KeyboardAwareScrollView/index.js.map +1 -1
- package/lib/module/components/KeyboardChatScrollView/hooks.js +2 -0
- package/lib/module/components/KeyboardChatScrollView/hooks.js.map +1 -0
- package/lib/module/components/KeyboardChatScrollView/index.js +73 -0
- package/lib/module/components/KeyboardChatScrollView/index.js.map +1 -0
- package/lib/module/components/KeyboardChatScrollView/types.js +2 -0
- package/lib/module/components/KeyboardChatScrollView/types.js.map +1 -0
- package/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js +125 -0
- package/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js.map +1 -0
- package/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +115 -0
- package/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js.map +1 -0
- package/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.js +199 -0
- package/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.js.map +1 -0
- package/lib/module/components/KeyboardChatScrollView/useChatKeyboard/types.js +2 -0
- package/lib/module/components/KeyboardChatScrollView/useChatKeyboard/types.js.map +1 -0
- package/lib/module/components/KeyboardStickyView/index.js +9 -5
- package/lib/module/components/KeyboardStickyView/index.js.map +1 -1
- package/lib/module/components/ScrollViewWithBottomPadding/index.js +47 -14
- package/lib/module/components/ScrollViewWithBottomPadding/index.js.map +1 -1
- package/lib/module/components/hooks/useScrollState.js +21 -2
- package/lib/module/components/hooks/useScrollState.js.map +1 -1
- package/lib/module/components/index.js +1 -0
- package/lib/module/components/index.js.map +1 -1
- package/lib/module/index.js +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/specs/ClippingScrollViewDecoratorViewNativeComponent.js.map +1 -1
- package/lib/module/types/views.js.map +1 -1
- package/lib/typescript/components/KeyboardChatScrollView/hooks.d.ts +2 -0
- package/lib/typescript/components/KeyboardChatScrollView/index.d.ts +12 -0
- package/lib/typescript/components/KeyboardChatScrollView/types.d.ts +48 -0
- package/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts +77 -0
- package/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/index.d.ts +21 -0
- package/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/index.ios.d.ts +21 -0
- package/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts +22 -0
- package/lib/typescript/components/ScrollViewWithBottomPadding/index.d.ts +3 -0
- package/lib/typescript/components/hooks/useScrollState.d.ts +3 -0
- package/lib/typescript/components/index.d.ts +2 -0
- package/lib/typescript/index.d.ts +2 -2
- package/lib/typescript/specs/ClippingScrollViewDecoratorViewNativeComponent.d.ts +1 -0
- package/lib/typescript/types/views.d.ts +2 -0
- package/package.json +12 -4
- package/src/components/KeyboardAwareScrollView/index.tsx +10 -3
- package/src/components/KeyboardChatScrollView/hooks.ts +2 -0
- package/src/components/KeyboardChatScrollView/index.tsx +106 -0
- package/src/components/KeyboardChatScrollView/types.ts +49 -0
- package/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts +163 -0
- package/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts +180 -0
- package/src/components/KeyboardChatScrollView/useChatKeyboard/index.ts +300 -0
- package/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts +30 -0
- package/src/components/KeyboardStickyView/index.tsx +8 -8
- package/src/components/ScrollViewWithBottomPadding/index.tsx +63 -17
- package/src/components/hooks/useScrollState.ts +24 -2
- package/src/components/index.ts +2 -0
- package/src/index.ts +2 -0
- package/src/specs/ClippingScrollViewDecoratorViewNativeComponent.ts +1 -0
- package/src/types/views.ts +2 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { interpolate } from "react-native-reanimated";
|
|
2
|
+
|
|
3
|
+
import type { KeyboardLiftBehavior } from "./types";
|
|
4
|
+
|
|
5
|
+
const AT_END_THRESHOLD = 20;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Map the current keyboard height to an effective height that accounts for a
|
|
9
|
+
* fixed offset (e.g. bottom safe-area or tab-bar height).
|
|
10
|
+
*
|
|
11
|
+
* @param height - Current keyboard height.
|
|
12
|
+
* @param targetKeyboardHeight - Full target keyboard height (captured on keyboard open).
|
|
13
|
+
* @param offset - Fixed distance between the scroll-view bottom and the screen bottom.
|
|
14
|
+
* @returns Effective height after subtracting the offset proportionally.
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* getEffectiveHeight(300, 300, 50); // 250
|
|
18
|
+
* getEffectiveHeight(150, 300, 50); // 125
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export function getEffectiveHeight(
|
|
22
|
+
height: number,
|
|
23
|
+
targetKeyboardHeight: number,
|
|
24
|
+
offset: number,
|
|
25
|
+
): number {
|
|
26
|
+
"worklet";
|
|
27
|
+
|
|
28
|
+
if (offset === 0 || targetKeyboardHeight === 0) {
|
|
29
|
+
return height;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return interpolate(
|
|
33
|
+
height,
|
|
34
|
+
[0, targetKeyboardHeight],
|
|
35
|
+
[0, Math.max(targetKeyboardHeight - offset, 0)],
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check whether the scroll view is at the end of its content.
|
|
41
|
+
*
|
|
42
|
+
* For non-inverted lists the "end" is the bottom of the content.
|
|
43
|
+
* For inverted lists the "end" is the top (scroll offset near 0),
|
|
44
|
+
* because that is where the latest messages are displayed.
|
|
45
|
+
*
|
|
46
|
+
* @param scrollOffset - Current vertical scroll offset.
|
|
47
|
+
* @param layoutHeight - Visible height of the scroll view.
|
|
48
|
+
* @param contentHeight - Total height of the scrollable content.
|
|
49
|
+
* @param inverted - Whether the list is inverted.
|
|
50
|
+
* @returns `true` if the scroll position is within the threshold of the content end.
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* const atEnd = isScrollAtEnd(100, 800, 920); // true (100 + 800 >= 920 - 20)
|
|
54
|
+
* const atEndInverted = isScrollAtEnd(5, 800, 2000, true); // true (5 <= 20)
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export function isScrollAtEnd(
|
|
58
|
+
scrollOffset: number,
|
|
59
|
+
layoutHeight: number,
|
|
60
|
+
contentHeight: number,
|
|
61
|
+
inverted: boolean = false,
|
|
62
|
+
): boolean {
|
|
63
|
+
"worklet";
|
|
64
|
+
|
|
65
|
+
if (inverted) {
|
|
66
|
+
return scrollOffset <= AT_END_THRESHOLD;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return scrollOffset + layoutHeight >= contentHeight - AT_END_THRESHOLD;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Decide whether content should be shifted based on the keyboard lift behavior.
|
|
74
|
+
*
|
|
75
|
+
* @param behavior - The configured keyboard lift behavior.
|
|
76
|
+
* @param isAtEnd - Whether the scroll view is currently at the end.
|
|
77
|
+
* @returns `true` if content should be shifted.
|
|
78
|
+
* @example
|
|
79
|
+
* ```ts
|
|
80
|
+
* shouldShiftContent("always", false); // true
|
|
81
|
+
* shouldShiftContent("whenAtEnd", false); // false
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
84
|
+
export function shouldShiftContent(
|
|
85
|
+
behavior: KeyboardLiftBehavior,
|
|
86
|
+
isAtEnd: boolean,
|
|
87
|
+
): boolean {
|
|
88
|
+
"worklet";
|
|
89
|
+
|
|
90
|
+
switch (behavior) {
|
|
91
|
+
case "always":
|
|
92
|
+
return true;
|
|
93
|
+
case "never":
|
|
94
|
+
return false;
|
|
95
|
+
case "whenAtEnd":
|
|
96
|
+
return isAtEnd;
|
|
97
|
+
case "persistent":
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Compute the clamped scroll target for non-inverted lists.
|
|
104
|
+
*
|
|
105
|
+
* @param offsetBeforeScroll - Scroll position before keyboard appeared.
|
|
106
|
+
* @param keyboardHeight - Current keyboard height.
|
|
107
|
+
* @param contentHeight - Total height of the scrollable content.
|
|
108
|
+
* @param layoutHeight - Visible height of the scroll view.
|
|
109
|
+
* @returns Clamped scroll target between 0 and maxScroll.
|
|
110
|
+
* @example
|
|
111
|
+
* ```ts
|
|
112
|
+
* clampedScrollTarget(100, 300, 1000, 800); // 400
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
export function clampedScrollTarget(
|
|
116
|
+
offsetBeforeScroll: number,
|
|
117
|
+
keyboardHeight: number,
|
|
118
|
+
contentHeight: number,
|
|
119
|
+
layoutHeight: number,
|
|
120
|
+
): number {
|
|
121
|
+
"worklet";
|
|
122
|
+
|
|
123
|
+
const maxScroll = Math.max(contentHeight - layoutHeight + keyboardHeight, 0);
|
|
124
|
+
|
|
125
|
+
return Math.min(Math.max(offsetBeforeScroll + keyboardHeight, 0), maxScroll);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Compute contentOffset.y for iOS lists.
|
|
130
|
+
*
|
|
131
|
+
* @param relativeScroll - Scroll position relative to current inset.
|
|
132
|
+
* @param keyboardHeight - Target keyboard height.
|
|
133
|
+
* @param contentHeight - Total height of the scrollable content.
|
|
134
|
+
* @param layoutHeight - Visible height of the scroll view.
|
|
135
|
+
* @param inverted - Whether the list is inverted.
|
|
136
|
+
* @returns The absolute contentOffset.y to set.
|
|
137
|
+
* @example
|
|
138
|
+
* ```ts
|
|
139
|
+
* computeIOSContentOffset(100, 300, 1000, 800, false); // 400
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
export function computeIOSContentOffset(
|
|
143
|
+
relativeScroll: number,
|
|
144
|
+
keyboardHeight: number,
|
|
145
|
+
contentHeight: number,
|
|
146
|
+
layoutHeight: number,
|
|
147
|
+
inverted: boolean,
|
|
148
|
+
): number {
|
|
149
|
+
"worklet";
|
|
150
|
+
|
|
151
|
+
if (inverted) {
|
|
152
|
+
const maxScroll = Math.max(contentHeight - layoutHeight, 0);
|
|
153
|
+
|
|
154
|
+
return Math.max(
|
|
155
|
+
Math.min(relativeScroll - keyboardHeight, maxScroll),
|
|
156
|
+
-keyboardHeight,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const maxScroll = Math.max(contentHeight - layoutHeight + keyboardHeight, 0);
|
|
161
|
+
|
|
162
|
+
return Math.min(Math.max(keyboardHeight + relativeScroll, 0), maxScroll);
|
|
163
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { useSharedValue } from "react-native-reanimated";
|
|
2
|
+
|
|
3
|
+
import { useKeyboardHandler } from "../../../hooks";
|
|
4
|
+
import useScrollState from "../../hooks/useScrollState";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
computeIOSContentOffset,
|
|
8
|
+
getEffectiveHeight,
|
|
9
|
+
isScrollAtEnd,
|
|
10
|
+
shouldShiftContent,
|
|
11
|
+
} from "./helpers";
|
|
12
|
+
|
|
13
|
+
import type { UseChatKeyboardOptions, UseChatKeyboardReturn } from "./types";
|
|
14
|
+
import type { AnimatedRef } from "react-native-reanimated";
|
|
15
|
+
import type Reanimated from "react-native-reanimated";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Hook that manages keyboard-driven scrolling for chat-style scroll views.
|
|
19
|
+
* Calculates padding (extra scrollable space) and content shift values,
|
|
20
|
+
* using iOS-specific strategy (contentOffset set once in onStart).
|
|
21
|
+
*
|
|
22
|
+
* @param scrollViewRef - Animated ref to the scroll view.
|
|
23
|
+
* @param options - Configuration for inverted and keyboardLiftBehavior.
|
|
24
|
+
* @returns Shared values for padding and contentOffsetY.
|
|
25
|
+
* @example
|
|
26
|
+
* ```tsx
|
|
27
|
+
* const { padding, contentOffsetY } = useChatKeyboard(ref, {
|
|
28
|
+
* inverted: false,
|
|
29
|
+
* keyboardLiftBehavior: "always",
|
|
30
|
+
* });
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
function useChatKeyboard(
|
|
34
|
+
scrollViewRef: AnimatedRef<Reanimated.ScrollView>,
|
|
35
|
+
options: UseChatKeyboardOptions,
|
|
36
|
+
): UseChatKeyboardReturn {
|
|
37
|
+
const { inverted, keyboardLiftBehavior, freeze, offset } = options;
|
|
38
|
+
|
|
39
|
+
const padding = useSharedValue(0);
|
|
40
|
+
const currentHeight = useSharedValue(0);
|
|
41
|
+
const contentOffsetY = useSharedValue(0);
|
|
42
|
+
const targetKeyboardHeight = useSharedValue(0);
|
|
43
|
+
|
|
44
|
+
const {
|
|
45
|
+
layout,
|
|
46
|
+
size,
|
|
47
|
+
offset: scroll,
|
|
48
|
+
onLayout,
|
|
49
|
+
onContentSizeChange,
|
|
50
|
+
} = useScrollState(scrollViewRef);
|
|
51
|
+
|
|
52
|
+
useKeyboardHandler(
|
|
53
|
+
{
|
|
54
|
+
onStart: (e) => {
|
|
55
|
+
"worklet";
|
|
56
|
+
|
|
57
|
+
if (freeze) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (e.height > 0) {
|
|
62
|
+
// eslint-disable-next-line react-compiler/react-compiler
|
|
63
|
+
targetKeyboardHeight.value = e.height;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const effective = getEffectiveHeight(
|
|
67
|
+
e.height,
|
|
68
|
+
targetKeyboardHeight.value,
|
|
69
|
+
offset,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const atEnd = isScrollAtEnd(
|
|
73
|
+
scroll.value,
|
|
74
|
+
layout.value.height,
|
|
75
|
+
size.value.height,
|
|
76
|
+
inverted,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// persistent mode: when keyboard shrinks, snap to end or hold position
|
|
80
|
+
if (
|
|
81
|
+
keyboardLiftBehavior === "persistent" &&
|
|
82
|
+
effective < padding.value
|
|
83
|
+
) {
|
|
84
|
+
padding.value = effective;
|
|
85
|
+
|
|
86
|
+
if (atEnd) {
|
|
87
|
+
if (inverted) {
|
|
88
|
+
contentOffsetY.value = -effective;
|
|
89
|
+
} else {
|
|
90
|
+
contentOffsetY.value = Math.max(
|
|
91
|
+
size.value.height - layout.value.height + effective,
|
|
92
|
+
0,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
// Preserve current scroll position so the animated props
|
|
97
|
+
// don't re-apply the stale contentOffset from keyboard open
|
|
98
|
+
contentOffsetY.value = scroll.value;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// never mode: when keyboard shrinks and at end, snap to end
|
|
105
|
+
// to avoid ghost padding
|
|
106
|
+
if (
|
|
107
|
+
keyboardLiftBehavior === "never" &&
|
|
108
|
+
effective < padding.value &&
|
|
109
|
+
atEnd
|
|
110
|
+
) {
|
|
111
|
+
padding.value = effective;
|
|
112
|
+
|
|
113
|
+
if (inverted) {
|
|
114
|
+
contentOffsetY.value = -effective;
|
|
115
|
+
} else {
|
|
116
|
+
contentOffsetY.value = Math.max(
|
|
117
|
+
size.value.height - layout.value.height + effective,
|
|
118
|
+
0,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const relativeScroll = inverted
|
|
126
|
+
? scroll.value + padding.value
|
|
127
|
+
: scroll.value - padding.value;
|
|
128
|
+
|
|
129
|
+
padding.value = effective;
|
|
130
|
+
|
|
131
|
+
if (!shouldShiftContent(keyboardLiftBehavior, atEnd)) {
|
|
132
|
+
// Preserve current scroll position so animated props
|
|
133
|
+
// don't re-apply a stale contentOffset when padding changes
|
|
134
|
+
contentOffsetY.value = scroll.value;
|
|
135
|
+
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
contentOffsetY.value = computeIOSContentOffset(
|
|
140
|
+
relativeScroll,
|
|
141
|
+
effective,
|
|
142
|
+
size.value.height,
|
|
143
|
+
layout.value.height,
|
|
144
|
+
inverted,
|
|
145
|
+
);
|
|
146
|
+
},
|
|
147
|
+
onMove: () => {
|
|
148
|
+
"worklet";
|
|
149
|
+
|
|
150
|
+
// iOS doesn't need per-frame updates (contentOffset handles it)
|
|
151
|
+
},
|
|
152
|
+
onEnd: (e) => {
|
|
153
|
+
"worklet";
|
|
154
|
+
|
|
155
|
+
if (freeze) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const effective = getEffectiveHeight(
|
|
160
|
+
e.height,
|
|
161
|
+
targetKeyboardHeight.value,
|
|
162
|
+
offset,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
padding.value = effective;
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
[inverted, keyboardLiftBehavior, freeze, offset],
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
padding,
|
|
173
|
+
currentHeight,
|
|
174
|
+
contentOffsetY,
|
|
175
|
+
onLayout,
|
|
176
|
+
onContentSizeChange,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export { useChatKeyboard };
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { scrollTo, useSharedValue } from "react-native-reanimated";
|
|
2
|
+
|
|
3
|
+
import { useKeyboardHandler } from "../../../hooks";
|
|
4
|
+
import useScrollState from "../../hooks/useScrollState";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
clampedScrollTarget,
|
|
8
|
+
getEffectiveHeight,
|
|
9
|
+
isScrollAtEnd,
|
|
10
|
+
shouldShiftContent,
|
|
11
|
+
} from "./helpers";
|
|
12
|
+
|
|
13
|
+
import type { UseChatKeyboardOptions, UseChatKeyboardReturn } from "./types";
|
|
14
|
+
import type { AnimatedRef } from "react-native-reanimated";
|
|
15
|
+
import type Reanimated from "react-native-reanimated";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Hook that manages keyboard-driven scrolling for chat-style scroll views.
|
|
19
|
+
* Calculates padding (extra scrollable space) and content shift values,
|
|
20
|
+
* using per-frame scrollTo updates (Android and other platforms).
|
|
21
|
+
*
|
|
22
|
+
* @param scrollViewRef - Animated ref to the scroll view.
|
|
23
|
+
* @param options - Configuration for inverted and keyboardLiftBehavior.
|
|
24
|
+
* @returns Shared values for padding and contentOffsetY (always `undefined`).
|
|
25
|
+
* @example
|
|
26
|
+
* ```tsx
|
|
27
|
+
* const { padding, contentOffsetY } = useChatKeyboard(ref, {
|
|
28
|
+
* inverted: false,
|
|
29
|
+
* keyboardLiftBehavior: "always",
|
|
30
|
+
* });
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
function useChatKeyboard(
|
|
34
|
+
scrollViewRef: AnimatedRef<Reanimated.ScrollView>,
|
|
35
|
+
options: UseChatKeyboardOptions,
|
|
36
|
+
): UseChatKeyboardReturn {
|
|
37
|
+
const { inverted, keyboardLiftBehavior, freeze, offset } = options;
|
|
38
|
+
|
|
39
|
+
const padding = useSharedValue(0);
|
|
40
|
+
const currentHeight = useSharedValue(0);
|
|
41
|
+
const offsetBeforeScroll = useSharedValue(0);
|
|
42
|
+
const targetKeyboardHeight = useSharedValue(0);
|
|
43
|
+
const closing = useSharedValue(false);
|
|
44
|
+
|
|
45
|
+
const {
|
|
46
|
+
layout,
|
|
47
|
+
size,
|
|
48
|
+
offset: scroll,
|
|
49
|
+
onLayout,
|
|
50
|
+
onContentSizeChange,
|
|
51
|
+
} = useScrollState(scrollViewRef);
|
|
52
|
+
|
|
53
|
+
const clampScrollIfNeeded = (effective: number) => {
|
|
54
|
+
"worklet";
|
|
55
|
+
|
|
56
|
+
const maxScroll = Math.max(
|
|
57
|
+
size.value.height - layout.value.height + effective,
|
|
58
|
+
0,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
if (scroll.value > maxScroll) {
|
|
62
|
+
scrollTo(scrollViewRef, 0, maxScroll, false);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
useKeyboardHandler(
|
|
67
|
+
{
|
|
68
|
+
onStart: (e) => {
|
|
69
|
+
"worklet";
|
|
70
|
+
|
|
71
|
+
if (freeze) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (e.height > 0) {
|
|
76
|
+
// eslint-disable-next-line react-compiler/react-compiler
|
|
77
|
+
targetKeyboardHeight.value = e.height;
|
|
78
|
+
closing.value = false;
|
|
79
|
+
} else {
|
|
80
|
+
closing.value = true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const effective = getEffectiveHeight(
|
|
84
|
+
e.height,
|
|
85
|
+
targetKeyboardHeight.value,
|
|
86
|
+
offset,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const atEnd = isScrollAtEnd(
|
|
90
|
+
scroll.value,
|
|
91
|
+
layout.value.height,
|
|
92
|
+
size.value.height,
|
|
93
|
+
inverted,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
if (inverted && e.duration === -1) {
|
|
97
|
+
// Android inverted: skip post-interactive snap-back events
|
|
98
|
+
// (duration === -1 means the keyboard is re-establishing its
|
|
99
|
+
// position after an interactive gesture, not a real animation)
|
|
100
|
+
return;
|
|
101
|
+
} else if (e.height > 0) {
|
|
102
|
+
// Android: keyboard opening — set padding + capture scroll position
|
|
103
|
+
padding.value = effective;
|
|
104
|
+
offsetBeforeScroll.value = scroll.value;
|
|
105
|
+
|
|
106
|
+
if (!inverted && keyboardLiftBehavior === "whenAtEnd" && !atEnd) {
|
|
107
|
+
// Sentinel: don't scroll in onMove (non-inverted only)
|
|
108
|
+
offsetBeforeScroll.value = -1;
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
// Android: keyboard closing — re-capture scroll position
|
|
112
|
+
if (inverted) {
|
|
113
|
+
offsetBeforeScroll.value = scroll.value;
|
|
114
|
+
} else {
|
|
115
|
+
// Preserve "whenAtEnd" sentinel: if open didn't shift, close shouldn't either
|
|
116
|
+
if (offsetBeforeScroll.value !== -1) {
|
|
117
|
+
// Non-inverted: subtract padding to get the "natural" position
|
|
118
|
+
// so onMove smoothly scrolls back from where the user is now
|
|
119
|
+
offsetBeforeScroll.value = scroll.value - padding.value;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
onMove: (e) => {
|
|
125
|
+
"worklet";
|
|
126
|
+
|
|
127
|
+
if (freeze) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
currentHeight.value = e.height;
|
|
132
|
+
|
|
133
|
+
if (inverted) {
|
|
134
|
+
// Skip post-interactive snap-back (duration === -1)
|
|
135
|
+
if (e.duration === -1) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const effective = getEffectiveHeight(
|
|
140
|
+
e.height,
|
|
141
|
+
targetKeyboardHeight.value,
|
|
142
|
+
offset,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Check if we should shift content based on position when keyboard started
|
|
146
|
+
const wasAtEnd = isScrollAtEnd(
|
|
147
|
+
offsetBeforeScroll.value,
|
|
148
|
+
layout.value.height,
|
|
149
|
+
size.value.height,
|
|
150
|
+
inverted,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// "never" at end: scroll along when keyboard closes to avoid jump
|
|
154
|
+
if (
|
|
155
|
+
keyboardLiftBehavior === "never" &&
|
|
156
|
+
wasAtEnd &&
|
|
157
|
+
effective < padding.value
|
|
158
|
+
) {
|
|
159
|
+
padding.value = effective;
|
|
160
|
+
scrollTo(scrollViewRef, 0, 0, false);
|
|
161
|
+
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!shouldShiftContent(keyboardLiftBehavior, wasAtEnd)) {
|
|
166
|
+
// Closing, not shifting: reduce padding to avoid gap
|
|
167
|
+
if (closing.value && effective < padding.value) {
|
|
168
|
+
padding.value = effective;
|
|
169
|
+
clampScrollIfNeeded(effective);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Persistent: don't let shift decrease
|
|
176
|
+
if (keyboardLiftBehavior === "persistent") {
|
|
177
|
+
const currentShift =
|
|
178
|
+
offsetBeforeScroll.value + padding.value - scroll.value;
|
|
179
|
+
|
|
180
|
+
if (effective < currentShift) {
|
|
181
|
+
// When at end, allow scrolling back (snap to end + reduce padding)
|
|
182
|
+
if (wasAtEnd) {
|
|
183
|
+
padding.value = effective;
|
|
184
|
+
scrollTo(scrollViewRef, 0, 0, false);
|
|
185
|
+
} else if (closing.value) {
|
|
186
|
+
// Not at end: reduce padding to avoid gap
|
|
187
|
+
padding.value = effective;
|
|
188
|
+
clampScrollIfNeeded(effective);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const target = offsetBeforeScroll.value + padding.value - effective;
|
|
196
|
+
|
|
197
|
+
scrollTo(scrollViewRef, 0, target, false);
|
|
198
|
+
} else {
|
|
199
|
+
const effective = getEffectiveHeight(
|
|
200
|
+
e.height,
|
|
201
|
+
targetKeyboardHeight.value,
|
|
202
|
+
offset,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// "never" closing: scroll along when at end to avoid jump
|
|
206
|
+
if (
|
|
207
|
+
keyboardLiftBehavior === "never" &&
|
|
208
|
+
closing.value &&
|
|
209
|
+
effective < padding.value
|
|
210
|
+
) {
|
|
211
|
+
const wasAtEnd = isScrollAtEnd(
|
|
212
|
+
offsetBeforeScroll.value + padding.value,
|
|
213
|
+
layout.value.height,
|
|
214
|
+
size.value.height,
|
|
215
|
+
false,
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
if (wasAtEnd) {
|
|
219
|
+
const target = clampedScrollTarget(
|
|
220
|
+
offsetBeforeScroll.value,
|
|
221
|
+
effective,
|
|
222
|
+
size.value.height,
|
|
223
|
+
layout.value.height,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
scrollTo(scrollViewRef, 0, target, false);
|
|
227
|
+
} else {
|
|
228
|
+
// Clamp to valid range as padding shrinks
|
|
229
|
+
clampScrollIfNeeded(effective);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!shouldShiftContent(keyboardLiftBehavior, true)) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// "whenAtEnd" sentinel check
|
|
240
|
+
if (offsetBeforeScroll.value === -1) {
|
|
241
|
+
if (closing.value) {
|
|
242
|
+
// Keyboard didn't shift on open; ensure valid position on close
|
|
243
|
+
clampScrollIfNeeded(effective);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// "persistent" closing: maintain position, clamped to valid range
|
|
250
|
+
if (keyboardLiftBehavior === "persistent" && closing.value) {
|
|
251
|
+
const keepAt = offsetBeforeScroll.value + padding.value;
|
|
252
|
+
const maxScroll = Math.max(
|
|
253
|
+
size.value.height - layout.value.height + effective,
|
|
254
|
+
0,
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
scrollTo(scrollViewRef, 0, Math.min(keepAt, maxScroll), false);
|
|
258
|
+
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const target = clampedScrollTarget(
|
|
263
|
+
offsetBeforeScroll.value,
|
|
264
|
+
effective,
|
|
265
|
+
size.value.height,
|
|
266
|
+
layout.value.height,
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
scrollTo(scrollViewRef, 0, target, false);
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
onEnd: (e) => {
|
|
273
|
+
"worklet";
|
|
274
|
+
|
|
275
|
+
if (freeze) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const effective = getEffectiveHeight(
|
|
280
|
+
e.height,
|
|
281
|
+
targetKeyboardHeight.value,
|
|
282
|
+
offset,
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
padding.value = effective;
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
[inverted, keyboardLiftBehavior, freeze, offset],
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
padding,
|
|
293
|
+
currentHeight,
|
|
294
|
+
contentOffsetY: undefined,
|
|
295
|
+
onLayout,
|
|
296
|
+
onContentSizeChange,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export { useChatKeyboard };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { LayoutChangeEvent } from "react-native";
|
|
2
|
+
import type { SharedValue } from "react-native-reanimated";
|
|
3
|
+
|
|
4
|
+
type KeyboardLiftBehavior = "always" | "whenAtEnd" | "persistent" | "never";
|
|
5
|
+
|
|
6
|
+
type UseChatKeyboardOptions = {
|
|
7
|
+
inverted: boolean;
|
|
8
|
+
keyboardLiftBehavior: KeyboardLiftBehavior;
|
|
9
|
+
freeze: boolean;
|
|
10
|
+
offset: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type UseChatKeyboardReturn = {
|
|
14
|
+
/** Extra scrollable space (= keyboard height). Used as contentInset on iOS, contentInsetBottom/contentInsetTop on Android. */
|
|
15
|
+
padding: SharedValue<number>;
|
|
16
|
+
/** Raw keyboard height updated every frame in onMove. Used to force Reanimated commits on Fabric. */
|
|
17
|
+
currentHeight: SharedValue<number>;
|
|
18
|
+
/** Absolute Y content offset for iOS (set once in onStart). `undefined` on Android. */
|
|
19
|
+
contentOffsetY: SharedValue<number> | undefined;
|
|
20
|
+
/** Callback to attach to ScrollView's onLayout prop to capture initial viewport dimensions. */
|
|
21
|
+
onLayout: (e: LayoutChangeEvent) => void;
|
|
22
|
+
/** Callback to attach to ScrollView's onContentSizeChange prop to capture initial content dimensions. */
|
|
23
|
+
onContentSizeChange: (w: number, h: number) => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type {
|
|
27
|
+
KeyboardLiftBehavior,
|
|
28
|
+
UseChatKeyboardOptions,
|
|
29
|
+
UseChatKeyboardReturn,
|
|
30
|
+
};
|
|
@@ -58,17 +58,17 @@ const KeyboardStickyView = forwardRef<
|
|
|
58
58
|
outputRange: [closed, opened],
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
const styles = useMemo(
|
|
62
|
-
()
|
|
61
|
+
const styles = useMemo(() => {
|
|
62
|
+
const disabled = Animated.add(Animated.multiply(height, 0), closed);
|
|
63
|
+
const active = Animated.add(height, offset);
|
|
64
|
+
|
|
65
|
+
return [
|
|
63
66
|
{
|
|
64
|
-
transform: [
|
|
65
|
-
{ translateY: enabled ? Animated.add(height, offset) : closed },
|
|
66
|
-
],
|
|
67
|
+
transform: [{ translateY: enabled ? active : disabled }],
|
|
67
68
|
},
|
|
68
69
|
style,
|
|
69
|
-
]
|
|
70
|
-
|
|
71
|
-
);
|
|
70
|
+
];
|
|
71
|
+
}, [closed, enabled, height, offset, style]);
|
|
72
72
|
|
|
73
73
|
return (
|
|
74
74
|
<Animated.View ref={ref} style={styles} {...props}>
|