react-native-big-list
Version:
High-performance, virtualized list for React Native. Efficiently renders large datasets with recycler API for smooth scrolling and low memory usage. Ideal for fast, scalable, customizable lists on Android, iOS, and web.
1,403 lines (1,323 loc) • 39.3 kB
JSX
import React, { PureComponent } from "react";
import PropTypes from "prop-types";
import {
Animated,
Platform,
RefreshControl,
ScrollView,
View,
} from "react-native";
import BigListItem, { BigListItemType } from "./BigListItem";
import BigListPlaceholder from "./BigListPlaceholder";
import BigListProcessor from "./BigListProcessor";
import BigListSection from "./BigListSection";
import {
autobind,
createElement,
isNumeric,
mergeViewStyle,
processBlock,
} from "./utils";
class BigList extends PureComponent {
/**
* Constructor.
* @param props
*/
constructor(props) {
super(props);
autobind(this);
// Initialize properties and state
this.containerHeight = 0;
this.scrollTop = 0;
this.scrollTopValue = new Animated.Value(0);
this.scrollView = React.createRef();
this.state = this.getListState();
this.viewableItems = [];
this.hasScrolledToInitialIndex = false;
}
/**
* Get list state.
* @param {array} data
* @param {array[]|object|null|undefined} sections
* @param {array} prevItems
* @param {number|null} batchSizeThreshold
* @param {number|function|null|undefined} headerHeight
* @param {number|function|null|undefined} footerHeight
* @param {number|function|null|undefined} sectionHeaderHeight
* @param {number|function|null|undefined} itemHeight
* @param {number|function|null|undefined} sectionFooterHeight
* @param {number|null|undefined} insetTop
* @param {number|null|undefined} insetBottom
* @param {number|null|undefined} numColumns
* @param {number|null|undefined} batchSize
* @param {number|null|undefined} blockStart
* @param {number|null|undefined} blockEnd
* @param {function|null|undefined} getItemLayout
* @returns {{blockStart: *, batchSize: *, blockEnd: *, items: [], height: *}|{blockStart, batchSize, blockEnd, items: [], height: *}}
*/
static getListState(
{
data,
sections,
batchSizeThreshold,
headerHeight,
footerHeight,
sectionHeaderHeight,
itemHeight,
sectionFooterHeight,
insetTop,
insetBottom,
numColumns,
getItemLayout,
},
{ batchSize, blockStart, blockEnd, items: prevItems },
) {
if (batchSize === 0) {
return {
batchSize,
blockStart,
blockEnd,
height: insetTop + insetBottom,
items: [],
};
}
const self = BigList;
const layoutItemHeight = self.getItemHeight(itemHeight, getItemLayout, data, sections);
const sectionLengths = self.getSectionLengths(sections, data);
const processor = new BigListProcessor({
sections: sectionLengths,
sectionsData: sections,
itemHeight: layoutItemHeight,
headerHeight,
footerHeight,
sectionHeaderHeight,
sectionFooterHeight,
insetTop,
insetBottom,
numColumns,
});
return {
...{
batchSize,
blockStart,
blockEnd,
},
...processor.process(
blockStart - batchSize,
blockEnd + batchSize,
prevItems || [],
),
};
}
/**
* Get list state
* @param {object} props
* @param {object} options.
* @return {{blockStart: *, batchSize: *, blockEnd: *, items: *[], height: *}|{blockStart, batchSize, blockEnd, items: *[], height: *}}
*/
getListState(props, options) {
const stateProps = props || this.props;
return this.constructor.getListState(
stateProps,
options ||
processBlock({
containerHeight: this.containerHeight,
scrollTop: this.scrollTop,
batchSizeThreshold: stateProps.batchSizeThreshold,
}),
);
}
/**
* Get sections item lengths.
* @param {array[]|object<string, object>|null|undefined} sections
* @param {array} data
* @returns {int[]}
*/
static getSectionLengths(sections = null, data = null) {
if (sections !== null) {
return sections.map((section) => {
return section.length;
});
}
return [data?.length];
}
/**
* Get sections item lengths.
* @returns {int[]}
*/
getSectionLengths() {
const { sections, data } = this.props;
return this.constructor.getSectionLengths(sections, data);
}
/**
* Get item height.
* @param {number} itemHeight
* @param {function|null|undefined} getItemLayout
* @param {array|null|undefined} data
* @param {array[]|object|null|undefined} sections
* @return {null|*}
*/
static getItemHeight(itemHeight, getItemLayout, data = null, sections = null) {
if (getItemLayout) {
// Pass the actual data array to getItemLayout (sections takes precedence over data)
const dataArray = sections || data || [];
const itemLayout = getItemLayout(dataArray, 0);
return itemLayout.length;
}
if (itemHeight) {
return itemHeight;
}
return null;
}
/**
* Get item height.
* @return {null|*}
*/
getItemHeight() {
const { itemHeight, getItemLayout, data, sections } = this.props;
return this.constructor.getItemHeight(itemHeight, getItemLayout, data, sections);
}
/**
* Is item visible.
* @param {int} index
* @param {int} section
* @returns {boolean}
*/
isVisible({ index, section = 0 }) {
const position = this.getItemOffset({ index, section });
return (
position >= this.scrollTop &&
position <= this.scrollTop + this.containerHeight
);
}
/**
* Provides a reference to the underlying scroll component.
* @returns {ScrollView|null}
*/
getNativeScrollRef() {
return this.scrollView.current;
}
/**
* Get list processor,
* @returns {BigListProcessor}
*/
getListProcessor() {
const scrollView = this.getNativeScrollRef();
if (scrollView != null) {
const {
headerHeight,
footerHeight,
sectionHeaderHeight,
sectionFooterHeight,
insetTop,
insetBottom,
numColumns,
sections,
} = this.props;
const itemHeight = this.getItemHeight();
const sectionLengths = this.getSectionLengths();
return new BigListProcessor({
sections: sectionLengths,
sectionsData: sections,
headerHeight,
footerHeight,
sectionHeaderHeight,
sectionFooterHeight,
itemHeight,
insetTop,
insetBottom,
scrollView,
numColumns,
});
}
return null;
}
/**
* Displays the scroll indicators momentarily.
*/
flashScrollIndicators() {
const scrollView = this.getNativeScrollRef();
if (scrollView != null) {
scrollView.flashScrollIndicators();
}
}
/**
* Scrolls to a given x, y offset, either immediately, with a smooth animation.
* @param {int} x
* @param {int} y
* @param {bool} animated
*/
scrollTo({ x = 0, y = 0, animated = true } = {}) {
const scrollView = this.getNativeScrollRef();
if (scrollView != null) {
scrollView.scrollTo({
x: x,
y: y,
animated,
});
}
}
/**
* Scroll to index.
* @param {int} index
* @param {int} section
* @param {bool} animated
* @returns {bool}
*/
scrollToIndex({ index, section = 0, animated = true }) {
const processor = this.getListProcessor();
if (processor != null && index != null && section != null) {
return processor.scrollToPosition(section, index, animated);
}
return false;
}
/**
* Alias to scrollToIndex with polyfill for SectionList.
* @see scrollToIndex
* @param {int} itemIndex
* @param {int} sectionIndex
* @param {bool} animated
* @returns {bool}
*/
scrollToLocation({ itemIndex, sectionIndex, animated = true }) {
return this.scrollToIndex({
section: sectionIndex,
index: itemIndex,
animated,
});
}
/**
* Scroll to item.
* @param {object} item
* @param {bool} animated
* @returns {bool}
*/
scrollToItem({ item, animated = false }) {
let index;
if (this.hasSections()) {
const coords = JSON.stringify(
this.map((a) => {
return a[0] + "|" + a[1];
}),
);
index = coords.indexOf(item[0] + "|" + item[1]) !== -1;
} else {
index = this.props.data.indexOf(item);
}
return this.scrollToIndex({ index, animated });
}
/**
* Scroll to offset.
* @param {number} offset
* @param {bool} animated
* @returns {bool}
*/
scrollToOffset({ offset, animated = false }) {
const scrollRef = this.getNativeScrollRef();
if (scrollRef != null) {
scrollRef.scrollTo({
x: 0,
y: offset,
animated,
});
return true;
}
return false;
}
/**
* Scroll to top.
* @param {bool} animated
* @returns {bool}
*/
scrollToTop({ animated = true } = {}) {
return this.scrollTo({ x: 0, y: 0, animated });
}
/**
* Scroll to end.
* @param {bool} animated
* @returns {bool}
*/
scrollToEnd({ animated = true } = {}) {
const { data } = this.props;
let section = 0;
let index = 0;
if (this.hasSections()) {
const sectionLengths = this.getSectionLengths();
section = sectionLengths[sectionLengths.length - 1];
} else {
index = data.length;
}
return this.scrollToIndex({ section, index, animated });
}
/**
* Scroll to section.
* @param {int} section
* @param {bool} animated
* @returns {bool}
*/
scrollToSection({ section, animated = true }) {
return this.scrollToIndex({ index: 0, section, animated });
}
/**
* On viewable items changed.
*/
onViewableItemsChanged() {
const { onViewableItemsChanged } = this.props;
if (onViewableItemsChanged) {
const prevItems = this.viewableItems;
const currentItems = this.state.items
.map(({ type, section, index, key }) => {
if (type === BigListItemType.ITEM) {
// Ensure section and index are valid numbers to prevent NaN in calculations
const validSection = typeof section === 'number' ? section : 0;
const validIndex = typeof index === 'number' ? index : 0;
return {
item: this.getItem({ section, index }),
section: section,
key: key,
index: (validSection + 1) * validIndex,
isViewable: this.isVisible({ section, index }),
};
}
return false;
})
.filter(Boolean);
this.viewableItems = currentItems.filter((item) => item.isViewable);
const changed = prevItems
.filter(
({ index: prevIndex }) =>
!this.viewableItems.some(
({ index: nextIndex }) => nextIndex === prevIndex,
),
)
.map((item) => {
item.isViewable = this.isVisible({
section: item.section,
index: item.index,
});
return item;
});
const prevViewableItem = prevItems.length;
const currentViewableItem = this.viewableItems.length;
if (changed.length > 0 || prevViewableItem !== currentViewableItem) {
onViewableItemsChanged({ viewableItems: this.viewableItems, changed });
}
}
}
/**
* Handle scroll.
* @param event
*/
onScroll(event) {
const { nativeEvent } = event;
const {
contentInset,
batchSizeThreshold,
onViewableItemsChanged,
horizontal,
} = this.props;
const axis = horizontal ? "width" : "height";
const offset = horizontal ? "x" : "y";
const insetStart = horizontal
? contentInset.left || 0
: contentInset.top || 0;
const insetEnd = horizontal
? contentInset.right || 0
: contentInset.bottom || 0;
this.containerHeight =
nativeEvent.layoutMeasurement[axis] - insetStart - insetEnd;
this.scrollTop = Math.min(
Math.max(0, nativeEvent.contentOffset[offset]),
nativeEvent.contentSize[axis] - this.containerHeight,
);
const nextState = processBlock({
containerHeight: this.containerHeight,
scrollTop: this.scrollTop,
batchSizeThreshold,
});
if (
nextState.batchSize !== this.state.batchSize ||
nextState.blockStart !== this.state.blockStart ||
nextState.blockEnd !== this.state.blockEnd
) {
this.setState(nextState);
}
if (onViewableItemsChanged) {
this.onViewableItemsChanged();
}
// Note: User's onScroll is called in the handleScroll wrapper in render()
// to properly support Reanimated worklets
const { onEndReached, onEndReachedThreshold } = this.props;
const { layoutMeasurement, contentOffset, contentSize } = nativeEvent;
const distanceFromEnd =
contentSize[axis] - (layoutMeasurement[axis] + contentOffset[offset]);
if (distanceFromEnd <= layoutMeasurement[axis] * onEndReachedThreshold) {
if (!this.endReached) {
this.endReached = true;
onEndReached && onEndReached({ distanceFromEnd });
}
} else {
this.endReached = false;
}
}
/**
* Handle layout.
* @param event
*/
onLayout(event) {
const { nativeEvent } = event;
const { contentInset, batchSizeThreshold, horizontal, initialScrollIndex } =
this.props;
const axis = horizontal ? "width" : "height";
const insetStart = horizontal
? contentInset.left || 0
: contentInset.top || 0;
const insetEnd = horizontal
? contentInset.right || 0
: contentInset.bottom || 0;
this.containerHeight = nativeEvent.layout[axis] - insetStart - insetEnd;
const nextState = processBlock({
containerHeight: this.containerHeight,
scrollTop: this.scrollTop,
batchSizeThreshold,
});
if (
nextState.batchSize !== this.state.batchSize ||
nextState.blockStart !== this.state.blockStart ||
nextState.blockEnd !== this.state.blockEnd
) {
this.setState(nextState);
}
// Scroll to initial index after layout is complete
if (
!this.hasScrolledToInitialIndex &&
initialScrollIndex != null &&
initialScrollIndex > 0 &&
this.containerHeight > 0
) {
this.hasScrolledToInitialIndex = true;
// Use setTimeout to ensure the list is fully rendered
setTimeout(() => {
this.scrollToIndex({ index: initialScrollIndex, animated: false });
}, 0);
}
const { onLayout } = this.props;
if (onLayout) {
onLayout(event);
}
}
/**
* Handle scroll end.
* @param event
*/
onScrollEnd(event) {
const { renderAccessory, onScrollEnd } = this.props;
if (renderAccessory != null) {
this.forceUpdate();
}
if (onScrollEnd) {
onScrollEnd(event);
}
}
/**
* Handle scroll end.
* @param event
*/
onMomentumScrollEnd(event) {
const { onMomentumScrollEnd } = this.props;
this.onScrollEnd(event);
if (onMomentumScrollEnd) {
onMomentumScrollEnd(event);
}
}
/**
* Handle scroll begin drag.
* @param event
*/
onScrollBeginDrag(event) {
const { onScrollBeginDrag } = this.props;
if (onScrollBeginDrag) {
onScrollBeginDrag(event);
}
}
/**
* Handle scroll end drag.
* @param event
*/
onScrollEndDrag(event) {
const { onScrollEndDrag } = this.props;
this.onScrollEnd(event);
if (onScrollEndDrag) {
onScrollEndDrag(event);
}
}
/**
* Is empty
* @returns {boolean}
*/
isEmpty() {
const sectionLengths = this.getSectionLengths();
const length = sectionLengths.reduce((total, sectionLength) => {
return total + sectionLength;
}, 0);
return length === 0;
}
/**
* Get derived state.
* @param props
* @param state
* @returns {{blockStart: *, batchSize: *, blockEnd: *, items: *[], height: *}|{blockStart, batchSize, blockEnd, items: *[], height: *}}
*/
static getDerivedStateFromProps(props, state) {
return BigList.getListState(props, state);
}
/**
* Has sections.
* @returns {boolean}
*/
hasSections() {
return this.props.sections !== null;
}
/**
* Get item scroll view offset.
* @param {int} section
* @param {int} index
* @returns {*}
*/
getItemOffset({ section = 0, index }) {
const {
insetTop,
headerHeight,
sectionHeaderHeight,
sectionFooterHeight,
numColumns,
itemHeight,
} = this.props;
// Header + inset
let offset =
insetTop + isNumeric(headerHeight)
? Number(headerHeight)
: headerHeight();
const sections = this.getSectionLengths();
let foundIndex = false;
let s = 0;
while (s <= section) {
const rows = Math.ceil(sections[s] / numColumns);
if (rows === 0) {
s += 1;
continue;
}
// Section header
offset += isNumeric(sectionHeaderHeight)
? Number(sectionHeaderHeight)
: sectionHeaderHeight(s);
// Items
if (isNumeric(itemHeight)) {
const uniformHeight = this.getItemHeight(section);
if (s === section) {
offset += uniformHeight * Math.ceil(index / numColumns);
foundIndex = true;
} else {
offset += uniformHeight * rows;
}
} else {
for (let i = 0; i < rows; i++) {
if (s < section || (s === section && i < index)) {
const sectionDataForHeight = this.hasSections()
? this.props.sections[s]
: null;
offset += itemHeight(s, Math.ceil(i / numColumns), sectionDataForHeight);
} else if (s === section && i === index) {
foundIndex = true;
break;
}
}
}
// Section footer
if (!foundIndex) {
offset += isNumeric(sectionFooterHeight)
? Number(sectionFooterHeight)
: sectionFooterHeight(s);
}
s += 1;
}
return offset;
}
/**
* Get item data.
* @param {int} section
* @param {int} index
* @returns {*}
*/
getItem({ index, section = 0 }) {
if (this.hasSections()) {
return this.props.sections[section][index];
} else {
return this.props.data[index];
}
}
/**
* Get items data.
* @returns {*}
*/
getItems() {
return this.hasSections() ? this.props.sections : this.props.data;
}
/**
* Render all list items.
* @returns {[]|*}
*/
renderItems() {
const {
keyExtractor,
numColumns,
hideMarginalsOnEmpty,
hideHeaderOnEmpty,
hideFooterOnEmpty,
renderEmptySections,
columnWrapperStyle,
controlItemRender,
placeholder,
placeholderComponent,
placeholderImage,
ListEmptyComponent,
ListFooterComponent,
ListFooterComponentStyle,
ListHeaderComponent,
ListHeaderComponentStyle,
renderHeader,
renderFooter,
renderSectionHeader,
renderItem,
renderSectionFooter,
renderEmpty,
} = this.props;
const { items = [] } = this.state;
const itemStyle = this.getBaseStyle();
const fullItemStyle = mergeViewStyle(itemStyle, {
width: "100%",
});
// On empty list
const isEmptyList = this.isEmpty();
const emptyItem = ListEmptyComponent
? createElement(ListEmptyComponent, {style: fullItemStyle})
: renderEmpty
? renderEmpty()
: null;
if (isEmptyList && emptyItem) {
if (hideMarginalsOnEmpty || (hideHeaderOnEmpty && hideFooterOnEmpty)) {
// Render empty - return as array since renderItems should return array of children
return [emptyItem];
} else {
// Add empty item
const headerIndex = items.findIndex(
(item) => item.type === BigListItemType.HEADER,
);
items.splice(headerIndex + 1, 0, {
type: BigListItemType.EMPTY,
key: 'empty',
});
if (hideHeaderOnEmpty) {
// Hide header
items.splice(headerIndex, 1);
}
if (hideFooterOnEmpty) {
// Hide footer
const footerIndex = items.findIndex(
(item) => item.type === BigListItemType.FOOTER,
);
items.splice(footerIndex, 1);
}
}
}
// Get section lengths to check if individual sections are empty
const sectionLengths = this.getSectionLengths();
// Sections positions
const sectionPositions = [];
items.forEach(({ type, position }) => {
if (type === BigListItemType.SECTION_HEADER) {
sectionPositions.push(position);
}
});
// Render items
const children = [];
items.forEach(({ type, key, position, height, section, index }) => {
const itemKey = key || position; // Fallback fix
// Ensure section and index are valid numbers to prevent NaN in calculations
const validSection = typeof section === 'number' ? section : 0;
const validIndex = typeof index === 'number' ? index : 0;
let uniqueKey = String((validSection + 1) * validIndex);
let child;
let style;
switch (type) {
case BigListItemType.HEADER:
if (ListHeaderComponent != null) {
child = createElement(ListHeaderComponent);
style = mergeViewStyle(fullItemStyle, ListHeaderComponentStyle);
} else {
child = renderHeader();
style = fullItemStyle;
}
if (child != null) {
// Validate that child is a valid React child before rendering
if (typeof child === 'object' && child !== null && !React.isValidElement(child)) {
console.warn('BigList: Invalid header child object detected. Child should be a valid React element, string, or number.', child);
} else {
children.push(
<BigListItem
key={itemKey}
uniqueKey={uniqueKey}
height={height}
width="100%"
style={style}
>
{child}
</BigListItem>
);
}
}
break;
case BigListItemType.FOOTER:
if (ListFooterComponent != null) {
child = createElement(ListFooterComponent);
style = mergeViewStyle(fullItemStyle, ListFooterComponentStyle);
} else {
child = renderFooter();
style = fullItemStyle;
}
if (child != null) {
// Validate that child is a valid React child before rendering
if (typeof child === 'object' && child !== null && !React.isValidElement(child)) {
console.warn('BigList: Invalid footer child object detected. Child should be a valid React element, string, or number.', child);
} else {
children.push(
<BigListItem
key={itemKey}
uniqueKey={uniqueKey}
height={height}
width="100%"
style={style}
>
{child}
</BigListItem>
);
}
}
break;
case BigListItemType.SECTION_FOOTER:
const isSectionEmptyForFooter = sectionLengths[section] === 0;
// Hide section footer on empty list or when section is empty and renderEmptySections is false
height = isEmptyList ? 0 : (isSectionEmptyForFooter && !renderEmptySections ? 0 : height);
const sectionDataForFooter = this.hasSections()
? this.props.sections[section]
: null;
child = renderSectionFooter(section, sectionDataForFooter);
style = fullItemStyle;
if (child != null) {
// Validate that child is a valid React child before rendering
if (typeof child === 'object' && child !== null && !React.isValidElement(child)) {
console.warn('BigList: Invalid section footer child object detected. Child should be a valid React element, string, or number.', child);
} else {
children.push(
<BigListItem
key={itemKey}
uniqueKey={uniqueKey}
height={height}
width="100%"
style={style}
>
{child}
</BigListItem>
);
}
}
break;
// falls through
case BigListItemType.ITEM:
if (type === BigListItemType.ITEM) {
const item = this.getItem({ section, index });
uniqueKey = keyExtractor
? keyExtractor(item, uniqueKey)
: uniqueKey;
style =
numColumns > 1
? mergeViewStyle(itemStyle, columnWrapperStyle || {})
: itemStyle;
const renderArguments = {
item,
index,
section: undefined,
key: undefined,
style: undefined,
};
if (this.hasSections()) {
renderArguments.section = section;
}
if (controlItemRender) {
renderArguments.key = uniqueKey;
renderArguments.style = mergeViewStyle(style, {
height,
width: 100 / numColumns + "%",
});
}
child = renderItem(renderArguments);
}
if (child != null) {
// Validate that child is a valid React child before rendering
if (typeof child === 'object' && child !== null && !React.isValidElement(child)) {
console.warn('BigList: Invalid child object detected. Child should be a valid React element, string, or number.', child);
return; // Skip rendering this invalid child
}
if (type === BigListItemType.ITEM && controlItemRender) {
// When users control item rendering we must still ensure a key is
// supplied to each child when we insert it into the children array.
// If the rendered child is a valid React element, clone it with
// the key. Otherwise wrap it in a keyed fragment.
if (React.isValidElement(child)) {
children.push(React.cloneElement(child, { key: itemKey }));
} else {
children.push(
<React.Fragment key={itemKey}>{child}</React.Fragment>,
);
}
} else {
children.push(
<BigListItem
key={itemKey}
uniqueKey={uniqueKey}
height={height}
width={100 / numColumns + "%"}
style={style}
>
{child}
</BigListItem>,
);
}
}
break;
case BigListItemType.EMPTY:
children.push(
<React.Fragment key={itemKey}>
{emptyItem}
</React.Fragment>
);
break;
case BigListItemType.SPACER:
children.push(
placeholder ? (
<BigListPlaceholder
key={itemKey}
height={height}
image={placeholderImage}
component={placeholderComponent}
/>
) : (
<BigListItem key={itemKey} height={height} />
),
);
break;
case BigListItemType.SECTION_HEADER:
const isSectionEmpty = sectionLengths[section] === 0;
// Hide section header on empty list or when section is empty and renderEmptySections is false
height = isEmptyList ? 0 : (isSectionEmpty && !renderEmptySections ? 0 : height);
sectionPositions.shift();
const sectionDataForHeader = this.hasSections()
? this.props.sections[section]
: null;
child = renderSectionHeader(section, sectionDataForHeader);
if (child != null) {
// Validate that child is a valid React child before rendering
if (typeof child === 'object' && child !== null && !React.isValidElement(child)) {
console.warn('BigList: Invalid section header child object detected. Child should be a valid React element, string, or number.', child);
} else {
children.push(
<BigListSection
key={itemKey}
style={fullItemStyle}
height={height}
position={position}
nextSectionPosition={sectionPositions[0]}
scrollTopValue={this.scrollTopValue}
>
{child}
</BigListSection>,
);
}
}
break;
}
});
return children;
}
/**
* Component did mount.
*/
componentDidMount() {
const { stickySectionHeadersEnabled, nativeOffsetValues } = this.props;
const scrollView = this.getNativeScrollRef();
if (
stickySectionHeadersEnabled &&
scrollView != null &&
Platform.OS !== "web"
) {
// Disabled on web
this.scrollTopValueAttachment = Animated.attachNativeEvent(
scrollView,
"onScroll",
[{ nativeEvent: { contentOffset: { y: this.scrollTopValue } } }],
);
}
if (nativeOffsetValues && scrollView != null && Platform.OS !== "web") {
Animated.attachNativeEvent(scrollView, "onScroll", [
{
nativeEvent: {
contentOffset: nativeOffsetValues,
},
},
]);
}
}
/**
* Component did update.
* @param prevProps
*/
componentDidUpdate(prevProps) {
if (prevProps.initialScrollIndex !== this.props.initialScrollIndex) {
throw new Error("initialScrollIndex cannot be changed after mounting");
}
}
/**
* Component will unmount.
*/
componentWillUnmount() {
if (this.scrollTopValueAttachment != null) {
this.scrollTopValueAttachment.detach();
}
}
/**
* Get base style.
* @return {{transform: [{scaleX: number}]}|{transform: [{scaleY: number}]}}
*/
getBaseStyle() {
const { inverted, horizontal } = this.props;
if (inverted) {
if (horizontal) {
return {
transform: [{ scaleX: -1 }],
};
} else {
return {
transform: [{ scaleY: -1 }],
};
}
}
return {};
}
/**
* Render.
* @returns {JSX.Element}
*/
render() {
// Reduce list properties
const {
data,
keyExtractor,
inverted,
horizontal,
placeholder,
placeholderImage,
placeholderComponent,
sections,
initialScrollIndex,
columnWrapperStyle,
numColumns,
renderHeader,
renderFooter,
renderSectionHeader,
renderItem,
renderSectionFooter,
renderScrollViewWrapper,
renderEmpty,
renderAccessory,
itemHeight,
footerHeight,
headerHeight,
sectionHeaderHeight,
sectionFooterHeight,
insetTop,
insetBottom,
actionSheetScrollRef,
stickySectionHeadersEnabled,
onEndReached,
onEndReachedThreshold,
onRefresh,
refreshing,
ListEmptyComponent,
ListFooterComponent,
ListFooterComponentStyle,
ListHeaderComponent,
ListHeaderComponentStyle,
hideMarginalsOnEmpty,
hideFooterOnEmpty,
hideHeaderOnEmpty,
ScrollViewComponent,
...props
} = this.props;
const wrapper = renderScrollViewWrapper || ((val) => val);
const offset = horizontal ? "x" : "y";
// Handle scroll events - support both regular callbacks and Reanimated worklets
// The key is to call both the internal handler and the user's handler
const userOnScroll = props.onScroll;
let handleScroll;
if (stickySectionHeadersEnabled && Platform.OS === "web") {
// Web platform with sticky headers - use Animated.event with listener
handleScroll = Animated.event(
[
{
nativeEvent: {
contentOffset: { [offset]: this.scrollTopValue },
},
},
],
{
listener: (event) => {
this.onScroll(event);
// Call user's onScroll if provided (after internal handling)
if (userOnScroll) {
userOnScroll(event);
}
},
useNativeDriver: false,
},
);
} else if (userOnScroll) {
// Combine internal and user scroll handlers
// This works for both regular callbacks and Reanimated worklets
handleScroll = (event) => {
this.onScroll(event);
userOnScroll(event);
};
} else {
// No user onScroll, just use internal handler
handleScroll = this.onScroll;
}
const defaultProps = {
refreshControl:
onRefresh && !this.props.refreshControl ? (
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
) : null,
contentContainerStyle: {
maxWidth: "100%",
...(numColumns > 1 && !horizontal
? {
flexDirection: "row",
flexWrap: "wrap",
}
: {}),
},
};
const overwriteProps = {
horizontal,
ref: (ref) => {
this.scrollView.current = ref;
if (actionSheetScrollRef) {
actionSheetScrollRef.current = ref;
}
},
onScroll: handleScroll,
onLayout: this.onLayout,
onMomentumScrollEnd: this.onMomentumScrollEnd,
onScrollBeginDrag: this.onScrollBeginDrag,
onScrollEndDrag: this.onScrollEndDrag,
};
const scrollViewProps = {
...defaultProps,
...props,
...overwriteProps,
};
// Content container style merge
scrollViewProps.contentContainerStyle = mergeViewStyle(
props.contentContainerStyle,
defaultProps.contentContainerStyle,
);
const ListScrollView = ScrollViewComponent || Animated.ScrollView;
const scrollView = wrapper(
<ListScrollView {...scrollViewProps}>
{this.renderItems()}
</ListScrollView>,
);
const scrollStyle = mergeViewStyle(
{
flex: 1,
maxHeight: Platform.select({ web: "100vh", default: "100%" }),
},
this.getBaseStyle(),
);
return (
<View style={scrollStyle}>
{scrollView}
{renderAccessory != null ? renderAccessory(this) : null}
</View>
);
}
}
BigList.propTypes = {
inverted: PropTypes.bool,
horizontal: PropTypes.bool,
actionSheetScrollRef: PropTypes.any,
batchSizeThreshold: PropTypes.number,
bottom: PropTypes.number,
numColumns: PropTypes.number,
columnWrapperStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.array]),
contentInset: PropTypes.shape({
bottom: PropTypes.number,
left: PropTypes.number,
right: PropTypes.number,
top: PropTypes.number,
}),
controlItemRender: PropTypes.bool,
data: PropTypes.array,
placeholder: PropTypes.bool,
placeholderImage: PropTypes.any,
placeholderComponent: PropTypes.oneOfType([
PropTypes.elementType,
PropTypes.element,
PropTypes.node,
]),
footerHeight: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
PropTypes.func,
]),
getItemLayout: PropTypes.func,
headerHeight: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
PropTypes.func,
]),
insetBottom: PropTypes.number,
insetTop: PropTypes.number,
itemHeight: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
PropTypes.func,
]),
keyboardDismissMode: PropTypes.string,
keyboardShouldPersistTaps: PropTypes.string,
ListEmptyComponent: PropTypes.oneOfType([
PropTypes.elementType,
PropTypes.element,
PropTypes.node,
]),
ListFooterComponent: PropTypes.oneOfType([
PropTypes.elementType,
PropTypes.element,
PropTypes.node,
]),
ListFooterComponentStyle: PropTypes.oneOfType([
PropTypes.object,
PropTypes.array,
]),
ListHeaderComponent: PropTypes.oneOfType([
PropTypes.elementType,
PropTypes.element,
PropTypes.node,
]),
ListHeaderComponentStyle: PropTypes.oneOfType([
PropTypes.object,
PropTypes.array,
]),
onEndReached: PropTypes.func,
onEndReachedThreshold: PropTypes.number,
onLayout: PropTypes.func,
onRefresh: PropTypes.func,
onScroll: PropTypes.func,
onScrollEnd: PropTypes.func,
onScrollBeginDrag: PropTypes.func,
onScrollEndDrag: PropTypes.func,
onViewableItemsChanged: PropTypes.func,
removeClippedSubviews: PropTypes.bool,
renderAccessory: PropTypes.func,
renderScrollViewWrapper: PropTypes.func,
renderEmpty: PropTypes.func,
renderFooter: PropTypes.func,
renderHeader: PropTypes.func,
renderItem: PropTypes.func.isRequired,
renderSectionHeader: PropTypes.func,
renderSectionFooter: PropTypes.func,
keyExtractor: PropTypes.func,
refreshing: PropTypes.bool,
refreshControl: PropTypes.element,
scrollEventThrottle: PropTypes.number,
initialScrollIndex: PropTypes.number,
hideMarginalsOnEmpty: PropTypes.bool,
hideHeaderOnEmpty: PropTypes.bool,
hideFooterOnEmpty: PropTypes.bool,
renderEmptySections: PropTypes.bool,
sectionFooterHeight: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
PropTypes.func,
]),
sectionHeaderHeight: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
PropTypes.func,
]),
sections: PropTypes.array,
stickySectionHeadersEnabled: PropTypes.bool,
nativeOffsetValues: PropTypes.shape({
x: PropTypes.instanceOf(Animated.Value),
y: PropTypes.instanceOf(Animated.Value),
}),
ScrollViewComponent: PropTypes.oneOfType([
PropTypes.func,
PropTypes.elementType,
]),
};
BigList.defaultProps = {
// Data
data: [],
inverted: false,
horizontal: false,
sections: null,
refreshing: false,
batchSizeThreshold: 1,
numColumns: 1,
placeholder: Platform.select({
web: false,
default: false /* TODO: default disabled until a solution for different screen sizes is found */,
}),
// Renders
renderItem: () => null,
renderHeader: () => null,
renderFooter: () => null,
renderSectionHeader: () => null,
renderSectionFooter: () => null,
hideMarginalsOnEmpty: false,
hideFooterOnEmpty: false,
hideHeaderOnEmpty: false,
renderEmptySections: false,
controlItemRender: false,
// Height
itemHeight: 50,
headerHeight: 0,
footerHeight: 0,
sectionHeaderHeight: 0,
sectionFooterHeight: 0,
// Scroll
stickySectionHeadersEnabled: true,
removeClippedSubviews: false,
scrollEventThrottle: Platform.OS === "web" ? 5 : 16,
// Keyboard
keyboardShouldPersistTaps: "always",
keyboardDismissMode: "interactive",
// Insets
insetTop: 0,
insetBottom: 0,
contentInset: { top: 0, right: 0, left: 0, bottom: 0 },
onEndReachedThreshold: 0,
nativeOffsetValues: undefined,
ScrollViewComponent: Animated.ScrollView,
};
export default BigList;