手写滚动条设计与实现
在前端的日常开发中,难免会出现内容过长导致需要滚动的情况,一般的做法是在父盒子上面使用overflow:scroll 或者 auto,这两者的区别是一个始终出现滚动条,一个根据内容自适应出现滚动条,但是这种出现的滚动条没办法很细腻的控制滚动条高度,完全由浏览器控制,且始终滚动到最上和最右,并且每个浏览器的滚动条样式表现不统一,最近我在开发中发现了figma一个功能比较有意思 ,如下图
在页面的右边局部放大,滚动条会自动靠右,并且计算的相对距离,此时我萌生了写一个这样功能的想法,并且想嵌入到自己写的画板工具(正在开发)中,话不多说,
第一步分析需求
根据figma的表现形式,猜测,是一个满屏div里面放了个子div,每次局部放大根据当前的鼠标坐标点,放大,然后去判断上下左右是否超出,超出距离多少,并且根据超出距离计算滚动条高度以及已滚动距离。
- 子div超出父div显示滚动条
- 点击滚动条栈道 滚动到指定地方
- 按下拖动滚动条,计算相对距离平移子div
第二步实现
根据需求画图分析相对坐标关系得出以下图,已竖向滚动条为例 ,两种情况,横向同理
<body>
<div class="wrapper">
<div class="content">1234567890</div>
</div>
</div>
</body>
* {
margin: 0;
padding: 0;
border: 0;
}
.wrapper {
position: relative;
width: 500px;
height: 500px;
/* overflow: hidden; */
border: 1px solid #000;
user-select: none;
margin-top: 100px;
margin-left: 500px;
overflow: hidden;
}
.content {
width: 1600px;
height: 1600px;
background-color: #ccc;
/* 圆锥渐变 */
background: conic-gradient(red 0deg 90deg,
yellow 90deg 180deg,
green 180deg 270deg,
blue 270deg 360deg);
}
.scroll-bar-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: #ffffff;
padding-right: 10px;
padding-bottom: 10px;
box-sizing: border-box;
overflow: hidden;
}
.scroll-bar-thumb,
.scroll-bar-track {
position: absolute;
background-color: rgba(209, 209, 209, .3);
z-index: 33;
}
.horizontal-track {
display: none;
bottom: 0;
width: calc(100% - 10px);
align-items: center;
}
.horizontal-thumb {
position: absolute;
left: 0;
top: 50%;
height: 6px;
width: 100%;
border-radius: 10px;
transform: translateY(-50%);
background-color: rgb(252, 162, 162);
}
.vertical-track {
display: none;
right: 0;
height: calc(100% - 10px);
}
.vertical-thumb {
position: absolute;
left: 50%;
top: 0;
width: 6px;
height: 100%;
border-radius: 10px;
transform: translateX(-50%);
background-color: rgb(252, 162, 162);
}
/* 显示 */
.show {
display: block;
}
首先编写
function getMousePos(event, el) {
const rect = el.getBoundingClientRect();
return {
x: (event.clientX - rect.left),
y: (event.clientY - rect.top)
};
}
class ScrollBar {
constructor(el, options) {
this.el = el;
this.options = options;
// 竖向滚动轨道实例
this.verticalBox = null;
// 竖向滚动条实例
this.verticalScrollBar = null;
// 竖向滚动条高度
this.verticalScrollBarHeight = 0;
// 竖向滚动条总滚动距离
this.verticalScrollBarTotal = 0;
// 竖向内容已滚动的距离
this.verticalContentTop = 0
// 横向滚动条实例
this.horizontalBox = null;
// 横向滚动条实例
this.horizontalScrollBar = null;
// 横向滚动条宽度
this.horizontalScrollBarWidth = 0;
// 横向可滚动距离
this.horizontalContentTotal = 0;
// 横向已滚动的距离
this.horizontalContentLeft = 0
// 超出边界 left top all
this.isOverflow = '';
// 初始滚动值
this.scrolll = 0;
this.init();
}
init() {
this.createScrollBar();
this.addEventListeners()
}
// 判断当前div 是否只有一个子元素
getChildren(el) {
const children = el.children;
if (children.length !== 1) {
console.warn('Parent element does not have exactly one child.');
return;
}
return children[0];
}
// 检测元素超出边界
isOverflowing(parentDiv, child) {
const parentRect = parentDiv.getBoundingClientRect();
const childRect = child.getBoundingClientRect();
const isHorizontalOverflow = childRect.left < parentRect.left || childRect.right > parentRect.right;
const isVerticalOverflow = childRect.top < parentRect.top || childRect.bottom > parentRect.bottom;
if (isHorizontalOverflow && isVerticalOverflow) return 'all'; // 上下左右都超出
if (isHorizontalOverflow) return 'horizontal'; // 左右超出
if (isVerticalOverflow) return 'vertical'; // 上下超出
return ''; // 未超出
};
// 更新类名
updateClassName() {
// 先清除所有可能的样式,避免残留
this.horizontalBox.classList.remove('show');
this.verticalBox.classList.remove('show');
// 根据超出方向添加对应的样式
switch (this.isOverflow) {
case 'all':
this.horizontalBox.classList.add('show');
this.verticalBox.classList.add('show');
break;
case 'horizontal':
this.horizontalBox.classList.add('show');
break;
case 'vertical':
this.verticalBox.classList.add('show');
break;
default:
// 如果没有超出,不做任何显示
break;
}
}
// 更新滚动条高度
updateScrollBarHeight() {
this.verticalScrollBar.style.height = `${this.verticalScrollBarHeight}px`;
this.horizontalScrollBar.style.width = `${this.horizontalScrollBarWidth}px`;
}
createScrollBar() {
this.children = this.getChildren(this.el)
const scrollBarContainer = document.createElement('div');
scrollBarContainer.className = 'scroll-bar-container';
// 横向滚动条
const horizontalTrack = document.createElement('div');
horizontalTrack.className = 'scroll-bar-track horizontal-track ';
horizontalTrack.dataset.type = 'horizontal-track';
const horizontalThumb = document.createElement('div');
horizontalThumb.dataset.type = 'horizontal';
horizontalThumb.className = 'scroll-bar-thumb horizontal-thumb ';
this.horizontalBox = horizontalTrack
this.horizontalScrollBar = horizontalThumb
// 设置横向滚动条样式
horizontalTrack.style.height = (this.options.scrollBarWidth || 10) + 'px';
// horizontalThumb.style.height = (this.options.scrollBarWidth || 10) + 'px';
horizontalTrack.appendChild(horizontalThumb);
// 竖向滚动条
const verticalTrack = document.createElement('div');
verticalTrack.className = 'scroll-bar-track vertical-track ';
verticalTrack.dataset.type = 'vertical-track';
const verticalThumb = document.createElement('div');
verticalThumb.dataset.type = 'vertical';
verticalThumb.className = 'scroll-bar-thumb vertical-thumb';
this.verticalBox = verticalTrack
this.verticalScrollBar = verticalThumb
// 设置竖向滚动条样式
verticalTrack.style.width = (this.options.scrollBarWidth || 10) + 'px';
verticalTrack.appendChild(verticalThumb);
// 将滚动条添加到容器
scrollBarContainer.appendChild(horizontalTrack);
scrollBarContainer.appendChild(verticalTrack);
this.scrollBarContainer = scrollBarContainer;
// 复制之前的子类元素
scrollBarContainer.appendChild(this.children);
this.el.innerHTML = '';
// 更新滚动条显示
this.updateClassName();
this.el.appendChild(scrollBarContainer);
// 注册实时监听
this.resizeObserver = new ResizeObserver(() => {
this.isOverflow = this.isOverflowing(this.el, this.children);
this.updateClassName()
if (this.isOverflow != '') {
console.log('子元素超出边界');
this.scrollBarEvent()
} else {
console.log('没有超出边界');
}
});
window.addEventListener('resize', () => {
this.isOverflow = this.isOverflowing(this.el, this.children);
this.updateClassName()
if (this.isOverflow != '') {
console.log('子元素超出边界');
this.scrollBarEvent()
} else {
console.log('没有超出边界');
}
});
this.resizeObserver.observe(this.el);
this.resizeObserver.observe(this.children);
}
// 滚动条事件处理
scrollBarEvent() {
const wrapRect = this.el.getBoundingClientRect();
const childrenRect = this.children.getBoundingClientRect();
console.log(this.isOverflow);
// 判断当前 超出边长的滚动条 是哪边
switch (this.isOverflow) {
case 'all':
this.horizontalBox.classList.add('show');
this.verticalBox.classList.add('show');
// 滚动条竖向总滚动距离 子盒子总滚动距离=子盒子高度-大盒子高度 滚动条总滚动距离=子盒子总滚动距离/子盒子高度 *大盒子高度
this.verticalScrollBarTotal =(childrenRect.height - wrapRect.height) / childrenRect.height *wrapRect.height ;
// 内容总滚动距离 = 子盒子高度 - 大盒子高度
this.verticaContentTotal = (childrenRect.height - wrapRect.height) ;
// 竖向滚动条高度 = 大盒子高度 - 竖向滚动条高度
this.verticalScrollBarHeight = wrapRect.height - this.verticalScrollBarTotal-10 ;
this.horizontaScrollBarTotal=(childrenRect.width - wrapRect.width) / childrenRect.width *wrapRect.width
// 横向内容总滚动距离
this.horizontalContentTotal = (childrenRect.width - wrapRect.width);
// 横向滚动条宽度
this.horizontalScrollBarWidth = wrapRect.width - this.horizontaScrollBarTotal-10 ;
console.log('横向滚动条宽度',this.horizontalScrollBarWidth);
console.log('横向总滚动距离',this.horizontaScrollBarTotal);
console.log(childrenRect,wrapRect);
this.updateScrollBarHeight()
break;
case 'horizontal':
this.horizontalBox.classList.add('show');
// 横向滚动条总滚动距离
this.horizontaScrollBarTotal=(childrenRect.width - wrapRect.width) / childrenRect.width *wrapRect.width
// 横向内容总滚动距离
this.horizontalContentTotal = (childrenRect.width - wrapRect.width);
// 横向滚动条宽度
this.horizontalScrollBarWidth = wrapRect.width - this.horizontaScrollBarTotal-10 ;
console.log('横向滚动条宽度',this.horizontalScrollBarWidth);
console.log('横向总滚动距离',this.horizontaScrollBarTotal);
this.updateScrollBarHeight()
break;
case 'vertical':
this.verticalBox.classList.add('show');
// 竖向滚动条总滚动距离 子盒子总滚动距离=子盒子高度-大盒子高度 滚动条总滚动距离=子盒子总滚动距离/子盒子高度 *大盒子高度
this.verticalScrollBarTotal =(childrenRect.height - wrapRect.height) / childrenRect.height *wrapRect.height ;
// 内容总滚动距离 = 子盒子高度 - 大盒子高度
this.verticaContentTotal = (childrenRect.height - wrapRect.height) ;
// 竖向滚动条高度 = 大盒子高度 - 竖向滚动条高度
this.verticalScrollBarHeight = wrapRect.height - this.verticalScrollBarTotal-10 ;
this.updateScrollBarHeight()
break;
default:
break;
}
}
// 事件处理
addEventListeners() {
document.addEventListener('mousedown', this.handleMouseDown.bind(this))
// 监听移动事件
document.addEventListener('mousemove', this.handleMouseMove.bind(this))
// 监听松手事件
document.addEventListener('mouseup', this.handleMouseUp.bind(this))
// 监听鼠标滚轮事件
document.addEventListener('wheel', this.handleWheel.bind(this))
}
//鼠标按下
handleMouseDown( event) {
event.preventDefault()
event.stopPropagation()
const target = event.target;
const type = target.dataset.type;
// 判断按下的元素是否为 div
if (target.tagName.toLowerCase() !== 'div' || !type) {
return
}
const {x,y}=getMousePos(event,target)
if (type === 'vertical' || type === 'horizontal') {
console.log('鼠标按下', type, x, y);
this.isMouseDown = {
type: type,
x,
y,
...this.isMouseDown,
};
return
}
if (type === 'vertical-track') {
const offsetY = Math.max(Math.min((y), this.verticalScrollBarTotal), 0)
// 移动滚动条
this.verticalScrollBar.style.transform = `translate(-50%, ${offsetY}px)`
const contentY = offsetY / this.verticalScrollBarTotal * this.verticaContentTotal;
// 移动内容
this.children.style.transform = `translate(${-this.horizontalContentLeft}px,${-contentY}px)`;
return
}
if (type === 'horizontal-track') {
const offsetX = Math.max(Math.min((x), this.horizontaScrollBarTotal), 0)
const contentX = offsetX / this.horizontaScrollBarTotal * this.horizontalContentTotal;
// 移动滚动条
this.horizontalScrollBar.style.transform = `translate(${offsetX}px,-50% )`;
// 移动内容
this.children.style.transform = `translate(${-contentX}px,${-this.verticalContentTop}px)`;
return
}
console.log('鼠标按下', this.isMouseDown);
}
// 鼠标移动
handleMouseMove(event) {
if (!this.isMouseDown) {
return
}
const { type, x, y, } = this.isMouseDown;
console.log('鼠标移动', type, x, y, event);
// 提取当前鼠标位置
const offset = getMousePos(event, this.el);
const deltaX = offset.x - x;
const deltaY = offset.y - y;
console.log('鼠标移动', deltaX, deltaY);
switch (type) {
case 'vertical':
// 每次移动的是总数
const offsetY = Math.max(Math.min(deltaY, this.verticalScrollBarTotal), 0)
console.log('已滚动距离',offsetY );
console.log('总滚动距离',this.verticalScrollBarTotal);
// 计算相对比例 (每次滚动距离/总滚动距离)*内容高度
const contentY = offsetY / this.verticalScrollBarTotal * this.verticaContentTotal;
console.log('内容滚动距离',this.verticalScrollBarTotal,this.verticaContentTotal);
// 竖向内容已滚动距离
this.verticalContentTop=contentY
// 移动滚动条
this.verticalScrollBar.style.transform = `translate(-50%, ${offsetY}px)`;
// 移动内容
this.children.style.transform = `translate(${-this.horizontalContentLeft}px,${-contentY}px)`;
break;
case 'horizontal':
// 每次移动的是总数
const offsetX = Math.max(Math.min(deltaX, this.horizontaScrollBarTotal), 0)
console.log('已滚动距离',offsetX );
console.log('总滚动距离',this.horizontaScrollBarTotal);
// 计算相对比例 (每次滚动距离/总滚动距离)*内容高度
const contentX = offsetX / this.horizontaScrollBarTotal * this.horizontalContentTotal;
console.log('内容滚动距离',this.horizontaScrollBarTotal,this.horizontalContentTotal);
// 竖向内容已滚动距离
this.horizontalContentLeft=contentX
// 移动滚动条
this.horizontalScrollBar.style.transform = `translate(${offsetX}px,-50% )`;
// 移动内容
this.children.style.transform = `translate(${-contentX}px,${-this.verticalContentTop}px)`;
break;
default:
break;
}
}
// 鼠标松手
handleMouseUp(event) {
if (!this.isMouseDown) {
return
};
this.isMouseDown = null;
}
// 鼠标滚轮
handleWheel(event) {
const {deltaY,deltaX} = event;
if (deltaY > 0) {
this.scrolll +=10
} else {
this.scrolll -=10
}
// 每次移动的是总数
const offsetY = Math.max(Math.min(this.scrolll, this.verticalScrollBarTotal), 0)
console.log('已滚动距离',offsetY );
console.log('总滚动距离',this.verticalScrollBarTotal);
// 计算相对比例 (每次滚动距离/总滚动距离)*内容高度
const contentY = offsetY / this.verticalScrollBarTotal * this.verticaContentTotal;
console.log('内容滚动距离',this.verticalScrollBarTotal,this.verticaContentTotal);
// 竖向内容已滚动距离
this.verticalContentTop=contentY
// 移动滚动条
this.verticalScrollBar.style.transform = `translate(-50%, ${offsetY}px)`;
// 移动内容
this.children.style.transform = `translate(${-this.horizontalContentLeft}px,${-contentY}px)`;
}
}
new ScrollBar(document.querySelector('.wrapper'), {
// 滚动条宽度
scrollBarHeight: 10,
scrollBarWidth: 10,
scrollBarColor: '#ccc'
})
以上就是手写滚动条的核心实现代码,主要实现了以下功能:计算滚动条高度和宽度、监听鼠标事件处理滚动、实现内容区域的同步滚动。通过这个简单的实现,我们可以完全控制滚动条的样式和行为,使其更符合特定的交互需求
主要功能总结
- 实现了自定义滚动条的样式控制,包括宽度、颜色等属性
- 支持鼠标拖动、点击和滚轮事件,实现平滑滚动效果
- 通过计算父子容器尺寸比例,自动调整滚动条长度
- 实现了横向和纵向滚动的同步控制
- 可以精确计算并控制内容区域的滚动位置
- 支持滚动条轨道的点击定位功能