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,10 +15,12 @@ import {
invertedContentContainerStyle,
} from "./styles";

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

type VirtualizedListScrollViewProps = ScrollViewProps & {
extraContentPadding?: SharedValue<number>;
chatScrollViewRef?: { current: VirtualizedListScrollViewRef | null };
};

export type VirtualizedListScrollViewRef = React.ElementRef<
Expand All @@ -28,50 +30,83 @@ export type VirtualizedListScrollViewRef = React.ElementRef<
const VirtualizedListScrollView = forwardRef<
VirtualizedListScrollViewRef,
VirtualizedListScrollViewProps
>(({ onLayout: onLayoutProp, extraContentPadding, ...props }, ref) => {
const [layoutPass, setLayoutPass] = useState(0);
const { bottom } = useSafeAreaInsets();
const chatKitOffset = bottom - MARGIN;
>(
(
{
onLayout: onLayoutProp,
extraContentPadding,
chatScrollViewRef,
...props
},
ref,
) => {
const setScrollViewRef = useCallback(
(instance: VirtualizedListScrollViewRef | null) => {
if (chatScrollViewRef) {
// eslint-disable-next-line react-compiler/react-compiler
chatScrollViewRef.current =
instance as VirtualizedListScrollViewRef | null;
}
},
[chatScrollViewRef],
);
const combinedRef: RefCallback<VirtualizedListScrollViewRef> = useCallback(
(instance) => {
if (typeof ref === "function") {
ref(instance);
} else if (ref) {
ref.current = instance;
}

const { inverted, freeze, mode, keyboardLiftBehavior } = useChatConfigStore();
setScrollViewRef(instance);
},
[ref, setScrollViewRef],
);
const [layoutPass, setLayoutPass] = useState(0);
const { bottom } = useSafeAreaInsets();
const chatKitOffset = bottom - MARGIN;

// on new arch only FlatList supports `inverted` prop
const isInvertedSupported = inverted && mode === "flat" ? inverted : false;
const onLayout = useCallback(
(e: LayoutChangeEvent) => {
setLayoutPass((l) => l + 1);
onLayoutProp?.(e);
},
[onLayoutProp],
);
const { inverted, freeze, mode, keyboardLiftBehavior } =
useChatConfigStore();

return (
<>
<KeyboardChatScrollView
ref={ref}
automaticallyAdjustContentInsets={false}
contentContainerStyle={
isInvertedSupported
? invertedContentContainerStyle
: contentContainerStyle
}
contentInsetAdjustmentBehavior="never"
extraContentPadding={extraContentPadding}
freeze={freeze}
inverted={isInvertedSupported}
keyboardDismissMode="interactive"
keyboardLiftBehavior={keyboardLiftBehavior}
offset={chatKitOffset}
testID="chat.scroll"
onLayout={onLayout}
{...props}
/>
<Text style={styles.counter} testID="layout_passes">
Layout pass: {layoutPass}
</Text>
</>
);
});
// on new arch only FlatList supports `inverted` prop
const isInvertedSupported = inverted && mode === "flat" ? inverted : false;
const onLayout = useCallback(
(e: LayoutChangeEvent) => {
setLayoutPass((l) => l + 1);
onLayoutProp?.(e);
},
[onLayoutProp],
);

return (
<>
<KeyboardChatScrollView
ref={combinedRef}
automaticallyAdjustContentInsets={false}
contentContainerStyle={
isInvertedSupported
? invertedContentContainerStyle
: contentContainerStyle
}
contentInsetAdjustmentBehavior="never"
extraContentPadding={extraContentPadding}
freeze={freeze}
inverted={isInvertedSupported}
keyboardDismissMode="interactive"
keyboardLiftBehavior={keyboardLiftBehavior}
offset={chatKitOffset}
testID="chat.scroll"
onLayout={onLayout}
{...props}
/>
<Text style={styles.counter} testID="layout_passes">
Layout pass: {layoutPass}
</Text>
</>
);
},
);

const styles = StyleSheet.create({
counter: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { LegendList, type LegendListRef } from "@legendapp/list";
import { FlashList, type FlashListRef } from "@shopify/flash-list";
import { LegendList } from "@legendapp/list";
import { FlashList } from "@shopify/flash-list";
import React, {
useCallback,
useEffect,
Expand Down Expand Up @@ -39,13 +39,10 @@ import VirtualizedListScrollView, {
type VirtualizedListScrollViewRef,
} from "./VirtualizedListScrollView";

import type { MessageProps } from "../../../components/Message/types";
import type { LayoutChangeEvent, ScrollViewProps } from "react-native";

function KeyboardChatScrollViewPlayground() {
const legendRef = useRef<LegendListRef>(null);
const flashRef = useRef<FlashListRef<MessageProps>>(null);
const flatRef = useRef<FlatList<MessageProps>>(null);
const chatScrollViewRef = useRef<VirtualizedListScrollViewRef | null>(null);
const scrollRef = useRef<VirtualizedListScrollViewRef>(null);
const [text, setText] = useState("");
const [inputHeight, setInputHeight] = useState(TEXT_INPUT_HEIGHT);
Expand Down Expand Up @@ -84,22 +81,15 @@ function KeyboardChatScrollViewPlayground() {
}, [addMessage, text]);

useEffect(() => {
legendRef.current?.scrollToOffset({
animated: true,
offset: Number.MAX_SAFE_INTEGER,
});
flashRef.current?.scrollToEnd({ animated: true });
flatRef.current?.scrollToOffset({
animated: true,
offset: Number.MAX_SAFE_INTEGER,
});
chatScrollViewRef.current?.scrollToEnd({ animated: true });
scrollRef.current?.scrollToEnd({ animated: true });
}, [messages]);

const memoList = useCallback(
(props: ScrollViewProps) => (
<VirtualizedListScrollView
{...props}
chatScrollViewRef={chatScrollViewRef}
extraContentPadding={extraContentPadding}
/>
),
Expand All @@ -116,7 +106,6 @@ function KeyboardChatScrollViewPlayground() {
>
{mode === "legend" && (
<LegendList
ref={legendRef}
alignItemsAtEnd={inverted}
contentContainerStyle={contentContainerStyle}
data={messages}
Expand All @@ -128,7 +117,6 @@ function KeyboardChatScrollViewPlayground() {
)}
{mode === "flash" && (
<FlashList
ref={flashRef}
contentContainerStyle={contentContainerStyle}
data={messages}
keyExtractor={(item) => item.text}
Expand All @@ -141,7 +129,6 @@ function KeyboardChatScrollViewPlayground() {
)}
{mode === "flat" && (
<FlatList
ref={flatRef}
data={inverted ? reversedMessages : messages}
inverted={inverted}
keyExtractor={(item) => item.text}
Expand Down
64 changes: 64 additions & 0 deletions docs/docs/api/components/keyboard-chat-scroll-view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -306,3 +306,67 @@ To fix this, enable the [DISABLE_COMMIT_PAUSING_MECHANISM](https://docs.swmansio
:::info Do I need to enable this flag?
This issue can occur even if the state update comes from a different screen (e.g. a parent navigator). To check, open the React Profiler and look for any React commits that happen just before the keyboard event — if you see one, you likely need this flag.
:::

### `scrollToEnd` doesn't scroll to the correct position when keyboard is open

React Native's `FlatList.scrollToEnd()` calculates the scroll offset using `visibleLength` without accounting for the keyboard-adjusted layout. This means calling `listRef.current?.scrollToEnd()` may stop short of the actual end when the keyboard is visible.

**Workaround:** call `scrollToEnd` on the underlying `KeyboardChatScrollView` instead of the `FlatList`. Since `FlatList` internally assigns its own ref to the scroll component, you need a separate ref to reach it. Create a wrapper component that accepts a custom ref prop and forwards it alongside `FlatList`'s internal ref:

```tsx ChatScrollView.tsx
import { forwardRef, useCallback } from "react";
import { KeyboardChatScrollView } from "react-native-keyboard-controller";

import type { RefCallback } from "react";
import type { ScrollViewProps } from "react-native";

type ChatScrollViewRef = React.ElementRef<typeof KeyboardChatScrollView>;
type ChatScrollViewProps = ScrollViewProps & {
chatScrollViewRef?: { current: ChatScrollViewRef | null };
};

const ChatScrollView = forwardRef<ChatScrollViewRef, ChatScrollViewProps>(
({ chatScrollViewRef, ...props }, ref) => {
const combinedRef: RefCallback<ChatScrollViewRef> = useCallback(
(instance) => {
// forward to FlatList's internal ref
if (typeof ref === "function") {
ref(instance);
} else if (ref) {
ref.current = instance;
}

// forward to the user-provided ref
if (chatScrollViewRef) {
chatScrollViewRef.current = instance as ChatScrollViewRef | null;
}
},
[ref, chatScrollViewRef],
);

return <KeyboardChatScrollView ref={combinedRef} {...props} />;
},
);
```

Then use it with `FlatList` via `renderScrollComponent`:

```tsx
const chatScrollViewRef = useRef<ChatScrollViewRef>(null);

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

<FlatList
data={messages}
renderItem={renderItem}
renderScrollComponent={renderScrollComponent}
/>;

// Instead of listRef.current?.scrollToEnd()
chatScrollViewRef.current?.scrollToEnd();
```
Original file line number Diff line number Diff line change
Expand Up @@ -306,3 +306,67 @@ To fix this, enable the [DISABLE_COMMIT_PAUSING_MECHANISM](https://docs.swmansio
:::info Do I need to enable this flag?
This issue can occur even if the state update comes from a different screen (e.g. a parent navigator). To check, open the React Profiler and look for any React commits that happen just before the keyboard event — if you see one, you likely need this flag.
:::

### `scrollToEnd` doesn't scroll to the correct position when keyboard is open

React Native's `FlatList.scrollToEnd()` calculates the scroll offset using `visibleLength` without accounting for the keyboard-adjusted layout. This means calling `listRef.current?.scrollToEnd()` may stop short of the actual end when the keyboard is visible.

**Workaround:** call `scrollToEnd` on the underlying `KeyboardChatScrollView` instead of the `FlatList`. Since `FlatList` internally assigns its own ref to the scroll component, you need a separate ref to reach it. Create a wrapper component that accepts a custom ref prop and forwards it alongside `FlatList`'s internal ref:

```tsx ChatScrollView.tsx
import { forwardRef, useCallback } from "react";
import { KeyboardChatScrollView } from "react-native-keyboard-controller";

import type { RefCallback } from "react";
import type { ScrollViewProps } from "react-native";

type ChatScrollViewRef = React.ElementRef<typeof KeyboardChatScrollView>;
type ChatScrollViewProps = ScrollViewProps & {
chatScrollViewRef?: { current: ChatScrollViewRef | null };
};

const ChatScrollView = forwardRef<ChatScrollViewRef, ChatScrollViewProps>(
({ chatScrollViewRef, ...props }, ref) => {
const combinedRef: RefCallback<ChatScrollViewRef> = useCallback(
(instance) => {
// forward to FlatList's internal ref
if (typeof ref === "function") {
ref(instance);
} else if (ref) {
ref.current = instance;
}

// forward to the user-provided ref
if (chatScrollViewRef) {
chatScrollViewRef.current = instance as ChatScrollViewRef | null;
}
},
[ref, chatScrollViewRef],
);

return <KeyboardChatScrollView ref={combinedRef} {...props} />;
},
);
```

Then use it with `FlatList` via `renderScrollComponent`:

```tsx
const chatScrollViewRef = useRef<ChatScrollViewRef>(null);

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

<FlatList
data={messages}
renderItem={renderItem}
renderScrollComponent={renderScrollComponent}
/>;

// Instead of listRef.current?.scrollToEnd()
chatScrollViewRef.current?.scrollToEnd();
```
Loading
Loading