阅读 726

自定义滚动条的实现思路与关键算法

在web开发中,自定义滚动条是个常见的需求,虽然浏览器原生的滚动条很强大并且在大多数场景下表现的很好,但某些时候我们仍然希望修改他的样式,比如变细一点,或者去掉圆角和轨道,又或者隐藏他们。这些都属于自定义行为,本篇文章将介绍自定义滚动条的几种实现思路,并着重讲解最流行的js方案。


上图是自定义的效果(视频在转换时降速了,其实非常快)

在正文开始前,我们先统一滚动条各个部分的名称。


一、实现思路

实现自定义滚动条的方式不止一种,这里列出三种方式。

1、css修改。

这是最简单的方式,你可以通过::-webkit-scrollbar这个css伪类选择器去修改滚动条样式,包括滚动条轨道、滑块以及上下箭头等,但它只支持webkit内核的浏览器,并且它不是css标准的一部分,这意味着除了浏览器兼容性问题外,将来还可能被浏览器厂商删掉并转而采用新标准。

2、自行实现滚动条部分,但scroll行为交给浏览器原生实现。

这种思路的关键是不能将容器的overflow设为hidden,这样虽然隐藏了滚动条,但也禁止了滚动行为。所以开发者尝试将滚动条遮盖起来,一般通过多个div的嵌套和偏移(偏移量恰好是滚动条的宽度)来实现。遮盖后再将模拟的滚动条固定在容器右侧和底部。之后的关键点就是计算模拟滚动条的宽高与位置,并且监听容器的scroll事件,及时更新滚动条的状态,如果用户拖动滚动条,则此时不能依靠原生滚动行为,需要自己计算实际滚动距离去更新容器的scrollLeft及scrollTop。参考simplebarreact-custom-scrollbars

该方案有很多优点,首先你可以完全自定义滚动条的样式而不用考虑兼容性问题,其次它的性价比非常高,绝大多数时间,你使用的是浏览器默认的行为(他们性能优秀而且覆盖了边际情况),只有在用户拖动滚动条时,才需要手动计算并更新容器的滚动距离。不过该方案也并非完美无缺,最大的问题是你需要添加多层div才能覆遮盖住原生滚动条,这在一定程度上破坏了开发者预先设想的文档结构。

3、自行实现滚动行为与滚动条样式。

该方案比较复杂,因为滚动行为通常由三个条件触发,分别是鼠标滚轮(或触控板)滑动、键盘导航、鼠标拖动(选择文字时),你得同时监听这三种事件,同时要考虑兼容问题,因为这三种事件在各个浏览器不统一。滚动条部分与方案2相同,这里不再赘述。 虽然这个方案不好搞,但正因为完全自定义,你得以写出更丰富的滚动逻辑,比如整屏滚动或者增加颜色特效。该方案在社区最为流行。

二、js实现思路(pc端)

这里会详细阐述方案3的实现思路。让我们从零开始,现在有一个容器,他的子元素高度超过了容器的高度,需要给他添加一个纵向的滚动条,从交互角度出发,可以分解成以下步骤。

1、监听容器的mousewheel事件。

通过鼠标滚轮或者触控板的滑动,浏览器会生成mousewheel事件,事件中带有滚动偏移量,我们要利用该数值来修改容器的scrollTop以达到滚动效果。这里的问题是mousewheel不是一个标准事件,各个浏览器携带不一样的事件信息,滚动偏移量也不同,所以我们需要抹平他们的差异。一个好的办法是将滚动偏移量统一设为1。

const userAgent = window.navigator.userAgent; 
let isSafari = (userAgent.indexOf('Chrome') === -1) && (userAgent.indexOf('Safari') >= 0);
function standardizedWheel(e) {  
  let wheelEvent = Object.assign({}, e);  
  // vertical    
  if (typeof e.wheelDeltaY !== 'undefined') {    
    // webkit        
    wheelEvent.deltaY = e.wheelDeltaY / 120;  
  } else if (typeof e.VERTICAL_AXIS !== 'undefined' && e.axis === e.VERTICAL_AXIS) {    
    // Firefox < 17        
    wheelEvent.deltaY = -e.detail / 3;  
  }  

  // horizental    
  if (typeof e.wheelDeltaX !== 'undefined') {    
    // webkit        
    if (isSafari) {      
      wheelEvent.deltaX = - (e.wheelDeltaX / 120);    
    } else {      
      wheelEvent.deltaX = e.wheelDeltaX / 120;    
    }  
  } else if (typeof e.HORIZONTAL_AXIS !== 'undefined' && e2.axis === e2.HORIZONTAL_AXIS) {    
    // Firefox < 17        
    wheelEvent.deltaX = -e.detail / 3;  
  } 

  if (wheelEvent.deltaY === 0 && wheelEvent.deltaX === 0 && e.wheelDelta) {    
    // IE        
    wheelEvent.deltaY = e.wheelDelta / 120;    
  }  
  return wheelEvent;
}复制代码

通常来讲,向下滚动偏移量为-1,反之为1。

之后我们设定一个滚动系数(scrollFactor),它可以是字的行高,也可以是任意数值,这取决于你希望在一次滚动中经过的像素是多少。然后用它乘以偏移量,作为最终的滚动偏移量。

let containerDom;
const scrollFactor = 50;
containerDom.addEventListener('mousewheel', (e) => {  
  let wheelEvent = standardizedWheel(e);  
  let scrollTop = containerDom.scrollTop - e.deltaY * scrollFactor;  
  containerDom.scrollTop = scrollTop;
})复制代码

2、校准滑块的大小与位置

在纵向的滚动条中,滑块的高度如何计算呢?如果把内容与滚动条分成两个区域,那么他们的可见区域和可滚动区域的比是相等的。

// 根据 visibleHeight / scrollHeight = sliderHeight / scrollbarHeight 得出
sliderHeight = visbileHeight * scrollbarHeight / scrollHeight复制代码

接下来解决滑块的位置,在先前的方法中,我们已经知道了scrollTop,只需要让它乘以两个区域的可滚动高度比即可。

// 滑块区域与内容区域的比例
sliderRatio = (scrollHeight - visibleHeight) / (scrollbarHeight - sliderHeight)

sliderTop = scrollTop * sliderRatio 复制代码

我们将滑块状态的计算方式写成一个函数,随着页面滚动scrollTop始终在变化,需要不停地调用它来更新滑块状态。另外你要处理好边界情况,判断滚动行为是否到了可滚动区域的上限,不要让滚动无休止的下去。

let scrollbar /* 滚动条元素 */
let sliderDom /* 滑块元素 */
function updateSlider(scrollTop) {
    sliderHeight = containerDom.clientHeight * scrollbar.clientHeight / containerDom.scrollHeight;
    sliderRatio = (scrollbar.clientHeight - sliderDom.clientHeight) / (containerDom.scrollHeight - containerDom.clientHeight);
    sliderTop = scrollTop * sliderRatio;
    // 更新滑块的高度和位置
    sliderDom.style.height = sliderHeight + 'px';
    sliderDom.style.top = sliderTop + 'px';
}复制代码

3、滑块拖拽

本质上我们可以把滑块的状态作为容器的衍生状态来看待,所以只要有容器的scrollTop,滑块的位置就能确定。现在我们使用兼容性更好的mouse事件。当鼠标点击滑块时,触发mousedown,记录下当时滑块的位置(pageY),随后开始mousemove的监听,在鼠标移动的过程中,我们使用新的pageY减去初始pageY,作为该次滚动的差值moveDelta,得出滑块滚动的位置 sliderTop = lastedSliderTop + moveDelta。还记得我们之前提到的公式吗,稍微改下就得出scrollTop = sliderTop / sliderRatio。之后根据scrollTop校准滑块的位置即可。

sliderDom.addEventListener('mousedown', (e) => {  
  let lastedPageY = e.pageY;  
  let lastedScrollTop = containerDom.scrollTop * sliderRatio;  
  let scrollTop;  
  document.addEventListener('mousemove', (e) => {    
    let moveDelta = e.pageY - lastedPageY;    
    let sliderTop = lastedScrollTop + moveDelta;    
    scrollTop = sliderTop / sliderRatio;    
    containerDom.scollTop = scrollTop;    
    updateSlider(scrollTop);  
  });
})复制代码

4、点击滚动轨道的特定位置

我们的算法不变,假设用户在轨道上随机一个位置点击,我们只需得出该位置相对于滚动条的偏移量即可。在现代浏览器中,mousedown事件会直接返回给你offsetY,假如没有就需要简单算下。我们需要使用pageY,注意这个属性是包含文档的滚动距离的。

// 事实上pageY与scrollY在那些陈旧的浏览器也不支持,你可以参考mdn给出的兼容方案
offsetY = e.pageY - scrollbar.getBoundingClientRect().top - window.scrollY;复制代码

offsetY减去滑块的高度就是这次滚动的末端位置,不过浏览器通常会定位到滑块的中心点,我们也遵守这个原则,只需要除以2即可。现在我们算出了滑块的位置,将它除以sliderRatio,就得出 scrollTop = (offsetY - sliderHeight / 2) / sliderRatio

scollbar.addEventListener('mousedown', (e) => {
  if (e.target !== sliderDom) {
    let offsetY = e.pageY - scrollbar.getBoundingClientRect().top - window.scrollY;
    scrollTop = (offsetY - sliderDom.clientHeight / 2) / sliderRatio;
    containerDom.scrollTop = scrollTop;
    updateSlider(scrollTop);
  }
})复制代码

5、平滑滚动

如果一次滚动直愣愣的到达终点,是不是很生硬?我们让他看起来更丝滑一些,这也是原生滚动具有的效果。因为scrollTop属性无法用css做动画,所以只能用js实现。我们希望滚动在一开始很快,随着时间推移在快到终点时变慢,所以定义一个缓动函数

// 参数t是时间进度
function easeOutCubic(t) {
  return 1 - Math.pow(1 - t, 3);
}复制代码

比如滚动开始时间是0,你希望在3s内滚动到终点,那么他在前2s行动的很快,在最后一秒又降速变慢,当然过程是很平缓的。我们的目标是根据当前的时间进度,得出滚动距离。

function scrollToSmooth(from/* 起点 */, to/* 终点 */, duration/* 持续时间 */) {  
  let startTime = Date.now();  
  let delta = to - from; 
  function tick(now) {    
    // 计算完成度    
    let completion = (now - startTime) / duration;  
    if (completion < 1) {      
      // 小于1表示没有完成滚动      
      let newScrollTop = from + delta * easeOutCubic(completion);      
      return {        
        scrollTop: newScrollTop,        
        done: false      
      }    
    }    
    return {      
      scrollTop: to,      
      done: true    
    }  
  }  
  function performScrolling() {    
    let update = tick(Date.now());    
    if (update.done) {      
      return    
    }    
    // 用新的距离更新容器的    
    containerDom.scrollTop = update.newScrollTop;    
    requestAnimationFrame(performScrolling());  
  }  
  //为了获得性能提升,这里用requestAnimationFrame执行它。  
  requestAnimationFrame(performScrolling());
}复制代码

6、滚动条隐藏与显示

mouseover时,处理好滚动条的display即可。

7、键盘导航

容器要响应上下左右,PageUp,PageDown, Home, End按键,每个按键有不同的滚动offset。难点在键盘事件的兼容性上,参考KeyboardEvent和快捷键

8、内容选中滚动

这种场景在鼠标选中容器子元素的并且越过容器边界的时候发生。需要容器监听mousedown,在mousemove的过程中检查鼠标坐标是否越过容器边界,再根据鼠标停留时间做若干次偏移。

三、还需要做什么

做完了上面这些,你已经搞出一个可用的滚动条了,但这只是为了讲述思路的玩具代码,并不能用在真实环境。在实际开发中,你可能需要使用面向对象设计来组织你的代码,并处理好所有事件的回收,此外,你还要小心处理以下问题。

1、容器的resize。你要重新计算滚动条的所有状态,以保证显示正确。

2、小心iframe。在鼠标事件经过iframe时会产生各种匪夷所思的问题,如果你不幸使用了它,最简单的办法是跨过iframe时销毁监听函数,保证内存不泄露。

3、别忘了横向滚动条。

4、别忘了手机与平板环境下的滚动。


关注下面的标签,发现更多相似文章
评论