简单且丝滑的虚拟列表实现

137 阅读1分钟

效果预览

屏幕录制2024-09-0916.55.12.-迅捷PDF转换器_1.gif

该虚拟列表项可为动态高度,在浏览过程中根据用户事件调整高度(如展开,收起等)也能满足。

实现步骤

一: 获取元素高度:

  • 使用相关API(querySelector 等)获取, 然后将其高度缓存起来:
  • 在设置元素高度时,重新渲染列表
  const id = useId();
  const idToSelect = "wrap" + id
  const updateSize = () => {
    getRectSizeASync(`#${idToSelect}`).then(rect => {
      setSize(index, rect.height);
    })
  }
  useEffect(() => {
    updateSize();
  }, []);
  const sizeMap = useRef(new Map());
 
  const setSize = useCallback((index, size) => {
    sizeMap.current.set(index, size);
    setRefreshCount(val => val + 1);
  }, []);
  
  const getSize = useCallback((index) => {
    return sizeMap.current.get(index) || defaultHeight;
  }, []);

二: 确定视口范围内显示的元素索引, startIndex 和 endIndex:

  • startIndex: 从 0 开始查找,累加元素高度,直到总和大于滚动高度
  • endIndex: 与 startIndex 类似,总和应大于滚动高度加上滚动容器的高度
 const getStartIndex = useCallback((scrollTop) => {
    let sum = 0;
    const target = list.findIndex((_, index) => {
      sum += getSize(index);
      return sum > scrollTop;
    });
    return Math.max(0, target - 3); // 3 为向上缓冲数量
  },[list]);

  const getEndIndex = useCallback((startIndex, scrollTop) => {
    let sum = 0;
    const target = list.findIndex((_, index) => {
      sum += getSize(index);
      return sum > scrollTop + height; // height 为滚动容器高度
    });
    if (!~target) return list.length;
    return Math.max(startIndex, Math.min(list.length, target + 3)) // 3 为向下缓冲数量
  }, [list, height]);

二、获取元素位置

  • 第一个元素的位置即为0
  • 非第一个元素:前一元素的偏移值再加上其高度
const offsetMap = useRef(new Map());

const getOffset = useCallback(index => {
    if (index === 0) {
      offsetMap.current.set(index, 0);
      return 0;
    }
    const offset =  offsetMap.current.get(index - 1) + getSize(index - 1);
    offsetMap.current.set(index, offset);
    return offset;
  }, [list]);

三、增加列表容器

  • 列表容器高度为从 0 到 maxEndIndex 的元素高度之和,以此来撑开列表在滚动容器的大小:
const getWrapHeight = () => {
    const newWrapHeight = list.slice(0, maxEndIndex.current).reduce((sum, _, index) => {
      return sum + getSize(index);
    }, 0);
    wrapHeight.current = newWrapHeight;
    return newWrapHeight
  }

四、增加滚动监听事件,每次滚动更新 scrollTop 值,通过该值计算新的 startIndex 和 endIndex 即可。

完整代码


const ItemWrap = ({ setSize, offset, item: Item, list, index, updateLastFoldIndex }) => {
  const id = useId();
  const idToSelect = "wrap" + id
  const updateSize = () => {
    getRectSizeASync(`#${idToSelect}`).then(rect => {
      setSize(index, rect.height);
    })
  }
  useEffect(() => {
    updateSize();
  }, []);
  return <View id={idToSelect} style={{
    position: "absolute",
    width: "100%",
    transform: `translateY(${offset}px)`,
    overflow: "hidden"
  }}>
    <Item
      data={list}
      index={index}
      updateSize={updateSize}
      updateLastFoldIndex={updateLastFoldIndex}
    ></Item>
  </View>
}

const VirtualList = ({
  item: Item,
  itemData: list, 
  defaultHeight=100,
  }: Props<T, U>) => {
  const listId = useId();
  const idToSelect = "list" + listId;
  const [scrollTop, setScrollTop] = useState(0);
  const [refreshCount, setRefreshCount] = useState(0);
  const [height, setHeight] = useState(0);

  const maxEndIndex = useRef(0);
  const wrapHeight = useRef(0);
  const sizeMap = useRef(new Map());
  const offsetMap = useRef(new Map());
  const lastFoldIndex = useRef(-1);

  const getStartIndex = useCallback((scrollTop) => {
    let sum = 0;
    const target = list.findIndex((_, index) => {
      sum += getSize(index);
      return sum > scrollTop;
    });
    return Math.max(0, target - 3);
  },[list]);

  const getEndIndex = useCallback((startIndex, scrollTop) => {
    let sum = 0;
    const target = list.findIndex((_, index) => {
      sum += getSize(index);
      return sum > scrollTop + height;
    });
    if (!~target) return list.length;
    return Math.max(startIndex, Math.min(list.length, target + 3))
  }, [list, height]);

  const getSize = useCallback((index) => {
    return sizeMap.current.get(index) || defaultHeight
  }, []);

  const setSize = useCallback((index, size) => {
    sizeMap.current.set(index, size);
    setRefreshCount(val => val + 1);
  }, []);

  const getOffset = useCallback(index => {
    if (index === 0) {
      offsetMap.current.set(index, 0);
      return 0;
    }
    const offset =  offsetMap.current.get(index - 1) + getSize(index - 1);
    offsetMap.current.set(index, offset);
    return offset;
  }, [list]);

  let startIndex = getStartIndex(scrollTop);
  const endIndex = getEndIndex(startIndex, scrollTop);

  if (endIndex > maxEndIndex.current) {
    maxEndIndex.current = endIndex
  }

  if (~lastFoldIndex.current) {
    const index = lastFoldIndex.current
    startIndex = Math.max(0, Math.min(startIndex, index));
  }

  const getWrapHeight = () => {
    const newWrapHeight = list.slice(0, maxEndIndex.current).reduce((sum, _, index) => {
      return sum + getSize(index);
    }, 0);
    wrapHeight.current = newWrapHeight;
    return newWrapHeight
  }

  useEffect(() => {
    getRectSizeASync(`#${idToSelect}`).then(rect => {
      if (rect.height) setHeight(rect.height)
    })
  }, []);

  return <ScrollView
    id={idToSelect}
    scrollY
    style={{
      height: '100%',
      width: '100%',
      flex: 1
    }}
    onScroll={({ detail: { scrollTop } }) => {
      setScrollTop(scrollTop);
      lastFoldIndex.current = -1;
    }}
    >
    <View className="wrap" style={{
      height: getWrapHeight() + 40,
      position: "relative",
    }}>
    {
     list.slice(startIndex, endIndex).map((_, index) =>
      <ItemWrap
        item={Item}
        index={index + startIndex}
        list={list}
        key={index + startIndex}
        offset={getOffset(index + startIndex)}
        updateLastFoldIndex={(i) => lastFoldIndex.current = i}
        setSize={setSize}
      ></ItemWrap>).reverse()
    }
    </View>
  </ScrollView>
}

export default VirtualList;