likes
comments
collection
share

自定义滚动条,手撸不等高虚拟列表

作者站长头像
站长
· 阅读数 18

前言

虚拟列表分为两种情等高的情况和不等高的情况,前几天写了一篇自定义滚动条,手撸等高虚拟列表,对于不等高的情况也有所思考,跟等高的思路是一样的,不同的只是计算重要值的方式不一样。思路就不讲了,建议去看一下前面等高的思路。后面附上完整的代码

正文

还是先看看效果

自定义滚动条,手撸不等高虚拟列表

记录列表元素高度信息

不等高虚拟列表的难点就是我们无法知道元素实际的高度,导致我们无法知道应该滚动的距离,所以我们要记录列表元素高度信息,包括元素的实际高度和上下边界在实际列表高度中的距离

interface Sign {
  top: number,
  height: number,
  bottom: number
}
/** 标记*/
const [sign, setSign] = useState<Sign[]>([])

同时,我们也需要一个默认值来当做元素还未渲染时的高度。

自定义滚动条,手撸不等高虚拟列表

我们通过这个标记,列表的实际高度其实已经知道了

自定义滚动条,手撸不等高虚拟列表

更新列表元素高度信息

当我们获取到渲染元素时,要去更新我们记录的元素高度信息。

对于更新渲染部分的元素高度信息,没什么好办法,只能乖乖走循环一个一个去更新。 但是要知道在记录的元素高度信息列表中一个元素高度的改变,那么这个元素之后的所有元素高度信息的top和bottom都会改变,我们都要去更新,这边我们可以小小的优化一下,不是改变一个元素信息就去循环更新剩下的元素高度信息,而是计算整个实际渲染部分与记录部分的高度总差值然后再统一的更新

自定义滚动条,手撸不等高虚拟列表

寻找当前渲染元素开始下标

在等高的情况中我们可以通过计算算出下标

  • 当前列表开始渲染下标 = Math.floor(内容实际滚动距离 / 列表每一项高度)

但是在不等高的情况下,我们不能通过计算算出下标,而是通过查找。这里我们又要注意,不能使用单粗暴for循环在全部中查找,站在算法的角度来说就是会超时。记录元素高度信息列表是有规律的,top和bottom都是递增的,在这种有明细递增的一组数据中寻找数据使用二分是不错的选择

自定义滚动条,手撸不等高虚拟列表

具体使用,也是不等高虚拟列表的核心代码 自定义滚动条,手撸不等高虚拟列表

完整代码

/** 不等高*/

import { useState, useEffect, useRef, useMemo, useCallback, useTransition } from 'react'
import img1 from '../assets/imgs/1.jpg'
import './VirtualScrollOne.scss'

interface Sign {
  top: number,
  height: number,
  bottom: number
}

/** 查找第一个小于等于的值*/

/**
* @description 查找第一个小于等于的num的下标
* @param l 开始下标
* @param r 结束下标
* @param list 所查询列表
* @param num 目标值
* @returns 下标
*/
function dichotomy(l: number, r: number, list: Sign[], num: number) {
  while (l <= r) {
    let mid = Math.floor((l + r) / 2);
    if (list[mid].top <= num) {
      l = mid + 1;
    }
    else {
      r = mid - 1;
    }
  }
  return r
}

export default function VirtualScrollOn() {
  /** 默认高度*/
  const [listItemDefaultHeight, setListItemDefaultHeight] = useState(50)
  /** 滑块DOM*/
  const scrollBar = useRef<any>(null)
  /** 滑块样式*/
  const [scrollBarStyle, setScrollBarStyle] = useState<React.CSSProperties>({})
  /** 列表样式*/
  const [listStyle, setListStyle] = useState<React.CSSProperties>({})
  /** 滑块是否可以移动*/
  const isMove = useRef(false);
  /** 列表在视口的坐标*/
  const initPointerObj = useRef({ x: 0, y: 0 })
  /** 鼠标点击时在滑块中的坐标*/
  const mouseInBar = useRef({ x: 0, y: 0 })
  const frameDom = useRef(null)
  /** 列表可渲染数量*/
  const renderQuantity = useRef(20)
  /** 列表DOM*/
  const listDom = useRef(null)
  /** 列表可见区域高度*/
  const listVisualHeight = useRef(0);
  /** 数据开始下标*/
  const [startInd, setStartInd] = useState(0);
  /** 总数据*/
  const [allList, setAllList] = useState<any[]>([])
  /** 渲染数据*/
  const [resultList, setResultList] = useState<any[]>([]);
  /** 标记*/
  const [sign, setSign] = useState<Sign[]>([])


  /** 列表数量*/
  const listNum = useMemo(() => {
    return allList.length
  }, [allList])

  /** 列表实际高度*/
  const listActualHeight = useMemo(() => {
    if (listNum == 0 || sign.length == 0) {
      return 0
    }
    return sign[listNum - 1].bottom;
  }, [listNum, sign])

  /** 滑块高度*/
  const barHeight = useMemo(() => {
    let height = listVisualHeight.current / (listActualHeight) * listVisualHeight.current
    if (height < 20) {
      return height * (Math.floor(20 / height) + 1)
    } else {
      return height
    }
  }, [listActualHeight])

  /** 滑块最大可滚动距离*/
  const maxBarTransformY = useMemo(() => {
    if (scrollBar.current === null) {
      return listVisualHeight.current
    }
    return listVisualHeight.current - barHeight;
  }, [barHeight])

  /** 更新dom高度信息*/
  const updateSign = useCallback(() => {
    let childNodes = (listDom.current as unknown as HTMLDivElement).childNodes;
    let temporarySign = [...sign];

    if (resultList.length === 0) {
      return
    }

    /** 计算实际渲染部分与记录部分的总差值,用于同一计算*/
    let addSubSum = 0;
    for (let i = 0; i < childNodes.length && startInd + i < listNum; i++) {
      let itemInfo = (childNodes[i] as HTMLDivElement).getBoundingClientRect();
      addSubSum += (itemInfo.height - temporarySign[startInd + i].height);
    }

    //更新 渲染元素高度信息
    for (let i = 0; i < childNodes.length && startInd + i < listNum; i++) {
      let itemInfo = (childNodes[i] as HTMLDivElement).getBoundingClientRect();
      if (itemInfo.height === temporarySign[startInd + i].height) {
        continue;
      } else {
        if (startInd + i === 0) {
          temporarySign[i] = {
            height: itemInfo.height,
            top: 0,
            bottom: itemInfo.height
          }
        } else {
          temporarySign[startInd + i] = {
            height: itemInfo.height,
            top: temporarySign[startInd + i - 1].bottom,
            bottom: temporarySign[startInd + i - 1].bottom + itemInfo.height,
          }
        }
      }
    }

    //更新 渲染元素之后的元素高度信息
    if (addSubSum != 0) {
      for (let i = startInd + childNodes.length; i < listNum; i++) {
        temporarySign[i] = {
          ...temporarySign[i],
          top: temporarySign[i].top + addSubSum,
          bottom: temporarySign[i].bottom + addSubSum,
        }
      }
    }

    setSign(temporarySign)
  }, [startInd, listNum, sign, resultList])

  /** 获取数据,初始化数据*/
  useEffect(() => {
    let list = []
    for (let i = 0; i < 10000; i++) {
      let content = `我是第${i}张图`
      for (let j = 0; j < Math.floor(Math.random() * (7 - 3 + 1)) + 3; j++) {
        content += `${1 * 100000000000}`
      }
      list.push({
        img: img1,
        content: content
      })
    }
    setAllList(list);

    //初始化列表元素高度信息
    let temporarySign: Sign[] = [];
    for (let i = 0; i < list.length; i++) {
      if (i === 0) {
        temporarySign[i] = {
          height: listItemDefaultHeight,
          top: 0,
          bottom: listItemDefaultHeight
        }
      } else {
        let detail: Sign = {
          height: listItemDefaultHeight,
          top: temporarySign[i - 1].bottom,
          bottom: temporarySign[i - 1].bottom + listItemDefaultHeight,
        }
        temporarySign[i] = detail;
      }
    }

    setSign([...temporarySign])
  }, [listItemDefaultHeight])

  /** 初始化渲染数据*/
  useEffect(() => {
    let list = []
    for (let i = startInd; i < startInd + renderQuantity.current && i < allList.length; i++) {
      list.push(allList[i])
    }
    setResultList(list)

  }, [allList, startInd])

  useEffect(() => {
    updateSign()
  }, [resultList])

  /** 设置鼠标在滑块中点击的y轴坐标*/
  const onMousedown = useCallback((e: MouseEvent) => {
    e.preventDefault()
    document.documentElement.style.cursor = 'grabbing';
    mouseInBar.current.y = e.y - (scrollBar.current as HTMLDivElement).getBoundingClientRect().y
    isMove.current = true;
  }, [])

  const onMouseup = useCallback(() => {
    isMove.current = false
    document.documentElement.style.cursor = 'default';
  }, [])


  const onMousemove = useCallback((e: MouseEvent) => {
    if (isMove.current === false) {
      return
    }

    /** 滚动条移动距离*/
    let barTransformY = e.y - initPointerObj.current.y - mouseInBar.current.y;

    if (barTransformY > maxBarTransformY) {
      barTransformY = maxBarTransformY
    }
    if (barTransformY <= 0) {
      barTransformY = 0
    }

    /** 实际内容移动距离*/
    let listTransformY = barTransformY * (listActualHeight - listVisualHeight.current) / maxBarTransformY;

    let showInd = dichotomy(0, listNum - 1, sign, listTransformY)

    setScrollBarStyle({
      transform: `translate(${0}px,${barTransformY}px)`
    })
    setListStyle({
      transform: `translate(${0}px,-${listTransformY - sign[showInd].top}px)`
    })

    setStartInd(showInd)
  }, [maxBarTransformY, startInd, listItemDefaultHeight, sign, listNum])

  const onWheel = useCallback((e: WheelEvent) => {
    e.preventDefault()

    let transformValue = window.getComputedStyle(listDom.current as unknown as HTMLDivElement).getPropertyValue('transform');
    let translateYValue = 0;
    if (transformValue !== 'none') {
      const matrixValues = (transformValue as any).match(/matrix.*\((.+)\)/)[1].split(', ');
      translateYValue = parseInt(matrixValues[5], 10);
    }
    let listTransformY = sign[startInd].top + Math.abs(translateYValue)

    /** 判断是向上还是向下滚动*/
    const deltaY = e.deltaY;
    if (deltaY > 0) {
      listTransformY = listTransformY + 70;
      if (listTransformY > listActualHeight - listVisualHeight.current) {
        listTransformY = listActualHeight - listVisualHeight.current;
      }
    } else if (deltaY < 0) {
      listTransformY = listTransformY - 70;
      if (listTransformY < 0) {
        listTransformY = 0
      }
    }

    let barTransformY = listTransformY * maxBarTransformY / (listActualHeight - listVisualHeight.current);
    let showInd = dichotomy(0, listNum - 1, sign, listTransformY)

    setScrollBarStyle({
      transform: `translate(${0}px,${barTransformY}px)`
    })
    setListStyle({
      transform: `translate(${0}px,-${listTransformY - sign[showInd].top}px)`
    })

    setStartInd(showInd)
  }, [startInd, listActualHeight, listItemDefaultHeight, listNum, sign])

  /** 设置初始化滑块在页面中的y轴坐标*/
  const setInitPointer = useCallback(() => {
    if (frameDom.current === null) {
      return
    }
    let frameDomInfo = (frameDom.current as HTMLDivElement).getBoundingClientRect();
    initPointerObj.current.y = frameDomInfo.y;
  }, [])

  /** 初始化数据*/
  useEffect(() => {
    if (frameDom.current === null) {
      return
    }
    setInitPointer()

    let frameDomInfo = (frameDom.current as HTMLDivElement).getBoundingClientRect();
    listVisualHeight.current = frameDomInfo.height;

    (scrollBar.current as HTMLDivElement).addEventListener('mousedown', onMousedown);
    window.addEventListener('resize', setInitPointer)
    window.addEventListener('scroll', setInitPointer)
    document.addEventListener('mouseup', onMouseup)

    return () => {
      (scrollBar.current as HTMLDivElement).removeEventListener('mousedown', onMousedown);
      document.removeEventListener('mouseup', onMouseup)
      window.removeEventListener('resize', setInitPointer)
      window.removeEventListener('scroll', setInitPointer)
    }
  }, [])


  useEffect(() => {
    if (frameDom.current === null) {
      return
    }
    document.addEventListener('mousemove', onMousemove);
    (frameDom.current as HTMLDivElement).addEventListener('wheel', onWheel);

    return () => {
      (frameDom.current as unknown as HTMLDivElement).removeEventListener('wheel', onWheel);
      document.removeEventListener('mousemove', onMousemove)
    }
  }, [onWheel, onMousemove])


  return (
    <>
      <div ref={frameDom} className='virtualScroll'>
        <div ref={listDom} className='virtualScroll__list'
          style={
            {
              ...listStyle,
              height: listActualHeight + 'px'
            }}
        >
          {
            resultList.map((item, ind) => {
              return (
                <div className='list__item' key={ind}>
                  <img src={item.img} alt="" />
                  <div>{item.content}</div>
                </div>
              )
            })
          }
        </div>
        <div ref={scrollBar} draggable={false} style={
          {
            ...scrollBarStyle,
            height: `${barHeight}px`
          }
        } className='virtualScroll__scrollBar'></div>
      </div>
    </>
  )
}

结语

感兴趣的可以去试试