Skip to content

Instantly share code, notes, and snippets.

@vishnevskiy
Created November 8, 2019 07:00
Show Gist options
  • Save vishnevskiy/f4ba74adf5cf1d269b860fab86e8feef to your computer and use it in GitHub Desktop.
Save vishnevskiy/f4ba74adf5cf1d269b860fab86e8feef to your computer and use it in GitHub Desktop.
// @flow
import * as React from 'react';
// $FlowFixMe
import {Animated, View, ScrollView} from 'react-native';
import lodash from 'lodash';
type HeaderHeight = number | (() => number);
type FooterHeight = number | (() => number);
type SectionHeight = number | ((section: number) => number);
type RowHeight = number | ((section: number, row?: number) => number);
type SectionFooterHeight = number | ((section: number) => number);
export const FastListItemTypes = {
SPACER: 0,
HEADER: 1,
FOOTER: 2,
SECTION: 3,
ROW: 4,
SECTION_FOOTER: 5,
};
type FastListItemType = $Values<typeof FastListItemTypes>;
export type FastListItem = {
type: FastListItemType,
key: number,
layoutY: number,
layoutHeight: number,
section: number,
row: number,
...
};
export type ScrollEvent = {
nativeEvent: $ReadOnly<{|
contentInset: $ReadOnly<{|
bottom: number,
left: number,
right: number,
top: number,
|}>,
contentOffset: $ReadOnly<{|
y: number,
x: number,
|}>,
contentSize: $ReadOnly<{|
height: number,
width: number,
|}>,
layoutMeasurement: $ReadOnly<{|
height: number,
width: number,
|}>,
targetContentOffset?: $ReadOnly<{|
y: number,
x: number,
|}>,
velocity?: $ReadOnly<{|
y: number,
x: number,
|}>,
zoomScale?: number,
responderIgnoreScroll?: boolean,
|}>,
...
};
export type LayoutEvent = {
nativeEvent: $ReadOnly<{|
layout: $ReadOnly<{|
x: number,
y: number,
width: number,
height: number,
|}>,
|}>,
...
};
/**
* FastListItemRecycler is used to recycle FastListItem objects between recomputations
* of the list. By doing this we ensure that components maintain their keys and avoid
* reallocations.
*/
class FastListItemRecycler {
static _LAST_KEY: number = 0;
_items: {[FastListItemType]: {[string]: FastListItem, ...}, ...} = {};
_pendingItems: {[FastListItemType]: Array<FastListItem>, ...} = {};
constructor(items: Array<FastListItem>) {
items.forEach(item => {
const {type, section, row} = item;
const [items] = this._itemsForType(type);
items[`${type}:${section}:${row}`] = item;
});
}
_itemsForType(type: FastListItemType): [{[string]: FastListItem, ...}, Array<FastListItem>] {
return [this._items[type] || (this._items[type] = {}), this._pendingItems[type] || (this._pendingItems[type] = [])];
}
get(
type: FastListItemType,
layoutY: number,
layoutHeight: number,
section: number = 0,
row: number = 0
): FastListItem {
const [items, pendingItems] = this._itemsForType(type);
return this._get(type, layoutY, layoutHeight, section, row, items, pendingItems);
}
_get(
type: FastListItemType,
layoutY: number,
layoutHeight: number,
section: number,
row: number,
items: {[string]: FastListItem, ...},
pendingItems: Array<FastListItem>
) {
const itemKey = `${type}:${section}:${row}`;
let item = items[itemKey];
if (item == null) {
item = {type, key: -1, layoutY, layoutHeight, section, row};
pendingItems.push(item);
} else {
item.layoutY = layoutY;
item.layoutHeight = layoutHeight;
delete items[itemKey];
}
return item;
}
fill() {
lodash.forEach(FastListItemTypes, type => {
const [items, pendingItems] = this._itemsForType(type);
this._fill(items, pendingItems);
});
}
_fill(items: {[string]: FastListItem, ...}, pendingItems: Array<FastListItem>) {
let index = 0;
lodash.forEach(items, ({key}) => {
const item = pendingItems[index];
if (item == null) {
return false;
}
item.key = key;
index++;
});
for (; index < pendingItems.length; index++) {
pendingItems[index].key = ++FastListItemRecycler._LAST_KEY;
}
pendingItems.length = 0;
}
}
type FastListComputerProps = {|
headerHeight: HeaderHeight,
footerHeight: FooterHeight,
sectionHeight: SectionHeight,
rowHeight: RowHeight,
sectionFooterHeight: SectionFooterHeight,
sections: Array<number>,
insetTop: number,
insetBottom: number,
|};
class FastListComputer {
headerHeight: HeaderHeight;
footerHeight: FooterHeight;
sectionHeight: SectionHeight;
rowHeight: RowHeight;
sectionFooterHeight: SectionFooterHeight;
sections: Array<number>;
insetTop: number;
insetBottom: number;
uniform: boolean;
constructor({
headerHeight,
footerHeight,
sectionHeight,
rowHeight,
sectionFooterHeight,
sections,
insetTop,
insetBottom,
}: FastListComputerProps) {
this.headerHeight = headerHeight;
this.footerHeight = footerHeight;
this.sectionHeight = sectionHeight;
this.rowHeight = rowHeight;
this.sectionFooterHeight = sectionFooterHeight;
this.sections = sections;
this.insetTop = insetTop;
this.insetBottom = insetBottom;
this.uniform = typeof rowHeight === 'number';
}
getHeightForHeader(): number {
const {headerHeight} = this;
return typeof headerHeight === 'number' ? headerHeight : headerHeight();
}
getHeightForFooter(): number {
const {footerHeight} = this;
return typeof footerHeight === 'number' ? footerHeight : footerHeight();
}
getHeightForSection(section: number): number {
const {sectionHeight} = this;
return typeof sectionHeight === 'number' ? sectionHeight : sectionHeight(section);
}
getHeightForRow(section: number, row?: number): number {
const {rowHeight} = this;
return typeof rowHeight === 'number' ? rowHeight : rowHeight(section, row);
}
getHeightForSectionFooter(section: number): number {
const {sectionFooterHeight} = this;
return typeof sectionFooterHeight === 'number' ? sectionFooterHeight : sectionFooterHeight(section);
}
compute(
top: number,
bottom: number,
prevItems: Array<FastListItem>
): {
height: number,
items: Array<FastListItem>,
...
} {
const {sections} = this;
let height = this.insetTop;
let spacerHeight = height;
let items = [];
const recycler = new FastListItemRecycler(prevItems);
function isVisible(itemHeight: number): boolean {
const prevHeight = height;
height += itemHeight;
if (height < top || prevHeight > bottom) {
spacerHeight += itemHeight;
return false;
} else {
return true;
}
}
function isBelowVisibility(itemHeight: number): boolean {
if (height > bottom) {
spacerHeight += itemHeight;
return false;
} else {
return true;
}
}
function push(item: FastListItem) {
if (spacerHeight > 0) {
items.push(
recycler.get(FastListItemTypes.SPACER, item.layoutY - spacerHeight, spacerHeight, item.section, item.row)
);
spacerHeight = 0;
}
items.push(item);
}
let layoutY;
const headerHeight = this.getHeightForHeader();
if (headerHeight > 0) {
layoutY = height;
if (isVisible(headerHeight)) {
push(recycler.get(FastListItemTypes.HEADER, layoutY, headerHeight));
}
}
for (let section = 0; section < sections.length; section++) {
const rows = sections[section];
if (rows === 0) {
continue;
}
const sectionHeight = this.getHeightForSection(section);
layoutY = height;
height += sectionHeight;
// Replace previous spacers and sections, so we only render section headers
// whose children are visible + previous section (required for sticky header animation).
if (section > 1 && items.length > 0 && items[items.length - 1].type === FastListItemTypes.SECTION) {
const spacerLayoutHeight = items.reduce((totalHeight, item, i) => {
if (i !== items.length - 1) {
return totalHeight + item.layoutHeight;
}
return totalHeight;
}, 0);
const prevSection = items[items.length - 1];
const spacer = recycler.get(FastListItemTypes.SPACER, 0, spacerLayoutHeight, prevSection.section, 0);
items = [spacer, prevSection];
}
if (isBelowVisibility(sectionHeight)) {
push(recycler.get(FastListItemTypes.SECTION, layoutY, sectionHeight, section));
}
if (this.uniform) {
const rowHeight = this.getHeightForRow(section);
for (let row = 0; row < rows; row++) {
layoutY = height;
if (isVisible(rowHeight)) {
push(recycler.get(FastListItemTypes.ROW, layoutY, rowHeight, section, row));
}
}
} else {
for (let row = 0; row < rows; row++) {
const rowHeight = this.getHeightForRow(section, row);
layoutY = height;
if (isVisible(rowHeight)) {
push(recycler.get(FastListItemTypes.ROW, layoutY, rowHeight, section, row));
}
}
}
const sectionFooterHeight = this.getHeightForSectionFooter(section);
if (sectionFooterHeight > 0) {
layoutY = height;
if (isVisible(sectionFooterHeight)) {
push(recycler.get(FastListItemTypes.SECTION_FOOTER, layoutY, sectionFooterHeight, section));
}
}
}
const footerHeight = this.getHeightForFooter();
if (footerHeight > 0) {
layoutY = height;
if (isVisible(footerHeight)) {
push(recycler.get(FastListItemTypes.FOOTER, layoutY, footerHeight));
}
}
height += this.insetBottom;
spacerHeight += this.insetBottom;
if (spacerHeight > 0) {
items.push(recycler.get(FastListItemTypes.SPACER, height - spacerHeight, spacerHeight, sections.length));
}
recycler.fill();
return {
height,
items,
};
}
computeScrollPosition(
targetSection: number,
targetRow: number
): {
scrollTop: number,
sectionHeight: number,
...
} {
const {sections, insetTop} = this;
let scrollTop = insetTop + this.getHeightForHeader();
let section = 0;
let foundRow = false;
while (section <= targetSection) {
const rows = sections[section];
if (rows === 0) {
section += 1;
continue;
}
scrollTop += this.getHeightForSection(section);
if (this.uniform) {
const uniformHeight = this.getHeightForRow(section);
if (section === targetSection) {
scrollTop += uniformHeight * targetRow;
foundRow = true;
} else {
scrollTop += uniformHeight * rows;
}
} else {
for (let row = 0; row < rows; row++) {
if (section < targetSection || (section === targetSection && row < targetRow)) {
scrollTop += this.getHeightForRow(section, row);
} else if (section === targetSection && row === targetRow) {
foundRow = true;
break;
}
}
}
if (!foundRow) scrollTop += this.getHeightForSectionFooter(section);
section += 1;
}
return {
scrollTop,
sectionHeight: this.getHeightForSection(targetSection),
};
}
}
const FastListSectionRenderer = ({
layoutY,
layoutHeight,
nextSectionLayoutY,
scrollTopValue,
children,
}: {
layoutY: number,
layoutHeight: number,
nextSectionLayoutY?: number,
scrollTopValue: Animated.Value,
children: React.Node,
...
}): React.Node => {
const inputRange: Array<number> = [-1, 0];
const outputRange: Array<number> = [0, 0];
inputRange.push(layoutY);
outputRange.push(0);
const collisionPoint = (nextSectionLayoutY || 0) - layoutHeight;
if (collisionPoint >= layoutY) {
inputRange.push(collisionPoint, collisionPoint + 1);
outputRange.push(collisionPoint - layoutY, collisionPoint - layoutY);
} else {
inputRange.push(layoutY + 1);
outputRange.push(1);
}
const translateY = scrollTopValue.interpolate({
inputRange,
outputRange,
});
const child = React.Children.only(children);
return (
<Animated.View
style={[
child.props.style,
{
zIndex: 10,
height: layoutHeight,
transform: [{translateY}],
},
]}>
{React.cloneElement(child, {
style: {flex: 1},
})}
</Animated.View>
);
};
const FastListItemRenderer = ({
layoutHeight: height,
children,
}: {
layoutHeight: number,
children?: React.Node,
...
}): React.Node => <View style={{height}}>{children}</View>;
export type FastListProps = {
renderActionSheetScrollViewWrapper?: React.Node => React.Node,
actionSheetScrollRef?: {current: ?React.Node, ...},
onScroll?: (event: ScrollEvent) => any,
onScrollEnd?: (event: ScrollEvent) => any,
onLayout?: (event: LayoutEvent) => any,
renderHeader: () => ?React.Element<any>,
renderFooter: () => ?React.Element<any>,
renderSection: (section: number) => ?React.Element<any>,
renderRow: (section: number, row: number) => ?React.Element<any>,
renderSectionFooter: (section: number) => ?React.Element<any>,
renderAccessory?: (list: FastList) => React.Node,
renderEmpty?: () => ?React.Element<any>,
headerHeight: HeaderHeight,
footerHeight: FooterHeight,
sectionHeight: SectionHeight,
sectionFooterHeight: SectionFooterHeight,
rowHeight: RowHeight,
sections: Array<number>,
insetTop: number,
insetBottom: number,
scrollTopValue?: Animated.Value,
contentInset: {
top?: number,
left?: number,
right?: number,
bottom?: number,
...
},
...
};
type FastListState = {
batchSize: number,
blockStart: number,
blockEnd: number,
height: number,
items: Array<FastListItem>,
...
};
function computeBlock(containerHeight: number, scrollTop: number): $Shape<FastListState> {
if (containerHeight === 0) {
return {
batchSize: 0,
blockStart: 0,
blockEnd: 0,
};
}
const batchSize = Math.ceil(containerHeight / 2);
const blockNumber = Math.ceil(scrollTop / batchSize);
const blockStart = batchSize * blockNumber;
const blockEnd = blockStart + batchSize;
return {batchSize, blockStart, blockEnd};
}
function getFastListState(
{
headerHeight,
footerHeight,
sectionHeight,
rowHeight,
sectionFooterHeight,
sections,
insetTop,
insetBottom,
}: FastListProps,
{batchSize, blockStart, blockEnd, items: prevItems}: $Shape<FastListState>
): FastListState {
if (batchSize === 0) {
return {
batchSize,
blockStart,
blockEnd,
height: insetTop + insetBottom,
items: [],
};
}
const computer = new FastListComputer({
headerHeight,
footerHeight,
sectionHeight,
rowHeight,
sectionFooterHeight,
sections,
insetTop,
insetBottom,
});
return {
batchSize,
blockStart,
blockEnd,
...computer.compute(blockStart - batchSize, blockEnd + batchSize, prevItems || []),
};
}
export default class FastList extends React.PureComponent<FastListProps, FastListState> {
static defaultProps = {
isFastList: true,
renderHeader: () => null,
renderFooter: () => null,
renderSection: () => null,
renderSectionFooter: () => null,
headerHeight: 0,
footerHeight: 0,
sectionHeight: 0,
sectionFooterHeight: 0,
insetTop: 0,
insetBottom: 0,
contentInset: {top: 0, right: 0, left: 0, bottom: 0},
};
containerHeight: number = 0;
scrollTop: number = 0;
scrollTopValue: Animated.Value = this.props.scrollTopValue || new Animated.Value(0);
scrollTopValueAttachment: ?{detach: () => void, ...};
scrollView: {current: ?ScrollView, ...} = React.createRef();
state = getFastListState(this.props, computeBlock(this.containerHeight, this.scrollTop));
static getDerivedStateFromProps(props: FastListProps, state: FastListState) {
return getFastListState(props, state);
}
getItems(): Array<FastListItem> {
return this.state.items;
}
isVisible = (layoutY: number): boolean => {
return layoutY >= this.scrollTop && layoutY <= this.scrollTop + this.containerHeight;
};
scrollToLocation = (section: number, row: number, animated?: boolean = true) => {
const scrollView = this.scrollView.current;
if (scrollView != null) {
const {
headerHeight,
footerHeight,
sectionHeight,
rowHeight,
sectionFooterHeight,
sections,
insetTop,
insetBottom,
} = this.props;
const computer = new FastListComputer({
headerHeight,
footerHeight,
sectionHeight,
sectionFooterHeight,
rowHeight,
sections,
insetTop,
insetBottom,
});
const {scrollTop: layoutY, sectionHeight: layoutHeight} = computer.computeScrollPosition(section, row);
scrollView.scrollTo({
x: 0,
y: Math.max(0, layoutY - layoutHeight),
animated,
});
}
};
handleScroll = (event: ScrollEvent) => {
const {nativeEvent} = event;
const {contentInset} = this.props;
this.containerHeight = nativeEvent.layoutMeasurement.height - (contentInset.top || 0) - (contentInset.bottom || 0);
this.scrollTop = Math.min(
Math.max(0, nativeEvent.contentOffset.y),
nativeEvent.contentSize.height - this.containerHeight
);
const nextState = computeBlock(this.containerHeight, this.scrollTop);
if (
nextState.batchSize !== this.state.batchSize ||
nextState.blockStart !== this.state.blockStart ||
nextState.blockEnd !== this.state.blockEnd
) {
this.setState(nextState);
}
const {onScroll} = this.props;
if (onScroll != null) {
onScroll(event);
}
};
handleLayout = (event: LayoutEvent) => {
const {nativeEvent} = event;
const {contentInset} = this.props;
this.containerHeight = nativeEvent.layout.height - (contentInset.top || 0) - (contentInset.bottom || 0);
const nextState = computeBlock(this.containerHeight, this.scrollTop);
if (
nextState.batchSize !== this.state.batchSize ||
nextState.blockStart !== this.state.blockStart ||
nextState.blockEnd !== this.state.blockEnd
) {
this.setState(nextState);
}
const {onLayout} = this.props;
if (onLayout != null) {
onLayout(event);
}
};
/**
* FastList only re-renders when items change which which does not happen with
* every scroll event. Since an accessory might depend on scroll position this
* ensures the accessory at least re-renders when scrolling ends
*/
handleScrollEnd = (event: ScrollEvent) => {
const {renderAccessory, onScrollEnd} = this.props;
if (renderAccessory != null) {
this.forceUpdate();
}
onScrollEnd && onScrollEnd(event);
};
renderItems() {
const {renderHeader, renderFooter, renderSection, renderRow, renderSectionFooter, renderEmpty} = this.props;
const {items} = this.state;
if (renderEmpty != null && this.isEmpty()) {
return renderEmpty();
}
const sectionLayoutYs = [];
items.forEach(({type, layoutY}) => {
if (type === FastListItemTypes.SECTION) {
sectionLayoutYs.push(layoutY);
}
});
const children = [];
items.forEach(({type, key, layoutY, layoutHeight, section, row}) => {
switch (type) {
case FastListItemTypes.SPACER: {
children.push(<FastListItemRenderer key={key} layoutHeight={layoutHeight} />);
break;
}
case FastListItemTypes.HEADER: {
const child = renderHeader();
if (child != null) {
children.push(
<FastListItemRenderer key={key} layoutHeight={layoutHeight}>
{child}
</FastListItemRenderer>
);
}
break;
}
case FastListItemTypes.FOOTER: {
const child = renderFooter();
if (child != null) {
children.push(
<FastListItemRenderer key={key} layoutHeight={layoutHeight}>
{child}
</FastListItemRenderer>
);
}
break;
}
case FastListItemTypes.SECTION: {
sectionLayoutYs.shift();
const child = renderSection(section);
if (child != null) {
children.push(
<FastListSectionRenderer
key={key}
layoutY={layoutY}
layoutHeight={layoutHeight}
nextSectionLayoutY={sectionLayoutYs[0]}
scrollTopValue={this.scrollTopValue}>
{child}
</FastListSectionRenderer>
);
}
break;
}
case FastListItemTypes.ROW: {
const child = renderRow(section, row);
if (child != null) {
children.push(
<FastListItemRenderer key={key} layoutHeight={layoutHeight}>
{child}
</FastListItemRenderer>
);
}
break;
}
case FastListItemTypes.SECTION_FOOTER: {
const child = renderSectionFooter(section);
if (child != null) {
children.push(
<FastListItemRenderer key={key} layoutHeight={layoutHeight}>
{child}
</FastListItemRenderer>
);
}
break;
}
}
});
return children;
}
componentDidMount() {
if (this.scrollView.current != null) {
this.scrollTopValueAttachment = Animated.attachNativeEvent(this.scrollView.current, 'onScroll', [
{nativeEvent: {contentOffset: {y: this.scrollTopValue}}},
]);
}
}
componentDidUpdate(prevProps: FastListProps) {
if (prevProps.scrollTopValue !== this.props.scrollTopValue) {
throw new Error('scrollTopValue cannot changed after mounting');
}
}
componentWillUnmount() {
if (this.scrollTopValueAttachment != null) {
this.scrollTopValueAttachment.detach();
}
}
isEmpty = () => {
const {sections} = this.props;
const length = sections.reduce((length, rowLength) => {
return length + rowLength;
}, 0);
return length === 0;
};
render() {
const {
/* eslint-disable no-unused-vars */
renderSection,
renderRow,
renderAccessory,
sectionHeight,
rowHeight,
sections,
insetTop,
insetBottom,
actionSheetScrollRef,
renderActionSheetScrollViewWrapper,
renderEmpty,
/* eslint-enable no-unused-vars */
...props
} = this.props;
// what is this??
// well! in order to support continuous scrolling of a scrollview/list/whatever in an action sheet, we need
// to wrap the scrollview in a NativeViewGestureHandler. This wrapper does that thing that need do
const wrapper = renderActionSheetScrollViewWrapper || (val => val);
const scrollView = wrapper(
<ScrollView
{...props}
ref={ref => {
this.scrollView.current = ref;
if (actionSheetScrollRef) {
actionSheetScrollRef.current = ref;
}
}}
removeClippedSubviews={false}
scrollEventThrottle={16}
onScroll={this.handleScroll}
onLayout={this.handleLayout}
onMomentumScrollEnd={this.handleScrollEnd}
onScrollEndDrag={this.handleScrollEnd}>
{this.renderItems()}
</ScrollView>
);
return (
<React.Fragment>
{scrollView}
{renderAccessory != null ? renderAccessory(this) : null}
</React.Fragment>
);
}
}
@LouisJS
Copy link

LouisJS commented Nov 8, 2019

Holy Grail ! 🤩Can't wait to test this

@dougkeen
Copy link

So how do you actually load FastListItems into this thing?

@Kida007
Copy link

Kida007 commented Jul 24, 2020

If you are wondering how to use FastList :

<FastList 
  sections = {[1,4,5,3]}   // number of elements is each section
   renderRow = {(sectionIndex, rowIndex) =>  renderItem(sectionIndex,rowIndex)  }
   rowHeight={(sectionIndex, rowIndex) =>  getItemHeight(sectionIndex,rowIndex) }
   renderSection = {}
   sectionHeight = {}
   renderSectionFooter ={}
   footerHeight ={}
/>

@derekstavis
Copy link

@vishnevskiy what's the licensing on this component? I wanted to ship a package for it but I want to make sure I follow the right attribution and licensing.

@a-eid
Copy link

a-eid commented Jan 27, 2021

is there an npm package that could be maintained and people could track issues on it.

@timurridjanovic
Copy link

This seems to work pretty well, but I see a lot white space when scrolling fast, until the new rows load. Is it possible to preload more than just what is visible to prevent all that white space?

@asalha
Copy link

asalha commented Aug 4, 2021

This seems to work pretty well, but I see a lot white space when scrolling fast, until the new rows load. Is it possible to preload more than just what is visible to prevent all that white space?

How did you make it work? That's what I am getting:

fastlist

@asalha
Copy link

asalha commented Aug 4, 2021

Does anybody know what is $Values? Thanks

@Ciach0
Copy link

Ciach0 commented Aug 9, 2023

Does anybody know what is $Values? Thanks

That's a Flow construct, it's the same as T[keyof T] in typescript

@derekstavis
Copy link

Get my fork if you're looking for a TypeScript variant:
https://gist.github.com/derekstavis/d8f6a00e14a4259224aafafab14ffd3e

I have since moved to FlashList instead. Much, much better performance.

@Ciach0
Copy link

Ciach0 commented Aug 9, 2023

Get my fork if you're looking for a TypeScript variant: https://gist.github.com/derekstavis/d8f6a00e14a4259224aafafab14ffd3e

I have since moved to FlashList instead. Much, much better performance.

Yeah, it's faster and easier to use

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment