react-native-largelist
Version:
The best performance large list component which is much better than SectionList for React Native.
512 lines (488 loc) • 16.2 kB
JavaScript
/*
*
* Created by Stone
* https://github.com/bolan9999
* Email: shanshang130@gmail.com
* Date: 2018/7/17
*
*/
import React from "react";
import { Animated, StyleSheet, Dimensions, Platform } from "react-native";
import { styles } from "./styles";
import { SpringScrollView } from "react-native-spring-scrollview";
import type { IndexPath, LargeListPropType, Offset } from "./Types";
import { Group } from "./Group";
import { idx } from "./idx";
import { Section } from "./Section";
const screenLayout = Dimensions.get("window");
const screenHeight = Math.max(screenLayout.width, screenLayout.height);
export class LargeList extends React.PureComponent<LargeListPropType> {
_groupRefs = [];
_offset: Animated.Value;
_scrollView = React.createRef();
_shouldUpdateContent = true;
_lastTick = 0;
_contentOffsetY = 0;
_headerLayout;
_footerLayout;
_nativeOffset;
_size;
_sectionRefs: [];
_orgOnHeaderLayout: () => 0;
_orgOnFooterLayout: () => 0;
static defaultProps = {
heightForSection: () => 0,
renderSection: () => null,
groupCount: 4,
groupMinHeight: screenHeight / 3,
updateTimeInterval: 150,
};
constructor(props) {
super(props);
for (let i = 0; i < props.groupCount; ++i) {
this._groupRefs.push(React.createRef());
}
if (props.initialContentOffset) {
this._contentOffsetY = props.initialContentOffset.y;
}
this._nativeOffset = {
x: new Animated.Value(0),
y: new Animated.Value(0),
...this.props.onNativeContentOffsetExtract,
};
this._offset = this._nativeOffset.y;
}
render() {
//#region compute before render
const {
data,
heightForSection,
heightForIndexPath,
groupMinHeight,
groupCount,
headerStickyEnabled,
} = this.props;
if (
this.props.renderEmpty &&
(data.length === 0 || data[0].items.length === 0)
)
return this._renderEmpty();
const groupIndexes = [];
let indexes = [];
const sectionTops = [];
const sectionHeights = [];
let currentGroupIndex = 0;
const inputs = [];
const outputs = [];
const lastOffset = [];
const sectionInputs = [];
const sectionOutputs = [];
const sectionIndexes = [];
const sections = [0];
let sumHeight = this._headerLayout ? this._headerLayout.height : 0;
const wrapperHeight = idx(() => this._size.height, 700);
const shouldRenderContent = this._shouldRenderContent();
if (shouldRenderContent) {
let currentGroupHeight = 0;
for (let i = 0; i < groupCount; ++i) {
inputs.push(i === 0 ? [Number.MIN_SAFE_INTEGER] : []);
outputs.push(i === 0 ? [sumHeight] : []);
lastOffset.push(sumHeight);
groupIndexes.push([]);
}
for (let section = 0; section < data.length; ++section) {
for (let row = -1; row < data[section].items.length; ++row) {
let height;
if (row === -1) {
height = heightForSection(section);
sectionHeights.push(height);
sectionTops[section] = sumHeight;
} else {
height = heightForIndexPath({ section: section, row: row });
}
currentGroupHeight += height;
sumHeight += height;
indexes.push({ section: section, row: row });
if (
currentGroupHeight >= groupMinHeight ||
(section === data.length - 1 &&
row === data[section].items.length - 1)
) {
groupIndexes[currentGroupIndex].push(indexes);
indexes = [];
currentGroupHeight = 0;
currentGroupIndex++;
currentGroupIndex %= groupCount;
if (
section === data.length - 1 &&
row === data[section].items.length - 1
)
break;
if (inputs[currentGroupIndex].length === 0) {
inputs[currentGroupIndex].push(Number.MIN_SAFE_INTEGER);
}
inputs[currentGroupIndex].push(sumHeight - wrapperHeight);
inputs[currentGroupIndex].push(sumHeight + 0.1 - wrapperHeight);
if (outputs[currentGroupIndex].length === 0) {
outputs[currentGroupIndex].push(sumHeight);
outputs[currentGroupIndex].push(sumHeight);
} else {
outputs[currentGroupIndex].push(lastOffset[currentGroupIndex]);
}
outputs[currentGroupIndex].push(sumHeight);
lastOffset[currentGroupIndex] = sumHeight;
}
}
}
inputs.forEach((range) => range.push(Number.MAX_SAFE_INTEGER));
outputs.forEach((range) => range.push(range[range.length - 1]));
let viewport = [];
sectionTops.forEach((top) => {
const first = viewport[0];
if (first !== undefined && top - first > screenHeight) {
viewport.splice(0, 1);
}
viewport.push(top);
if (sections.length < viewport.length + 1)
sections.push(sections.length);
});
this._sectionRefs = [];
sections.forEach(() => {
sectionInputs.push([]);
sectionOutputs.push([]);
sectionIndexes.push([]);
this._sectionRefs.push(React.createRef());
});
for (let section = 0; section < data.length; section++) {
const index = section % sections.length;
const headerHeight = this._headerLayout ? this._headerLayout.height : 0;
const first = sectionInputs[index].length <= 0;
sectionInputs[index].push(
first
? sectionTops[section] - 1 - headerHeight
: sectionInputs[index][sectionInputs[index].length - 1] + 0.1,
sectionTops[section] - headerHeight,
sectionTops[section]
);
sectionIndexes[index].push(section, section, section);
sectionOutputs[index].push(
sectionTops[section],
sectionTops[section],
sectionTops[section]
);
if (section + 1 < data.length) {
sectionInputs[index].push(
sectionTops[section + 1] - sectionHeights[section],
sectionTops[section + 1]
);
sectionIndexes[index].push(section, section);
sectionOutputs[index].push(
sectionTops[section + 1] - sectionHeights[section],
sectionTops[section + 1] - sectionHeights[section]
);
} else {
const last = sectionTops[section] + sectionHeights[section];
sectionInputs[index].push(last);
sectionIndexes[index].push(section);
sectionOutputs[index].push(last);
}
}
headerStickyEnabled &&
sectionInputs.forEach((inputs) =>
inputs.forEach((input, index) => {
const mod = index % 5;
if (mod > 1 && mod < 4)
inputs[index] -= idx(() => this._headerLayout.height, 0);
})
);
sectionInputs.forEach((inputs, index) => {
while (
inputs.length > 1 &&
inputs[inputs.length - 1] === inputs[inputs.length - 2]
) {
inputs.splice(inputs.length - 1, 1);
sectionIndexes[index].splice(sectionIndexes[index].length - 1, 1);
sectionOutputs[index].splice(sectionOutputs[index].length - 1, 1);
}
});
}
if (this._footerLayout) sumHeight += this._footerLayout.height;
const contentStyle =
sumHeight > 0
? {
height:
sumHeight > wrapperHeight
? sumHeight
: wrapperHeight + StyleSheet.hairlineWidth,
}
: null;
//#endregion
return (
<SpringScrollView
{...this.props}
ref={this._scrollView}
onSizeChange={this._onSizeChange}
contentStyle={StyleSheet.flatten([
this.props.contentStyle,
contentStyle,
])}
onNativeContentOffsetExtract={this._nativeOffset}
onScroll={this._onScroll}
onMomentumScrollEnd={this._onScrollEnd}
>
{shouldRenderContent &&
groupIndexes.map((indexes, index) => {
return (
<Group
{...this.props}
index={index}
key={index}
ref={this._groupRefs[index]}
indexes={indexes}
input={inputs[index]}
output={outputs[index]}
offset={this._contentOffsetY}
nativeOffset={{ value: this._offset }}
/>
);
})}
{shouldRenderContent &&
sections.map((value, index) => {
let transform;
if (sectionInputs[index].length > 1)
transform = [
{
translateY: this._offset.interpolate({
inputRange: sectionInputs[index],
outputRange: sectionOutputs[index],
}),
},
];
const style = StyleSheet.flatten([styles.abs, { transform }]);
return (
<Section
{...this.props}
key={index}
ref={this._sectionRefs[index]}
style={style}
input={sectionInputs[index]}
output={sectionOutputs[index]}
sectionIndexes={sectionIndexes[index]}
offset={this._contentOffsetY}
/>
);
})}
{this._renderHeaderBackground()}
{this._renderHeader()}
{this._renderFooter()}
</SpringScrollView>
);
}
_renderHeaderBackground() {
const { renderScaleHeaderBackground } = this.props;
if (
!renderScaleHeaderBackground ||
!renderScaleHeaderBackground() ||
!this._headerLayout
)
return null;
const height = this._headerLayout.height;
const style = {
position: "absolute",
left: 0,
top: 0,
right: 0,
height,
transform: [
{
scale: this._offset.interpolate({
inputRange: [-height, 0, 1],
outputRange: [2, 1, 1],
}),
},
{
translateY: Animated.divide(
this._offset.interpolate({
inputRange: [-1, 0, 1],
outputRange: [-1 / 2, 0, 0],
}),
this._offset.interpolate({
inputRange: [-height, 0, 1],
outputRange: [2, 1, 1],
})
),
},
],
};
return (
<Animated.View style={style}>
{renderScaleHeaderBackground()}
</Animated.View>
);
}
_renderEmpty() {
return (
<SpringScrollView
contentStyle={{ flex: 1 }}
{...this.props}
ref={this._scrollView}
onSizeChange={this._onSizeChange}
onNativeContentOffsetExtract={this._nativeOffset}
onScroll={this._onScroll}
>
{this._renderHeader && this._renderHeader()}
{this.props.renderEmpty && this.props.renderEmpty()}
{this.props.renderFooter && this.props.renderFooter()}
</SpringScrollView>
);
}
_renderHeader() {
const { renderHeader, inverted, headerStickyEnabled } = this.props;
if (!renderHeader || !renderHeader()) return null;
const transform = [];
const zIndex = headerStickyEnabled ? 9999 : undefined;
if (this._shouldRenderContent()) {
headerStickyEnabled &&
transform.push({
translateY: this._offset.interpolate({
inputRange: [-1, 0, 1],
outputRange: [0, 0, 1],
}),
});
if (inverted) transform.push({ scaleY: -1 });
} else {
transform.push({ translateY: 10000 });
}
return (
<Animated.View
style={StyleSheet.flatten({ alignSelf: "stretch", transform, zIndex })}
onLayout={this._onHeaderLayout}
>
{renderHeader()}
</Animated.View>
);
}
_renderFooter() {
const { renderFooter, inverted } = this.props;
if (!renderFooter || !renderFooter()) return null;
const transform = {
transform: [
{ translateY: this._shouldRenderContent() ? 0 : 10000 },
{ scaleY: inverted ? -1 : 1 },
],
};
const footer = React.Children.only(renderFooter());
this._orgOnFooterLayout = footer.onLayout;
return React.cloneElement(footer, {
style: StyleSheet.flatten([styles.footer, footer.props.style, transform]),
onLayout: this._onFooterLayout,
});
}
_shouldRenderContent() {
const { renderHeader, renderFooter } = this.props;
return (
this._size &&
(!renderHeader || !renderHeader() || this._headerLayout) &&
(!renderFooter || !renderFooter() || this._footerLayout)
);
}
_onSizeChange = (size) => {
this._size = size;
this.props.onSizeChange && this.props.onSizeChange(size);
if (this._shouldRenderContent()) this.forceUpdate();
};
_onHeaderLayout = (e) => {
if (
this._headerLayout &&
this._headerLayout.height === e.nativeEvent.layout.height
)
return;
this._headerLayout = e.nativeEvent.layout;
this._orgOnHeaderLayout && this._orgOnHeaderLayout(e);
if (this._shouldRenderContent()) this.forceUpdate();
};
_onFooterLayout = (e) => {
if (
this._footerLayout &&
this._footerLayout.height === e.nativeEvent.layout.height
)
return;
this._footerLayout = e.nativeEvent.layout;
this._orgOnFooterLayout && this._orgOnFooterLayout(e);
if (this._shouldRenderContent()) this.forceUpdate();
};
_onScrollEnd = () => {
this._groupRefs.forEach((group) =>
idx(() => group.current.contentConversion(this._contentOffsetY))
);
idx(() =>
this._sectionRefs.forEach((sectionRef) => {
sectionRef.current.updateOffset(this._contentOffsetY);
})
);
this.props.onMomentumScrollEnd && this.props.onMomentumScrollEnd();
};
_onScroll = (e) => {
const offsetY = e.nativeEvent.contentOffset.y;
this._contentOffsetY = offsetY;
this._shouldUpdateContent &&
idx(() =>
this._sectionRefs.forEach((sectionRef) => {
sectionRef.current.updateOffset(this._contentOffsetY);
})
);
this.props.onScroll && this.props.onScroll(e);
const now = new Date().getTime();
if (this._lastTick - now > 30) {
this._lastTick = now;
return;
}
this._lastTick = now;
this._shouldUpdateContent &&
this._groupRefs.forEach((group) =>
idx(() => group.current.contentConversion(offsetY))
);
};
scrollTo(offset: Offset, animated: boolean = true): Promise<void> {
if (!this._scrollView.current)
return Promise.reject("LargeList has not been initialized yet!");
this._shouldUpdateContent = false;
this._groupRefs.forEach((group) =>
idx(() => group.current.contentConversion(offset.y))
);
this._sectionRefs.forEach((sectionRef) =>
idx(() => sectionRef.current.updateOffset(offset.y))
);
return this._scrollView.current.scrollTo(offset, animated).then(() => {
this._shouldUpdateContent = true;
return Promise.resolve();
});
}
scrollToIndexPath(
indexPath: IndexPath,
animated: boolean = true
): Promise<void> {
const { data, heightForSection, heightForIndexPath } = this.props;
let ht = idx(() => this._headerLayout.height, 0);
for (let s = 0; s < data.length && s <= indexPath.section; ++s) {
if (indexPath.section === s && indexPath.row === -1) break;
ht += heightForSection(s);
for (let r = 0; r < data[s].items.length; ++r) {
if (indexPath.section === s && indexPath.row === r) break;
ht += heightForIndexPath({ section: s, row: r });
}
}
return this.scrollTo({ x: 0, y: ht }, animated);
}
beginRefresh() {
if (!this._scrollView.current)
return Promise.reject("LargeList does not initialize yet");
return this._scrollView.current.beginRefresh();
}
endRefresh() {
idx(() => this._scrollView.current.endRefresh());
}
endLoading(rebound: boolean = false) {
idx(() => this._scrollView.current.endLoading(rebound));
}
}