实现一个自定义滚动条

10,602 阅读10分钟
原文链接: github.com

因为最近在写项目的UI库,遇到自定义滚动条这一个槛还是卡了我挺久的,主要卡在了如何自动监听内容变化并更新滚动条高度。市面上基本所有的滚动条插件都没有实现这一点,最后面扒了element的源码才最终解决。本文主要讲的也是这个。

首先,我们先把需要实现的功能先确定下来。

  • 鼠标左键点击可以拖动
  • 鼠标滑轮滚动
  • 内容发生变化,自动更新滚动条长度
  • 提供开发者一个滚动回调的接口

前面两点依靠原生滚动条其实比较简单,但是在第三点上实在是卡了我好久,想了好久都没有想出来。最后还是看了element源码才实现成功。

接下去我会以垂直滚动条为例(水平滚动条基本同理),实现一个自定义的滚动条出来。我争取把其中原理细节讲清楚。

1、搭建好基本的样式框架

开始我们先把HTML和样式写好

<div class="scrollbar">
	<div class="scrollbar-content">
		<ul class="box">
			<li>11111</li><li>11111</li><li>11111</li><li>11111</li>
			<li>11111</li><li>11111</li><li>11111</li><li>11111</li>
			<li>11111</li><li>11111</li><li>11111</li><li>11111</li>
			<li>11111</li><li>11111</li><li>11111</li><li>11111</li>
		</ul>
	</div>
	<div class='scrollbar-bar'>
		<div ref="thumb" class="scrollbar-thumb"></div>
	</div>
</div>

第一步的HTML和CSS

滚动条的框架如上面所示,接下午我会以简称wrapbar,thumb进行简称

  • wrap :内容区域包裹框
  • bar : 包裹区域中自定义滚动条的滚动框
  • thumb :自定义滚动条

开始之前要大家可以先记住一点,我们并不是不用原生滚动条,实际上我们所有的操作都需要依靠原生滚动条才能实现。只不过它隐藏在了暗处,而让UI更好看的自定义滚动条出现在明处。

1.1计算出滚动条的宽度。

第一步我们先将原生的滚动条隐藏掉。但是这里涉及到第一个问题,那就是不同浏览器的下滚动条宽度是不一样的。我们需要准确的知道,如果wrap产生了滚动条,那它的宽度是多少。

先写一个获取到区域内滚动条的宽度(scrollWidth)的回调函数getScrollWidth,获取到滚动条高度之后,

function getScrollWidth(){
    const outer = document.createElement("div");
    outer.className = "el-scrollbar__wrap";
    outer.style.width = '100px';
    outer.style.visibility = "hidden";
    outer.style.position = "absolute";
    outer.style.top = "-9999px";
    document.body.appendChild(outer);

    const widthNoScroll = outer.offsetWidth;
    outer.style.overflow = "scroll";

    const inner = document.createElement("div");
    inner.style.width = "100%";
    outer.appendChild(inner);

    const widthWithScroll = inner.offsetWidth;
    outer.parentNode.removeChild(outer);
    scrollBarWidth = widthNoScroll - widthWithScroll;

    return scrollBarWidth;
}

获取到滚动条的宽度scrollBarWidth之后,通过再来设置wrap的css样式,通过marginRight将滚动条移动到视线之外

wrap.style.overflow = scroll;
wrap.style.marginRight = -scrollWidth + "px";
1.2计算出滚动条的高度。

第二步我们需要计算出滚动条的高度。计算方法也很简单,元素高度scrollHieght/内容高度clientHeight,得出来的就是滚动条所占的百分比。

因为内容高度经常变更,我们可以写一个更新滚动条高度的回调函数updateThumb,方便后期s随时调用。

function updateThumb(){
    let heightPercentage = (wrap.clientHeight * 100 / wrap.scrollHeight);
    thumb.style.height = heightPercentage + "%";   
}

到了这一步,基本上一个滚动条的基本样式已经出来了。接下去我们要实现它的使用功能。

查看第一步的成果

2、添加滚动条滑动功能

到这里我们已经可以看到成型的滚动条的UI界面了,但是仍然缺少滚动和拖动的功能。关键点是在于如何去监听滚动条的变化。

2.1滚轮滑动

还记得文章开头说过,我们所有功能的实现都依赖隐藏起来的原生滚动条。如果大家理解了我上面说的话,那么问题就简单了。当我们开始滑动滚轮的时候,隐藏在暗处的原生滚动条也会同时滚动,此时便会触发原生滚动条的scroll事件

这里可以再详细说明下。只要元素的scrollTop发生变化,就必然会触发scroll事件。所以我们操作滚轮,其实本质上是改变元素的scrollTop。

所以我们只需要写一个相应的回调函数handleScroll,在每次触发回调的时候,实时修改我们自定义滚动条的样式就行了。

function handleScroll(){
    this.moveY = (wrap.scrollTop *100 / wrap.clientHeight);
    //通过计算出来的百分比,然后对滚动条执行translate移动
    thumb.style.transform = "translateY" + moveY;
},

wrap.addEventListener('scroll',handleScroll);

查看滚轮滑动效果

2.2点击滚动框,滚动条及内容移动到相应位置

接下去我们实现第二个功能。当我们点击滚动框的一个位置时,滚动条也会跳到这个位置,同时内容位置也会发生改变。

第一步先获得点击的y坐标,然后计算出和滚动框bar顶部的距离,再算出占滚动框的百分比,这个百分比就是滚动条的高度

function clickTrackHandle(e){
    //获得点击位置与滚动框顶部之间的距离
    const offset = Math.abs(e.target.getBoundingClientRect().top - e.clientY)
    //让点击位置处于滚动条的中间
    const thumbHalf = thumb.offsetHeight / 2;
    //计算出滚动条在滚动框的百分比位置
    const thumbPositionPercentage = (offset - thumbHalf) * 100 / wrap.offsetHeight;
    //通过改变scrollTop来操作。所有操作滚动条的最后一步都是通过handleScroll来实现
    wrap.scrollTop = (thumbPositionPercentage * wrap.scrollHeight / 100);
}

bar.addEventListener("click",clickTrackHandle);

只要scrollTop值发生变化就会触发我们上一步写的回调。

查看点击滚动框的效果

2.3拖动滚动条,移动内容

接下来我们再去实现手动拖拽滚动条去实现移动内容,这个知识点就是拖拽的知识点,不过在看源码的时候发现element的习惯很好,他是在当你点击滚动条的时候绑定拖拽,然后松开的时候取消绑定。

function mouseMoveDocumentHandler(){};   //实时记录滚动条位置的拖拽函数

//当点击滚动条时
document.addEventListener("mousedown",mouseMoveDocumentHandler);
document.onselectstart = false; //同时阻止选中
//当松开滚动条时
document.removeEventListener("mousedown",mouseMoveDocumentHandler);
document.onselectstart = null; //同时阻止选中

因为这一块代码比较多,就不贴文章里,大家可以直接链接里看就是了。
查看拖动滚动条的效果

3、实现滚动条随内容实时更新

第二章讲的主要都是实现滚动条功能,这一章讲的是纠结 😖我很久的功能。

因为滚动条的高度并不是我们一开始能够确定的,它需要在dom内容渲染出来之后才能确定。而且有时候随着内容的变化,还需要实时改变滚动条的高度。再看了市面上的滚动条之后,发现基本都没有满足这一功能。

事实上缺少了这一点,使用起来是缺少视觉交互的。举个例子,加入一个原来有滚动条的元素因为内容减少导致了滚动条小时,但是自定义滚动条因为没有检测到变化仍然存在,那就会给用户造成困扰。

我不希望每次更新内容都要通过加一步回调函数来更新一下滚动条,而是希望它自己实时更新。在网上没有找到答案之后,最终去翻了element源码,研究了好久,总算找到了想要的答案。

关键点就在于我能前面之前说的那一句话——如果我们改变元素的scrollTop,是会触发scroll事件。

大家想象一个情景,如果滚动条永远出现在最底部,比如下图
image

那么只要我内容发生了一点变化,滚动条必然会变长或者变短。那么在滚动条长度变化时,scrollTop自然发生了改变(滚动条消失则scrollTop变为0),那么就会触发scroll的回调函数,那么我们就自动监测到了啊 😊。

在明白了这一点后,却又冒出来一个问题。正常情况下,滚动条不可能出现在最底部啊,那怎么办呢?

element选择了自己造一个置于底部的滚动条来满足自己需求。

做了个demo,查看效果点这里

<script>
    const ul = document.getElementById("ul");
    const resizeTrigger = document.createElement("div");
        resizeTrigger.className = "resize-triggers";
        resizeTrigger.innerHTML = '<div class="expand-trigger"><div><div></div></div></div>';
        ul.appendChild(resizeTrigger);
    	
    const resetTrigger = function (element) {
        const trigger = element.__resizeTrigger__;
        const expand = trigger.firstElementChild;
        const expandChild = expand.firstElementChild;
        expandChild.style.height = expand.offsetHeight + 1 + 'px';
        expand.scrollTop = expand.scrollHeight;
    };
    
    ul.addEventListener("scroll",function(){
        resetTrigger(this);
    },true)
</script>

ul是我们包裹内容的DOM元素。

配合着css来看,第一段JS我们创建出了resizeTrigger这个div,并且我们将他的height:100%。这样子如果内容发生变化,resizeTrigger永远和父元素ul同时改变高度。这里设置成高度100%非常重要,这样子才能主动同步到内容的变化

注意到resizeTrigger里面还有有一个父子元素expandexpandChild。在第二段JS的resetTrigger函数中。然后设置expandChild的高度超过父元素expand的高度,促使expand产生滚动条。然后我们再将滚动条的scrollTop设置为最大,这样子滚动条就会出现在滚动区域resizeTrigger的最底部了。

现在我们做到了将滚动条设置在了最底部,所以只要内容发生了变化,那么滚动条的scrollTop必然也会发生变化

最后一段代码就是scroll的监听。当监听到scrollTop值发生变化时,触发相应的回调函数。

所以这块代码最后的逻辑其实是这样的。内容改变 --> ul高度改变--> resizeTrigger高度改变 --> expand滚动条的scrollTop发生变化 --> 触发scroll的回调函数,在函数里面调整再次调整滚动条的高度,保证滚动条高度正确。

通过这三段代码,我们也基本实现了自动监听内容变化来更新滚动条。

简单画了个配图来帮助理解逻辑
image

image

通过两个小蓝框产生的滚动条来帮助监听内容变化

4、实现组件化,方便开发者使用

经过以上3大步基本上是可以实现一个自定义的滚动条的。上面的代码是面向原生js的。在我们的项目里面,实现第4点是通过封装成一个scrollbar的的组件,在项目里面进行使用。

这一条要求因为不同框架展现方式都不一样,所以就不详细贴代码了因为自己项目用的是一个Vue框架,所以是个Vue组件,有需求可以自己去看。

没有写过写Vue组件的可以看看这一篇,少踩些坑

查看scrollbar组件


好了文章就到此结束了,在看人家源码的过程中也学到了许多。比如使用JSX来编写组件;scroll监听其实就是判断scrollTop;比如通过自己造滚动条的方法监听scrollTop来实现自动更新。最后通过写文章,对一些新的知识点理解还是加深了许多。