尽管目前大多数 UI 框架都有 tab 组件,但是有时候并不能满足需求,或者需要对组件进行二次开发,考虑到总总原因,还是决定自己亲手写一个算了。
Element.getBoundingClientRect()
实现其实不难,这里只需使用 getBoundingClientRect 这个函数就可以,根据文档的介绍,该方法返回元素的大小及其相对于视窗的位置。
看图后应该不难理解,图中 0,0 指定是浏览器中窗口的左上角,因此使用该函数后元素返回的 top、bottom、left、right 都是相对窗口左上角的位置。
设计分析
由于 tab 的数量不是固定的,很有可能超出元素边界,所以需要用外层来包裹 tab 层,并且是 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;
}
}
}
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。