Skip to content

Instantly share code, notes, and snippets.

@march213
Forked from derekstavis/FastList.tsx
Created June 13, 2021 19:56
Show Gist options
  • Save march213/fa8c7fda5e940ac475f67d0d597a27ca to your computer and use it in GitHub Desktop.
Save march213/fa8c7fda5e940ac475f67d0d597a27ca to your computer and use it in GitHub Desktop.
Discord's FastList, but in TypeScript
import { forEachObjIndexed } from "ramda";
import * as React from "react";
import {
Animated,
ScrollView,
View,
ViewStyle,
LayoutChangeEvent,
NativeScrollEvent,
} from "react-native";
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);
enum FastListItemType {
SPACER = "spacer",
HEADER = "header",
SECTION = "section",
ROW = "row",
FOOTER = "footer",
SECTION_FOOTER = "footer",
}
type FastListItem = {
type: FastListItemType;
key: number;
layoutY: number;
layoutHeight: number;
section: number;
row: number;
};
interface ScrollEvent {
nativeEvent: NativeScrollEvent;
}
interface FastListComputerProps {
headerHeight: HeaderHeight;
footerHeight: FooterHeight;
sectionHeight: SectionHeight;
rowHeight: RowHeight;
sectionFooterHeight: SectionFooterHeight;
sections: number[];
insetTop: number;
insetBottom: number;
}
export class FastListComputer {
headerHeight: HeaderHeight;
footerHeight: FooterHeight;
sectionHeight: SectionHeight;
rowHeight: RowHeight;
sectionFooterHeight: SectionFooterHeight;
sections: 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: FastListItem[]
): {
height: number;
items: FastListItem[];
} {
const { sections } = this;
let height = this.insetTop;
let spacerHeight = height;
let items = [] as FastListItem[];
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(
FastListItemType.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(FastListItemType.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 === FastListItemType.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(
FastListItemType.SPACER,
0,
spacerLayoutHeight,
prevSection.section,
0
);
items = [spacer, prevSection];
}
if (isBelowVisibility(sectionHeight)) {
push(
recycler.get(
FastListItemType.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(
FastListItemType.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(
FastListItemType.ROW,
layoutY,
rowHeight,
section,
row
)
);
}
}
}
const sectionFooterHeight = this.getHeightForSectionFooter(section);
if (sectionFooterHeight > 0) {
layoutY = height;
if (isVisible(sectionFooterHeight)) {
push(
recycler.get(
FastListItemType.SECTION_FOOTER,
layoutY,
sectionFooterHeight,
section
)
);
}
}
}
const footerHeight = this.getHeightForFooter();
if (footerHeight > 0) {
layoutY = height;
if (isVisible(footerHeight)) {
push(recycler.get(FastListItemType.FOOTER, layoutY, footerHeight));
}
}
height += this.insetBottom;
spacerHeight += this.insetBottom;
if (spacerHeight > 0) {
items.push(
recycler.get(
FastListItemType.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),
};
}
}
/**
* 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.
*/
export class FastListItemRecycler {
static _LAST_KEY: number = 0;
items: Partial<
{
[key in FastListItemType]: Partial<{
[key: string]: FastListItem;
}>;
}
> = {};
pendingItems: Partial<{
[key: string]: FastListItem[];
}> = {};
constructor(items: FastListItem[]) {
items.forEach((item) => {
const { type, section, row } = item;
const [itemsForType] = this.itemsForType(type);
itemsForType[`${type}:${section}:${row}`] = item;
});
}
itemsForType(
type: FastListItemType
): [
{
[key: string]: FastListItem;
},
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: {
[key: string]: FastListItem;
},
pendingItems: 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() {
forEachObjIndexed((type) => {
const [items, pendingItems] = this.itemsForType(type);
this._fill(items, pendingItems);
}, FastListItemType);
}
_fill(
items: {
[key: string]: FastListItem;
},
pendingItems: FastListItem[]
) {
let index = 0;
forEachObjIndexed(({ key }) => {
const item = pendingItems[index];
if (item == null) {
return false;
}
item.key = key;
index++;
}, items);
for (; index < pendingItems.length; index++) {
pendingItems[index].key = ++FastListItemRecycler._LAST_KEY;
}
pendingItems.length = 0;
}
}
const FastListSectionRenderer = ({
layoutY,
layoutHeight,
nextSectionLayoutY,
scrollTopValue,
children,
}: {
layoutY: number;
layoutHeight: number;
nextSectionLayoutY?: number;
scrollTopValue: Animated.Value;
children: React.ReactElement<{ style?: ViewStyle }>;
}) => {
const inputRange: number[] = [-1, 0];
const outputRange: 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={[
React.isValidElement(child) && child.props.style
? child.props.style
: undefined,
{
zIndex: 10,
height: layoutHeight,
transform: [{ translateY }],
},
]}
>
{React.isValidElement(child) &&
React.cloneElement(child, {
style: { flex: 1 },
})}
</Animated.View>
);
};
const FastListItemRenderer = ({
layoutHeight: height,
children,
}: {
layoutHeight: number;
children?: React.ReactNode;
}) => <View style={{ height }}>{children}</View>;
export interface FastListProps {
renderActionSheetScrollViewWrapper?: (
wrapper: React.ReactNode
) => React.ReactNode;
actionSheetScrollRef?: { current: React.ReactNode | null | undefined };
onScroll?: (event: ScrollEvent) => any;
onScrollEnd?: (event: ScrollEvent) => any;
onLayout?: (event: LayoutChangeEvent) => any;
renderHeader: () => React.ReactElement<any> | null | undefined;
renderFooter: () => React.ReactElement<any> | null | undefined;
renderSection: (
section: number
) => React.ReactElement<any> | null | undefined;
renderRow: (
section: number,
row: number
) => React.ReactElement<any> | null | undefined;
renderSectionFooter: (
section: number
) => React.ReactElement<any> | null | undefined;
renderAccessory?: (list: FastList) => React.ReactNode;
renderEmpty?: () => React.ReactElement<any> | null | undefined;
headerHeight: HeaderHeight;
footerHeight: FooterHeight;
sectionHeight: SectionHeight;
sectionFooterHeight: SectionFooterHeight;
rowHeight: RowHeight;
sections: number[];
insetTop: number;
insetBottom: number;
scrollTopValue?: Animated.Value;
contentInset: {
top?: number;
left?: number;
right?: number;
bottom?: number;
};
}
interface FastListState {
batchSize: number;
blockStart: number;
blockEnd: number;
height?: number;
items?: FastListItem[];
}
const computeBlock = (
containerHeight: number,
scrollTop: number
): 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 }: 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 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 } | null | undefined;
scrollView: { current: ScrollView | null | undefined } = React.createRef();
state = getFastListState(
this.props,
computeBlock(this.containerHeight, this.scrollTop)
);
static getDerivedStateFromProps(props: FastListProps, state: FastListState) {
return getFastListState(props, state);
}
getItems(): 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: LayoutChangeEvent) => {
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();
}
if (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 = [] as number[];
items.forEach(({ type, layoutY }) => {
if (type === FastListItemType.SECTION) {
sectionLayoutYs.push(layoutY);
}
});
const children = [] as JSX.Element[];
items.forEach(({ type, key, layoutY, layoutHeight, section, row }) => {
switch (type) {
case FastListItemType.SPACER: {
const child = (
<FastListItemRenderer key={key} layoutHeight={layoutHeight} />
);
children.push(child);
break;
}
case FastListItemType.HEADER: {
const child = renderHeader();
if (child != null) {
children.push(
<FastListItemRenderer key={key} layoutHeight={layoutHeight}>
{child}
</FastListItemRenderer>
);
}
break;
}
case FastListItemType.FOOTER: {
const child = renderFooter();
if (child != null) {
children.push(
<FastListItemRenderer key={key} layoutHeight={layoutHeight}>
{child}
</FastListItemRenderer>
);
}
break;
}
case FastListItemType.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 FastListItemType.ROW: {
const child = renderRow(section, row);
if (child != null) {
children.push(
<FastListItemRenderer key={key} layoutHeight={layoutHeight}>
{child}
</FastListItemRenderer>
);
}
break;
}
case FastListItemType.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) {
// @ts-ignore: Types for React Native doesn't include attachNativeEvent
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((total, rowLength) => {
return total + 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>
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment