From dd9ee20049cd4b9d39df15ae6f33ba789b19e6f0 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Mon, 23 Mar 2026 15:01:58 +0100 Subject: [PATCH 1/2] docs: add workaround for non-working `scrollToEnd` --- .../components/keyboard-chat-scroll-view.mdx | 30 +++++ .../VirtualizedListScrollView.tsx | 110 +++++++++++------- 2 files changed, 100 insertions(+), 40 deletions(-) diff --git a/docs/docs/api/components/keyboard-chat-scroll-view.mdx b/docs/docs/api/components/keyboard-chat-scroll-view.mdx index 710ddd01ab..645b282134 100644 --- a/docs/docs/api/components/keyboard-chat-scroll-view.mdx +++ b/docs/docs/api/components/keyboard-chat-scroll-view.mdx @@ -306,3 +306,33 @@ 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`. To do that, pass a separate `scrollViewRef` through your `renderScrollComponent` wrapper and use it for scrolling: + +```tsx +const scrollViewRef = useRef(null); + +const renderScrollComponent = useCallback( + (props) => ( + + ), + [], +); + +; + +// Instead of listRef.current?.scrollToEnd() +scrollViewRef.current?.scrollToEnd(); +``` + +:::note +`FlatList` internally assigns its own ref to the component returned by `renderScrollComponent`. The `scrollViewRef` prop gives you a **second** ref to the same `KeyboardChatScrollView` instance so you can call `scrollToEnd` without interfering with `FlatList`'s internals. +::: diff --git a/example/src/screens/Examples/KeyboardChatScrollView/VirtualizedListScrollView.tsx b/example/src/screens/Examples/KeyboardChatScrollView/VirtualizedListScrollView.tsx index 26b02d186c..ad291239c6 100644 --- a/example/src/screens/Examples/KeyboardChatScrollView/VirtualizedListScrollView.tsx +++ b/example/src/screens/Examples/KeyboardChatScrollView/VirtualizedListScrollView.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useCallback, useState } from "react"; +import React, { forwardRef, useCallback, useRef, useState } from "react"; import { type LayoutChangeEvent, type ScrollViewProps, @@ -15,10 +15,12 @@ import { invertedContentContainerStyle, } from "./styles"; +import type { Ref } from "react"; import type { SharedValue } from "react-native-reanimated"; type VirtualizedListScrollViewProps = ScrollViewProps & { extraContentPadding?: SharedValue; + scrollViewRef?: Ref; }; export type VirtualizedListScrollViewRef = React.ElementRef< @@ -28,49 +30,77 @@ 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, scrollViewRef, ...props }, + ref, + ) => { + // combine FlatList's internal ref with the user-provided scrollViewRef + const scrollViewRefStable = useRef>(null); - const { inverted, freeze, mode, keyboardLiftBehavior } = useChatConfigStore(); + scrollViewRefStable.current = scrollViewRef ?? null; + const combinedRef = useCallback( + (instance: VirtualizedListScrollViewRef | null) => { + if (typeof ref === "function") { + ref(instance); + } else if (ref) { + ref.current = instance; + } - // on old arch only FlatList and FlashList supports `inverted` prop - const isInvertedSupported = - inverted && (mode === "flat" || mode === "flash") ? inverted : false; - const onLayout = useCallback( - (e: LayoutChangeEvent) => { - setLayoutPass((l) => l + 1); - onLayoutProp?.(e); - }, - [onLayoutProp], - ); + const svRef = scrollViewRefStable.current; - return ( - <> - - - Layout pass: {layoutPass} - - - ); -}); + }, + [ref, scrollViewRefStable], + ); + const [layoutPass, setLayoutPass] = useState(0); + const { bottom } = useSafeAreaInsets(); + const chatKitOffset = bottom - MARGIN; + + const { inverted, freeze, mode, keyboardLiftBehavior } = + useChatConfigStore(); + + // on old arch only FlatList and FlashList supports `inverted` prop + const isInvertedSupported = + inverted && (mode === "flat" || mode === "flash") ? inverted : false; + const onLayout = useCallback( + (e: LayoutChangeEvent) => { + setLayoutPass((l) => l + 1); + onLayoutProp?.(e); + }, + [onLayoutProp], + ); + + return ( + <> + + + Layout pass: {layoutPass} + + + ); + }, +); const styles = StyleSheet.create({ counter: { From 0df6683f4dd924aa7a99df822ec1795105044166 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Wed, 25 Mar 2026 20:30:19 +0100 Subject: [PATCH 2/2] fix: no TS errors --- .../VirtualizedListScrollView.tsx | 117 ++++++++++++------ .../Examples/KeyboardChatScrollView/index.tsx | 23 +--- .../components/keyboard-chat-scroll-view.mdx | 52 ++++++-- .../components/keyboard-chat-scroll-view.mdx | 64 ++++++++++ .../VirtualizedListScrollView.tsx | 39 +++--- .../Examples/KeyboardChatScrollView/index.tsx | 21 +--- 6 files changed, 214 insertions(+), 102 deletions(-) diff --git a/FabricExample/src/screens/Examples/KeyboardChatScrollView/VirtualizedListScrollView.tsx b/FabricExample/src/screens/Examples/KeyboardChatScrollView/VirtualizedListScrollView.tsx index a00df6f348..567e603a68 100644 --- a/FabricExample/src/screens/Examples/KeyboardChatScrollView/VirtualizedListScrollView.tsx +++ b/FabricExample/src/screens/Examples/KeyboardChatScrollView/VirtualizedListScrollView.tsx @@ -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; + chatScrollViewRef?: { current: VirtualizedListScrollViewRef | null }; }; export type VirtualizedListScrollViewRef = React.ElementRef< @@ -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 = 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 ( - <> - - - Layout pass: {layoutPass} - - - ); -}); + // 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 ( + <> + + + Layout pass: {layoutPass} + + + ); + }, +); const styles = StyleSheet.create({ counter: { diff --git a/FabricExample/src/screens/Examples/KeyboardChatScrollView/index.tsx b/FabricExample/src/screens/Examples/KeyboardChatScrollView/index.tsx index 639ae65a8c..0f5753597c 100644 --- a/FabricExample/src/screens/Examples/KeyboardChatScrollView/index.tsx +++ b/FabricExample/src/screens/Examples/KeyboardChatScrollView/index.tsx @@ -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, @@ -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(null); - const flashRef = useRef>(null); - const flatRef = useRef>(null); + const chatScrollViewRef = useRef(null); const scrollRef = useRef(null); const [text, setText] = useState(""); const [inputHeight, setInputHeight] = useState(TEXT_INPUT_HEIGHT); @@ -84,15 +81,7 @@ 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]); @@ -100,6 +89,7 @@ function KeyboardChatScrollViewPlayground() { (props: ScrollViewProps) => ( ), @@ -116,7 +106,6 @@ function KeyboardChatScrollViewPlayground() { > {mode === "legend" && ( item.text} @@ -141,7 +129,6 @@ function KeyboardChatScrollViewPlayground() { )} {mode === "flat" && ( item.text} diff --git a/docs/docs/api/components/keyboard-chat-scroll-view.mdx b/docs/docs/api/components/keyboard-chat-scroll-view.mdx index 645b282134..f8a07dcedb 100644 --- a/docs/docs/api/components/keyboard-chat-scroll-view.mdx +++ b/docs/docs/api/components/keyboard-chat-scroll-view.mdx @@ -311,14 +311,52 @@ This issue can occur even if the state update comes from a different screen (e.g 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`. To do that, pass a separate `scrollViewRef` through your `renderScrollComponent` wrapper and use it for scrolling: +**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; +type ChatScrollViewProps = ScrollViewProps & { + chatScrollViewRef?: { current: ChatScrollViewRef | null }; +}; + +const ChatScrollView = forwardRef( + ({ chatScrollViewRef, ...props }, ref) => { + const combinedRef: RefCallback = 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 ; + }, +); +``` + +Then use it with `FlatList` via `renderScrollComponent`: ```tsx -const scrollViewRef = useRef(null); +const chatScrollViewRef = useRef(null); const renderScrollComponent = useCallback( - (props) => ( - + (props: ScrollViewProps) => ( + ), [], ); @@ -330,9 +368,5 @@ const renderScrollComponent = useCallback( />; // Instead of listRef.current?.scrollToEnd() -scrollViewRef.current?.scrollToEnd(); +chatScrollViewRef.current?.scrollToEnd(); ``` - -:::note -`FlatList` internally assigns its own ref to the component returned by `renderScrollComponent`. The `scrollViewRef` prop gives you a **second** ref to the same `KeyboardChatScrollView` instance so you can call `scrollToEnd` without interfering with `FlatList`'s internals. -::: diff --git a/docs/versioned_docs/version-1.21.0/api/components/keyboard-chat-scroll-view.mdx b/docs/versioned_docs/version-1.21.0/api/components/keyboard-chat-scroll-view.mdx index 710ddd01ab..f8a07dcedb 100644 --- a/docs/versioned_docs/version-1.21.0/api/components/keyboard-chat-scroll-view.mdx +++ b/docs/versioned_docs/version-1.21.0/api/components/keyboard-chat-scroll-view.mdx @@ -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; +type ChatScrollViewProps = ScrollViewProps & { + chatScrollViewRef?: { current: ChatScrollViewRef | null }; +}; + +const ChatScrollView = forwardRef( + ({ chatScrollViewRef, ...props }, ref) => { + const combinedRef: RefCallback = 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 ; + }, +); +``` + +Then use it with `FlatList` via `renderScrollComponent`: + +```tsx +const chatScrollViewRef = useRef(null); + +const renderScrollComponent = useCallback( + (props: ScrollViewProps) => ( + + ), + [], +); + +; + +// Instead of listRef.current?.scrollToEnd() +chatScrollViewRef.current?.scrollToEnd(); +``` diff --git a/example/src/screens/Examples/KeyboardChatScrollView/VirtualizedListScrollView.tsx b/example/src/screens/Examples/KeyboardChatScrollView/VirtualizedListScrollView.tsx index ad291239c6..2781eb91c5 100644 --- a/example/src/screens/Examples/KeyboardChatScrollView/VirtualizedListScrollView.tsx +++ b/example/src/screens/Examples/KeyboardChatScrollView/VirtualizedListScrollView.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useCallback, useRef, useState } from "react"; +import React, { forwardRef, useCallback, useState } from "react"; import { type LayoutChangeEvent, type ScrollViewProps, @@ -15,12 +15,12 @@ import { invertedContentContainerStyle, } from "./styles"; -import type { Ref } from "react"; +import type { RefCallback } from "react"; import type { SharedValue } from "react-native-reanimated"; type VirtualizedListScrollViewProps = ScrollViewProps & { extraContentPadding?: SharedValue; - scrollViewRef?: Ref; + chatScrollViewRef?: { current: VirtualizedListScrollViewRef | null }; }; export type VirtualizedListScrollViewRef = React.ElementRef< @@ -32,30 +32,35 @@ const VirtualizedListScrollView = forwardRef< VirtualizedListScrollViewProps >( ( - { onLayout: onLayoutProp, extraContentPadding, scrollViewRef, ...props }, + { + onLayout: onLayoutProp, + extraContentPadding, + chatScrollViewRef, + ...props + }, ref, ) => { - // combine FlatList's internal ref with the user-provided scrollViewRef - const scrollViewRefStable = useRef>(null); - - scrollViewRefStable.current = scrollViewRef ?? null; - const combinedRef = useCallback( + 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 = useCallback( + (instance) => { if (typeof ref === "function") { ref(instance); } else if (ref) { ref.current = instance; } - const svRef = scrollViewRefStable.current; - - if (typeof svRef === "function") { - svRef(instance); - } else if (svRef) { - svRef.current = instance; - } + setScrollViewRef(instance); }, - [ref, scrollViewRefStable], + [ref, setScrollViewRef], ); const [layoutPass, setLayoutPass] = useState(0); const { bottom } = useSafeAreaInsets(); diff --git a/example/src/screens/Examples/KeyboardChatScrollView/index.tsx b/example/src/screens/Examples/KeyboardChatScrollView/index.tsx index 728e183c84..cd4ea16d11 100644 --- a/example/src/screens/Examples/KeyboardChatScrollView/index.tsx +++ b/example/src/screens/Examples/KeyboardChatScrollView/index.tsx @@ -1,4 +1,4 @@ -import { LegendList, type LegendListRef } from "@legendapp/list"; +import { LegendList } from "@legendapp/list"; import { FlashList } from "@shopify/flash-list"; import React, { useCallback, @@ -40,13 +40,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(null); - const flashRef = useRef>(null); - const flatRef = useRef>(null); + const chatScrollViewRef = useRef(null); const scrollRef = useRef(null); const textInputRef = useRef(null); const textRef = useRef(""); @@ -90,15 +87,7 @@ function KeyboardChatScrollViewPlayground() { }, [addMessage]); 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]); @@ -106,6 +95,7 @@ function KeyboardChatScrollViewPlayground() { (props: ScrollViewProps) => ( ), @@ -122,7 +112,6 @@ function KeyboardChatScrollViewPlayground() { > {mode === "legend" && ( item.text}