-
Matiss Janis Aboltins authoredMatiss Janis Aboltins authored
FixedSizeList.tsx 15.24 KiB
// @ts-strict-ignore
import {
createRef,
PureComponent,
type ReactNode,
type Ref,
type MutableRefObject,
type UIEvent,
} from 'react';
import memoizeOne from 'memoize-one';
import { type CSSProperties } from '../style';
import { View } from './common/View';
const IS_SCROLLING_DEBOUNCE_INTERVAL = 150;
const defaultItemKey: FixedSizeListProps['itemKey'] = (index: number) => index;
type FixedSizeListProps = {
className?: string;
direction?: 'rtl' | 'ltr';
renderRow: (props: {
index: number;
key: string | number;
style: CSSProperties;
isScrolling?: boolean;
isAnimating: boolean;
}) => ReactNode;
layout?: 'vertical' | 'horizontal';
overscanCount?: number;
useIsScrolling?: boolean;
headerHeight?: number;
initialScrollOffset?: number;
itemCount?: number;
outerRef?: MutableRefObject<HTMLDivElement>;
itemSize?: number;
onItemsRendered?: (config: {
overscanStartIndex: number;
overscanStopIndex: number;
visibleStartIndex?: number;
visibleStopIndex?: number;
}) => void;
onScroll?: (config: {
scrollDirection: FixedSizeListState['scrollDirection'];
scrollOffset: FixedSizeListState['scrollOffset'];
scrollUpdateWasRequested: FixedSizeListState['scrollUpdateWasRequested'];
}) => void;
indexForKey?: (key: string | number) => number;
height?: number;
width?: number;
header?: ReactNode;
innerRef?: Ref<HTMLDivElement>;
itemKey?: (index: number) => string | number;
};
type FixedSizeListState = {
isScrolling: boolean;
scrollDirection: 'forward' | 'backward';
scrollOffset: number;
scrollUpdateWasRequested: boolean;
};
export class FixedSizeList extends PureComponent<
FixedSizeListProps,
FixedSizeListState
> {
_outerRef: HTMLDivElement;
_resetIsScrollingTimeoutId = null;
lastPositions: MutableRefObject<Map<string | number, number>>;
needsAnimationRerender: MutableRefObject<boolean>;
animationEnabled: boolean;
requestScrollUpdateHandled: boolean;
anchored: null | {
key: string | number;
offset: number;
} = null;
rerenderTimeout: ReturnType<typeof setTimeout>;
static defaultProps: Partial<FixedSizeListProps> = {
direction: 'ltr',
renderRow: undefined,
layout: 'vertical',
overscanCount: 2,
useIsScrolling: false,
headerHeight: 0,
};
constructor(props: FixedSizeListProps) {
super(props);
this.lastPositions = createRef();
this.lastPositions.current = new Map();
this.needsAnimationRerender = createRef();
this.needsAnimationRerender.current = false;
this.animationEnabled = false;
this.state = {
isScrolling: false,
scrollDirection: 'forward',
scrollOffset:
typeof this.props.initialScrollOffset === 'number'
? this.props.initialScrollOffset
: 0,
scrollUpdateWasRequested: false,
};
}
scrollTo(scrollOffset: number) {
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,
scrollUpdateWasRequested: true,
};
}, this._resetIsScrollingDebounced);
}
scrollToItem(
index: number,
align: 'start' | 'end' | 'smart' | 'auto' | 'center' = '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) {
const index = this.props.indexForKey(this.anchored.key);
const baseOffset = this.getOffsetForIndexAndAlignment(index, 'start');
return baseOffset + this.anchored.offset;
}
return null;
}
componentDidUpdate() {
const { scrollOffset, scrollUpdateWasRequested } = this.state;
const 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,
itemCount,
renderRow,
itemKey = defaultItemKey,
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++) {
const key = itemKey(index);
let style = this._getItemStyle(index);
const 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.
const estimatedTotalSize = this.getEstimatedTotalSize();
return (
<div
className={className}
onScroll={this._onScrollVertical}
ref={this._outerRefSetter}
style={{
height,
width,
overflow: 'hidden auto',
}}
>
<View>{header}</View>
<div
ref={innerRef}
style={{
position: 'relative',
height: estimatedTotalSize,
width: '100%',
pointerEvents: isScrolling ? 'none' : undefined,
}}
>
{items}
</div>
</div>
);
}
setRowAnimation = (flag: boolean) => {
this.animationEnabled = flag;
const outerRef = this._outerRef;
if (outerRef) {
if (this.animationEnabled) {
outerRef.classList.add('animated');
} else {
outerRef.classList.remove('animated');
}
}
};
anchor() {
const itemKey = this.props.itemKey || defaultItemKey;
const outerRef = this._outerRef;
const scrollOffset = outerRef ? outerRef.scrollTop : 0;
const index = this.getStartIndexForOffset(scrollOffset);
const key = itemKey(index);
this.anchored = {
key,
offset: scrollOffset - this.getItemOffset(index),
};
}
unanchor() {
this.anchored = null;
}
isAnchored() {
return this.anchored != null;
}
getItemOffset = (index: number) => index * this.props.itemSize;
getItemSize = () => this.props.itemSize;
getEstimatedTotalSize = () => this.props.itemSize * this.props.itemCount;
getOffsetForIndexAndAlignment = (
index: number,
align: 'start' | 'end' | 'smart' | 'auto' | 'center',
scrollOffset?: number,
) => {
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: number) =>
Math.max(
0,
Math.min(
this.props.itemCount - 1,
Math.floor(offset / this.props.itemSize),
),
);
getStopIndexForStartIndex = (startIndex: number, scrollOffset: number) => {
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: number) => {
const { direction, itemSize, layout } = this.props;
const itemStyleCache = this._getItemStyleCache(itemSize, layout, direction);
let style: CSSProperties;
if (itemStyleCache.hasOwnProperty(index)) {
style = itemStyleCache[index];
} else {
const offset = this.getItemOffset(index);
const size = this.getItemSize();
itemStyleCache[index] = style = {
position: 'absolute',
left: 0,
top: offset,
height: size,
width: '100%',
};
}
return style;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_getItemStyleCache = memoizeOne((_, __, ___) => ({}));
_getRangeToRender() {
const { itemCount, overscanCount } = this.props;
const {
isScrolling,
scrollDirection,
scrollOffset: originalScrollOffset,
} = this.state;
const anchoredPos = this.getAnchoredScrollPos();
let scrollOffset = originalScrollOffset;
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: UIEvent<HTMLDivElement>) => {
const { 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;
}
const scrollOffset = scrollTop;
return {
isScrolling: true,
scrollDirection:
prevState.scrollOffset < scrollOffset ? 'forward' : 'backward',
scrollOffset,
scrollUpdateWasRequested: false,
};
}, this._resetIsScrollingDebounced);
};
_outerRefSetter = (ref: HTMLDivElement) => {
const { outerRef } = this.props;
this._outerRef = ref;
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.
// @ts-expect-error fix me
this._getItemStyleCache(-1, null);
});
};
}