在web开发中,自定义滚动条是个常见的需求,虽然浏览器原生的滚动条很强大而且在大多数场景下表现的很好,但某些时候咱们仍然但愿修改他的样式,好比变细一点,或者去掉圆角和轨道,又或者隐藏他们。这些都属于自定义行为,本篇文章将介绍自定义滚动条的几种实现思路,并着重讲解最流行的js方案。javascript
上图是自定义的效果(视频在转换时降速了,其实很是快)css
在正文开始前,咱们先统一滚动条各个部分的名称。java
实现自定义滚动条的方式不止一种,这里列出三种方式。react
这是最简单的方式,你能够经过::-webkit-scrollbar这个css伪类选择器去修改滚动条样式,包括滚动条轨道、滑块以及上下箭头等,但它只支持webkit内核的浏览器,而且它不是css标准的一部分,这意味着除了浏览器兼容性问题外,未来还可能被浏览器厂商删掉并转而采用新标准。git
这种思路的关键是不能将容器的overflow设为hidden,这样虽然隐藏了滚动条,但也禁止了滚动行为。因此开发者尝试将滚动条遮盖起来,通常经过多个div的嵌套和偏移(偏移量刚好是滚动条的宽度)来实现。遮盖后再将模拟的滚动条固定在容器右侧和底部。以后的关键点就是计算模拟滚动条的宽高与位置,而且监听容器的scroll事件,及时更新滚动条的状态,若是用户拖动滚动条,则此时不能依靠原生滚动行为,须要本身计算实际滚动距离去更新容器的scrollLeft及scrollTop。参考simplebar和react-custom-scrollbars。github
该方案有不少优势,首先你能够彻底自定义滚动条的样式而不用考虑兼容性问题,其次它的性价比很是高,绝大多数时间,你使用的是浏览器默认的行为(他们性能优秀并且覆盖了边际状况),只有在用户拖动滚动条时,才须要手动计算并更新容器的滚动距离。不过该方案也并不是天衣无缝,最大的问题是你须要添加多层div才能覆遮盖住原生滚动条,这在必定程度上破坏了开发者预先设想的文档结构。web
该方案比较复杂,由于滚动行为一般由三个条件触发,分别是鼠标滚轮(或触控板)滑动、键盘导航、鼠标拖动(选择文字时),你得同时监听这三种事件,同时要考虑兼容问题,由于这三种事件在各个浏览器不统一。滚动条部分与方案2相同,这里再也不赘述。 虽然这个方案很差搞,但正由于彻底自定义,你得以写出更丰富的滚动逻辑,好比整屏滚动或者增长颜色特效。该方案在社区最为流行。算法
这里会详细阐述方案3的实现思路。让咱们从零开始,如今有一个容器,他的子元素高度超过了容器的高度,须要给他添加一个纵向的滚动条,从交互角度出发,能够分解成如下步骤。
segmentfault
经过鼠标滚轮或者触控板的滑动,浏览器会生成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的过程当中检查鼠标坐标是否越过容器边界,再根据鼠标停留时间作若干次偏移。
作完了上面这些,你已经搞出一个可用的滚动条了,但这只是为了讲述思路的玩具代码,并不能用在真实环境。在实际开发中,你可能须要使用面向对象设计来组织你的代码,并处理好全部事件的回收,此外,你还要当心处理如下问题。
一、容器的resize。你要从新计算滚动条的全部状态,以保证显示正确。
二、当心iframe。在鼠标事件通过iframe时会产生各类匪夷所思的问题,若是你不幸使用了它,最简单的办法是跨过iframe时销毁监听函数,保证内存不泄露。
三、别忘了横向滚动条。
四、别忘了手机与平板环境下的滚动。