Skip to content
Snippets Groups Projects
FixedSizeList.js 13.8 KiB
Newer Older
  • Learn to ignore specific revisions
  • import React, { createRef, PureComponent } from 'react';
    
    James Long's avatar
    James Long committed
    import memoizeOne from 'memoize-one';
    
    import useResizeObserver from '../hooks/useResizeObserver';
    
    
    James Long's avatar
    James Long committed
    
    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 {
    
    James Long's avatar
    James Long committed
      _outerRef;
      _resetIsScrollingTimeoutId = null;
    
      static defaultProps = {
        direction: 'ltr',
        renderRow: undefined,
        layout: 'vertical',
        overscanCount: 2,
        useIsScrolling: false,
    
    James Long's avatar
    James Long committed
      };
    
      constructor(props) {
        super(props);
    
    
        this.lastPositions = createRef();
    
    James Long's avatar
    James Long committed
        this.lastPositions.current = new Map();
    
        this.needsAnimationRerender = createRef();
    
    James Long's avatar
    James Long committed
        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,
    
    James Long's avatar
    James Long committed
        };
      }
    
      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,
    
    James Long's avatar
    James Long committed
          };
        }, 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),
    
    James Long's avatar
    James Long committed
        );
      }
    
      componentDidMount() {
    
        const { initialScrollOffset } = this.props;
    
    James Long's avatar
    James Long committed
    
        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,
    
    James Long's avatar
    James Long committed
        } = 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,
    
    James Long's avatar
    James Long committed
            );
          }
        }
    
        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,
    
    James Long's avatar
    James Long committed
                }}
              >
                <View innerRef={headerRef}>{header}</View>
                <InnerElement
                  ref={innerRef}
                  style={{
                    position: 'relative',
                    height: estimatedTotalSize,
                    width: '100%',
    
                    pointerEvents: isScrolling ? 'none' : undefined,
    
    James Long's avatar
    James Long committed
                  }}
                >
                  {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),
    
    James Long's avatar
    James Long committed
        };
      }
    
      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,
    
    James Long's avatar
    James Long committed
        );
        const maxOffset = Math.min(lastItemOffset, index * this.props.itemSize);
        const minOffset = Math.max(
          0,
    
          index * this.props.itemSize - size + this.props.itemSize,
    
    James Long's avatar
    James Long committed
        );
    
        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,
    
    James Long's avatar
    James Long committed
            );
            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),
          ),
    
    James Long's avatar
    James Long committed
        );
    
      getStopIndexForStartIndex = (startIndex, scrollOffset) => {
        const offset = startIndex * this.props.itemSize;
    
        const size = this.props.height;
    
    James Long's avatar
    James Long committed
        const numVisibleItems = Math.ceil(
    
          (size + scrollOffset - offset) / this.props.itemSize,
    
    James Long's avatar
    James Long committed
        );
        return Math.max(
          0,
          Math.min(
            this.props.itemCount - 1,
    
            startIndex + numVisibleItems - 1, // -1 is because stop index is inclusive
          ),
    
    James Long's avatar
    James Long committed
        );
      };
    
      _callOnItemsRendered = memoizeOne(
        (
          overscanStartIndex,
          overscanStopIndex,
          visibleStartIndex,
    
    James Long's avatar
    James Long committed
        ) =>
          this.props.onItemsRendered({
            overscanStartIndex,
            overscanStopIndex,
            visibleStartIndex,
    
    James Long's avatar
    James Long committed
      );
    
      _callOnScroll = memoizeOne(
        (scrollDirection, scrollOffset, scrollUpdateWasRequested) =>
          this.props.onScroll({
            scrollDirection,
            scrollOffset,
    
            scrollUpdateWasRequested,
          }),
    
    James Long's avatar
    James Long committed
      );
    
      _callPropsCallbacks() {
        if (typeof this.props.onItemsRendered === 'function') {
          const { itemCount } = this.props;
          if (itemCount > 0) {
            const [
              overscanStartIndex,
              overscanStopIndex,
              visibleStartIndex,
    
    James Long's avatar
    James Long committed
            ] = this._getRangeToRender();
            this._callOnItemsRendered(
              overscanStartIndex,
              overscanStopIndex,
              visibleStartIndex,
    
    James Long's avatar
    James Long committed
            );
          }
        }
    
        if (typeof this.props.onScroll === 'function') {
    
          const { scrollDirection, scrollOffset, scrollUpdateWasRequested } =
            this.state;
    
    James Long's avatar
    James Long committed
          this._callOnScroll(
            scrollDirection,
            scrollOffset,
    
            scrollUpdateWasRequested,
    
    James Long's avatar
    James Long committed
          );
        }
      }
    
      // 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,
    
    James Long's avatar
    James Long committed
          };
        }
    
        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,
    
    James Long's avatar
    James Long committed
        ];
      }
    
      _onScrollVertical = event => {
    
        let { scrollTop } = event.currentTarget;
    
    James Long's avatar
    James Long committed
    
        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,
    
    James Long's avatar
    James Long committed
          };
        }, 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,
    
    James Long's avatar
    James Long committed
        );
      };
    
      _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);
        });
      };
    }