一个简单的自定义滚动条

675 阅读3分钟

近期在看 CSSOM View 相关的一些东西,为了加深印象,便寻思着如何付诸实践,于是想起之前至少有过几次的类似下面的对话:

产品:这个滚动条样式太丑了,怎么跟 UI 上不一样

我:xx IE 不支持自定义浏览器滚动条样式,我有什么办法啊

产品:你看别人的为啥就可以

我:这个滚动条是人自己实现通过 DOM 模拟的,很麻烦(极尽夸张之能事)的,再说了这年头正经公司谁还用 xx IE 嘛,balabalabala……

正好也试着自定义一下,只知道肯定能做,但是具体实现思路以及能做到什么程度也没有整体把握,但最终实现了一个基本能用的自定义滚动条之后,再回过头来总结一下大致思路(只拿垂直方向上的举例):

  1. 不需要自定义滚动条的情形(非 IE、scrollHeight 和 clientHeight 相等)
  2. 屏蔽浏览器自带的滚动条(设置对应方向上的 overflow 为 hidden)
  3. 创建的 scroll 元素通过 absolute 定位时,需要保证 elm 为定位元素(即 position 的 computed value 为 static 时,需要修改为 relative)
  4. Track(滑道)需要「吸顶」,即 thumb(滑块)滑动时,track 需要始终保持在 scroll view 的右边,也就是说 elm 进行 scroll 多少距离,track 也要跟着 scroll 多少距离
  5. 确定 thumb 的长度,可以使其与 elm.clientHeight 的比例等同于 elm.clientHeight 与 elm.scrollHeight 的比例来计算出长度,但这样会出现 thumb 的长度很小的可能,所以浏览器一般都保证了 thumb 有个最小的长度,以及确定 elm scroll 时,thumb 在 track 上对应的 scroll 距离,显然二者是准在一定的比例关系的,即:thumbScrollTop / (elm.clientHeight - thumbHeight) = elm.scrollTop / (elm.scrollHeight - elm.clientHeight)
  6. 确定哪些事件会触发 scroll,主要是鼠标的 wheel,键盘的方向键和翻页键、鼠标点击 thumb 拖动,以及点击 track 等事件

下面便是一个为 elm 设置自定义滚动条的简单示例:

function customizeScroll (elm) {
    if (!(elm instanceof HTMLElement)) {
      return
    }
​
    if (elm.scrollHeight === elm.clientHeight) {
      return;
    }
​
    const scrollElm = document.createElement("div");
    const trackElm = document.createElement("div");
    const thumbElm = document.createElement("div");
    const elmComputedStyle = getComputedStyle(elm);
​
    if (elmComputedStyle.overflowY !== "hidden") {
      elm.style.overflowY = "hidden";
      elm.style.tabIndex = -1;
      elm.style.outline = "none";
    }
​
    if (elmComputedStyle.position === "static") {
      elm.style.position = "relative";
    }
​
    scrollElm.style.position = "absolute";
    scrollElm.style.top = 0;
    scrollElm.style.bottom = 0;
    scrollElm.style.right = 0;
​
    trackElm.style.height = "100%";
    trackElm.style.width = "10px";
    trackElm.style.borderRadius = "10px";
    trackElm.style.backgroundColor = "rgba(128, 128, 128, 0.26)";
​
    thumbElm.style.width = "8px";
    thumbElm.style.borderRadius = "8px";
    thumbElm.style.padding = "0 1px";
    thumbElm.style.backgroundColor = "#333";
    thumbElm.style.opacity = 0.3;
​
    const scrollHeight = elm.scrollHeight;
    const clientHeight = elm.clientHeight;
    const maxScrollTop = scrollHeight - clientHeight;
    const thumbHeight = Math.max(
      24,
      (clientHeight * clientHeight) / scrollHeight
    );
​
    let mouseDownOnThumb = false;
    let mouseDownScreenY = 0;
​
    thumbElm.style.height = thumbHeight + "px";
    trackElm.appendChild(thumbElm);
    scrollElm.appendChild(trackElm);
    elm.appendChild(scrollElm);
​
    elm.addEventListener("wheel", onWheel);
    elm.addEventListener("keydown", onKeyDown);
    trackElm.addEventListener("click", onClickTrack);
    thumbElm.addEventListener("mousedown", onMouseDown);
    window.addEventListener("mouseup", onWindowMouseUp);
​
    let tId = null;
​
    function scrollByEvent(e, deltaY) {
      if (tId) {
        clearInterval(tId);
      }
​
      if (e.type !== "mousemove") {
        const scrollTop = elm.scrollTop;
​
        const duration = 100;
        const frequency = 10;
        const deltaT = duration / frequency;
        const deltaYPerDeltaT = deltaY / frequency;
        let count = 1;
​
        tId = setInterval(function () {
          const targetScrollTop = Math.max(
            0,
            Math.min(scrollTop + deltaYPerDeltaT * count, maxScrollTop)
          );
          const deltaYThumb = parseInt(
            (targetScrollTop * (clientHeight - thumbHeight)) /
              (scrollHeight - clientHeight)
          );
​
          elm.scrollTop = scrollTop + deltaYPerDeltaT * count;
          scrollElm.style.transform =
            "translateY" + "(" + targetScrollTop + "px" + ")";
          thumbElm.style.transform =
            "translateY" + "(" + deltaYThumb + "px" + ")";
​
          if (count < 10) {
            count++;
          } else {
            clearInterval(tId);
            tId = null;
          }
        }, deltaT);
​
        const realScrollTop = Math.max(
          0,
          Math.min(scrollTop + deltaY, maxScrollTop)
        );
​
        if (realScrollTop > 0 && realScrollTop < maxScrollTop) {
          e.preventDefault();
        }
      } else {
        const scrollTop = elm.scrollTop;
        const targetScrollTop = Math.max(
          0,
          Math.min(scrollTop + deltaY, maxScrollTop)
        );
        const deltaYThumb = parseInt(
          (targetScrollTop * (clientHeight - thumbHeight)) /
            (scrollHeight - clientHeight)
        );
​
        elm.scrollTop = targetScrollTop;
        scrollElm.style.transform = "translateY(" + targetScrollTop + "px)";
        thumbElm.style.transform = "translateY(" + deltaYThumb + "px)";
      }
    }
​
    function onWheel(e) {
      if (e.deltaY !== 0) {
        scrollByEvent(e, (e.deltaY / Math.abs(e.deltaY)) * 100);
      }
    }
​
    function onClickTrack(e) {
      if (e.target === trackElm) {
        const transformY = parseInt(
          thumbElm.style.transform.replace(/\D/g, "") || 0
        );
​
        const delta = e.offsetY - transformY;
​
        if (delta !== 0) {
          scrollByEvent(e, (delta / Math.abs(delta)) * 150);
        }
      }
    }
​
    function onKeyDown(e) {
      if (e.metaKey || e.shiftKey || e.ctrlKey || e.altKey) {
        return;
      }
      if (e.keyCode === 40) {
        scrollByEvent(e, 40);
      } else if (e.keyCode === 38) {
        scrollByEvent(e, -40);
      } else if (e.keyCode === 34) {
        scrollByEvent(e, 150);
      } else if (e.keyCode === 33) {
        scrollByEvent(e, -150);
      }
    }
​
    function onWindowMouseMove(e) {
      const deltaY = e.screenY - mouseDownScreenY;
      const scrollTop = elm.scrollTop;
​
      mouseDownScreenY = e.screenY;
​
      scrollByEvent(e, (deltaY * scrollHeight) / clientHeight);
    }
​
    function onMouseDown(e) {
      if (e.button === 0) {
        mouseDownOnThumb = true;
        mouseDownScreenY = e.screenY;
        thumbElm.style.opacity = 1;
        window.addEventListener("mousemove", onWindowMouseMove);
      }
    }
​
    function onWindowMouseUp(e) {
      if (mouseDownOnThumb) {
        mouseDownOnThumb = false;
        thumbElm.style.opacity = 0.3;
        window.removeEventListener("mousemove", onWindowMouseMove);
      }
    }
  }

诚然,这只是一个满足基本条件的示例,但实际做的过程中却也不出意外地体会到「纸上得来终觉浅」:

  • 普通元素需要监听 keydown 事件需要通过设置其 tabindex attribute 将其置为 focusable
  • 有些浏览器的 wheel 事件、以及 key 为 pageDown、arrowDown 等的 keyDown 事件会伴随一个 default action,即触发 viewport 对应的滚动条(如果有的话)的相应行为,所以在 elm 上特定条件下,那些事件相关的 default action 可以通过 e.preventDefault() 来终止(以优化体验)
  • 通过设置 elm.scrollTop 改变其值,浏览器就会 emit 一个以 elm 为 target 的 scroll 事件(原先还以为需要在 scroll 时手动调用 elm.dispatchEvent)
  • keyCode 都建议不被使用了,上面的代码好像也只配在 IE 上跑