From 39c75a892093f02d2522db3678b345a62130e6db Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 16 Jun 2026 17:03:00 +0300 Subject: [PATCH] add scrollToIndex on web --- src/view/com/util/List.web.tsx | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx index 787ca9e278..441297f120 100644 --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -30,6 +30,15 @@ export type ListMethods = { scrollToTop: () => void scrollToOffset: (options: {animated: boolean; offset: number}) => void scrollToEnd: (options?: {animated?: boolean}) => void + // Signature kept compatible with FlatList's scrollToIndex (the native + // ListMethods type) so callers stay platform-agnostic. viewOffset is + // accepted for parity but not currently used by the web implementation. + scrollToIndex: (params: { + animated?: boolean | null + index: number + viewOffset?: number + viewPosition?: number + }) => void } export type ListProps = Omit< FlatListProps, @@ -216,6 +225,22 @@ function ListImpl( }, [disableFullWindowScroll]) const nativeRef = useRef(null) + + // Registry of item index -> row DOM node. The list renders header/footer and + // visibility-detector siblings too, so we can't index into the container's + // children directly; each Row registers its own node here keyed by index. + const rowNodesRef = useRef>(new Map()) + const registerRowNode = useCallback( + (index: number, node: HTMLElement | null) => { + if (node) { + rowNodesRef.current.set(index, node) + } else { + rowNodesRef.current.delete(index) + } + }, + [], + ) + useImperativeHandle( ref, () => ({ @@ -239,6 +264,17 @@ function ListImpl( behavior: animated ? 'smooth' : 'instant', }) }, + + scrollToIndex({animated = true, index}) { + const node = rowNodesRef.current.get(index) + // scrollIntoView with block: 'center' roughly matches the caller's + // viewPosition of 0.3 - not exact, but close enough and it respects + // whichever element is the scroll container (window or nativeRef). + node?.scrollIntoView({ + block: 'center', + behavior: animated ? 'smooth' : 'instant', + }) + }, }), [getScrollableNode], ) @@ -392,6 +428,7 @@ function ListImpl( renderItem={renderItem} extraData={extraData} onItemSeen={onItemSeen} + registerRowNode={registerRowNode} /> ) })} @@ -470,6 +507,7 @@ let Row = function RowImpl({ renderItem, extraData: _unused, onItemSeen, + registerRowNode, }: { item: ItemT index: number @@ -479,6 +517,7 @@ let Row = function RowImpl({ | ((info: ListRenderItemInfo) => React.ReactNode) extraData: unknown onItemSeen: ((item: ItemT) => void) | undefined + registerRowNode: (index: number, node: HTMLElement | null) => void }): React.ReactNode { const rowRef = useRef(null) const intersectionTimeout = useRef | undefined>( @@ -529,6 +568,15 @@ let Row = function RowImpl({ } }, [handleIntersection, onItemSeen]) + // Register this row's DOM node so the list can scroll to it by index. + useEffect(() => { + const node: HTMLElement | null = rowRef.current + registerRowNode(index, node) + return () => { + registerRowNode(index, null) + } + }, [index, registerRowNode]) + if (!renderItem) { return null } @@ -552,6 +600,7 @@ Row = memo(Row) as (props: { | ((info: ListRenderItemInfo) => React.ReactNode) extraData: unknown onItemSeen: ((item: ItemT) => void) | undefined + registerRowNode: (index: number, node: HTMLElement | null) => void }) => React.ReactNode let Visibility = ({