Skip to content

Commit 83e34b7

Browse files
authored
feat: extraContentPadding prop (#1332)
## 📜 Description Added `extraContentPadding` prop for `KeyboardChatScrollView` component. ## 💡 Motivation and Context This prop is very useful when you want to extend scrollable range and re-arrange the content so that it takes the same position as before (relatively). Theoretically "re-arrange" functionality must be configurable, but I tested many apps and what I can tell is that it looks like almost always this "re-arrange" respects `keyboardLiftBehavior` prop. Later we can introduce additional prop to have a better control, but for now we keep it simple. I was thinking on whether to make this prop a `SharedValue` or a plain value and I decided to make it `SharedValue` because: - theoretically we may do all work on UI thread, i. e. synchronously detect "input growth" and synchronously adjust position; - we may want to change position with animation (with plain number we would have to add `animated` prop or smth like that); - we may want to have a customizable animation (duration, easing, so SharedValue can be a perfect driver). We also heavily use reanimated hooks internally, so integration with `SharedValue` looks very natural. I intentionally didn't want to modify original hooks and I extended functionality using new hook: `useExtraContentPadding`. This new hook extends `contentInsets` + adjust scroll position. Maybe we'll et conflicts between hook in future, but at the moment it's extendable 😎 Closes #1324 ## 📢 Changelog <!-- High level overview of important changes --> <!-- For example: fixed status bar manipulation; added new types declarations; --> <!-- If your changes don't affect one of platform/language below - then remove this platform/language --> ### Docs - added new prop description; - added new prop usage in guide page; ### JS - added `extraContentPadding` prop for `KeyboardChatScrollView` component - cover new functionality by unit tests; ## 🤔 How Has This Been Tested? Tested manually on: - iPhone 17 Pro (iOS 26.2); - Pixel 7 Pro (Android 16) ## 📸 Screenshots (if appropriate): |Android|iOS| |-------|-----| |<video src="https://github.com/user-attachments/assets/00b90e82-a410-41d2-9f5d-6a7d71330ff5">|<video src="https://github.com/user-attachments/assets/a919b334-83fe-48a5-afdf-76406ce00f66">| ## 📝 Checklist - [x] CI successfully passed - [x] I added new mocks and corresponding unit-tests if library API was changed
1 parent a096e39 commit 83e34b7

24 files changed

Lines changed: 1067 additions & 40 deletions

File tree

FabricExample/src/screens/Examples/KeyboardChatScrollView/VirtualizedListScrollView.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,20 @@ import {
1515
invertedContentContainerStyle,
1616
} from "./styles";
1717

18+
import type { SharedValue } from "react-native-reanimated";
19+
20+
type VirtualizedListScrollViewProps = ScrollViewProps & {
21+
extraContentPadding?: SharedValue<number>;
22+
};
23+
1824
export type VirtualizedListScrollViewRef = React.ElementRef<
1925
typeof KeyboardChatScrollView
2026
>;
2127

2228
const VirtualizedListScrollView = forwardRef<
2329
VirtualizedListScrollViewRef,
24-
ScrollViewProps
25-
>(({ onLayout: onLayoutProp, ...props }, ref) => {
30+
VirtualizedListScrollViewProps
31+
>(({ onLayout: onLayoutProp, extraContentPadding, ...props }, ref) => {
2632
const [layoutPass, setLayoutPass] = useState(0);
2733
const { bottom } = useSafeAreaInsets();
2834
const chatKitOffset = bottom - MARGIN;
@@ -50,6 +56,7 @@ const VirtualizedListScrollView = forwardRef<
5056
: contentContainerStyle
5157
}
5258
contentInsetAdjustmentBehavior="never"
59+
extraContentPadding={extraContentPadding}
5360
freeze={freeze}
5461
inverted={isInvertedSupported}
5562
keyboardDismissMode="interactive"

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

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
KeyboardGestureArea,
2020
KeyboardStickyView,
2121
} from "react-native-keyboard-controller";
22+
import { useSharedValue } from "react-native-reanimated";
2223
import {
2324
SafeAreaView,
2425
useSafeAreaInsets,
@@ -49,6 +50,7 @@ function KeyboardChatScrollViewPlayground() {
4950
const textInputRef = useRef<TextInput>(null);
5051
const textRef = useRef("");
5152
const [inputHeight, setInputHeight] = useState(TEXT_INPUT_HEIGHT);
53+
const extraContentPadding = useSharedValue(0);
5254
const { inverted, messages, reversedMessages, addMessage, mode } =
5355
useChatConfigStore();
5456
const { bottom } = useSafeAreaInsets();
@@ -58,9 +60,16 @@ function KeyboardChatScrollViewPlayground() {
5860
[bottom],
5961
);
6062

61-
const onInputLayoutChanged = useCallback((e: LayoutChangeEvent) => {
62-
setInputHeight(e.nativeEvent.layout.height);
63-
}, []);
63+
const onInputLayoutChanged = useCallback(
64+
(e: LayoutChangeEvent) => {
65+
const height = e.nativeEvent.layout.height;
66+
67+
// eslint-disable-next-line react-compiler/react-compiler
68+
extraContentPadding.value = Math.max(height - TEXT_INPUT_HEIGHT, 0);
69+
setInputHeight(height);
70+
},
71+
[extraContentPadding],
72+
);
6473
const onInput = useCallback((text: string) => {
6574
textRef.current = text;
6675
}, []);
@@ -90,8 +99,13 @@ function KeyboardChatScrollViewPlayground() {
9099
}, [messages]);
91100

92101
const memoList = useCallback(
93-
(props: ScrollViewProps) => <VirtualizedListScrollView {...props} />,
94-
[],
102+
(props: ScrollViewProps) => (
103+
<VirtualizedListScrollView
104+
{...props}
105+
extraContentPadding={extraContentPadding}
106+
/>
107+
),
108+
[extraContentPadding],
95109
);
96110

97111
return (
@@ -124,7 +138,7 @@ function KeyboardChatScrollViewPlayground() {
124138
startRenderingFromBottom: inverted,
125139
}}
126140
renderItem={({ item }) => <Message {...item} />}
127-
renderScrollComponent={VirtualizedListScrollView}
141+
renderScrollComponent={memoList}
128142
/>
129143
)}
130144
{mode === "flat" && (
@@ -138,7 +152,10 @@ function KeyboardChatScrollViewPlayground() {
138152
/>
139153
)}
140154
{mode === "scroll" && (
141-
<VirtualizedListScrollView ref={scrollRef}>
155+
<VirtualizedListScrollView
156+
ref={scrollRef}
157+
extraContentPadding={extraContentPadding}
158+
>
142159
{messages.map((message, index) => (
143160
<Message key={index} {...message} />
144161
))}

docs/docs/api/components/keyboard-chat-scroll-view.mdx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,23 @@ The distance between the bottom of the screen and the `ScrollView`. When the key
8484

8585
This is useful when the input is not at the very bottom of the screen — for example, when the `ScrollView` sits above a **safe area** inset, **bottom tabs**, or **any other fixed-height element**. In that case, set `offset` to the height of the elements between the `ScrollView` and the bottom of the screen.
8686

87+
### `extraContentPadding`
88+
89+
A [Reanimated `SharedValue<number>`](https://docs.swmansion.com/react-native-reanimated/docs/core/useSharedValue/) representing additional padding introduced by an element **outside** the keyboard — for example, a growing multiline `TextInput` inside a `KeyboardStickyView`.
90+
91+
When this value changes, `KeyboardChatScrollView` does two things:
92+
93+
1. **Extends the scrollable range** — the extra amount is added to `contentInset`, keeping all content reachable.
94+
2. **Adjusts the scroll position** — conditionally, based on `keyboardLiftBehavior`, so the bottom messages stay visible as the input grows.
95+
96+
:::tip When to use it?
97+
Use `extraContentPadding` whenever an element that is **not** the keyboard changes the amount of space the chat list has to work with. The most common case is a multiline text input that grows as the user types.
98+
:::
99+
100+
:::note
101+
The value must be a `SharedValue` (from `useSharedValue`) — not a plain number — so that changes are tracked on the UI thread without triggering a React re-render.
102+
:::
103+
87104
## Usage with virtualized lists
88105

89106
`KeyboardChatScrollView` doesn't ship with built-in wrappers for third-party virtualized list libraries, but since all of them (`FlatList`, `FlashList`, `LegendList`) accept a custom scroll component, integration is straightforward.

docs/docs/guides/building-chat-app.mdx

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,69 @@ const onKeyboardPress = () => {
197197

198198
When `freeze` is `true`, all keyboard-driven layout changes (padding, content offset, scroll position) are paused.
199199

200+
### Handling a growing multiline input
201+
202+
If your composer has a `multiline` `TextInput` that grows as the user types, you need to tell `KeyboardChatScrollView` about the extra space it takes up — otherwise the component doesn't know the scrollable range has changed and the bottom messages can get clipped under the input.
203+
204+
Pass a `SharedValue<number>` via the `extraContentPadding` prop. Update it in the `TextInput`'s `onLayout` callback whenever the height changes:
205+
206+
```tsx
207+
import { useCallback } from "react";
208+
import { TextInput, type LayoutChangeEvent } from "react-native";
209+
import { useSharedValue, withTiming } from "react-native-reanimated";
210+
211+
const MIN_INPUT_HEIGHT = 36;
212+
213+
function ChatScreen() {
214+
const extraContentPadding = useSharedValue(0);
215+
216+
const onInputLayout = useCallback(
217+
(e: LayoutChangeEvent) => {
218+
const height = e.nativeEvent.layout.height;
219+
220+
extraContentPadding.value = withTiming(
221+
Math.max(height - MIN_INPUT_HEIGHT, 0),
222+
{ duration: 250 },
223+
);
224+
},
225+
[extraContentPadding],
226+
);
227+
228+
return (
229+
<>
230+
<KeyboardChatScrollView extraContentPadding={extraContentPadding}>
231+
{/* ...messages... */}
232+
</KeyboardChatScrollView>
233+
<KeyboardStickyView>
234+
<TextInput multiline onLayout={onInputLayout} />
235+
</KeyboardStickyView>
236+
</>
237+
);
238+
}
239+
```
240+
241+
The value represents the **delta** above the baseline input height. When the input is at its minimum height the value is `0`; each line of growth adds to it. The component uses this value to:
242+
243+
1. Extend the scrollable range so the last messages aren't hidden under the taller input.
244+
2. Scroll the list down (based on `keyboardLiftBehavior`) so the bottom messages stay visible.
245+
246+
:::note Using with virtualized lists
247+
When using `extraContentPadding` with `FlatList`, `FlashList`, or `LegendList`, pass the shared value through your wrapper component — both to `KeyboardChatScrollView` and to `useCallback` dependencies so the reference is stable:
248+
249+
```tsx
250+
const renderScrollComponent = useCallback(
251+
(props: ScrollViewProps) => (
252+
<VirtualizedListScrollView
253+
{...props}
254+
extraContentPadding={extraContentPadding}
255+
/>
256+
),
257+
[extraContentPadding],
258+
);
259+
```
260+
261+
:::
262+
200263
## Using with virtualized lists
201264

202265
For production chat apps you'll likely use a virtualized list (`FlatList`, `FlashList`, or `LegendList`) instead of a plain `ScrollView`. All of these accept a custom scroll component, making integration straightforward.
@@ -312,13 +375,15 @@ import {
312375
TextInput,
313376
TouchableOpacity,
314377
View,
378+
type LayoutChangeEvent,
315379
type ScrollViewProps,
316380
} from "react-native";
317381
import {
318382
KeyboardChatScrollView,
319383
KeyboardGestureArea,
320384
KeyboardStickyView,
321385
} from "react-native-keyboard-controller";
386+
import { useSharedValue, withTiming } from "react-native-reanimated";
322387
import {
323388
SafeAreaView,
324389
useSafeAreaInsets,
@@ -355,10 +420,13 @@ function ChatScreen() {
355420
const textRef = useRef("");
356421
const [messages, setMessages] = useState(INITIAL_MESSAGES);
357422
const { bottom } = useSafeAreaInsets();
423+
const extraContentPadding = useSharedValue(0);
358424

359425
const renderScrollComponent = useCallback(
360-
(props: ScrollViewProps) => <ChatScrollView {...props} />,
361-
[],
426+
(props: ScrollViewProps) => (
427+
<ChatScrollView {...props} extraContentPadding={extraContentPadding} />
428+
),
429+
[extraContentPadding],
362430
);
363431

364432
const onSend = useCallback(() => {
@@ -370,6 +438,16 @@ function ChatScreen() {
370438
textRef.current = "";
371439
}, []);
372440

441+
const onInputLayout = useCallback(
442+
(e: LayoutChangeEvent) => {
443+
extraContentPadding.value = withTiming(
444+
Math.max(e.nativeEvent.layout.height - INPUT_HEIGHT, 0),
445+
{ duration: 250 },
446+
);
447+
},
448+
[extraContentPadding],
449+
);
450+
373451
return (
374452
<SafeAreaView edges={["bottom"]} style={styles.container}>
375453
<KeyboardGestureArea
@@ -397,6 +475,7 @@ function ChatScreen() {
397475
placeholder="Type a message..."
398476
style={styles.input}
399477
onChangeText={(text) => (textRef.current = text)}
478+
onLayout={onInputLayout}
400479
/>
401480
<TouchableOpacity onPress={onSend}>
402481
<Text>Send</Text>

example/src/screens/Examples/KeyboardChatScrollView/VirtualizedListScrollView.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,20 @@ import {
1515
invertedContentContainerStyle,
1616
} from "./styles";
1717

18+
import type { SharedValue } from "react-native-reanimated";
19+
20+
type VirtualizedListScrollViewProps = ScrollViewProps & {
21+
extraContentPadding?: SharedValue<number>;
22+
};
23+
1824
export type VirtualizedListScrollViewRef = React.ElementRef<
1925
typeof KeyboardChatScrollView
2026
>;
2127

2228
const VirtualizedListScrollView = forwardRef<
2329
VirtualizedListScrollViewRef,
24-
ScrollViewProps
25-
>(({ onLayout: onLayoutProp, ...props }, ref) => {
30+
VirtualizedListScrollViewProps
31+
>(({ onLayout: onLayoutProp, extraContentPadding, ...props }, ref) => {
2632
const [layoutPass, setLayoutPass] = useState(0);
2733
const { bottom } = useSafeAreaInsets();
2834
const chatKitOffset = bottom - MARGIN;
@@ -49,6 +55,7 @@ const VirtualizedListScrollView = forwardRef<
4955
inverted ? invertedContentContainerStyle : contentContainerStyle
5056
}
5157
contentInsetAdjustmentBehavior="never"
58+
extraContentPadding={extraContentPadding}
5259
freeze={freeze}
5360
inverted={isInvertedSupported}
5461
keyboardDismissMode="interactive"

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

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
KeyboardGestureArea,
2020
KeyboardStickyView,
2121
} from "react-native-keyboard-controller";
22+
import { useSharedValue, withTiming } from "react-native-reanimated";
2223
import {
2324
SafeAreaView,
2425
useSafeAreaInsets,
@@ -50,6 +51,7 @@ function KeyboardChatScrollViewPlayground() {
5051
const textInputRef = useRef<TextInput>(null);
5152
const textRef = useRef("");
5253
const [inputHeight, setInputHeight] = useState(TEXT_INPUT_HEIGHT);
54+
const extraContentPadding = useSharedValue(0);
5355
const { inverted, messages, reversedMessages, addMessage, mode } =
5456
useChatConfigStore();
5557
const { bottom } = useSafeAreaInsets();
@@ -59,9 +61,19 @@ function KeyboardChatScrollViewPlayground() {
5961
[bottom],
6062
);
6163

62-
const onInputLayoutChanged = useCallback((e: LayoutChangeEvent) => {
63-
setInputHeight(e.nativeEvent.layout.height);
64-
}, []);
64+
const onInputLayoutChanged = useCallback(
65+
(e: LayoutChangeEvent) => {
66+
const height = e.nativeEvent.layout.height;
67+
68+
// eslint-disable-next-line react-compiler/react-compiler
69+
extraContentPadding.value = withTiming(
70+
Math.max(height - TEXT_INPUT_HEIGHT, 0),
71+
{ duration: 250 },
72+
);
73+
setInputHeight(height);
74+
},
75+
[extraContentPadding],
76+
);
6577
const onInput = useCallback((text: string) => {
6678
textRef.current = text;
6779
}, []);
@@ -91,8 +103,13 @@ function KeyboardChatScrollViewPlayground() {
91103
}, [messages]);
92104

93105
const memoList = useCallback(
94-
(props: ScrollViewProps) => <VirtualizedListScrollView {...props} />,
95-
[],
106+
(props: ScrollViewProps) => (
107+
<VirtualizedListScrollView
108+
{...props}
109+
extraContentPadding={extraContentPadding}
110+
/>
111+
),
112+
[extraContentPadding],
96113
);
97114

98115
return (
@@ -124,7 +141,7 @@ function KeyboardChatScrollViewPlayground() {
124141
inverted={inverted}
125142
keyExtractor={(item) => item.text}
126143
renderItem={({ item }) => <Message {...item} />}
127-
renderScrollComponent={VirtualizedListScrollView}
144+
renderScrollComponent={memoList}
128145
/>
129146
)}
130147
{mode === "flat" && (
@@ -138,7 +155,10 @@ function KeyboardChatScrollViewPlayground() {
138155
/>
139156
)}
140157
{mode === "scroll" && (
141-
<VirtualizedListScrollView ref={scrollRef}>
158+
<VirtualizedListScrollView
159+
ref={scrollRef}
160+
extraContentPadding={extraContentPadding}
161+
>
142162
{messages.map((message, index) => (
143163
<Message key={index} {...message} />
144164
))}

0 commit comments

Comments
 (0)