Skip to content

Commit 10c0218

Browse files
authored
docs: add workaround for non-working scrollToEnd (#1392)
## 📜 Description Added workaround for non-working `scrollToEnd` method for virtualized lists. ## 💡 Motivation and Context This code in `FlatList` causes a problem: ```tsx // scrollToEnd may be janky without getItemLayout prop scrollToEnd(params?: ?{animated?: ?boolean, ...}) { const animated = params ? params.animated : true; const veryLast = this.props.getItemCount(this.props.data) - 1; if (veryLast < 0) { return; } const frame = this._listMetrics.getCellMetricsApprox(veryLast, this.props); const offset = Math.max( 0, frame.offset + frame.length + this._footerLength - this._scrollMetrics.visibleLength, ); // TODO: consider using `ref.scrollToEnd` directly this.scrollToOffset({animated, offset}); } ``` So this function doesn't consider keyboard offset and because of that scrolls to wrong offset when keyboard is shown. In this PR I explained how to pass a ref directly to underlying `ScrollView` and use `scrollToEnd` of `ScrollView` level. Additionally in example project I started to use it as well. Closes #1357 ## 📢 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 --> ### JS - pass `scrollRef` to `VirtualizedList` in example appl - use `scrollTo` from `ScrollView` ref; ### Docs - added a new item to troubleshooting guide; ## 🤔 How Has This Been Tested? Tested manually in exampel app (paper/fabric). ## 📝 Checklist - [x] CI successfully passed - [x] I added new mocks and corresponding unit-tests if library API was changed
1 parent 75981ee commit 10c0218

6 files changed

Lines changed: 288 additions & 116 deletions

File tree

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

Lines changed: 76 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ import {
1515
invertedContentContainerStyle,
1616
} from "./styles";
1717

18+
import type { RefCallback } from "react";
1819
import type { SharedValue } from "react-native-reanimated";
1920

2021
type VirtualizedListScrollViewProps = ScrollViewProps & {
2122
extraContentPadding?: SharedValue<number>;
23+
chatScrollViewRef?: { current: VirtualizedListScrollViewRef | null };
2224
};
2325

2426
export type VirtualizedListScrollViewRef = React.ElementRef<
@@ -28,50 +30,83 @@ export type VirtualizedListScrollViewRef = React.ElementRef<
2830
const VirtualizedListScrollView = forwardRef<
2931
VirtualizedListScrollViewRef,
3032
VirtualizedListScrollViewProps
31-
>(({ onLayout: onLayoutProp, extraContentPadding, ...props }, ref) => {
32-
const [layoutPass, setLayoutPass] = useState(0);
33-
const { bottom } = useSafeAreaInsets();
34-
const chatKitOffset = bottom - MARGIN;
33+
>(
34+
(
35+
{
36+
onLayout: onLayoutProp,
37+
extraContentPadding,
38+
chatScrollViewRef,
39+
...props
40+
},
41+
ref,
42+
) => {
43+
const setScrollViewRef = useCallback(
44+
(instance: VirtualizedListScrollViewRef | null) => {
45+
if (chatScrollViewRef) {
46+
// eslint-disable-next-line react-compiler/react-compiler
47+
chatScrollViewRef.current =
48+
instance as VirtualizedListScrollViewRef | null;
49+
}
50+
},
51+
[chatScrollViewRef],
52+
);
53+
const combinedRef: RefCallback<VirtualizedListScrollViewRef> = useCallback(
54+
(instance) => {
55+
if (typeof ref === "function") {
56+
ref(instance);
57+
} else if (ref) {
58+
ref.current = instance;
59+
}
3560

36-
const { inverted, freeze, mode, keyboardLiftBehavior } = useChatConfigStore();
61+
setScrollViewRef(instance);
62+
},
63+
[ref, setScrollViewRef],
64+
);
65+
const [layoutPass, setLayoutPass] = useState(0);
66+
const { bottom } = useSafeAreaInsets();
67+
const chatKitOffset = bottom - MARGIN;
3768

38-
// on new arch only FlatList supports `inverted` prop
39-
const isInvertedSupported = inverted && mode === "flat" ? inverted : false;
40-
const onLayout = useCallback(
41-
(e: LayoutChangeEvent) => {
42-
setLayoutPass((l) => l + 1);
43-
onLayoutProp?.(e);
44-
},
45-
[onLayoutProp],
46-
);
69+
const { inverted, freeze, mode, keyboardLiftBehavior } =
70+
useChatConfigStore();
4771

48-
return (
49-
<>
50-
<KeyboardChatScrollView
51-
ref={ref}
52-
automaticallyAdjustContentInsets={false}
53-
contentContainerStyle={
54-
isInvertedSupported
55-
? invertedContentContainerStyle
56-
: contentContainerStyle
57-
}
58-
contentInsetAdjustmentBehavior="never"
59-
extraContentPadding={extraContentPadding}
60-
freeze={freeze}
61-
inverted={isInvertedSupported}
62-
keyboardDismissMode="interactive"
63-
keyboardLiftBehavior={keyboardLiftBehavior}
64-
offset={chatKitOffset}
65-
testID="chat.scroll"
66-
onLayout={onLayout}
67-
{...props}
68-
/>
69-
<Text style={styles.counter} testID="layout_passes">
70-
Layout pass: {layoutPass}
71-
</Text>
72-
</>
73-
);
74-
});
72+
// on new arch only FlatList supports `inverted` prop
73+
const isInvertedSupported = inverted && mode === "flat" ? inverted : false;
74+
const onLayout = useCallback(
75+
(e: LayoutChangeEvent) => {
76+
setLayoutPass((l) => l + 1);
77+
onLayoutProp?.(e);
78+
},
79+
[onLayoutProp],
80+
);
81+
82+
return (
83+
<>
84+
<KeyboardChatScrollView
85+
ref={combinedRef}
86+
automaticallyAdjustContentInsets={false}
87+
contentContainerStyle={
88+
isInvertedSupported
89+
? invertedContentContainerStyle
90+
: contentContainerStyle
91+
}
92+
contentInsetAdjustmentBehavior="never"
93+
extraContentPadding={extraContentPadding}
94+
freeze={freeze}
95+
inverted={isInvertedSupported}
96+
keyboardDismissMode="interactive"
97+
keyboardLiftBehavior={keyboardLiftBehavior}
98+
offset={chatKitOffset}
99+
testID="chat.scroll"
100+
onLayout={onLayout}
101+
{...props}
102+
/>
103+
<Text style={styles.counter} testID="layout_passes">
104+
Layout pass: {layoutPass}
105+
</Text>
106+
</>
107+
);
108+
},
109+
);
75110

76111
const styles = StyleSheet.create({
77112
counter: {

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

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { LegendList, type LegendListRef } from "@legendapp/list";
2-
import { FlashList, type FlashListRef } from "@shopify/flash-list";
1+
import { LegendList } from "@legendapp/list";
2+
import { FlashList } from "@shopify/flash-list";
33
import React, {
44
useCallback,
55
useEffect,
@@ -39,13 +39,10 @@ import VirtualizedListScrollView, {
3939
type VirtualizedListScrollViewRef,
4040
} from "./VirtualizedListScrollView";
4141

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

4544
function KeyboardChatScrollViewPlayground() {
46-
const legendRef = useRef<LegendListRef>(null);
47-
const flashRef = useRef<FlashListRef<MessageProps>>(null);
48-
const flatRef = useRef<FlatList<MessageProps>>(null);
45+
const chatScrollViewRef = useRef<VirtualizedListScrollViewRef | null>(null);
4946
const scrollRef = useRef<VirtualizedListScrollViewRef>(null);
5047
const [text, setText] = useState("");
5148
const [inputHeight, setInputHeight] = useState(TEXT_INPUT_HEIGHT);
@@ -84,22 +81,15 @@ function KeyboardChatScrollViewPlayground() {
8481
}, [addMessage, text]);
8582

8683
useEffect(() => {
87-
legendRef.current?.scrollToOffset({
88-
animated: true,
89-
offset: Number.MAX_SAFE_INTEGER,
90-
});
91-
flashRef.current?.scrollToEnd({ animated: true });
92-
flatRef.current?.scrollToOffset({
93-
animated: true,
94-
offset: Number.MAX_SAFE_INTEGER,
95-
});
84+
chatScrollViewRef.current?.scrollToEnd({ animated: true });
9685
scrollRef.current?.scrollToEnd({ animated: true });
9786
}, [messages]);
9887

9988
const memoList = useCallback(
10089
(props: ScrollViewProps) => (
10190
<VirtualizedListScrollView
10291
{...props}
92+
chatScrollViewRef={chatScrollViewRef}
10393
extraContentPadding={extraContentPadding}
10494
/>
10595
),
@@ -116,7 +106,6 @@ function KeyboardChatScrollViewPlayground() {
116106
>
117107
{mode === "legend" && (
118108
<LegendList
119-
ref={legendRef}
120109
alignItemsAtEnd={inverted}
121110
contentContainerStyle={contentContainerStyle}
122111
data={messages}
@@ -128,7 +117,6 @@ function KeyboardChatScrollViewPlayground() {
128117
)}
129118
{mode === "flash" && (
130119
<FlashList
131-
ref={flashRef}
132120
contentContainerStyle={contentContainerStyle}
133121
data={messages}
134122
keyExtractor={(item) => item.text}
@@ -141,7 +129,6 @@ function KeyboardChatScrollViewPlayground() {
141129
)}
142130
{mode === "flat" && (
143131
<FlatList
144-
ref={flatRef}
145132
data={inverted ? reversedMessages : messages}
146133
inverted={inverted}
147134
keyExtractor={(item) => item.text}

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,3 +306,67 @@ To fix this, enable the [DISABLE_COMMIT_PAUSING_MECHANISM](https://docs.swmansio
306306
:::info Do I need to enable this flag?
307307
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.
308308
:::
309+
310+
### `scrollToEnd` doesn't scroll to the correct position when keyboard is open
311+
312+
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.
313+
314+
**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:
315+
316+
```tsx ChatScrollView.tsx
317+
import { forwardRef, useCallback } from "react";
318+
import { KeyboardChatScrollView } from "react-native-keyboard-controller";
319+
320+
import type { RefCallback } from "react";
321+
import type { ScrollViewProps } from "react-native";
322+
323+
type ChatScrollViewRef = React.ElementRef<typeof KeyboardChatScrollView>;
324+
type ChatScrollViewProps = ScrollViewProps & {
325+
chatScrollViewRef?: { current: ChatScrollViewRef | null };
326+
};
327+
328+
const ChatScrollView = forwardRef<ChatScrollViewRef, ChatScrollViewProps>(
329+
({ chatScrollViewRef, ...props }, ref) => {
330+
const combinedRef: RefCallback<ChatScrollViewRef> = useCallback(
331+
(instance) => {
332+
// forward to FlatList's internal ref
333+
if (typeof ref === "function") {
334+
ref(instance);
335+
} else if (ref) {
336+
ref.current = instance;
337+
}
338+
339+
// forward to the user-provided ref
340+
if (chatScrollViewRef) {
341+
chatScrollViewRef.current = instance as ChatScrollViewRef | null;
342+
}
343+
},
344+
[ref, chatScrollViewRef],
345+
);
346+
347+
return <KeyboardChatScrollView ref={combinedRef} {...props} />;
348+
},
349+
);
350+
```
351+
352+
Then use it with `FlatList` via `renderScrollComponent`:
353+
354+
```tsx
355+
const chatScrollViewRef = useRef<ChatScrollViewRef>(null);
356+
357+
const renderScrollComponent = useCallback(
358+
(props: ScrollViewProps) => (
359+
<ChatScrollView {...props} chatScrollViewRef={chatScrollViewRef} />
360+
),
361+
[],
362+
);
363+
364+
<FlatList
365+
data={messages}
366+
renderItem={renderItem}
367+
renderScrollComponent={renderScrollComponent}
368+
/>;
369+
370+
// Instead of listRef.current?.scrollToEnd()
371+
chatScrollViewRef.current?.scrollToEnd();
372+
```

docs/versioned_docs/version-1.21.0/api/components/keyboard-chat-scroll-view.mdx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,3 +306,67 @@ To fix this, enable the [DISABLE_COMMIT_PAUSING_MECHANISM](https://docs.swmansio
306306
:::info Do I need to enable this flag?
307307
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.
308308
:::
309+
310+
### `scrollToEnd` doesn't scroll to the correct position when keyboard is open
311+
312+
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.
313+
314+
**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:
315+
316+
```tsx ChatScrollView.tsx
317+
import { forwardRef, useCallback } from "react";
318+
import { KeyboardChatScrollView } from "react-native-keyboard-controller";
319+
320+
import type { RefCallback } from "react";
321+
import type { ScrollViewProps } from "react-native";
322+
323+
type ChatScrollViewRef = React.ElementRef<typeof KeyboardChatScrollView>;
324+
type ChatScrollViewProps = ScrollViewProps & {
325+
chatScrollViewRef?: { current: ChatScrollViewRef | null };
326+
};
327+
328+
const ChatScrollView = forwardRef<ChatScrollViewRef, ChatScrollViewProps>(
329+
({ chatScrollViewRef, ...props }, ref) => {
330+
const combinedRef: RefCallback<ChatScrollViewRef> = useCallback(
331+
(instance) => {
332+
// forward to FlatList's internal ref
333+
if (typeof ref === "function") {
334+
ref(instance);
335+
} else if (ref) {
336+
ref.current = instance;
337+
}
338+
339+
// forward to the user-provided ref
340+
if (chatScrollViewRef) {
341+
chatScrollViewRef.current = instance as ChatScrollViewRef | null;
342+
}
343+
},
344+
[ref, chatScrollViewRef],
345+
);
346+
347+
return <KeyboardChatScrollView ref={combinedRef} {...props} />;
348+
},
349+
);
350+
```
351+
352+
Then use it with `FlatList` via `renderScrollComponent`:
353+
354+
```tsx
355+
const chatScrollViewRef = useRef<ChatScrollViewRef>(null);
356+
357+
const renderScrollComponent = useCallback(
358+
(props: ScrollViewProps) => (
359+
<ChatScrollView {...props} chatScrollViewRef={chatScrollViewRef} />
360+
),
361+
[],
362+
);
363+
364+
<FlatList
365+
data={messages}
366+
renderItem={renderItem}
367+
renderScrollComponent={renderScrollComponent}
368+
/>;
369+
370+
// Instead of listRef.current?.scrollToEnd()
371+
chatScrollViewRef.current?.scrollToEnd();
372+
```

0 commit comments

Comments
 (0)