Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/react-native/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
17 changes: 17 additions & 0 deletions packages/react-native/index.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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<T> {
+size: number;
+at: (index: number) => T;

constructor(input: Readonly<$ArrayLike<T>>) {
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];
};
}
}
Original file line number Diff line number Diff line change
@@ -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<TLayoutProps extends {...}> =
component(
children: ReadonlyArray<React.Node>,
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<TLayoutProps extends {...}> =
component<+TItem extends Item>(
children: (item: TItem, key: string) => React.Node,
items: VirtualCollection<TItem>,
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<TLayoutProps extends {...}>(
VirtualLayout: VirtualCollectionLayoutComponent<TLayoutProps>,
{initial, next}: VirtualCollectionGenerator,
): VirtualCollectionViewComponent<TLayoutProps> {
component VirtualCollectionView<+TItem extends Item>(
children: (item: TItem, key: string) => React.Node,
items: VirtualCollection<TItem>,
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 (
<VirtualView
key={key}
nativeID={key}
removeClippedSubviews={removeClippedSubviews}>
{FlingDebugItemOverlay == null ? null : (
<FlingDebugItemOverlay nativeID={key} />
)}
{children(item, key)}
</VirtualView>
);
},
[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 : (
<VirtualCollectionSpacer
nativeID={`${testID ?? ''}:Spacer`}
virtualItemCount={virtualItemCount}
onRenderMoreItems={(itemCount: number) => {
setDesiredItemCount(
prevElementCount => prevElementCount + itemCount,
);
}}
/>
),
[virtualItemCount, testID],
);

return (
<VirtualLayout {...layoutProps} spacer={virtualItemSpacer}>
{mountedItemViews}
</VirtualLayout>
);
}

function createSpacerView(spacerStyle: (itemCount: number) => ViewStyleProp) {
component SpacerView(
itemCount: number,
ref?: React.RefSetter<React.RefOf<VirtualView> | null>,
...props: Omit<React.PropsOf<VirtualView>, 'ref'>
) {
const HiddenVirtualView = useMemo(
() => createHiddenVirtualView(spacerStyle(itemCount)),
[itemCount],
);
return <HiddenVirtualView ref={ref} {...props} />;
}
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 (
<SpacerView
itemCount={virtualItemCount}
nativeID={nativeID}
onModeChange={handleModeChange}
/>
);
}

return VirtualCollectionView;
}

hook useMemoCallback<TInput extends interface {}, TOutput>(
callback: TInput => TOutput,
): TInput => TOutput {
return useMemo(() => memoize(callback), [callback]);
}

function memoize<TInput extends interface {}, TOutput>(
callback: TInput => TOutput,
): TInput => TOutput {
const cache = new WeakMap<TInput, TOutput>();
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;
}
Loading
Loading