只管目前大多数 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; } }}