Skip to content
Merged
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
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# react-native-tiny-list

Simple flat list component
Tiny, readable virtualized lists for React Native

## Installation

Expand Down Expand Up @@ -28,6 +28,37 @@ area and replaces the rest with spacer views. Item heights are measured with
`onLayout` and cached by index; unmeasured items are assumed to be 50 px tall.
Expect blank areas and content jumps while scrolling fast.

```tsx
import { TinyFlatList } from 'react-native-tiny-list';

<TinyFlatList
data={items}
renderItem={renderRow} // keep it stable, not an inline function
keyExtractor={(item) => item.id}
initialNumToRender={10}
windowSize={21}
maxToRenderPerBatch={10}
updateCellsBatchingPeriod={50}
/>
```

`TinyFlatList` adds the core ideas of React Native's `FlatList` on top of
`TinyList`:

- **Keys.** `keyExtractor` (default: `item.key`, then `item.id`, then the
index) keys both the items and the cached sizes, so sizes survive inserts and
reorders. Unmeasured items count as the average measured size.
- **`initialNumToRender`.** Items rendered before the viewport is measured.
They stay rendered, so scrolling back to the top shows them instantly.
- **`windowSize`.** The area kept rendered, in viewports, centered on the
visible one.
- **Batched rendering.** Scrolling does not re-render the list while the
visible items are already rendered. The area off screen is filled
`maxToRenderPerBatch` items at a time, every `updateCellsBatchingPeriod` ms.
When the visible area is blank, the list renders right away.
- **Memoized items.** Each item re-renders only when its props or `extraData`
change.


## Contributing

Expand Down
46 changes: 35 additions & 11 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { StyleSheet, Text, View } from 'react-native';
import { TinyList } from 'react-native-tiny-list';
import { useState } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import {
TinyFlatList,
TinyList,
type TinyFlatListRenderItemInfo,
} from 'react-native-tiny-list';

type Item = { id: number; title: string };

Expand All @@ -8,18 +13,31 @@ const DATA: Item[] = Array.from({ length: 10_000 }, (_, i) => ({
title: `Item ${i}`,
}));

// Defined outside the component so it stays stable and TinyFlatList can skip
// re-rendering unchanged items.
function renderItem({ item, index }: TinyFlatListRenderItemInfo<Item>) {
return (
// Vary heights to exercise measurement.
<View style={[styles.item, { height: 40 + (index % 5) * 15 }]}>
<Text>{item.title}</Text>
</View>
);
}

export default function App() {
const [list, setList] = useState<'list' | 'flatList'>('flatList');

return (
<View style={styles.container}>
<TinyList
data={DATA}
renderItem={({ item, index }) => (
// Vary heights to exercise measurement.
<View style={[styles.item, { height: 40 + (index % 5) * 15 }]}>
<Text>{item.title}</Text>
</View>
)}
/>
<View style={styles.toolbar}>
<Button title="TinyList" onPress={() => setList('list')} />
<Button title="TinyFlatList" onPress={() => setList('flatList')} />
</View>
{list === 'list' ? (
<TinyList data={DATA} renderItem={renderItem} />
) : (
<TinyFlatList data={DATA} renderItem={renderItem} />
)}
</View>
);
}
Expand All @@ -28,6 +46,12 @@ const styles = StyleSheet.create({
container: {
flex: 1,
},
toolbar: {
flexDirection: 'row',
justifyContent: 'space-around',
paddingTop: 48,
paddingBottom: 8,
},
item: {
justifyContent: 'center',
paddingHorizontal: 16,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "react-native-tiny-list",
"version": "0.1.0",
"description": "Simple flat list component",
"description": "Tiny educational rebuilds of FlatList, FlashList and LegendList for React Native",
"main": "./lib/module/index.js",
"types": "./lib/typescript/src/index.d.ts",
"exports": {
Expand Down
260 changes: 260 additions & 0 deletions src/TinyFlatList/TinyFlatList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
import {
memo,
useCallback,
useEffect,
useReducer,
useRef,
useState,
type ReactNode,
} from 'react';
import {
ScrollView,
View,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from 'react-native';
import {
computeItemOffsets,
findItemsInArea,
type ItemRange,
} from './item-offsets';
import {
addInitialItems,
computeRenderWindow,
isRangeInside,
isSameRange,
} from './render-window';

export interface TinyFlatListRenderItemInfo<T> {
item: T;
index: number;
}

export interface TinyFlatListProps<T> {
data: ReadonlyArray<T>;
/** Keep it stable (not an inline function) so unchanged items skip re-rendering. */
renderItem: (info: TinyFlatListRenderItemInfo<T>) => ReactNode;
/** Unique key per item. Defaults to `item.key`, then `item.id`, then the index. */
keyExtractor?: (item: T, index: number) => string;
/** Change it to re-render all items, e.g. when `renderItem` reads outside state. */
extraData?: unknown;
/** Items rendered before the viewport is measured, and kept rendered after. */
initialNumToRender?: number;
/** Area to keep rendered, in viewports, centered on the visible one. */
windowSize?: number;
/** Most items to add in one update. */
maxToRenderPerBatch?: number;
/** Delay between updates that only fill the area off screen, in ms. */
updateCellsBatchingPeriod?: number;
}

/**
* A minimal FlatList. Like TinyList it renders items near the viewport
* between two spacers, but it also:
*
* - caches item sizes by key, so they survive inserts and reorders;
* - skips re-rendering on scroll unless the visible area is not rendered yet;
* - fills the area off screen in small batches instead of all at once;
* - keeps the first `initialNumToRender` items rendered, for a fast scroll to top;
* - memoizes items, so a batch renders only the new ones.
*/
export function TinyFlatList<T>({
data,
renderItem,
keyExtractor = defaultKeyExtractor,
extraData,
initialNumToRender = 10,
windowSize = 21,
maxToRenderPerBatch = 10,
updateCellsBatchingPeriod = 50,
}: TinyFlatListProps<T>) {
const [viewportSize, setViewportSize] = useState(0);

// Read on demand instead of being state, so scrolling does not re-render.
const scrollOffsetRef = useRef(0);

// Measured item sizes by key. A change re-renders the list.
const sizesRef = useRef(new Map<string, number>());
const [, forceRender] = useReducer((count: number) => count + 1, 0);

// Items that are rendered. Grows in batches toward the target window.
const [renderWindow, setRenderWindow] = useState<ItemRange>(() => ({
start: 0,
end: Math.min(initialNumToRender, data.length),
}));

// Recomputed on every render. Cheap, as scrolling alone does not re-render.
const keys = data.map((item, index) => keyExtractor(item, index));
const offsets = computeItemOffsets(keys, sizesRef.current);

// Compute and render the next batch now.
const updateRenderWindow = () => {
cancelNextBatch();
const next = computeRenderWindow({
offsets,
scrollOffset: scrollOffsetRef.current,
viewportSize,
windowSize,
maxToRenderPerBatch,
initialNumToRender,
previous: renderWindow,
});
if (!isSameRange(next, renderWindow)) {
setRenderWindow(next);
}
};

// Only one batch is pending at a time.
const batchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const scheduleNextBatch = () => {
if (batchTimerRef.current == null) {
batchTimerRef.current = setTimeout(
updateRenderWindow,
updateCellsBatchingPeriod
);
}
};
const cancelNextBatch = () => {
if (batchTimerRef.current != null) {
clearTimeout(batchTimerRef.current);
batchTimerRef.current = null;
}
};

// After each render, schedule the next batch. The chain stops once a batch
// leaves the window unchanged. The cleanup cancels a timer scheduled by an
// earlier render, so a batch never runs with stale offsets.
useEffect(() => {
scheduleNextBatch();
return cancelNextBatch;
});

// Needed to establish viewport height
const handleLayout = (event: LayoutChangeEvent) => {
setViewportSize(event.nativeEvent.layout.height);
};

// Needed to get actual item size. Stable, so memoized cells stay memoized.
const handleCellLayout = useCallback(
(key: string, event: LayoutChangeEvent) => {
const size = event.nativeEvent.layout.height;
if (sizesRef.current.get(key) !== size) {
sizesRef.current.set(key, size);
forceRender();
}
},
[]
);

// Blank area on screen: render right away. Otherwise fill off screen later.
// The final drag offset arrives only in `onScrollEndDrag`, so listen to it too.
const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
const visible = findItemsInArea(
offsets,
scrollOffsetRef.current,
scrollOffsetRef.current + viewportSize
);
if (isRangeInside(visible, renderWindow)) {
scheduleNextBatch();
} else {
updateRenderWindow();
}
};

const rendered = addInitialItems(
renderWindow,
initialNumToRender,
data.length
);

const renderCells = ({ start, end }: ItemRange) => {
const cells: ReactNode[] = [];
for (let index = start; index < end; index++) {
const key = keys[index]!;
cells.push(
<Cell
key={key}
cellKey={key}
item={data[index] as T}
index={index}
renderItem={renderItem}
extraData={extraData}
onCellLayout={handleCellLayout}
/>
);
}
return cells;
};

return (
<ScrollView
onScroll={handleScroll}
onScrollEndDrag={handleScroll}
onMomentumScrollEnd={handleScroll}
onLayout={handleLayout}
scrollEventThrottle={16}
>
{renderCells(rendered.initial)}
{/* Stands in for the items between the initial items and the window. */}
<View
style={{
height:
offsets[rendered.window.start]! - offsets[rendered.initial.end]!,
}}
/>
{renderCells(rendered.window)}
{/* Stands in for the items after the window. */}
<View
style={{
height: offsets[data.length]! - offsets[rendered.window.end]!,
}}
/>
</ScrollView>
);
}

/** Same default as FlatList: `item.key`, then `item.id`, then the index. */
export function defaultKeyExtractor(item: unknown, index: number): string {
if (typeof item === 'object' && item != null) {
if ('key' in item && item.key != null) {
return String(item.key);
}
if ('id' in item && item.id != null) {
return String(item.id);
}
}
return String(index);
}

interface CellProps<T> {
cellKey: string;
item: T;
index: number;
renderItem: (info: TinyFlatListRenderItemInfo<T>) => ReactNode;
/** Unused here; a new value only makes `memo` re-render the cell. */
extraData: unknown;
onCellLayout: (key: string, event: LayoutChangeEvent) => void;
}

/**
* Wraps one item to measure it. Memoized below, so a new batch or a size
* change re-renders only cells whose props changed.
*/
function CellView<T>({
cellKey,
item,
index,
renderItem,
onCellLayout,
}: CellProps<T>) {
return (
<View onLayout={(event) => onCellLayout(cellKey, event)}>
{renderItem({ item, index })}
</View>
);
}

// `memo` loses the generic type parameter, so restore it with a cast.
const Cell = memo(CellView) as typeof CellView;
Loading