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 710ddd01ab..f8a07dcedb 100644 --- a/docs/docs/api/components/keyboard-chat-scroll-view.mdx +++ b/docs/docs/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/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 26b02d186c..2781eb91c5 100644 --- a/example/src/screens/Examples/KeyboardChatScrollView/VirtualizedListScrollView.tsx +++ b/example/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,49 +30,82 @@ 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 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 { inverted, freeze, mode, keyboardLiftBehavior } = + useChatConfigStore(); - return ( - <> - - - Layout pass: {layoutPass} - - - ); -}); + // 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: { 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}