乐趣区

关于vue.js:Vue实现动态tab标签滚动定位跟随动画

只管目前大多数 UI 框架都有 tab 组件,然而有时候并不能满足需要,或者须要对组件进行二次开发,思考到总总起因,还是决定本人亲手写一个算了。

Element.getBoundingClientRect()
实现其实不难,这里只需应用 getBoundingClientRect 这个函数就能够,依据文档的介绍,该办法返回元素的大小及其绝对于视窗的地位。

看图后应该不难理解,图中 0,0 指定是浏览器中窗口的左上角,因而应用该函数后元素返回的 top、bottom、left、right 都是绝对窗口左上角的地位。

设计剖析

因为 tab 的数量不是固定的,很有可能超出元素边界,所以须要用外层来包含 tablist,并且是 overflow: hidden,滚动成果通过扭转 css 中的 translate 来实现。

<template>
    <div>
        <button class="cat-button" type="button" @click="addTab"> 增加标签 </button>
    </div>
    <div class="cat-tabbar">
        <div class="cat-tabbar__arrow" @click="scrollTabbar('prev')" v-if="showArrow"> 后退 </div>
        <div ref="containerElement" class="cat-tabbar__container">
            <ul ref="tabbarElement" class="cat-tabbar__list">
                <template v-for="(item, index) in data" :key="index">
                    <li :class="['cat-tabbar__item', activeName === item.name &&'is-active']" @click="changeTab(index)">
                        <div class="tab-text">{{item.title}}</div>
                        <div class="tab-close" v-if="data.length > 1">
                            <span @click.stop="removeTab(index)">X</span>
                        </div>
                    </li>
                </template>
            </ul>
        </div>
        <div class="cat-tabbar__arrow" @click="scrollTabbar('next')" v-if="showArrow"> 后退 </div>
    </div>
</template>

实现剖析

如何计算滚动地位?只有通过 getBoundingClientRect 获得各元素的地位后,再判断 tab 是否超出父级元素的边界,而后依据以后选中的 tab 地位计算出向前或向后须要滚动多少像素。

<script lang="ts">
    import {defineComponent, ref, reactive} from "vue";

    export default defineComponent({
        name: "CatTabs",
        setup() {const containerElement = ref(),
                tabbarElement = ref(),
                showArrow = ref(false),
                activeName = ref("home");

            const data = reactive([
                {
                    name: "home",
                    title: "首页"
                }
            ]);

            const addTab = () => {const tabName = new Date().getTime().toString();
                data.push({
                    name: tabName,
                    title: "标签长一点 -" + (data.length + 1)
                })
                activeName.value = tabName;
                scrollTab();}

            // 抉择标签
            const changeTab = (index: number) => {activeName.value = data[index].name;
                scrollTab();}

            // 移除标签
            const removeTab = (index: number) => {data.splice(index, 1);
                const lastTab = data[data.length - 1];
                activeName.value = lastTab.name;
                scrollTab();}

            // 滚动标签
            const scrollTab = () => {setTimeout(() => {
                    const el = {
                        container: containerElement.value,
                        tabbar: tabbarElement.value,
                        activeTab: containerElement.value.querySelector("li.is-active"),
                        lastTab: containerElement.value.querySelector("li:last-child")
                    }

                    if (el.tabbar.scrollWidth > el.container.clientWidth) {
                        showArrow.value = true;
                        // 期待箭头元素呈现后再计算,不然可能呈现计算误差
                        setTimeout(() => {
                            const rect = {container: el.container.getBoundingClientRect(), // 外层容器
                                tabbar: el.tabbar.getBoundingClientRect(), // 标签栏
                                activeTab: el.activeTab?.getBoundingClientRect(), // 标签栏中被选中的标签
                                lastTab: el.lastTab?.getBoundingClientRect() // 标签栏中最初一个标签}

                            if (rect.activeTab && rect.lastTab) {
                                let tabbarOffset = rect.container.left - rect.tabbar.left, // 计算标签栏偏移容器间隔
                                    activeTabOffsetLeft = rect.container.left - rect.activeTab.left, // 计算标签偏移容器右边的间隔
                                    activeTabOffsetRight = rect.activeTab.right - rect.container.right; // 计算标签偏移容器左边的间隔

                                // 计算最初一个标签和容器最左边之间的间隔
                                const lastOffset = rect.container.right - rect.lastTab.right;
                                if (activeTabOffsetLeft < lastOffset) {activeTabOffsetLeft += lastOffset - activeTabOffsetLeft;}

                                // 判断标签是否超出父元素左边界
                                if (activeTabOffsetLeft > 0) {
                                    const scrollX = tabbarOffset - activeTabOffsetLeft;
                                    el.tabbar.style.transform = "translate3d(-" + scrollX + "px,0,0)";
                                }

                                // 判断标签是否超出父元素右边界
                                if (activeTabOffsetRight > 0) {
                                    const scrollX = tabbarOffset + activeTabOffsetRight;
                                    el.tabbar.style.transform = "translate3d(-" + scrollX + "px,0,0)";
                                }
                            }
                        }, 0)
                    } else {
                        showArrow.value = false;
                        el.tabbar.style.transform = "translate3d(0,0,0)";
                    }
                }, 0)
            }

            // 滚动标签栏
            const scrollTabbar = (direction: "prev" | "next") => {
                const el = {
                    container: containerElement.value,
                    tabbar: tabbarElement.value,
                    lastTab: containerElement.value.querySelector("li:last-child")
                }

                const rect = {container: el.container.getBoundingClientRect(), // 外层容器
                    tabbar: el.tabbar.getBoundingClientRect(), // 标签栏
                    lastTab: el.lastTab?.getBoundingClientRect() // 标签栏中最初一个标签}

                if (rect.lastTab) {
                    const barOffsetLeft = rect.container.left - rect.tabbar.left, // 计算标签栏偏移容器间隔
                        barOffsetRight = rect.lastTab.right - rect.container.right; // 计算标签偏移容器左边的间隔

                    // 判断标签栏是否超出父元素左边界 (后退)
                    if (direction === "prev" && barOffsetLeft > 0) {
                        let scrollX = 0;
                        // 每次滚动的最长距离为容器宽度
                        if (barOffsetLeft > el.container.clientWidth) {scrollX = barOffsetLeft - el.container.clientWidth;}
                        el.tabbar.style.transform = "translate3d(-" + scrollX + "px,0,0)";
                    }

                    // 判断标签栏是否超出父元素右边界 (后退)
                    if (direction === "next" && barOffsetRight > 0) {
                        let scrollX = barOffsetLeft + barOffsetRight;
                        // 每次滚动的最长距离为容器宽度
                        if (barOffsetRight > el.container.clientWidth) {scrollX = barOffsetLeft + el.container.clientWidth;}
                        el.tabbar.style.transform = "translate3d(-" + scrollX + "px,0,0)";
                    }
                }
            }

            return {
                containerElement,
                tabbarElement,
                showArrow,
                activeName,
                changeTab,
                removeTab,
                scrollTabbar,
                data,
                addTab
            };
        },
    });
</script>

less 款式

.cat-tabbar {
    display: flex;
    align-items: center;
    width: 100%;
    border-top: 1px solid #f2f2f2;
    padding-top: 8px;

    &__arrow {
        display:flex;
        align-items:center;
        height: 40px;
        line-height: 0;
        padding: 0 16px;
        cursor: pointer;
        color: #fff;
        background-color: #000;

        &:hover {color: dodgerblue;}
    }

    &__container {
        flex: 1;
        overflow: hidden;
    }

    &__list {
        display: flex;
        white-space: nowrap;
        transition: transform 200ms;
    }

    &__item {
        display: flex;
        align-items: stretch;
        height: 40px;
        cursor: pointer;
        border-top-left-radius: 4px;
        border-top-right-radius: 4px;

        .tab-text {
            display: flex;
            justify-content: center;
            align-items: center;
            padding-left: 20px;
            color: #777;
            // 既是最初一个,又是第一个
            &:last-child:first-child {padding-right: 20px;}
        }

        .tab-close {
            display: flex;
            justify-content: center;
            align-items: center;
            width: 40px;
            height: 100%;
            color: #313b46;

            span {
                font-size: 12px;
                transition: color 200ms;
            }

            span:hover {color: red;}
        }
    }

    &__item.is-active {
        background-color: dodgerblue;

        .tab-text {color: #fff;}

        .tab-close {color: #fff;}
    }
}
退出移动版