虚拟列表滚动-高度不定

1,804 阅读1分钟

虚拟列表无限滚动

源码

Demo

import { VirtualList } from './VirtualList';

const VirtualScroll = () => {
  const list = new Array(100).fill('');
  const str =
    'Voluptatem quia minima rerum culpa culpa ratione vel natus dolor. Voluptatem aut quae incidunt esse ipsum voluptates ratione perferendis qui. Beatae at aspernatur odio suscipit quidem odit.';

  return (
    <VirtualList
      list={list.map((it, index) => ({ id: index }))}
      height={800}
      estimatedRowHeight={50}
      renderItem={(it, id) => {
        const endIndex = Math.ceil(Math.random() * 100);
        const strContent = str.substring(0, endIndex);
        return (
          <div
            key={id}
            style={{
              lineHeight: '30px',
              padding: '5px',
              border: '1px solid #ddd',
            }}
            id={`item_${id}`}
          >{`item_${it.id}------------${strContent}`}</div>
        );
      }}
    />
  );
};

export default VirtualScroll;

/VirtualList/index.tsx

import { useEffect, useRef, useState, useLayoutEffect } from 'react';
import {
  initCachedPositions,
  CachedPosition,
  binarySearch,
  CompareResult,
} from './utils';
import './index.less';

type VirtualListProps = {
  height: number;
  list: Record<string, any>[];
  renderItem: (it: any, key: number) => any;
  estimatedRowHeight?: number;
};
let originStartIdx = 0;
let startIndex = 0;

export const VirtualList = ({
  height,
  list = [],
  renderItem,
  estimatedRowHeight = 50,
}: VirtualListProps) => {
  const total = list.length;
  const limit = Math.ceil(height / estimatedRowHeight);
  let endIndex = Math.min(originStartIdx + limit, total - 1);
  const scrollWrapperRef = useRef<HTMLDivElement>(null);
  const phantomContentRef = useRef<HTMLDivElement>(null);
  const actualContentRef = useRef<HTMLDivElement>(null);
  const cachedPositions: CachedPosition[] = initCachedPositions(
    list,
    estimatedRowHeight,
  );
  let phantomHeight = cachedPositions[cachedPositions.length - 1].bottom;
  const [transformY, setTransformY] = useState<number>(0);

  useEffect(() => {
    const updateCachedPositions = () => {
      const nodes: any = actualContentRef.current?.childNodes;
      const start = nodes[0];
      nodes.forEach((node: HTMLDivElement) => {
        if (!node) {
          // scroll to fast?
          return;
        }
        const rect = node.getBoundingClientRect();
        const { height } = rect;
        const index = Number(node.id.split('_')[1]);
        const oldHeight = cachedPositions[index].height;
        const dValue = oldHeight - height;
        if (dValue) {
          cachedPositions[index].bottom -= dValue;
          cachedPositions[index].height = height;
          cachedPositions[index].dValue = dValue;
        }
      });

      let startIdx = 0;
      if (start) {
        startIdx = Number(start.id.split('_')[1]);
      }
      const cachedPositionLen = cachedPositions.length;
      let cumulativeDiffHeight = cachedPositions[startIdx].dValue;
      cachedPositions[startIdx].dValue = 0;

      for (let i = startIdx + 1; i < cachedPositionLen; ++i) {
        const item = cachedPositions[i];
        cachedPositions[i].top = cachedPositions[i - 1].bottom;
        cachedPositions[i].bottom -= cumulativeDiffHeight;

        if (item.dValue !== 0) {
          cumulativeDiffHeight += item.dValue;
          item.dValue = 0;
        }
      }

      // 更新phantom的height
      const height = cachedPositions[cachedPositionLen - 1].bottom;
      phantomHeight = height;
      if (phantomContentRef.current) {
        phantomContentRef.current.style.height = `${height}px`;
      }
      console.log(cachedPositions, 'cachedPositions');
    };
    if (actualContentRef.current && list.length > 0) {
      updateCachedPositions();
    }
  }, [startIndex]);

  const getStartIndex = (scrollTop = 0) => {
    let idx = binarySearch<CachedPosition, number>(
      cachedPositions,
      scrollTop,
      (currentValue: CachedPosition, targetValue: number) => {
        const currentCompareValue = currentValue.bottom;
        if (currentCompareValue === targetValue) {
          return CompareResult.eq;
        }

        if (currentCompareValue < targetValue) {
          return CompareResult.lt;
        }

        return CompareResult.gt;
      },
    );

    const targetItem = cachedPositions[idx];

    if (targetItem.bottom < scrollTop) {
      idx += 1;
    }

    return idx;
  };

  const updateVisibleData = () => {
    startIndex = Math.max(originStartIdx, 0);
    endIndex = Math.min(originStartIdx + limit, total - 1);
    const transformY =
      startIndex >= 1 ? cachedPositions[startIndex - 1].bottom : 0;
    setTransformY(transformY);
  };
  const onScroll = () => {
    const scrollTop = scrollWrapperRef.current?.scrollTop;

    const currentStartIndex = getStartIndex(scrollTop);
    if (currentStartIndex !== originStartIdx) {
      // we need to update visualized data
      originStartIdx = currentStartIndex;
      updateVisibleData();
    }
  };

  useEffect(() => {
    updateVisibleData();
  }, []);

  useLayoutEffect(() => {
    const $el = scrollWrapperRef.current;
    $el?.addEventListener('scroll', onScroll);
    return () => {
      $el?.removeEventListener('scroll', onScroll);
    };
  }, []);

  const renderList = () => {
    const content: HTMLDivElement[] = [];
    for (let i = startIndex; i <= endIndex; ++i) {
      content.push(renderItem(list[i], i));
    }
    return content;
  };

  return (
    <div className="list-view" style={{ height }} ref={scrollWrapperRef}>
      <div
        className="list-view-phantom"
        style={{ height: phantomHeight }}
        ref={phantomContentRef}
      />
      <div
        className="list-view-actual"
        style={{ transform: `translate3d(0,${transformY}px,0)` }}
        ref={actualContentRef}
      >
        {renderList()}
      </div>
    </div>
  );
};

/VirtualList/utils.ts

export interface CachedPosition {
  index: number; // 当前pos对应的元素的下标
  top: number;
  bottom: number;
  height: number;
  dValue: number;
}

export const initCachedPositions = (list, estimatedRowHeight) => {
  const cachedPositions: CachedPosition[] = [];
  for (let i = 0; i < list.length; ++i) {
    cachedPositions[i] = {
      index: i,
      height: estimatedRowHeight, // 先使用estimateHeight估计
      top: i * estimatedRowHeight, // 同上
      bottom: (i + 1) * estimatedRowHeight, // same above
      dValue: 0,
    };
  }
  return cachedPositions;
};

export enum CompareResult {
  eq = 1,
  lt,
  gt,
}

export const binarySearch = <T, VT>(
  list: T[],
  value: VT,
  compareFunc: (current: T, value: VT) => CompareResult,
) => {
  let start = 0;
  let end = list.length - 1;
  let tempIndex = 0;

  while (start <= end) {
    tempIndex = Math.floor((start + end) / 2);
    const midValue = list[tempIndex];

    const compareRes: CompareResult = compareFunc(midValue, value);
    if (compareRes === CompareResult.eq) {
      return tempIndex;
    }

    if (compareRes === CompareResult.lt) {
      start = tempIndex + 1;
    } else if (compareRes === CompareResult.gt) {
      end = tempIndex - 1;
    }
  }

  return tempIndex;
};

/VirtualList/index.less

.list-view {
  position: relative;
  overflow: auto;
  border: 1px solid #aaa;
}

.list-view-phantom {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  z-index: -1;
}

.list-view-actual {
  left: 0;
  right: 0;
  top: 0;
  position: absolute;
}

注: 本文只是根据下面文章改写成hooks,非原创。

文章来源:如何实现一个高度自适应的虚拟列表