diff --git a/packages/react-native/index.js b/packages/react-native/index.js index b50fbadcf997..baa4d5b51bff 100644 --- a/packages/react-native/index.js +++ b/packages/react-native/index.js @@ -157,6 +157,34 @@ module.exports = { get unstable_VirtualView() { return require('./src/private/components/virtualview/VirtualView').default; }, + get unstable_VirtualArray() { + return require('./src/private/components/virtualcollection/Virtual') + .VirtualArray; + }, + get unstable_createVirtualCollectionView() { + return require('./src/private/components/virtualcollection/VirtualCollectionView') + .createVirtualCollectionView; + }, + get unstable_VirtualColumn() { + return require('./src/private/components/virtualcollection/column/VirtualColumn') + .default; + }, + get unstable_VirtualColumnGenerator() { + return require('./src/private/components/virtualcollection/column/VirtualColumnGenerator') + .default; + }, + get unstable_VirtualRow() { + return require('./src/private/components/virtualcollection/row/VirtualRow') + .default; + }, + get unstable_getScrollParent() { + return require('./src/private/components/virtualcollection/dom/getScrollParent') + .default; + }, + get unstable_DEFAULT_INITIAL_NUM_TO_RENDER() { + return require('./src/private/components/virtualcollection/FlingConstants') + .DEFAULT_INITIAL_NUM_TO_RENDER; + }, // #endregion // #region APIs get AccessibilityInfo() { diff --git a/packages/react-native/index.js.flow b/packages/react-native/index.js.flow index c712a9128788..fcb3ffc5ca4b 100644 --- a/packages/react-native/index.js.flow +++ b/packages/react-native/index.js.flow @@ -476,4 +476,21 @@ export { } from './src/private/components/virtualview/VirtualView'; export type {ModeChangeEvent} from './src/private/components/virtualview/VirtualView'; +export {VirtualArray as unstable_VirtualArray} from './src/private/components/virtualcollection/Virtual'; +export type { + Item as unstable_VirtualItem, + VirtualCollection as unstable_VirtualCollection, +} from './src/private/components/virtualcollection/Virtual'; +export {createVirtualCollectionView as unstable_createVirtualCollectionView} from './src/private/components/virtualcollection/VirtualCollectionView'; +export type { + VirtualCollectionGenerator as unstable_VirtualCollectionGenerator, + VirtualCollectionLayoutComponent as unstable_VirtualCollectionLayoutComponent, + VirtualCollectionViewComponent as unstable_VirtualCollectionViewComponent, +} from './src/private/components/virtualcollection/VirtualCollectionView'; +export {default as unstable_VirtualColumn} from './src/private/components/virtualcollection/column/VirtualColumn'; +export {default as unstable_VirtualColumnGenerator} from './src/private/components/virtualcollection/column/VirtualColumnGenerator'; +export {default as unstable_VirtualRow} from './src/private/components/virtualcollection/row/VirtualRow'; +export {default as unstable_getScrollParent} from './src/private/components/virtualcollection/dom/getScrollParent'; +export {DEFAULT_INITIAL_NUM_TO_RENDER as unstable_DEFAULT_INITIAL_NUM_TO_RENDER} from './src/private/components/virtualcollection/FlingConstants'; + // #endregion diff --git a/packages/react-native/src/private/components/virtualcollection/FlingConstants.js b/packages/react-native/src/private/components/virtualcollection/FlingConstants.js new file mode 100644 index 000000000000..561252eefcb1 --- /dev/null +++ b/packages/react-native/src/private/components/virtualcollection/FlingConstants.js @@ -0,0 +1,21 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import Dimensions from '../../../../Libraries/Utilities/Dimensions'; + +export const DEFAULT_INITIAL_NUM_TO_RENDER = 7; + +export const INITIAL_NUM_TO_RENDER: number = DEFAULT_INITIAL_NUM_TO_RENDER; + +export const FALLBACK_ESTIMATED_HEIGHT: number = + Dimensions.get('window').height / DEFAULT_INITIAL_NUM_TO_RENDER; + +export const FALLBACK_ESTIMATED_WIDTH: number = + Dimensions.get('window').width / DEFAULT_INITIAL_NUM_TO_RENDER; diff --git a/packages/react-native/src/private/components/virtualcollection/Virtual.js b/packages/react-native/src/private/components/virtualcollection/Virtual.js new file mode 100644 index 000000000000..8840ac17da4d --- /dev/null +++ b/packages/react-native/src/private/components/virtualcollection/Virtual.js @@ -0,0 +1,62 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + */ + +/** + * An item to virtualize must be an item. It can optionally have a string `id` + * parameter, but that is not currently represented because it makes the Flow + * types more complicated. + */ +export interface Item {} + +/** + * An interface for a collection of items, without requiring that each item be + * eagerly (or lazily) allocated. + */ +export interface VirtualCollection<+T extends Item> { + /** + * The number of items in the collection. This can either be a numeric scalar + * or a getter function that is computed on access. However, it should remain + * constant for the lifetime of this object. + */ + +size: number; + + /** + * If an item exists at the supplied index, this should return a consistent + * item for the lifetime of this object. If an item does not exist at the + * supplied index, this should throw an error. + */ + at(index: number): T; +} + +/** + * An implementation of `VirtualCollection` that wraps an array. Although easy to + * use, this is not recommended for larger arrays because each element of an + * array is eagerly allocated. + */ +export class VirtualArray<+T extends Item> implements VirtualCollection { + +size: number; + +at: (index: number) => T; + + constructor(input: Readonly<$ArrayLike>) { + const array = [...input]; + + // NOTE: This is implemented this way because Flow does not permit `input` + // to be a read-only instance property (even a private one). + this.size = array.length; + this.at = (index: number): T => { + if (index < 0 || index >= this.size) { + throw new RangeError( + `Cannot get index ${index} from a collection of size ${this.size}`, + ); + } + return array[index]; + }; + } +} diff --git a/packages/react-native/src/private/components/virtualcollection/VirtualCollectionView.js b/packages/react-native/src/private/components/virtualcollection/VirtualCollectionView.js new file mode 100644 index 000000000000..3a03e91f3252 --- /dev/null +++ b/packages/react-native/src/private/components/virtualcollection/VirtualCollectionView.js @@ -0,0 +1,238 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {ViewStyleProp} from '../../../../Libraries/StyleSheet/StyleSheet'; +import type {ModeChangeEvent} from '../virtualview/VirtualView'; +import type {Item, VirtualCollection} from './Virtual'; + +import VirtualView from '../virtualview/VirtualView'; +import { + VirtualViewMode, + createHiddenVirtualView, +} from '../virtualview/VirtualView'; +import FlingDebugItemOverlay from './debug/FlingDebugItemOverlay'; +import * as React from 'react'; +import {useCallback, useMemo, useState} from 'react'; + +export type VirtualCollectionLayoutComponent = + component( + children: ReadonlyArray, + spacer: React.Node, + ...TLayoutProps + ); + +export type VirtualCollectionGenerator = Readonly<{ + initial: Readonly<{ + itemCount: number, + spacerStyle: (itemCount: number) => ViewStyleProp, + }>, + next: (event: ModeChangeEvent) => { + itemCount: number, + spacerStyle: (itemCount: number) => ViewStyleProp, + }, +}>; + +export type VirtualCollectionViewComponent = + component<+TItem extends Item>( + children: (item: TItem, key: string) => React.Node, + items: VirtualCollection, + itemToKey?: (TItem) => string, + removeClippedSubviews?: boolean, + testID?: ?string, + ...TLayoutProps + ); + +/** + * Creates a component that virtually renders a collection of items and manages + * lazy rendering, memoization, and pagination. The resulting component accepts + * the following base props: + * + * - `children`: A function maps an item to a React node. + * - `items`: A collection of items to render. + * - `itemToKey`: A function maps an item to a unique key. + * + * The first argument is a layout component that defines layout of the item and + * spacer. It always receives the following props: + * + * - `children`: An array of React nodes (for items rendered so far). + * - `spacer`: A React node (estimates layout for items not yet rendered). + * + * The layout component must render `children` and `spacer`. It can also define + * additional props that will be passed through from the resulting component. + * + * The second argument is a generator that defines the initial rendering and + * pagination behavior. The initial rendering behavior is defined by the + * `initial` property with the following properties: + * + * - `itemCount`: Number of items to render initially. + * - `spacerStyle`: A function that estimates the layout of the spacer. It + * receives the number of items being rendered as an argument. + * + * The pagination behavior is defined by the `next` function that receives a + * `ModeChangeEvent` and then returns an object with the following properties: + * + * - `itemCount`: Number of additional items needed to fill `thresholdRect`. + * - `spacerStyle`: A function that estimates the layout of the spacer. It + * receives the number of items being rendered as an argument. + * + */ +export function createVirtualCollectionView( + VirtualLayout: VirtualCollectionLayoutComponent, + {initial, next}: VirtualCollectionGenerator, +): VirtualCollectionViewComponent { + component VirtualCollectionView<+TItem extends Item>( + children: (item: TItem, key: string) => React.Node, + items: VirtualCollection, + itemToKey: TItem => string = defaultItemToKey, + removeClippedSubviews: boolean = false, + testID?: ?string, + ...layoutProps: TLayoutProps + ) { + const [desiredItemCount, setDesiredItemCount] = useState( + Math.ceil(initial.itemCount), + ); + + const renderItem = useMemoCallback( + useCallback( + (item: TItem) => { + const key = itemToKey(item); + return ( + + {FlingDebugItemOverlay == null ? null : ( + + )} + {children(item, key)} + + ); + }, + [children, itemToKey, removeClippedSubviews], + ), + ); + + const mountedItemCount = Math.min(desiredItemCount, items.size); + const mountedItemViews = Array.from( + {length: mountedItemCount}, + (_, index) => renderItem(items.at(index)), + ); + + const virtualItemCount = items.size - mountedItemCount; + const virtualItemSpacer = useMemo( + () => + virtualItemCount === 0 ? null : ( + { + setDesiredItemCount( + prevElementCount => prevElementCount + itemCount, + ); + }} + /> + ), + [virtualItemCount, testID], + ); + + return ( + + {mountedItemViews} + + ); + } + + function createSpacerView(spacerStyle: (itemCount: number) => ViewStyleProp) { + component SpacerView( + itemCount: number, + ref?: React.RefSetter | null>, + ...props: Omit, 'ref'> + ) { + const HiddenVirtualView = useMemo( + () => createHiddenVirtualView(spacerStyle(itemCount)), + [itemCount], + ); + return ; + } + return SpacerView; + } + + const initialSpacerView = { + SpacerView: createSpacerView(initial.spacerStyle), + }; + + component VirtualCollectionSpacer( + nativeID: string, + virtualItemCount: number, + + onRenderMoreItems: (itemCount: number) => void, + ) { + // NOTE: Store `SpacerView` in a wrapper object because otherwise, `useState` + // will confuse `SpacerView` (a component) as being an updater function. + const [{SpacerView}, setSpacerView] = useState(initialSpacerView); + + const handleModeChange = (event: ModeChangeEvent) => { + if (event.mode === VirtualViewMode.Hidden) { + // This should never happen; this starts hidden and otherwise unmounts. + return; + } + const {itemCount, spacerStyle} = next(event); + + // Refine the estimated item size when computing spacer size. + setSpacerView({ + SpacerView: createSpacerView(spacerStyle), + }); + + // Render more items to fill `thresholdRect`. + onRenderMoreItems(Math.min(Math.ceil(itemCount), virtualItemCount)); + }; + + return ( + + ); + } + + return VirtualCollectionView; +} + +hook useMemoCallback( + callback: TInput => TOutput, +): TInput => TOutput { + return useMemo(() => memoize(callback), [callback]); +} + +function memoize( + callback: TInput => TOutput, +): TInput => TOutput { + const cache = new WeakMap(); + return (input: TInput) => { + let output = cache.get(input); + if (output == null) { + output = callback(input); + cache.set(input, output); + } + return output; + }; +} + +function defaultItemToKey(item: Item): string { + // $FlowExpectedError[prop-missing] - Flow cannot model this dynamic pattern. + const key = item.key; + if (typeof key !== 'string') { + throw new TypeError( + `Expected 'id' of item to be a string, got: ${typeof key}`, + ); + } + return key; +} diff --git a/packages/react-native/src/private/components/virtualcollection/column/VirtualColumn.js b/packages/react-native/src/private/components/virtualcollection/column/VirtualColumn.js new file mode 100644 index 000000000000..e1674d525402 --- /dev/null +++ b/packages/react-native/src/private/components/virtualcollection/column/VirtualColumn.js @@ -0,0 +1,43 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {Item, VirtualCollection} from '../Virtual'; + +import {createVirtualCollectionView} from '../VirtualCollectionView'; +import VirtualColumnGenerator from './VirtualColumnGenerator'; +import * as React from 'react'; + +component VirtualColumnLayout( + children: ReadonlyArray, + spacer: React.Node, +) { + return ( + <> + {children} + {spacer} + + ); +} + +const VirtualColumn = createVirtualCollectionView( + VirtualColumnLayout, + VirtualColumnGenerator, +); + +// TODO: Figure out component generic resolution. +// @see https://fb.workplace.com/groups/flow/posts/29355518614070041 +// export default VirtualColumn as VirtualCollectionViewComponent; +export default VirtualColumn as component<+TItem extends Item>( + children: (item: TItem, key: string) => React.Node, + items: VirtualCollection, + itemToKey?: (TItem) => string, + removeClippedSubviews?: boolean, + testID?: ?string, +); diff --git a/packages/react-native/src/private/components/virtualcollection/column/VirtualColumnGenerator.js b/packages/react-native/src/private/components/virtualcollection/column/VirtualColumnGenerator.js new file mode 100644 index 000000000000..7ae81324e387 --- /dev/null +++ b/packages/react-native/src/private/components/virtualcollection/column/VirtualColumnGenerator.js @@ -0,0 +1,72 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type ReadOnlyElement from '../../../webapis/dom/nodes/ReadOnlyElement'; +import type {ModeChangeEvent} from '../../virtualview/VirtualView'; +import type {VirtualCollectionGenerator} from '../VirtualCollectionView'; + +import ReactNativeElement from '../../../webapis/dom/nodes/ReactNativeElement'; +import { + FALLBACK_ESTIMATED_HEIGHT, + INITIAL_NUM_TO_RENDER, +} from '../FlingConstants'; + +function isVirtualView(element: ReadOnlyElement) { + // True for `VirtualView` and `VirtualViewExperimental`. + return element.nodeName.startsWith('RN:VirtualView'); +} + +const VirtualColumnGenerator: VirtualCollectionGenerator = { + initial: { + itemCount: INITIAL_NUM_TO_RENDER, + spacerStyle: (itemCount: number) => ({ + height: itemCount * FALLBACK_ESTIMATED_HEIGHT, + }), + }, + next({target, targetRect, thresholdRect}: ModeChangeEvent) { + if (!(target instanceof ReactNativeElement)) { + throw new Error( + 'Expected target to be a ReactNativeElement. VirtualColumn requires DOM APIs to be enabled in React Native.', + ); + } + + const heightToFill = + Math.min( + targetRect.y + targetRect.height, + thresholdRect.y + thresholdRect.height, + ) - Math.max(targetRect.y, thresholdRect.y); + + // Estimate each item's size by averaging up to the 3 last items. + let previous: ReadOnlyElement = target; + let count = 0; + let maybePrevious = previous.previousElementSibling; + while (count < 3 && maybePrevious != null && isVirtualView(maybePrevious)) { + previous = maybePrevious; + count++; + maybePrevious = previous.previousElementSibling; + } + + const itemHeight = + count > 0 + ? (target.getBoundingClientRect().top - + previous.getBoundingClientRect().top) / + count + : FALLBACK_ESTIMATED_HEIGHT; + + return { + itemCount: heightToFill / itemHeight, + spacerStyle: (itemCount: number) => ({ + height: itemCount * itemHeight, + }), + }; + }, +}; + +export default VirtualColumnGenerator; diff --git a/packages/react-native/src/private/components/virtualcollection/debug/FlingDebugItemOverlay.js b/packages/react-native/src/private/components/virtualcollection/debug/FlingDebugItemOverlay.js new file mode 100644 index 000000000000..5919a85a10c4 --- /dev/null +++ b/packages/react-native/src/private/components/virtualcollection/debug/FlingDebugItemOverlay.js @@ -0,0 +1,50 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import View from '../../../../../Libraries/Components/View/View'; +import StyleSheet from '../../../../../Libraries/StyleSheet/StyleSheet'; +import Text from '../../../../../Libraries/Text/Text'; +import * as ReactNativeFeatureFlags from '../../../featureflags/ReactNativeFeatureFlags'; +import * as React from 'react'; + +let FlingDebugItemOverlay: ?component(nativeID: string); + +if (ReactNativeFeatureFlags.enableVirtualViewDebugFeatures()) { + component FlingDebugItemOverlayInternal(nativeID: string) { + return ( + + {`(Fling Debug) ${nativeID}`} + + ); + } + + const styles = StyleSheet.create({ + container: { + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + right: 0, + pointerEvents: 'none', + backgroundColor: 'rgba(0,0,0, 0.1)', + borderWidth: 1, + borderColor: 'rgba(250,0,0, 0.5)', + zIndex: 1000, + }, + text: { + fontSize: 18, + backgroundColor: 'rgba(0,0,0, 0.5)', + color: 'white', + }, + }); + FlingDebugItemOverlay = FlingDebugItemOverlayInternal; +} + +export default FlingDebugItemOverlay; diff --git a/packages/react-native/src/private/components/virtualcollection/dom/getScrollParent.js b/packages/react-native/src/private/components/virtualcollection/dom/getScrollParent.js new file mode 100644 index 000000000000..21250997af66 --- /dev/null +++ b/packages/react-native/src/private/components/virtualcollection/dom/getScrollParent.js @@ -0,0 +1,43 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import ReactNativeElement from '../../../webapis/dom/nodes/ReactNativeElement'; +import isScrollableNode from './isScrollableNode'; + +/** + * Finds the nearest ancestor of the supplied node that is a scrollable node. + * + * Unlike the web-equivalent function, the return type is nullable because the + * root is not an implicitly scrollable node. + */ +export default function getScrollParent( + node: ReactNativeElement, +): ReactNativeElement | null { + let element: ReactNativeElement | null = node; + while (element != null) { + if (isScrollableNode(element)) { + return element; + } + const parent = element.parentElement; + // Currently, the only subclass of `ReadOnlyNode` is `ReactNativeElement`. + if (parent instanceof ReactNativeElement || parent == null) { + element = parent; + } else { + console.error( + 'Expected `element.parentElement` to be `?ReactNativeElement`, got: %s', + parent, + ); + element = null; + } + // So this is equivalent to a null check with type safety. + element = parent instanceof ReactNativeElement ? parent : null; + } + return null; +} diff --git a/packages/react-native/src/private/components/virtualcollection/dom/isScrollableNode.js b/packages/react-native/src/private/components/virtualcollection/dom/isScrollableNode.js new file mode 100644 index 000000000000..891ed0a3cd90 --- /dev/null +++ b/packages/react-native/src/private/components/virtualcollection/dom/isScrollableNode.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type ReactNativeElement from '../../../webapis/dom/nodes/ReactNativeElement'; + +/** + * Checks whether the supplied node is a scrollable node, ignoring whether + * there is sufficient content to scroll or whether scrolling is disabled. + */ +export default function isScrollableNode(node: ReactNativeElement): boolean { + // Applies for vertical and horizontal `ScrollView` on both Android and iOS. + // The content container might have a different `nodeName`, but its parent + // always has this `nodeName`. + return node.nodeName === 'RN:ScrollView'; +} diff --git a/packages/react-native/src/private/components/virtualcollection/row/VirtualRow.js b/packages/react-native/src/private/components/virtualcollection/row/VirtualRow.js new file mode 100644 index 000000000000..92b4bbd6ab53 --- /dev/null +++ b/packages/react-native/src/private/components/virtualcollection/row/VirtualRow.js @@ -0,0 +1,43 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {Item, VirtualCollection} from '../Virtual'; + +import {createVirtualCollectionView} from '../VirtualCollectionView'; +import VirtualRowGenerator from './VirtualRowGenerator'; +import * as React from 'react'; + +component VirtualRowLayout( + children: ReadonlyArray, + spacer: React.Node, +) { + return ( + <> + {children} + {spacer} + + ); +} + +const VirtualRow = createVirtualCollectionView( + VirtualRowLayout, + VirtualRowGenerator, +); + +// TODO: Figure out component generic resolution. +// @see https://fb.workplace.com/groups/flow/posts/29355518614070041 +// export default VirtualRow as VirtualCollectionViewComponent; +export default VirtualRow as component<+TItem extends Item>( + children: (item: TItem, key: string) => React.Node, + items: VirtualCollection, + itemToKey?: (TItem) => string, + removeClippedSubviews?: boolean, + testID?: ?string, +); diff --git a/packages/react-native/src/private/components/virtualcollection/row/VirtualRowGenerator.js b/packages/react-native/src/private/components/virtualcollection/row/VirtualRowGenerator.js new file mode 100644 index 000000000000..88ba1bda05bf --- /dev/null +++ b/packages/react-native/src/private/components/virtualcollection/row/VirtualRowGenerator.js @@ -0,0 +1,72 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type ReadOnlyElement from '../../../webapis/dom/nodes/ReadOnlyElement'; +import type {ModeChangeEvent} from '../../virtualview/VirtualView'; +import type {VirtualCollectionGenerator} from '../VirtualCollectionView'; + +import ReactNativeElement from '../../../webapis/dom/nodes/ReactNativeElement'; +import { + FALLBACK_ESTIMATED_WIDTH, + INITIAL_NUM_TO_RENDER, +} from '../FlingConstants'; + +function isVirtualView(element: ReadOnlyElement) { + // True for `VirtualView` and `VirtualViewExperimental`. + return element.nodeName.startsWith('RN:VirtualView'); +} + +const VirtualRowGenerator: VirtualCollectionGenerator = { + initial: { + itemCount: INITIAL_NUM_TO_RENDER, + spacerStyle: (itemCount: number) => ({ + width: itemCount * FALLBACK_ESTIMATED_WIDTH, + }), + }, + next({target, targetRect, thresholdRect}: ModeChangeEvent) { + if (!(target instanceof ReactNativeElement)) { + throw new Error( + 'Expected target to be a ReactNativeElement. VirtualRow requires DOM APIs to be enabled in React Native.', + ); + } + + const widthToFill = + Math.min( + targetRect.x + targetRect.width, + thresholdRect.x + thresholdRect.width, + ) - Math.max(targetRect.x, thresholdRect.x); + + // Estimate each item's size by averaging up to the 3 last items. + let previous: ReadOnlyElement = target; + let count = 0; + let maybePrevious = previous.previousElementSibling; + while (count < 3 && maybePrevious != null && isVirtualView(maybePrevious)) { + previous = maybePrevious; + count++; + maybePrevious = previous.previousElementSibling; + } + + const itemWidth = + count > 0 + ? (target.getBoundingClientRect().left - + previous.getBoundingClientRect().left) / + count + : FALLBACK_ESTIMATED_WIDTH; + + return { + itemCount: widthToFill / itemWidth, + spacerStyle: (itemCount: number) => ({ + width: itemCount * itemWidth, + }), + }; + }, +}; + +export default VirtualRowGenerator;