Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,20 @@ import {
invertedContentContainerStyle,
} from "./styles";

import type { SharedValue } from "react-native-reanimated";

type VirtualizedListScrollViewProps = ScrollViewProps & {
extraContentPadding?: SharedValue<number>;
};

export type VirtualizedListScrollViewRef = React.ElementRef<
typeof KeyboardChatScrollView
>;

const VirtualizedListScrollView = forwardRef<
VirtualizedListScrollViewRef,
ScrollViewProps
>(({ onLayout: onLayoutProp, ...props }, ref) => {
VirtualizedListScrollViewProps
>(({ onLayout: onLayoutProp, extraContentPadding, ...props }, ref) => {
const [layoutPass, setLayoutPass] = useState(0);
const { bottom } = useSafeAreaInsets();
const chatKitOffset = bottom - MARGIN;
Expand Down Expand Up @@ -50,6 +56,7 @@ const VirtualizedListScrollView = forwardRef<
: contentContainerStyle
}
contentInsetAdjustmentBehavior="never"
extraContentPadding={extraContentPadding}
freeze={freeze}
inverted={isInvertedSupported}
keyboardDismissMode="interactive"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
KeyboardGestureArea,
KeyboardStickyView,
} from "react-native-keyboard-controller";
import { useSharedValue } from "react-native-reanimated";
import {
SafeAreaView,
useSafeAreaInsets,
Expand Down Expand Up @@ -49,6 +50,7 @@ function KeyboardChatScrollViewPlayground() {
const textInputRef = useRef<TextInput>(null);
const textRef = useRef("");
const [inputHeight, setInputHeight] = useState(TEXT_INPUT_HEIGHT);
const extraContentPadding = useSharedValue(0);
const { inverted, messages, reversedMessages, addMessage, mode } =
useChatConfigStore();
const { bottom } = useSafeAreaInsets();
Expand All @@ -58,9 +60,16 @@ function KeyboardChatScrollViewPlayground() {
[bottom],
);

const onInputLayoutChanged = useCallback((e: LayoutChangeEvent) => {
setInputHeight(e.nativeEvent.layout.height);
}, []);
const onInputLayoutChanged = useCallback(
(e: LayoutChangeEvent) => {
const height = e.nativeEvent.layout.height;

// eslint-disable-next-line react-compiler/react-compiler
extraContentPadding.value = Math.max(height - TEXT_INPUT_HEIGHT, 0);
setInputHeight(height);
},
[extraContentPadding],
);
const onInput = useCallback((text: string) => {
textRef.current = text;
}, []);
Expand Down Expand Up @@ -90,8 +99,13 @@ function KeyboardChatScrollViewPlayground() {
}, [messages]);

const memoList = useCallback(
(props: ScrollViewProps) => <VirtualizedListScrollView {...props} />,
[],
(props: ScrollViewProps) => (
<VirtualizedListScrollView
{...props}
extraContentPadding={extraContentPadding}
/>
),
[extraContentPadding],
);

return (
Expand Down Expand Up @@ -124,7 +138,7 @@ function KeyboardChatScrollViewPlayground() {
startRenderingFromBottom: inverted,
}}
renderItem={({ item }) => <Message {...item} />}
renderScrollComponent={VirtualizedListScrollView}
renderScrollComponent={memoList}
/>
)}
{mode === "flat" && (
Expand All @@ -138,7 +152,10 @@ function KeyboardChatScrollViewPlayground() {
/>
)}
{mode === "scroll" && (
<VirtualizedListScrollView ref={scrollRef}>
<VirtualizedListScrollView
ref={scrollRef}
extraContentPadding={extraContentPadding}
>
{messages.map((message, index) => (
<Message key={index} {...message} />
))}
Expand Down
17 changes: 17 additions & 0 deletions docs/docs/api/components/keyboard-chat-scroll-view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,23 @@ The distance between the bottom of the screen and the `ScrollView`. When the key

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.

### `extraContentPadding`

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`.

When this value changes, `KeyboardChatScrollView` does two things:

1. **Extends the scrollable range** — the extra amount is added to `contentInset`, keeping all content reachable.
2. **Adjusts the scroll position** — conditionally, based on `keyboardLiftBehavior`, so the bottom messages stay visible as the input grows.

:::tip When to use it?
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.
:::

:::note
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.
:::

## Usage with virtualized lists

`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.
Expand Down
83 changes: 81 additions & 2 deletions docs/docs/guides/building-chat-app.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,69 @@ const onKeyboardPress = () => {

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

### Handling a growing multiline input

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.

Pass a `SharedValue<number>` via the `extraContentPadding` prop. Update it in the `TextInput`'s `onLayout` callback whenever the height changes:

```tsx
import { useCallback } from "react";
import { TextInput, type LayoutChangeEvent } from "react-native";
import { useSharedValue, withTiming } from "react-native-reanimated";

const MIN_INPUT_HEIGHT = 36;

function ChatScreen() {
const extraContentPadding = useSharedValue(0);

const onInputLayout = useCallback(
(e: LayoutChangeEvent) => {
const height = e.nativeEvent.layout.height;

extraContentPadding.value = withTiming(
Math.max(height - MIN_INPUT_HEIGHT, 0),
{ duration: 250 },
);
},
[extraContentPadding],
);

return (
<>
<KeyboardChatScrollView extraContentPadding={extraContentPadding}>
{/* ...messages... */}
</KeyboardChatScrollView>
<KeyboardStickyView>
<TextInput multiline onLayout={onInputLayout} />
</KeyboardStickyView>
</>
);
}
```

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:

1. Extend the scrollable range so the last messages aren't hidden under the taller input.
2. Scroll the list down (based on `keyboardLiftBehavior`) so the bottom messages stay visible.

:::note Using with virtualized lists
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:

```tsx
const renderScrollComponent = useCallback(
(props: ScrollViewProps) => (
<VirtualizedListScrollView
{...props}
extraContentPadding={extraContentPadding}
/>
),
[extraContentPadding],
);
```

:::

## Using with virtualized lists

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.
Expand Down Expand Up @@ -312,13 +375,15 @@ import {
TextInput,
TouchableOpacity,
View,
type LayoutChangeEvent,
type ScrollViewProps,
} from "react-native";
import {
KeyboardChatScrollView,
KeyboardGestureArea,
KeyboardStickyView,
} from "react-native-keyboard-controller";
import { useSharedValue, withTiming } from "react-native-reanimated";
import {
SafeAreaView,
useSafeAreaInsets,
Expand Down Expand Up @@ -355,10 +420,13 @@ function ChatScreen() {
const textRef = useRef("");
const [messages, setMessages] = useState(INITIAL_MESSAGES);
const { bottom } = useSafeAreaInsets();
const extraContentPadding = useSharedValue(0);

const renderScrollComponent = useCallback(
(props: ScrollViewProps) => <ChatScrollView {...props} />,
[],
(props: ScrollViewProps) => (
<ChatScrollView {...props} extraContentPadding={extraContentPadding} />
),
[extraContentPadding],
);

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

const onInputLayout = useCallback(
(e: LayoutChangeEvent) => {
extraContentPadding.value = withTiming(
Math.max(e.nativeEvent.layout.height - INPUT_HEIGHT, 0),
{ duration: 250 },
);
},
[extraContentPadding],
);

return (
<SafeAreaView edges={["bottom"]} style={styles.container}>
<KeyboardGestureArea
Expand Down Expand Up @@ -397,6 +475,7 @@ function ChatScreen() {
placeholder="Type a message..."
style={styles.input}
onChangeText={(text) => (textRef.current = text)}
onLayout={onInputLayout}
/>
<TouchableOpacity onPress={onSend}>
<Text>Send</Text>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,20 @@ import {
invertedContentContainerStyle,
} from "./styles";

import type { SharedValue } from "react-native-reanimated";

type VirtualizedListScrollViewProps = ScrollViewProps & {
extraContentPadding?: SharedValue<number>;
};

export type VirtualizedListScrollViewRef = React.ElementRef<
typeof KeyboardChatScrollView
>;

const VirtualizedListScrollView = forwardRef<
VirtualizedListScrollViewRef,
ScrollViewProps
>(({ onLayout: onLayoutProp, ...props }, ref) => {
VirtualizedListScrollViewProps
>(({ onLayout: onLayoutProp, extraContentPadding, ...props }, ref) => {
const [layoutPass, setLayoutPass] = useState(0);
const { bottom } = useSafeAreaInsets();
const chatKitOffset = bottom - MARGIN;
Expand All @@ -49,6 +55,7 @@ const VirtualizedListScrollView = forwardRef<
inverted ? invertedContentContainerStyle : contentContainerStyle
}
contentInsetAdjustmentBehavior="never"
extraContentPadding={extraContentPadding}
freeze={freeze}
inverted={isInvertedSupported}
keyboardDismissMode="interactive"
Expand Down
34 changes: 27 additions & 7 deletions example/src/screens/Examples/KeyboardChatScrollView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
KeyboardGestureArea,
KeyboardStickyView,
} from "react-native-keyboard-controller";
import { useSharedValue, withTiming } from "react-native-reanimated";
import {
SafeAreaView,
useSafeAreaInsets,
Expand Down Expand Up @@ -50,6 +51,7 @@ function KeyboardChatScrollViewPlayground() {
const textInputRef = useRef<TextInput>(null);
const textRef = useRef("");
const [inputHeight, setInputHeight] = useState(TEXT_INPUT_HEIGHT);
const extraContentPadding = useSharedValue(0);
const { inverted, messages, reversedMessages, addMessage, mode } =
useChatConfigStore();
const { bottom } = useSafeAreaInsets();
Expand All @@ -59,9 +61,19 @@ function KeyboardChatScrollViewPlayground() {
[bottom],
);

const onInputLayoutChanged = useCallback((e: LayoutChangeEvent) => {
setInputHeight(e.nativeEvent.layout.height);
}, []);
const onInputLayoutChanged = useCallback(
(e: LayoutChangeEvent) => {
const height = e.nativeEvent.layout.height;

// eslint-disable-next-line react-compiler/react-compiler
extraContentPadding.value = withTiming(
Math.max(height - TEXT_INPUT_HEIGHT, 0),
{ duration: 250 },
);
setInputHeight(height);
},
[extraContentPadding],
);
const onInput = useCallback((text: string) => {
textRef.current = text;
}, []);
Expand Down Expand Up @@ -91,8 +103,13 @@ function KeyboardChatScrollViewPlayground() {
}, [messages]);

const memoList = useCallback(
(props: ScrollViewProps) => <VirtualizedListScrollView {...props} />,
[],
(props: ScrollViewProps) => (
<VirtualizedListScrollView
{...props}
extraContentPadding={extraContentPadding}
/>
),
[extraContentPadding],
);

return (
Expand Down Expand Up @@ -124,7 +141,7 @@ function KeyboardChatScrollViewPlayground() {
inverted={inverted}
keyExtractor={(item) => item.text}
renderItem={({ item }) => <Message {...item} />}
renderScrollComponent={VirtualizedListScrollView}
renderScrollComponent={memoList}
/>
)}
{mode === "flat" && (
Expand All @@ -138,7 +155,10 @@ function KeyboardChatScrollViewPlayground() {
/>
)}
{mode === "scroll" && (
<VirtualizedListScrollView ref={scrollRef}>
<VirtualizedListScrollView
ref={scrollRef}
extraContentPadding={extraContentPadding}
>
{messages.map((message, index) => (
<Message key={index} {...message} />
))}
Expand Down
Loading
Loading