发布于 2026-01-06 8 阅读
0

一个简单的滚动框实现

一个简单的滚动框实现

它是移动和桌面用户界面中非常常见的组件,尤其适用于显示水平列表。下图展示了一个滚动框,用于显示颜色列表。我们将使用 React 重现这个示例,这样您就可以在自己的项目中应用它来显示任何您想要的内容!

所有代码都可以在这个git仓库中找到

让我们开始编写代码吧。

基本结构

我们的滚动框由一个带有水平滚动的包装器和一个具有其内容宽度的容器组成。



import React, { useRef } from 'react';
import PropTypes from 'prop-types';
import './scrollBox.css';

function ScrollBox({ children }) {
  return (
    <div className="scroll-box">
      <div className="scroll-box__wrapper">
        <div className="scroll-box__container" role="list">
          {children.map((child, i) => (
            <div className="scroll-box__item" role="listitem" key={`scroll-box-item-${i}`}>
              {child}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

ScrollBox.propTypes = {
  children: PropTypes.node.isRequired,
};

export default ScrollBox;


Enter fullscreen mode Exit fullscreen mode

样式应保证外层容器创建水平滚动条,而内层容器显示其内容。



.scroll-box {
  position: relative;
  width: 100%;
  overflow: hidden;
}

.scroll-box__wrapper {
  width: 100%;
  height: 100%;
  overflow-y: hidden;
  overflow-x: scroll;
}

.scroll-box__container {
  height: 100%;
  display: inline-flex;
}


Enter fullscreen mode Exit fullscreen mode

带滚动条的滚动框

去掉滚动条

正如您所见,移动版和桌面版仍然显示滚动条,这可能并非我们所需。因此,我们可以使用 CSS 将其隐藏。我们的 CSS 文件如下所示:



.scroll-box {
  position: relative;
  width: 100%;
  overflow: hidden;
}

.scroll-box__wrapper {
  width: 100%;
  height: 100%;
  overflow-y: hidden;
  overflow-x: scroll;
+   -ms-overflow-style: none; /* IE */
+   overflow: -moz-scrollbars-none; /* Firefox */
}

+ .scroll-box__wrapper::-webkit-scrollbar {
+   display: none; /* Chrome and Safari */
+ }

.scroll-box__container {
  height: 100%;
  display: inline-flex;
}


Enter fullscreen mode Exit fullscreen mode

移动端滚动框正常工作

现在滚动条消失了。如果您想将此组件用于移动端 UI,那就一切就绪!您已经可以通过屏幕触摸实现非常流畅的滚动效果。但如果您需要在桌面浏览器中使用鼠标指针滚动,请继续阅读以下内容。

接下来才是真正不容易的部分。

使用鼠标指针控制滚动

首先,我们需要获取包装器的 ref ref以便将函数附加到事件上onmousemoveonmousedown因此,我们使用 hook创建一个 ref并将其传递给包装器的 div。 下一步是在设置 ref 时将函数附加到上面列出的事件上。代码如下所示:onmouseuponmouseleaveuseRefscrollWrapperRef



import React, { useRef, useState, useEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
import './scrollBox.css';

function ScrollBox({ children }) {
  const scrollWrapperRef = useRef();

  const scrollWrapperCurrent = scrollWrapperRef.current;
  useEffect(() => {
    if (scrollWrapperRef.current) {
      const handleDragStart = () => {};
      const handleDragMove = () => {};
      const handleDragEnd = () => {};

      if (scrollWrapperRef.current.ontouchstart === undefined) {
        scrollWrapperRef.current.onmousedown = handleDragStart;
        scrollWrapperRef.current.onmousemove = handleDragMove;
        scrollWrapperRef.current.onmouseup = handleDragEnd;
        scrollWrapperRef.current.onmouseleave = handleDragEnd;
      }
    }
  }, [scrollWrapperCurrent]);

  return (
    <div className="scroll-box">
      <div className="scroll-box__wrapper" ref={scrollWrapperRef}>
        <div className="scroll-box__container" role="list">
          {children.map((child, i) => (
            <div role="listitem" key={`scroll-box-item-${i}`} className="scroll-box__item">
              {child}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

ScrollBox.propTypes = {
  children: PropTypes.node.isRequired,
};

export default ScrollBox;


Enter fullscreen mode Exit fullscreen mode
拖拽开始

当鼠标按钮被按下时,我们就知道拖拽操作开始了,我们需要保存指针在 X 轴上的初始位置和当前的滚动位置。这就是我们要做的事情。



...
const [clickStartX, setClickStartX] = useState();
const [scrollStartX, setScrollStartX] = useState();
...
const handleDragStart = e => {
  setClickStartX(e.screenX);
  setScrollStartX(scrollWrapperRef.current.scrollLeft);
};


Enter fullscreen mode Exit fullscreen mode
手柄拖拽移动

当鼠标按钮被按下并移动光标时,我们判断出滚动条正在被拖动,因此我们将鼠标 X 轴的移动量加上初始水平滚动量,设置为容器水平滚动条的移动量。这样,滚动条就能跟随鼠标指针的位置移动。



...
const [clickStartX, setClickStartX] = useState();
const [scrollStartX, setScrollStartX] = useState();
...
const handleDragMove = e => {
  if (clickStartX !== undefined && scrollStartX !== undefined) {
    const touchDelta = clickStartX - e.screenX;
    scrollWrapperRef.current.scrollLeft = scrollStartX + touchDelta;
  }
};


Enter fullscreen mode Exit fullscreen mode
处理拖拽结束

松开鼠标按钮或离开滚动框区域即视为停止拖动。因此,我们只需要取消设置 clickStartX 和 scrollStartX,这样 handleDragMove 函数就不会再设置 scrollLeft 了。



...
const [clickStartX, setClickStartX] = useState();
const [scrollStartX, setScrollStartX] = useState();
...
const handleDragEnd = () => {
  if (clickStartX !== undefined) {
    setClickStartX(undefined);
    setScrollStartX(undefined);
  }
};


Enter fullscreen mode Exit fullscreen mode

为什么要在 useEffect 中设置鼠标事件?

你可能会问,为什么我们需要在 useEffect 内部设置它。主要原因是,当 `<div>` 元素发生变化时scrollWrapperRef.current,我们需要触发相应的鼠标事件。但是,由于 `<div>`scrollWrapperRef是一个可变对象,我们需要将scrollWrapperRef.current其设置为 `const` scrollWrapperCurrent。这样 useEffect 才能识别出current内部的`<div>` 元素scrollWrapperRef已经发生了变化。

仅适用于桌面浏览器的鼠标位置跟踪

在移动浏览器中,该属性scrollWrapperRef.current.ontouchstart的值为null“否”,表示它可能被使用但尚未设置。在桌面浏览器中,该属性的值为“未定义”,因为屏幕上不会有“触摸”操作(至少在大多数计算机上是这样)。所以我们希望这种情况只发生在桌面浏览器中。

我还没机会在台式机触摸屏上测试一下。如果你测试过了,请留言!



  if (scrollWrapperRef.current.ontouchstart === undefined) {
    scrollWrapperRef.current.onmousedown = handleDragStart;
    scrollWrapperRef.current.onmousemove = handleDragMove;
    scrollWrapperRef.current.onmouseup = handleDragEnd;
    scrollWrapperRef.current.onmouseleave = handleDragEnd;
  }


Enter fullscreen mode Exit fullscreen mode

让我们加入物理学!

正如你所见,移动停止的位置与指针停止拖动的位置相同,但这与移动端的体验不符。为此,我们必须添加动量效果。它应该保持速度并缓慢减速。

我们需要获取鼠标的移动速度。为此handleDragMove,我们将使用速度方程v = ds/dt,即空间在时间间隔内的变化率。请参阅下面的代码以了解更多信息。



const timing = (1 / 60) * 1000;
...
const [isDragging, setIsDragging] = useState(false);
const [lastScreenX, setLastScreenX] = useState(0);
const [speed, setSpeed] = useState(0);
const [direction, setDirection] = useState(0);

const handleLastScrollX = useCallback(
  throttle(screenX => {
    setLastScreenX(screenX);
  }, timing),
  []
);
...
const handleDragMove = e => {
  if (clickStartX !== undefined && scrollStartX !== undefined) {
    const touchDelta = clickStartX - e.screenX;
    scrollWrapperRef.current.scrollLeft = scrollStartX + touchDelta;

    if (Math.abs(touchDelta) > 1) {
      setIsDragging(true);
      setDirection(touchDelta / Math.abs(touchDelta));
      setSpeed(Math.abs((lastScreenX - e.screenX) / timing));
      setLastScreenX(e.screenX);
    }
  }
};


Enter fullscreen mode Exit fullscreen mode

由此lodash我们得到节流函数,它保证我们每 16.666667 毫秒(即 16.666667 毫秒)只设置一次 `setLastScrollX`,(1 / 60) * 1000这与浏览器每秒 60 帧的屏幕刷新率相匹配。
(lastScreenX - e.screenX) / timing将提供鼠标指针的当前速度。并将touchDelta / Math.abs(touchDelta)返回 -1 或 1 作为移动方向的提示。

要在拖动滚动框后继续移动,可以使用 useEffect,如下所示。



const timing = (1 / 60) * 1000;
const decay = v => -0.1 * ((1 / timing) ^ 4) + v;
...
const [momentum, setMomentum] = useState(0);
...
const handleMomentum = useCallback(
  throttle(nextMomentum => {
    setMomentum(nextMomentum);
    scrollRef.current.scrollLeft = scrollRef.current.scrollLeft + nextMomentum * timing * direction;
  }, timing),
  [scrollWrapperCurrent, direction]
);

useEffect(() => {
  if (direction !== 0) {
    if (momentum > 0 && !isDragging) {
      handleMomentum(decay(momentum));
    } else if (isDragging) {
      setMomentum(speed);
    } else {
      setDirection(0);
    }
  }
}, [momentum, isDragging, speed, direction, handleMomentum]);


Enter fullscreen mode Exit fullscreen mode

衰减函数描述了数值随时间呈指数级下降的过程。这正是我们需要的!因此,在 handleDragEnd 函数中将 isDragging 设置为 false 后,它会开始累加一个由动量引起的位移值,该值会不断重新计算,直到达到零,从而停止运动。

点击滚动框后,要停止移动,我们将方向设置为零。



const handleDragStart = e => {
  ...
  setDirection(0);
};


Enter fullscreen mode Exit fullscreen mode

拖动链接和图像

利用我们已经跟踪的 isDragging 事件,我们可以将容器指针事件设置为none。这样,在拖动过程中,链接、按钮或图像都不会像预期那样工作,而是会像往常一样被拖动。



const handleDragMove = e => {
  e.preventDefault();
  e.stopPropagation();
  ...
}
...
return (
  <div className="scroll-box">
    <div className="scroll-box__wrapper" ref={scrollWrapperRef}>
      <div className="scroll-box__container" role="list" style={{ pointerEvents: isDragging ? 'none' : undefined }}>
        {children.map((child, i) => (
          <div role="listitem" key={`scroll-box-item-${i}`} className="scroll-box__item">
            {child}
          </div>
        ))}
      </div>
    </div>
  </div>
 );


Enter fullscreen mode Exit fullscreen mode

最后一个组成部分



import React, { useRef, useState, useEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
import throttle from 'lodash/throttle';
import './scrollBox.css';

const timing = (1 / 60) * 1000;
const decay = v => -0.1 * ((1 / timing) ^ 4) + v;

function ScrollBox({ children }) {
  const scrollWrapperRef = useRef();
  const [clickStartX, setClickStartX] = useState();
  const [scrollStartX, setScrollStartX] = useState();
  const [isDragging, setIsDragging] = useState(false);
  const [direction, setDirection] = useState(0);
  const [momentum, setMomentum] = useState(0);
  const [lastScrollX, setLastScrollX] = useState(0);
  const [speed, setSpeed] = useState(0);
  const handleLastScrollX = useCallback(
    throttle(screenX => {
      setLastScrollX(screenX);
    }, timing),
    []
  );
  const handleMomentum = useCallback(
    throttle(nextMomentum => {
      setMomentum(nextMomentum);
      scrollRef.current.scrollLeft = scrollRef.current.scrollLeft + nextMomentum * timing * direction;
    }, timing),
    [scrollWrapperCurrent, direction]
  );
  useEffect(() => {
    if (direction !== 0) {
      if (momentum > 0.1 && !isDragging) {
        handleMomentum(decay(momentum));
      } else if (isDragging) {
        setMomentum(speed);
      } else {
        setDirection(0);
      }
    }
  }, [momentum, isDragging, speed, direction, handleMomentum]);

  const scrollWrapperCurrent = scrollWrapperRef.current;
  useEffect(() => {
    if (scrollWrapperRef.current) {
      const handleDragStart = e => {
        setClickStartX(e.screenX);
        setScrollStartX(scrollWrapperRef.current.scrollLeft);
        setDirection(0);
      };
      const handleDragMove = e => {
        e.preventDefault();
        e.stopPropagation();

        if (clickStartX !== undefined && scrollStartX !== undefined) {
          const touchDelta = clickStartX - e.screenX;
          scrollWrapperRef.current.scrollLeft = scrollStartX + touchDelta;

          if (Math.abs(touchDelta) > 1) {
            setIsDragging(true);
            setDirection(touchDelta / Math.abs(touchDelta));
            setSpeed(Math.abs((lastScrollX - e.screenX) / timing));
            handleLastScrollX(e.screenX);
          }
        }
      };
      const handleDragEnd = () => {
        if (isDragging && clickStartX !== undefined) {
          setClickStartX(undefined);
          setScrollStartX(undefined);
          setIsDragging(false);
        }
      };

      if (scrollWrapperRef.current.ontouchstart === undefined) {
        scrollWrapperRef.current.onmousedown = handleDragStart;
        scrollWrapperRef.current.onmousemove = handleDragMove;
        scrollWrapperRef.current.onmouseup = handleDragEnd;
        scrollWrapperRef.current.onmouseleave = handleDragEnd;
      }
    }
  }, [scrollWrapperCurrent, clickStartX, isDragging, scrollStartX, handleLastScrollX, lastScrollX]);

  return (
    <div className="scroll-box">
      <div className="scroll-box__wrapper" ref={scrollWrapperRef}>
        <div className="scroll-box__container" role="list" style={{ pointerEvents: isDragging ? 'none' : undefined }}>
          {children.map((child, i) => (
            <div role="listitem" key={`scroll-box-item-${i}`} className="scroll-box__item">
              {child}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

ScrollBox.propTypes = {
  children: PropTypes.node.isRequired,
};

export default ScrollBox;


Enter fullscreen mode Exit fullscreen mode

有所改进!

我们可以通过创建一个钩子来移除组件中的所有逻辑!这非常简单!

我们的钩子函数将被命名为 useScrollBox:



import { useState, useEffect, useCallback } from 'react';
import throttle from 'lodash/throttle';

const timing = (1 / 60) * 1000;
const decay = v => -0.1 * ((1 / timing) ^ 4) + v;

function useScrollBox(scrollRef) {
  const [clickStartX, setClickStartX] = useState();
  const [scrollStartX, setScrollStartX] = useState();
  const [isDragging, setIsDragging] = useState(false);
  const [direction, setDirection] = useState(0);
  const [momentum, setMomentum] = useState(0);
  const [lastScrollX, setLastScrollX] = useState(0);
  const [speed, setSpeed] = useState(0);

  const scrollWrapperCurrent = scrollRef.current;
  const handleLastScrollX = useCallback(
    throttle(screenX => {
      setLastScrollX(screenX);
    }, timing),
    []
  );
  const handleMomentum = useCallback(
    throttle(nextMomentum => {
      setMomentum(nextMomentum);
      scrollRef.current.scrollLeft = scrollRef.current.scrollLeft + nextMomentum * timing * direction;
    }, timing),
    [scrollWrapperCurrent, direction]
  );
  useEffect(() => {
    if (direction !== 0) {
      if (momentum > 0.1 && !isDragging) {
        handleMomentum(decay(momentum));
      } else if (isDragging) {
        setMomentum(speed);
      } else {
        setDirection(0);
      }
    }
  }, [momentum, isDragging, speed, direction, handleMomentum]);

  useEffect(() => {
    if (scrollRef.current) {
      const handleDragStart = e => {
        setClickStartX(e.screenX);
        setScrollStartX(scrollRef.current.scrollLeft);
        setDirection(0);
      };
      const handleDragMove = e => {
        e.preventDefault();
        e.stopPropagation();

        if (clickStartX !== undefined && scrollStartX !== undefined) {
          const touchDelta = clickStartX - e.screenX;
          scrollRef.current.scrollLeft = scrollStartX + touchDelta;

          if (Math.abs(touchDelta) > 1) {
            setIsDragging(true);
            setDirection(touchDelta / Math.abs(touchDelta));
            setSpeed(Math.abs((lastScrollX - e.screenX) / timing));
            handleLastScrollX(e.screenX);
          }
        }
      };
      const handleDragEnd = () => {
        if (isDragging && clickStartX !== undefined) {
          setClickStartX(undefined);
          setScrollStartX(undefined);
          setIsDragging(false);
        }
      };

      if (scrollRef.current.ontouchstart === undefined) {
        scrollRef.current.onmousedown = handleDragStart;
        scrollRef.current.onmousemove = handleDragMove;
        scrollRef.current.onmouseup = handleDragEnd;
        scrollRef.current.onmouseleave = handleDragEnd;
      }
    }
  }, [scrollWrapperCurrent, clickStartX, isDragging, scrollStartX, handleLastScrollX, lastScrollX]);

  return { clickStartX, scrollStartX, isDragging, direction, momentum, lastScrollX, speed };
}

export default useScrollBox;


Enter fullscreen mode Exit fullscreen mode

我们的组件可以像使用其他任何钩子一样使用它。



import React, { useRef } from 'react';
import PropTypes from 'prop-types';
import useScrollBox from './useScrollBox';
import './scrollBox.css';

function ScrollBox({ children }) {
  const scrollWrapperRef = useRef();
  const { isDragging } = useScrollBox(scrollWrapperRef);
  return (
    <div className="scroll-box">
      <div className="scroll-box__wrapper" ref={scrollWrapperRef}>
        <div className="scroll-box__container" role="list" style={{ pointerEvents: isDragging ? 'none' : undefined }}>
          {children.map((child, i) => (
            <div role="listitem" key={`scroll-box-item-${i}`} className="scroll-box__item">
              {child}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

ScrollBox.propTypes = {
  children: PropTypes.node.isRequired,
};

export default ScrollBox;


Enter fullscreen mode Exit fullscreen mode

现在是不是很漂亮?希望你喜欢并且学到了新东西!

文章来源:https://dev.to/murilovarela/an-easy-scroll-box-implementation-2b6a