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

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


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

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


1、实现思路

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

一、css修改。

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

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

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

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

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

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

2、js实现思路(pc端)

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

一、监听容器的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;
})复制代码

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

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

// 根据 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';
}复制代码

三、滑块拖拽

本质上咱们能够把滑块的状态做为容器的衍生状态来看待,因此只要有容器的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);  
  });
})复制代码

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

咱们的算法不变,假设用户在轨道上随机一个位置点击,咱们只需得出该位置相对于滚动条的偏移量便可。在现代浏览器中,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);
  }
})复制代码

五、平滑滚动

若是一次滚动直愣愣的到达终点,是否是很生硬?咱们让他看起来更丝滑一些,这也是原生滚动具备的效果。由于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());
}复制代码

六、滚动条隐藏与显示

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

七、键盘导航

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

八、内容选中滚动

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

3、还须要作什么

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

一、容器的resize。你要从新计算滚动条的全部状态,以保证显示正确。

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

三、别忘了横向滚动条。

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

相关文章
相关标签/搜索