移动端自定义横向滚动条

787 阅读2分钟

导言

最近在开发移动端项目的时候,遇到了一个自定义横向滚动条的需求,大致的实现效果是这样的:

2022-06-30-145147.gif

需求解析

这个效果实际上就是一个普通的横向可滚动区域,加上一个自定义的滚动条。解析后的示意图如下:

image.png

  • containerWidth 容器宽度,也就是可视区域的宽度
  • containerRealWidth 滚动内容的实际宽度
  • scrollLeft 可视区域与滚动内容左侧的距离
  • blockWidth 滑块宽度
  • railWidth 轨道宽度
  • blockLeft 滑块距离轨道左侧的距离

上面这些变量就是实现这个效果的关键变量了,其中只有 blockWidthblockLeft 这两个变量需要通过计算获得。然而,通过仔细观察示意图,不难发现下面的滚动条实际上就是上面的可视区域和实际宽度的等比缩小版,所以我们可以通过比例来计算这两个变量:

  • blockWidth = railWidth * containerWidth / containerRealWidth
  • blockLeft = scrollLeft * railWidth / containerRealWidth

到这里,所有的变量就都可以取值了,但是还差了一步,也就是当内容滚动的时候,我们需要动态计算blockLeft的值来挪动滑块的位置。到这里其实是有两种方案的:

  • 监听容器的 scroll 事件,并通过设置节流来实现滑块的移动
  • 监听容器的 scroll 事件,通过 window.requestAnimationFrameApi 来代替节流函数的能力

window.requestAnimationFrame 是 js 中绘制帧动画的神器,顾名思义,是用来请求动画帧的。其相对于节流函数来说最大的优势就是稳定,传入的回调函数的执行时机由系统来决定,并能保证在屏幕每一次的刷新间隔中只被执行一次,这就使得呈现的动画效果比较丝滑。而其具体的执行时间间隔则由屏幕的刷新率决定,例如,你的电脑屏幕的刷新率为 60Hz,那执行间隔就是 1000ms / 60 = 16.67ms;如果刷新率为 75Hz,那执行间隔就是 1000ms / 75 = 13.33ms

剩下的还有浏览器自带的滚动条的问题,可以用 css 伪元素直接去掉,但是兼容性可能不太好。或者可以在外层包一个父容器,把滚动条盖住即可。

代码实现(用移动端模式运行)

<!DOCTYPE html>
<html lang="zh">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>

    <style>
      html,
      body {
        margin: 0;
        padding: 0;
      }
      
      .mask {
        width: 100vw;
        height: 90px;
        overflow: hidden;
        position: relative;
      }

      .container {
        width: 100vw;
        height: 100px;
        overflow-x: auto;
        white-space: nowrap;
        display: flex;
      }

      .item {
        min-width: 20%;
        height: 100%;
        line-height: 100px;
        text-align: center;
      }

      .rail {
        width: 50px;
        height: 2px;
        background-color: #ddd;
        position: absolute;
        bottom: 5px;
        left: 50%;
        margin-left: -25px;
      }

      .block {
        height: 100%;
        background-color: red;
        position: absolute;
        top: 0;
        left: 0;
      }
    </style>
  </head>
  <body>
    <div class="mask">
      <div class="container">
        <span class="item">item0</span>
        <span class="item">item1</span>
        <span class="item">item2</span>
        <span class="item">item3</span>
        <span class="item">item4</span>
        <span class="item">item5</span>
        <span class="item">item6</span>
      </div>
      <div class="rail">
        <div class="block"></div>
      </div>
    </div>
  </body>

  <script>
    (function () {
      const container = document.querySelector('.container');
      const containerWidth = container.getBoundingClientRect().width;
      const containerRealWidth = container.scrollWidth;

      let rate = containerWidth / containerRealWidth;

      const rail = document.querySelector('.rail');
      const block = document.querySelector('.block');
      const railWidth = rail.getBoundingClientRect().width;
      const blockWidth = railWidth * rate;
      block.style.width = blockWidth + 'px';

      rate = railWidth / containerRealWidth;

      function move() {
        const blockLeft = container.scrollLeft * rate;
        block.style.left = blockLeft + 'px';
      }

      let animId;
      container.addEventListener('scroll', () => {
        animId = requestAnimationFrame(move);
      });
      window.onbeforeunload = () => cancelAnimationFrame(animId);
    })();
  </script>
</html>