实现tab标签下选中条滑动效果-react组件

这个是模仿 ant design 的 Tabs 控件,当切换 tab 时,下面的蓝色条滑过的效果。
点击查看效果
我只是封装了 tab 的头部标签,并没有包含内容部分。
我的最终结果
head.gif

相关技术

transform

transform属性允许你旋转,缩放,倾斜或平移给定元素。这是通过修改 CSS 视觉格式化模型的坐标空间来实现的。
通过看 ant design 的代码,他使用的是translate3d平移函数。

1
transform: translate3d(100px, 0px, 0px);

translate3d 函数

translate3d() 这个 CSS 函数用于移动元素在 3D 空间中的位置。 这种变换的特点是三维矢量的坐标定义了它在每个方向上的移动量。

语法

1
translate3d(tx, ty, tz);

transition

transition控制滑动速度及滑动时间等。不用这个属性,效果没那么自然。

官方解释:

transitionCSS属性是一个简写属性,用于transition-propertytransition-durationtransition-timing-functiontransition-delay

上面就是实现需要用到的比较不常见的技术,所以专门列举出来。

布局分析

  1. 首先一个外层的 div 当做容器headerContainer

  2. 里面分为上下两部分,上面就是包含各个“标签”的容器header,下面是滑动条tab_bar

    注意:滑动条是一个专门的 div 来实现,并不是“标签”容器的下边框

  3. 标签容器里面放各个“标签”元素

代码布局如下:

1
2
3
4
5
6
7
<div class="headerContainer">
<div class="header">
<div class="headItem">tab 1</div>
<div class="headItemChecked">tab 2</div>
</div>
<div class="tab_bar"></div>
</div>

说明
headerContainer目前没有需要的 css,由于我是用 less 写的,只是用它当做一个容器来用。
headerheadItemChecked设置标签的排列方式、字体等样式
tab_bar的 css 样式是关键,它设置选中条的样式,值得注意的是,它需要和标签的状态和宽度保持一致。

如何和标签状态保持一致

当选中一个标签时,“选中条”需要滑动到对应的标签下面。
这个通过设置“选中条”的平移位置来实现,这个可以设置translate3d的参数来实现

1
translate3d(${checkedPosition}px, 0px, 0px)

当点击标签时,动态设置checkedPosition的值即可。

1
2
3
onClickHeader = (checkedHead, index) => {
this.setState({ checkedHead, checkedPosition: index * 100 });
};

但是这时虽然能滑过去,但是没有那种平滑的滑动效果,实现这个效果就需要transition来实现。

1
2
3
4
transition: transform 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
width 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
left 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
-webkit-transform 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);

这个和 ant design 的参数保持一致

但是每个标签的内容导致宽度是不一样的,所以不能乘以换一个固定的值(100)来计算每次平移的位置,需要每个标签的实际宽度来决定平移的位置。

计算每个标签的宽度

这个就用到了react不推荐使用的ref属性了。这里推荐使用回调函数的方式,不然 eslint 会警告你 ⚠️,当然你没用 eslint 就无所谓了。
通过 ref 来获取元素的宽度,然后计算容器的宽度选中条的宽度和位置

1
2
3
4
5
6
7
8
9
10
11
12
13
 <div
ref={r => {
this[`ref_${index}`] = r;
}}
key={item.code}
....省略部分

//计算宽度
onClickHeader = (checkedHead, index) => {
const preWidth = index > 0 ? this[`ref_${index - 1}`].offsetWidth : 0;
const barWidth = this[`ref_${index}`].offsetWidth;
this.setState({ checkedHead, checkedPosition: index * preWidth, barWidth });
};

解决 offsetWidth 四舍五入的问题

offsetWidth 虽然能获取元素的宽度,但是在使用过程中发现,它返回的都是整数,进行了四舍五入的情况,当宽度遇到小于 0.5 的情况,就会引起内容换行了,很不美观,所以不能使用 offsetWidth.

解决方法如下:

  1. Element.getBoundingClientRect()

    Element.getBoundingClientRect()方法返回元素的大小及其相对于视口的位置。

1
this[`ref_${index}`].getBoundingClientRect().width; //192.243

返回的是包含小数的数字,比如 192.243

  1. Window.getComputedStyle()

    Window.getComputedStyle()方法返回一个对象,该对象在应用活动样式表并解析这些值可能包含的任何基本计算后报告元素的所有 CSS 属性的值。 私有的 CSS 属性值可以通过对象提供的 API 或通过简单地使用 CSS 属性名称进行索引来访问。

1
getComputedStyle(this[`ref_${index}`], null).getPropertyValue("width"); //192.243px

返回的是带单位(px)的值,比如 192.243px。

由于涉及到计算,我上面使用了第一种解决方法。 ###完整 code
index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import React, { PureComponent } from "react";
import styles from "./index.less";

export default class TabHeader extends PureComponent {
constructor(props) {
super(props);
const { defaultHead } = this.props;

this.state = {
containerWidth: 1500,
checkedPosition: 0,
barWidth: 70,
checkedHead: defaultHead,
};
}

componentDidMount() {
const { heardList } = this.props;
let containerWidth = 0;
(heardList || []).forEach((item, index) => {
containerWidth += this[`ref_${index}`].getBoundingClientRect().width;
});

this.setState({ barWidth: this.ref_0.getBoundingClientRect().width, containerWidth });
}

onClickHeader = (checkedHead, index) => {
let preWidth = 0;
for (let i = 0; i < index; i += 1) {
preWidth += this[`ref_${i}`].offsetWidth;
}
const barWidth = this[`ref_${index}`].offsetWidth;
this.setState({ checkedHead, checkedPosition: preWidth, barWidth });
};

render() {
const { checkedHead, checkedPosition, containerWidth, barWidth } = this.state;
const { heardList, source } = this.props;
return (
<div className={styles.container} style={{ width: `${containerWidth}px` }}>
<div className={styles.headerContainer}>
<div className={styles.header}>
{heardList.map((item, index) => (
<div
ref={r => {
this[`ref_${index}`] = r;
}}
key={item.code}
className={checkedHead === item.code ? styles.headItemChecked : styles.headItem}
onClick={this.onClickHeader.bind(this, item.code, index)}
>
{item.text} {item.num}
</div>
))}
</div>
<div
className={styles.tab_bar}
style={{
transform: `translate3d(${checkedPosition}px, 0px, 0px)`,
width: `${barWidth}px`,
}}
/>
</div>
<div className={styles.list}>
{source.map(item => (
<div key={item.id} className={styles.row}>
<div>
<div className={styles.name}>{item.name}</div>
<div className={styles.phone}>{item.phone}</div>
</div>
<div className={styles.count}>
<span className={styles.doing}>{item.doing}</span> /
<span className={styles.error}> {item.error}</span> /
<span className={styles.all}> {item.all}</span>
</div>
</div>
))}
</div>
</div>
);
}
}

index.less

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
.container {
background: rgba(255, 255, 255, 1);
box-sizing: border-box;

.headerContainer {
position: relative;
box-sizing: border-box;

.header {
display: flex;
box-sizing: border-box;

.headItem {
box-sizing: border-box;

font-size: 14px;
font-family: PingFangSC-Regular;
font-weight: 400;
color: rgba(51, 51, 51, 1);
text-align: center;
padding: 12px 17px;
cursor: pointer;
border-bottom: 4px solid rgba(232, 232, 232, 1);
transition: border 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
}

.headItemChecked {
.headItem;
color: rgba(0, 155, 255, 1);
}
}

.tab_bar {
position: absolute;
bottom: 0px;
box-sizing: border-box;
background-color: #1890ff;
height: 4px;
transition: transform 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), width 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
left 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), -webkit-transform 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
}
}

.list {
.row {
display: flex;
justify-content: space-between;
padding: 14px 15px 10px 8px;
font-size: 12px;
font-family: PingFangSC-Regular;
font-weight: 400;
color: rgba(102, 102, 102, 1);
border-bottom: 1px solid rgba(232, 232, 232, 1);

.name {
font-size: 14px;
font-weight: 600;
color: rgba(51, 51, 51, 1);
}

.phone {
margin-top: 15px;
}

.count {
font-size: 14px;
font-family: PingFangSC-Semibold;
font-weight: 600;

.doing {
color: rgba(24, 137, 250, 1);
}

.error {
color: #eb9e08;
}

.all {
color: #5f636b;
}
}
}
}
}

调用 demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<TabHeader
defaultHead="abc"
heardList={[
{ code: "abc", text: "较长的名字数量", num: "10" },
{ code: "abcd", text: "男人", num: "101" },
{ code: "abce", text: "美女数", num: "121" },
]}
source={[
{
id: "12121",
name: "刘医生",
phone: "16807656551",
doing: "10",
error: "212",
all: "32",
},
{
id: "1211",
name: "张无忌",
phone: "16807656551",
doing: "10",
error: "22",
all: "322",
},
]}
/>
文章作者: wenmu
文章链接: http://blog.wangpengpeng.site/2020/01/09/%E5%AE%9E%E7%8E%B0tab%E6%A0%87%E7%AD%BE%E4%B8%8B%E9%80%89%E4%B8%AD%E6%9D%A1%E6%BB%91%E5%8A%A8%E6%95%88%E6%9E%9C-react%E7%BB%84%E4%BB%B6/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 温木的博客
微信打赏