betterscroll 核心滚动功能实现简版

340 阅读7分钟

手写滚动条设计与实现

在前端的日常开发中,难免会出现内容过长导致需要滚动的情况,一般的做法是在父盒子上面使用overflow:scroll 或者 auto,这两者的区别是一个始终出现滚动条,一个根据内容自适应出现滚动条,但是这种出现的滚动条没办法很细腻的控制滚动条高度,完全由浏览器控制,且始终滚动到最上和最右,并且每个浏览器的滚动条样式表现不统一,最近我在开发中发现了figma一个功能比较有意思 ,如下图

image.png

在页面的右边局部放大,滚动条会自动靠右,并且计算的相对距离,此时我萌生了写一个这样功能的想法,并且想嵌入到自己写的画板工具(正在开发)中,话不多说,

第一步分析需求

根据figma的表现形式,猜测,是一个满屏div里面放了个子div,每次局部放大根据当前的鼠标坐标点,放大,然后去判断上下左右是否超出,超出距离多少,并且根据超出距离计算滚动条高度以及已滚动距离。

  1. 子div超出父div显示滚动条
  2. 点击滚动条栈道 滚动到指定地方
  3. 按下拖动滚动条,计算相对距离平移子div

第二步实现

根据需求画图分析相对坐标关系得出以下图,已竖向滚动条为例 ,两种情况,横向同理

image 1.png

image 2.png


<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'
    })

以上就是手写滚动条的核心实现代码,主要实现了以下功能:计算滚动条高度和宽度、监听鼠标事件处理滚动、实现内容区域的同步滚动。通过这个简单的实现,我们可以完全控制滚动条的样式和行为,使其更符合特定的交互需求

主要功能总结

  • 实现了自定义滚动条的样式控制,包括宽度、颜色等属性
  • 支持鼠标拖动、点击和滚轮事件,实现平滑滚动效果
  • 通过计算父子容器尺寸比例,自动调整滚动条长度
  • 实现了横向和纵向滚动的同步控制
  • 可以精确计算并控制内容区域的滚动位置
  • 支持滚动条轨道的点击定位功能