实现一个简单的横向滑动菜单栏

提醒:本文最后更新于 1388 天前,文中所描述的信息可能已发生改变,请谨慎使用。

两年多没写过博客了,我也不知道都在忙什么,以后还是要坚持写博客的,起码把自己的一些想法记录下来。

前两天突然很好奇一个很常见的动效是怎么实现的,今天有空了就花了一会时间将它实现了一遍。

008iiGpDly1gt9bhredjtg30p403skjl

最终效果DEMO: https://vhvy-demo.netlify.app/horizontal-scroll

代码地址: https://github.com/vhvy/some-demo/tree/master/horizontal-scroll

分析动效

从效果图上可以看出,这一个文字内容不定长的Tab列表;点击tab项时tab会被高亮,并将tab滚动到屏幕正中间,同时tab下方的指示条在经过短暂的延迟后滑动到当前选中的tab下。

实现基本结构

为了方便演示我用Vue直接做的,先实现最基本的部分。循环tab数据项,将每个子tab项的index绑定到dataset,同时在列表外层容器上代理监听点击事件,并更新相应的idx; idx匹配的tab将进行高亮处理。

 <div id="root">
    <nav class="demo-nav-wrap" ref="nav_box">
        <ul class="demo-nav-list" @click="handleNavClick">
            <li class="demo-nav-item" :data-idx="index" :class="{ active: index == activeNavIdx }" ref="navs"
                v-for="(item, index) of menus" :key="item.id">{{ item.name }}</li>
        </ul>
    </nav>
</div>

const menus = [
    {
        id: 1,
        name: "首页"
    },
    ...
];

new Vue({
    el: "#root",
    data() {
        return {
            menus,
            activeNavIdx: 0
        }
    },
    methods: {
        handleNavClick({ target }) {
            const { idx } = target.dataset;
            if (!idx || idx == this.activeNavIdx) return;
            this.activeNavIdx = idx;
        },
    }
});

指示器

指示器可以用tab容器ul::after伪元素来做,相对于tab容器进行绝对定位,bottom的位置是固定的,只需要控制left的位置即可。

按照动效来看,需要在每次点击tab后更新指示器位置。可以使用Vue的$refs获取到对应的dom,然后获取dom的位置,然后进一步计算出tab的left

从动效图中可以看出,指示器和指示器在垂直方向是完全对齐的,也就是两者的的垂直平分线是完全重合的。

要达到这种效果,我们可以按照下图来进行计算:

008iiGpDly1gt9bhqxct8j30sk0tw0vr

图中最大的红色方框代表屏幕可视区域,红色方框顶部方框表示滚动区域;

屏幕外左侧虚线框代表tab滚动容器已经滚去的部分,而A代表当前选中的tab栏相对于滚动容器左侧的距离(offsetLeft),B则是当前选中tab栏一半的宽度(offsetWidth / 2),C则是指示器宽度的一半。

要想让指示器的垂线和当前选中tab栏的垂线重合,计算出指示器相对于滚动容器左侧的距离即可。

按图可知,用tab栏相对于滚动容器左侧距离加上当前tab栏宽度的一半数值,就可以得到当前tab栏中线相对于滚动容器左侧的距离;同时为了将指示器和tab栏垂线重合,再减去一半的指示器宽度即可。计算公式如下:

tab.offsetLeft + tab.offsetWidth / 2 - pointerWidth / 2;

接下来只要在点击tab后更新指示器位置就可以了,tab的dom可以用ref拿到,所以写这样一个函数,在handleNavClick里进行调用即可。

handleUpdatePointerPos() {
    const dom = this.$refs.navs[this.activeNavIdx];
    this.pointerX = dom.offsetLeft + dom.offsetWidth / 2 - this.pointerWidth / 2;
} 

指示器的宽度一般是固定的,所以将指示器宽度写在data里(pointerWidth),通过css变量赋值给html里的指示器dom使用。同时在计算指示器位置时也可以直接从data里取到对应的值。

计算出指示器位置后,将位置更新到data里,然后将css变量绑定到tab父元素上,供子元素指示器使用。

{
    data() {
        return {
            ...,
            pointerWidth: 12,
            pointerX: -100
        }
    },
    computed: {
        pointerStyle() {
            return {
                '--pointer-x': this.pointerX,
                '--pointer-width': this.pointerWidth
            }
        }
    },
}
<ul class="demo-nav-list" @click="handleNavClick" :style="pointerStyle">
...
</ul>
.demo-nav-list::after {
    content: "";
    position: absolute;
    bottom: 7px;
    left: calc(var(--pointer-x) * 1px);
    width: calc(var(--pointer-width) * 1px);
    border-top: 2px solid red;
    transition: left ease 0.3s;
}

同时在页面刚加载完没有发生任何点击事件时,指示器的位置是未知的,所以要在mounted生命周期里更新一下指示器位置。

mounted() {
    this.handleUpdatePointerPos();
}

到现在为止,指示器在tab栏发生点击后就会跟随移动到tab栏正下方了,再解决了tab栏点击后自动滚动至页面中间的问题后就算大功告成了。

滚动至页面中间

scrollIntoView这个API可以将元素滚动到可见位置,它的可选参数可以指定滚动到startcenterendnearest,还可以使用平滑滚动,难道这么简单就解决了?

实际测试之后发现Chrome和Firefox正常,而Safari的表现非常诡异,并没有按照指定的center来滚动,而且也没有平滑滚动的效果。

使用caniuse查询以后,Safari支持率一片爆红,只能弃之。

转换思路,用scrollTo进行滚动,同时使用CSS属性scroll-behavior: smooth;进行平滑滚动;然而Safari也不支持,这样只能手动实现平滑滚动效果了。

按照下图所示,A代表滚动容器当前已卷去的距离box.scrollLeft,B代表屏幕宽度的一半window.innerWidth / 2,即屏幕垂直平分线的位置;C代表tab栏宽度的一半tab.offsetWidth / 2;而D表示tab栏相对于滚动容器左侧的距离tab.offsetLeft

为了将tab栏滚动到屏幕中心位置,其实也就是将倒数第一条黑色竖线的位置移动到屏幕中线位置,即倒数第二条紫色竖线位置。

只要将滚动值减小,黑色竖线就会向左移动直到和紫色竖线重合。现在只要求出两条竖线之间的距离就可以知道要滚动多少距离了。

黑色竖线和紫色竖线之间的距离有以下公式:D + C - (A + B),用实际代码表现出来则是:

const size = tab.offsetLeft + tab.offsetWidth / 2 - (scrollBox.scrollLeft + window.innerWidth / 2);

现在求出了两竖线之间的距离,然而我们是要调用scrollTo进行滚动的,所以要给出一个准确的目标值。所以我们用当前的已滚动距离加上两线距离之差就可以得出一个准确的目标值。

const toPos = scroll.scrollLeft + size;

为什么是加呢?如图所示,要将黑色竖线往紫色竖线的位置移动直至重合,那么就意味着要将黑色竖线往回拉扯,也就是往左滚动增加滚动值了。

如果tab栏在紫色竖线左侧呢?那么上方的size变量将得到一个负值。加负值滚动值会变小,也就是往右滚动了。

008iiGpDly1gt9bhrn7i0j30sq0wcjv7

现在点击tab栏后,计算出需要滚动的距离,然后调用封装好的平滑滚动函数进行滚动即可。

修复指示器位置

虽然tab栏滚动的中间的问题解决了,然而指示器位置出现了异常。因为平滑滚动是异步函数,在滚动尚未结束时更新指示器位置的函数handleUpdatePointerPos就开始执行了,执行时平滑滚动函数还未执行完毕,此时handleUpdatePointerPos获取到的tab位置dom.offsetLeft是不准确的。

所以得在执行handleUpdatePointerPos时就提供准确的tab位置。

点击tab栏后,部分tab栏其实是不需要也不能往中间滚动的,如第一个tab和最后一个tab。所以上个章节中计算出的滚动目标有可能超出范围,所以要对其范围进行判断。

最小滚动范围为0,最大滚动范围则是用容器内容的总宽度减去可见区域宽度: const limitScroll = scroll.scrollWidth - window.innerWidth

如果超出了范围,说明不需要进行滚动也不会滚动,handleUpdatePointerPos照常执行即可。

如果没有超出范围,则计算相应的值。handleUpdatePointerPos函数中用tab栏相对于滚动容器左侧距离加上当前tab栏宽度的一半数值在此时相当于就是屏幕中线,所以用toPos + window.innerWidth / 2计算即可。

为了获取相应数值,将handleUpdatePointerPos移动到滚动tab函数的最后一部分。

{
    ...,
    handleScrollNavToCenter() {
        ...

        let v = toPos > 0 && toPos < limitScroll ? toPos + halfScreenWidth : null;
            // 计算滚动完成后元素相对于滚动容器的x轴位置

        this.handleUpdatePointerPos(v);
    },
    handleUpdatePointerPos(v) {
        const dom = this.$refs.navs[this.activeNavIdx];
        this.pointerX = (v ? v : (dom.offsetLeft + dom.offsetWidth / 2)) - this.pointerWidth / 2;
    },
}

平滑滚动

这个函数是以前封装好直接拿过来用的,在此就不再赘述了。

主要思想就是每次滚动剩余滚动距离的一个固定比例(如一半),然后在剩余滚动距离小于某个阈值时停止滚动。为了平滑滚动的效果,一般是跟随屏幕刷新率进行滚动。

window.requestAnimationFrame可以完美实现,如果浏览器不支持requestAnimationFrame可以用setTimeout进行实现,定时时间用1s === 1000ms / 屏幕刷新率(一般为60hz) ≈ 17ms即可。

Powered By Hexo & Theme Veni