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