FixedSizeList.js 13.84 KiB
import React, { createRef, PureComponent } from 'react';
import memoizeOne from 'memoize-one';
import useResizeObserver from '../hooks/useResizeObserver';
import { View } from './common';
const IS_SCROLLING_DEBOUNCE_INTERVAL = 150;
const defaultItemKey = (index, data) => index;
function ResizeObserver({ onResize, children }) {
let ref = useResizeObserver(onResize);
return children(ref);
}
export default class FixedSizeList extends PureComponent {
_outerRef;
_resetIsScrollingTimeoutId = null;
static defaultProps = {
direction: 'ltr',
renderRow: undefined,
layout: 'vertical',
overscanCount: 2,
useIsScrolling: false,
headerHeight: 0,
};
constructor(props) {
super(props);
this.lastPositions = createRef();
this.lastPositions.current = new Map();
this.needsAnimationRerender = createRef();
this.needsAnimationRerender.current = false;
this.animationEnabled = false;
this.state = {
instance: this,
isScrolling: false,
scrollDirection: 'forward',
scrollOffset:
typeof this.props.initialScrollOffset === 'number'
? this.props.initialScrollOffset
: 0,
scrollUpdateWasRequested: false,
};
}
scrollTo(scrollOffset) {
scrollOffset = Math.max(0, scrollOffset);
this.setState(prevState => {
if (prevState.scrollOffset === scrollOffset) {
return null;
}
this.requestScrollUpdateHandled = false;
return {
scrollDirection:
prevState.scrollOffset < scrollOffset ? 'forward' : 'backward',
scrollOffset: scrollOffset,
scrollUpdateWasRequested: true,
};
}, this._resetIsScrollingDebounced);
}
scrollToItem(index, align = 'auto') {
const { itemCount } = this.props;
const { scrollOffset } = this.state;
index = Math.max(0, Math.min(index, itemCount - 1));
this.scrollTo(
this.getOffsetForIndexAndAlignment(index, align, scrollOffset),
);
}
componentDidMount() {
const { initialScrollOffset } = this.props;
if (typeof initialScrollOffset === 'number' && this._outerRef != null) {
let outerRef = this._outerRef;
outerRef = this._outerRef;
outerRef.scrollTop = initialScrollOffset;
}
this._callPropsCallbacks();
}
getAnchoredScrollPos() {
if (this.anchored && this.props.indexForKey && this._outerRef != null) {
let index = this.props.indexForKey(this.anchored.key);
let baseOffset = this.getOffsetForIndexAndAlignment(index, 'start');
return baseOffset + this.anchored.offset;
}
return null;
}
componentDidUpdate() {
const { scrollOffset, scrollUpdateWasRequested } = this.state;
let anchoredPos = this.getAnchoredScrollPos();
if (anchoredPos != null) {
const outerRef = this._outerRef;
outerRef.scrollTop = anchoredPos;
} else if (
scrollUpdateWasRequested &&
!this.requestScrollUpdateHandled &&
this._outerRef != null
) {
this.requestScrollUpdateHandled = true;
const outerRef = this._outerRef;
outerRef.scrollTop = scrollOffset;
}
if (this.needsAnimationRerender.current) {
this.needsAnimationRerender.current = false;
this.rerenderTimeout = setTimeout(() => {
this.forceUpdate();
}, 10);
}
this._callPropsCallbacks();
}
componentWillUnmount() {
if (this._resetIsScrollingTimeoutId !== null) {
clearTimeout(this._resetIsScrollingTimeoutId);
}
}
render() {
const {
className,
height,
header,
innerRef,
innerElementType,
innerTagName,
itemCount,
renderRow,
itemKey = defaultItemKey,
outerElementType,
outerTagName,
style,
useIsScrolling,
width,
} = this.props;
const { isScrolling } = this.state;
const [startIndex, stopIndex] = this._getRangeToRender();
const positions = new Map();
const items = [];
if (itemCount > 0) {
for (let index = startIndex; index <= stopIndex; index++) {
let key = itemKey(index);
let style = this._getItemStyle(index);
let lastPosition = this.lastPositions.current.get(key);
let animating = false;
positions.set(key, style.top);
if (
this.animationEnabled &&
lastPosition != null &&
lastPosition !== style.top
) {
// A reorder has happened. Render it in the old place, then
// animate it to the new one
style = { ...style, top: lastPosition };
this.needsAnimationRerender.current = true;
animating = true;
}
items.push(
renderRow({
index,
key,
style,
isScrolling: useIsScrolling ? isScrolling : undefined,
isAnimating: animating,
}),
);
}
}
this.lastPositions.current = positions;
// Read this value AFTER items have been created,
// So their actual sizes (if variable) are taken into consideration.
let estimatedTotalSize = this.getEstimatedTotalSize();
let OuterElement = outerElementType || outerTagName || 'div';
let InnerElement = innerElementType || innerTagName || 'div';
return (
<ResizeObserver onResize={this.onHeaderResize}>
{headerRef => (
<OuterElement
className={className}
onScroll={this._onScrollVertical}
ref={this._outerRefSetter}
style={{
height,
width,
overflow: 'auto',
...style,
}}
>
<View innerRef={headerRef}>{header}</View>
<InnerElement
ref={innerRef}
style={{
position: 'relative',
height: estimatedTotalSize,
width: '100%',
pointerEvents: isScrolling ? 'none' : undefined,
}}
>
{items}
</InnerElement>
</OuterElement>
)}
</ResizeObserver>
);
}
setRowAnimation = flag => {
this.animationEnabled = flag;
let outerRef = this._outerRef;
if (outerRef) {
if (this.animationEnabled) {
outerRef.classList.add('animated');
} else {
outerRef.classList.remove('animated');
}
}
};
onHeaderResize = rect => {
// this.setState({ headerHeight: rect.height });
};
anchor() {
let itemKey = this.props.itemKey || defaultItemKey;
let outerRef = this._outerRef;
let scrollOffset = outerRef ? outerRef.scrollTop : 0;
let index = this.getStartIndexForOffset(scrollOffset);
let key = itemKey(index);
this.anchored = {
key,
offset: scrollOffset - this.getItemOffset(index),
};
}
unanchor() {
this.anchored = null;
}
isAnchored() {
return this.anchored != null;
}
getItemOffset = index => index * this.props.itemSize;
getItemSize = index => this.props.itemSize;
getEstimatedTotalSize = () => this.props.itemSize * this.props.itemCount;
getOffsetForIndexAndAlignment = (index, align, scrollOffset) => {
const size = this.props.height;
const lastItemOffset = Math.max(
0,
this.props.itemCount * this.props.itemSize - size,
);
const maxOffset = Math.min(lastItemOffset, index * this.props.itemSize);
const minOffset = Math.max(
0,
index * this.props.itemSize - size + this.props.itemSize,
);
if (align === 'smart') {
if (
scrollOffset >= minOffset - size &&
scrollOffset <= maxOffset + size
) {
align = 'auto';
} else {
align = 'center';
}
}
switch (align) {
case 'start':
return maxOffset;
case 'end':
return minOffset;
case 'center': {
// "Centered" offset is usually the average of the min and max.
// But near the edges of the list, this doesn't hold true.
const middleOffset = Math.round(
minOffset + (maxOffset - minOffset) / 2,
);
if (middleOffset < Math.ceil(size / 2)) {
return 0; // near the beginning
} else if (middleOffset > lastItemOffset + Math.floor(size / 2)) {
return lastItemOffset; // near the end
} else {
return middleOffset;
}
}
case 'auto':
default:
if (scrollOffset >= minOffset && scrollOffset <= maxOffset) {
return scrollOffset;
} else if (scrollOffset < minOffset) {
return minOffset;
} else {
return maxOffset;
}
}
};
getStartIndexForOffset = offset =>
Math.max(
0,
Math.min(
this.props.itemCount - 1,
Math.floor(offset / this.props.itemSize),
),
);
getStopIndexForStartIndex = (startIndex, scrollOffset) => {
const offset = startIndex * this.props.itemSize;
const size = this.props.height;
const numVisibleItems = Math.ceil(
(size + scrollOffset - offset) / this.props.itemSize,
);
return Math.max(
0,
Math.min(
this.props.itemCount - 1,
startIndex + numVisibleItems - 1, // -1 is because stop index is inclusive
),
);
};
_callOnItemsRendered = memoizeOne(
(
overscanStartIndex,
overscanStopIndex,
visibleStartIndex,
visibleStopIndex,
) =>
this.props.onItemsRendered({
overscanStartIndex,
overscanStopIndex,
visibleStartIndex,
visibleStopIndex,
}),
);
_callOnScroll = memoizeOne(
(scrollDirection, scrollOffset, scrollUpdateWasRequested) =>
this.props.onScroll({
scrollDirection,
scrollOffset,
scrollUpdateWasRequested,
}),
);
_callPropsCallbacks() {
if (typeof this.props.onItemsRendered === 'function') {
const { itemCount } = this.props;
if (itemCount > 0) {
const [
overscanStartIndex,
overscanStopIndex,
visibleStartIndex,
visibleStopIndex,
] = this._getRangeToRender();
this._callOnItemsRendered(
overscanStartIndex,
overscanStopIndex,
visibleStartIndex,
visibleStopIndex,
);
}
}
if (typeof this.props.onScroll === 'function') {
const { scrollDirection, scrollOffset, scrollUpdateWasRequested } =
this.state;
this._callOnScroll(
scrollDirection,
scrollOffset,
scrollUpdateWasRequested,
);
}
}
// Lazily create and cache item styles while scrolling,
// So that pure component sCU will prevent re-renders.
// We maintain this cache, and pass a style prop rather than index,
// So that List can clear cached styles and force item re-render if necessary.
_getItemStyle = index => {
const { direction, itemSize, layout } = this.props;
const itemStyleCache = this._getItemStyleCache(itemSize, layout, direction);
let style;
if (itemStyleCache.hasOwnProperty(index)) {
style = itemStyleCache[index];
} else {
const offset = this.getItemOffset(index);
const size = this.getItemSize(index);
itemStyleCache[index] = style = {
position: 'absolute',
left: 0,
top: offset,
height: size,
width: '100%',
};
}
return style;
};
_getItemStyleCache = memoizeOne((_, __, ___) => ({}));
_getRangeToRender() {
let { itemCount, overscanCount } = this.props;
let { isScrolling, scrollDirection, scrollOffset } = this.state;
let anchoredPos = this.getAnchoredScrollPos();
if (anchoredPos != null) {
scrollOffset = anchoredPos;
}
if (itemCount === 0) {
return [0, 0, 0, 0];
}
const startIndex = this.getStartIndexForOffset(scrollOffset);
const stopIndex = this.getStopIndexForStartIndex(startIndex, scrollOffset);
// Overscan by one item in each direction so that tab/focus works.
// If there isn't at least one extra item, tab loops back around.
const overscanBackward =
!isScrolling || scrollDirection === 'backward'
? Math.max(1, overscanCount)
: 1;
const overscanForward =
!isScrolling || scrollDirection === 'forward'
? Math.max(1, overscanCount)
: 1;
return [
Math.max(0, startIndex - overscanBackward),
Math.max(0, Math.min(itemCount - 1, stopIndex + overscanForward)),
startIndex,
stopIndex,
];
}
_onScrollVertical = event => {
let { scrollTop } = event.currentTarget;
this.setState(prevState => {
if (prevState.scrollOffset === scrollTop) {
// Scroll position may have been updated by cDM/cDU,
// In which case we don't need to trigger another render,
// And we don't want to update state.isScrolling.
return null;
}
let scrollOffset = scrollTop;
return {
isScrolling: true,
scrollDirection:
prevState.scrollOffset < scrollOffset ? 'forward' : 'backward',
scrollOffset,
scrollUpdateWasRequested: false,
};
}, this._resetIsScrollingDebounced);
};
_outerRefSetter = ref => {
const { outerRef } = this.props;
this._outerRef = ref;
if (typeof outerRef === 'function') {
outerRef(ref);
} else if (
outerRef != null &&
typeof outerRef === 'object' &&
outerRef.hasOwnProperty('current')
) {
outerRef.current = ref;
}
};
_resetIsScrollingDebounced = () => {
if (this._resetIsScrollingTimeoutId !== null) {
clearTimeout(this._resetIsScrollingTimeoutId);
}
this._resetIsScrollingTimeoutId = setTimeout(
this._resetIsScrolling,
IS_SCROLLING_DEBOUNCE_INTERVAL,
);
};
_resetIsScrolling = () => {
this._resetIsScrollingTimeoutId = null;
this.setState({ isScrolling: false }, () => {
// Clear style cache after state update has been committed.
// This way we don't break pure sCU for items that don't use isScrolling param.
this._getItemStyleCache(-1, null);
});
};
}