Skip to content
Snippets Groups Projects
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);
    });
  };
}