最近遇到了几个带图标(元素)的文本超过2行显示...的需求,查了一些资料,想了多种办法虽然最终实现了功能,但是不太完美,有些场景可以用纯 css 的方式实现,但是有些较复杂的场景只能通过计算的方式实现,会有一些性能上的损失,导致首屏时间延长。
实现这一类布局和交互难点主要有以下几点
- 位于多行文本右下角的“展开”按钮
- 文本不超过指定行数时不显示”展开”按钮
- 移动端的兼容性问题
本文的代码演示均是在 Taro 框架下的语法
1.需求一
UI图的要求是这样的:
-
投资策略文本不足两行时正常显示,当超过两行时显示“... 展开”。
-
点击“展开”按钮,文本完全显示。
1.1 多行文本截断
首先须要解决多行文本截断的问题,第一时间就想到了用CSS3的 line-clamp 属性,关键样式如下:
.text {
display: -webkit-box;
-webkit-line-clamp: 2;
overflow: hidden;
/* autoprefixer: off */
-webkit-box-orient: vertical;
}
注意:-webkit-box-orient: vertical 上面的 /* autoprefixer: off */ 一定要加,否则编译之后 -webkit-box-orient: vertical 会消失,达不到预期效果
以上代码能实现文本超过2行时显示"...",
1.2 右下角环绕效果
提到文本环绕效果,自然就想到了浮动 float,这是最适合 float 的场景,由于“展开”按钮在右下角,所以设置右浮动
.btn {
float: right;
/*其他装饰样式*/
}
可以看到已经有环绕的效果了,只是位于右上角,需要将“展开”按钮移到下面,可以用 margin 尝试下
.btn {
float: right;
margin-top: 20px;
/*其他装饰样式*/
}
可以看到,“展开”按钮到了右下角,但是文本却没有环绕到按钮上方的空间,空出了一截,显然 margin 并不能解决问题,但是整个文本还是受到了浮动按钮的影响,所以浮动按钮上方空出的一截还是得用浮动来达到文本环绕的效果,所以可以联想到如果浮动按钮“展开”的上方又有一个浮动按钮,且这个高度为一行文本的行高,岂不是就能解决这个问题?我们可以试验一下,这个浮动按钮可以用伪元素代替
.text::before {
content: '';
float: right;
width: 10px;
height: 20px;
background:red;
}
但是“展开”按钮到了浮动伪元素的左侧,如何将它移到下面呢,很简单,清除一下“展开”按钮的浮动就可以了
.btn {
float: right;
clear: both;
/*其他装饰样式*/
}
可以看到,现在文本是完全环绕在右侧的两个浮动元素了,只要把浮动伪元素的宽度设为0,就能实现我们想要的效果了
.text::before {
content: '';
float: right;
width: 0;
height: 20px;
}
1.3 兼容性
到目前为止我的需求似乎已经解决了,但是上面展示的效果都是在我自己的手机上测试的,如果在各种机型上包括各种安卓版本,鸿蒙系统,ios系统等都能有这样的效果,那问题才算真正解决。我们看下在别的机型上的效果。
一看傻眼了,“展开”按钮消失不见了,文本上面空出了大概两行文本高度的空间,我们看下 DOM 结构
可以看到“展开”按钮跑到上面去了,但是没有显示出来,再看下我的代码
/* .tsx 文件*/
/*省略其他装饰样式*/
<View className={styles.over2Line}>
<View style={{ float: 'right', clear: 'both' }}>
展开
</View>
<Text>投资策略:</Text>
{investStrategy}
</View>
/** .scss 文件 **/
.over2Line {
display: -webkit-box;
-webkit-line-clamp: 2;
overflow: hidden;
/* autoprefixer: off */
-webkit-box-orient: vertical;
}
.over2Line::before {
content: '';
float: right;
width: 0;
height: 20Px;
}
这里可以联想到 flex 布局与 float 的互斥关系:
- 当父容器设置为弹性盒子之后,父容器中的子元素想要设置float:right or float:left是不可能生效的;此外clear属性也不会生效
- 应用到弹性元素上的float和clear属性将被忽略
- 爷爷辈容器设置为了弹性盒子,那么该子元素的float属性是有效的,即float属性是否生效与直接父级元素是不是弹性盒子有关,与其他辈的父容器无关
再看我上面的代码,难道是因为 -webkit-box 的直接子元素是 float 元素,所以 float无效?如果我把 float 元素变为** -webkit-box** 的孙元素会不会解决问题呢?
/* .tsx 文件*/
/*省略其他装饰样式*/
<View className={styles.over2Line}>
<View>
<View className={styles.investStrategyContent}>
<View style={{ float: 'right', clear: 'both' }}>
展开
</View>
<Text>投资策略:</Text>
{investStrategy}
</View>
</View>
</View>
/** .scss 文件 **/
.over2Line {
display: -webkit-box;
-webkit-line-clamp: 2;
overflow: hidden;
/* autoprefixer: off */
-webkit-box-orient: vertical;
}
.investStrategyContent::before {
content: '';
float: right;
width: 0;
height: 20Px;
}
我将浮动伪元素和浮动的“展开”按钮变为了 -webkit-box 的孙元素,看看效果会如何呢?
nice!看似问题解决了,但是这依然只是在我自己手机上的效果,还要测试兼容性,于是我在其他不同版本的手机上测试了一下
又傻眼了。。。有的机型上的“...”与文本重叠了,有的虽然“...”显示正常,但是与“展开”按钮的距离太远,显然这种方案依然不行
1.4 另辟蹊径,自己添加“...”
用css自带的文本超过两行时显示“...”不能解决我们的需求,只能换一种方式,给文本设置一个最大高度(两行文本的行高),在“展开”按钮前面加"...",也就是“... 展开”组成一个新的浮动元素
/* .tsx 文件*/
/*省略其他装饰样式*/
<View className={styles.investStrategyContent}>
<View style={{ float: 'right', clear: 'both' }}>
<Text style={{ marginRight: '4px' }}>...</Text>展开
</View>
<Text>投资策略:</Text>
{investStrategy}
</View>
/** .scss 文件 **/
.investStrategyContent {
/* 两行文本高度*/
max-height: 40Px;
overflow: hidden;
}
.investStrategyContent::before {
content: '';
float: right;
width: 0;
height: 20Px;
}
可以看到,文本超过两行时已经显示正常了,并且做了兼容性测试,在各种机型上的显示都没有问题,但是很明显又出现了新的问题,当文本没有超过两行时在右下角依然会显示“展开”按钮,那么如何隐藏呢?通常用js的方式很容易,在 componentDidMount 生命周期钩子里计算文本高度,当文本超过两行时才做“... 展开”处理,但是这样做会增加首屏出现的时间,因为 componentDidMount 阻塞了首屏,需要再做一次 diff 算法首屏才出现。那么可以直接用CSS来实现这类判断吗?
可以肯定的是,CSS 是没有这类逻辑判断的,大多数我们都需要从别的角度,采用 “障眼法” 来实现。比如在这个场景,当没有发生截断的时候,表示文本完全可见了,这时,可以在文本末尾添加一个与背景色相同的元素来遮住“展开”按钮,为了不影响原有布局,可以将这个元素设置为绝对定位。
/* .tsx 文件*/
/*省略其他装饰样式*/
<View className={styles.investStrategyContent}>
<View style={{ float: 'right', clear: 'both' }}>
<Text style={{ marginRight: '4px' }}>...</Text>展开
</View>
<Text>投资策略:</Text>
{investStrategy}
</View>
/** .scss 文件 **/
.investStrategyContent {
/* 两行文本高度*/
max-height: 40Px;
overflow: hidden;
}
.investStrategyContent::before {
content: '';
float: right;
width: 0;
height: 20Px;
}
.investStrategyContent::after {
content: '';
width: 100%;
height: 100%;
position: absolute;
background: #fef9f6;
}
可以看到当文本只有一行时“展开”按钮已经被遮住了,且兼容性也很好,但是布局是乎有点别扭。当文本只有一行时由于右下角“展开”按钮的存在,导致文本依然占据两行行高的空间,所以导致文本与他下面的元素的距离变大了,所以这种方案依然不能很好的解决我们的需求,除非文本下面没有其他元素了。
所以最终还是采用了计算文本高度的方式来解决问题,但是首屏时间会延长,不过当文本元素不太多的时候影响可以忽略。
2 需求二
把需求一解决完之后接着又来了一个相似的需求,UI图是一个表格,表格第一列要求是这样的:
- 基金名称后面可能有图标,也可能没有
- 基金名称后面没有图标,基金名称超过2行显示“...”
- 基金名称后面有图标,且基金名称+图标超过两行时显示“... 图标”
- 基金名称或基金名称+图标没有超过两行时完全显示
这次的场景比需求一的场景更复杂,因为文本后面可能有图标,且文本+图标是一个整体,当这个整体超过两行时,整体又分开,显示成“文本...” + "图标"
有了需求一的经验积累,在做需求二时可以少走不少弯路,我们当然首先选择用纯 css 的方式实现,当基金名称后面没有图标时就变成了纯文本超过2行显示“...”的问题,可以直接用多行文本截断的方式
.text {
display: -webkit-box;
-webkit-line-clamp: 2;
overflow: hidden;
/* autoprefixer: off */
-webkit-box-orient: vertical;
}
当基金名称后面有图标时那就只能通过计算基金名称+图标的高度是否超过两行自己加“...”来实现
/* .tsx 文件*/
/*省略其他装饰样式*/
renderFundName = (): ReactElement => {
const { data } = this.props;
if (!data.tag) {
/* 文本后面没有图标,直接用多行文本截断的方式 */
return (
<View id='fundNameContent'>
<Text className={styles.over2Line}>{data.name}</Text>
</View>
);
} else if (this.isOver2Line) {
/* 文本后面有图标,且文本+图标超过了两行,通过自己加...的方式实现 */
return (
<View className={styles.fundNameOver2Content} id='fundNameContent'>
<Text
style={{
float: 'right',
clear: 'both',
}}
>
...
<Text className={styles.pic}>
{data.picName}
</Text>
</Text>
<Text >{data.name}</Text>
</View>
);
}
/* 文本后面有图标,文本+图标没有超过两行,完全显示 */
return (
<View id='fundNameContent'>
<Text>
{data.name}
<Text className={styles.pic}>
{data.picName}
</Text>
</Text>
</View>
);
};
/** .scss 文件 **/
.fundNameOver2Content {
text-align: justify;
max-height: 36Px;
overflow: hidden;
}
.fundNameOver2Content::before {
content: '';
float: right;
width: 0;
height: 18Px;
}
.over2Line {
display: -webkit-box;
-webkit-line-clamp: 2;
overflow: hidden;
/* autoprefixer: off */
-webkit-box-orient: vertical;
}
.pic {
padding: 1Px 4Px 1Px 4Px;
margin-left: 4Px;
background: #F91A1A;
border-radius: 100Px 100Px 100Px 0Px;
}
通过用 isOver2Line 标志位来判断文本+图标是否超过两行, 需要注意 isOver2Line 的初始值需要设为 false,也就是初始时全部都完全显示,在 componentDidMount 里计算文本后面带有图标的高度,如果高度大于两行行高,则做“文本... 图标”处理
componentDidMount(): void {
const { data, rowIndex } = this.props;
if (data.tag) {
// 只有文本后带有图标的才计算高度
Taro.createSelectorQuery()
.in(this.$scope)
.selectAll('#fundNameContent')
.boundingClientRect()
.exec((rec) => {
let result = rec[0];
if (result[index].height > 40) {
this.isOver2Line = true; // 高度大于两行行高,做“文本... 图标”处理
this.setState({});
}
});
}
}
这里与需求一还有个不同,一旦进入需求一的页面,页面内容不会再改变,但是需求二的页面有刷新按钮,也就是页面会有更新的过程,当页面更新时需要重新计算基金名称+图标是否超过两行,所以在页面初次渲染完成后 isOver2Line 需要再设为 false,要不然等页面更新时,所有的带图标的文本即使没超过两行也会当作超过两行处理,在更新完之后也需要将 isOver2Line 设为 false
componentDidMount(): void {
const { data, rowIndex } = this.props;
if (data.tag) {
this.calcIsOver2Line(rowIndex);
}
}
componentDidUpdate(): void {
const { data, rowIndex } = this.props;
if (data.tag) {
this.calcIsOver2Line(rowIndex);
}
}
private calcIsOver2Line = (index: number): void => {
Taro.createSelectorQuery()
.in(this.$scope)
.selectAll('#fundNameContent')
.boundingClientRect()
.exec((rec) => {
let result = rec[0];
if (result[index].height > 40) {
this.isOver2Line = true;
this.setState({}, () => {
this.isOver2Line = false; // 更新完之后设为 false
});
}
});
};
上面的代码有个致命的问题,就是如果在 componentDidUpdate 里又调用了 setState() 则会出现死循环,所以需要有个开关来控制,这里我用ts装饰器和观察者模式来实现
// 注入一个事件监听器
@Inject(DataStream)
private ds: DataStream;
private isOver2Line = false;
private isUpdateData = false;
componentDidMount(): void {
const { data, rowIndex, req } = this.props;
if (data.tag) {
this.calcIsOver2Line(rowIndex);
}
// 首次渲染后监听页面是否需要更新
this.ds.subscribe(EventName.FilterChange, () => {
this.isUpdateData = true;
});
}
componentDidUpdate(): void {
const { data, rowIndex } = this.props;
// 只有当文本带有图标,且需要更新时才进入
if (data.tag && this.isUpdateData) {
this.isUpdateData = false; //设为false,避免陷入死循环
this.calcIsOver2Line(rowIndex);
}
}
private calcIsOver2Line = (index: number): void => {
Taro.createSelectorQuery()
.in(this.$scope)
.selectAll('#fundNameContent')
.boundingClientRect()
.exec((rec) => {
let result = rec[0];
if (result[index].height > 40) {
this.isOver2Line = true;
this.setState({}, () => {
this.isOver2Line = false; // 更新完之后设为 false
});
}
});
};
事件监听器和装饰器代码如下
/* 事件监听器 */
export class DataStream {
protected readonly events = new Events();
public subscribe<T>(eventName: EventName, cb: Callback<T>): void {
this.events.on(eventName, cb);
}
public emit<T>(eventName: EventName, data: T): boolean {
return this.events.trigger(eventName, data);
}
}
/* 装饰器 */
interface Constructable<T> {
new (): T;
}
const actions = new Map();
export function getAction<T>(Context: Constructable<T>): T {
// 确保事件监听器是单例的
if (!actions.has(Context)) {
actions.set(Context, new Context());
}
return actions.get(Context);
}
export function Inject(Context: Constructable<{}>) {
return (target: object, name: string): void => {
target[name] = getAction(Context);
};
}
至此需求完成,但是由于是通过计算高度方式来实现的,所以依然有首屏时间延长的问题,如果表格行数特别多,且每一行的文本后面都有图片,那这个阻塞首屏的时间会加长,看到这篇文章的你会有更好的解决方案吗?