移动端带图标(元素)的多行文本超过n行隐藏显示

1,765 阅读10分钟

最近遇到了几个带图标(元素)的文本超过2行显示...的需求,查了一些资料,想了多种办法虽然最终实现了功能,但是不太完美,有些场景可以用纯 css 的方式实现,但是有些较复杂的场景只能通过计算的方式实现,会有一些性能上的损失,导致首屏时间延长。

实现这一类布局和交互难点主要有以下几点

  • 位于多行文本右下角的“展开”按钮
  • 文本不超过指定行数时不显示”展开”按钮
  • 移动端的兼容性问题

本文的代码演示均是在 Taro 框架下的语法

1.需求一

UI图的要求是这样的:

  1. 投资策略文本不足两行时正常显示,当超过两行时显示“... 展开”。 11.png

  2. 点击“展开”按钮,文本完全显示。

2.png

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; 
    /*其他装饰样式*/ 
}

1.png

可以看到已经有环绕的效果了,只是位于右上角,需要将“展开”按钮移到下面,可以用 margin 尝试下

.btn { 
    float: right; 
    margin-top: 20px; 
    /*其他装饰样式*/ 
}

2.png

可以看到,“展开”按钮到了右下角,但是文本却没有环绕到按钮上方的空间,空出了一截,显然 margin 并不能解决问题,但是整个文本还是受到了浮动按钮的影响,所以浮动按钮上方空出的一截还是得用浮动来达到文本环绕的效果,所以可以联想到如果浮动按钮“展开”的上方又有一个浮动按钮,且这个高度为一行文本的行高,岂不是就能解决这个问题?我们可以试验一下,这个浮动按钮可以用伪元素代替

.text::before { 
    content: ''; 
    float: right; 
    width: 10px; 
    height: 20px;
    background:red;
}

3.png

但是“展开”按钮到了浮动伪元素的左侧,如何将它移到下面呢,很简单,清除一下“展开”按钮的浮动就可以了

.btn { 
    float: right; 
    clear: both;
    /*其他装饰样式*/ 
}

4.png

可以看到,现在文本是完全环绕在右侧的两个浮动元素了,只要把浮动伪元素的宽度设为0,就能实现我们想要的效果了

.text::before { 
    content: ''; 
    float: right; 
    width: 0; 
    height: 20px;
}

5.png

1.3 兼容性

到目前为止我的需求似乎已经解决了,但是上面展示的效果都是在我自己的手机上测试的,如果在各种机型上包括各种安卓版本,鸿蒙系统,ios系统等都能有这样的效果,那问题才算真正解决。我们看下在别的机型上的效果。

不兼容float.png

一看傻眼了,“展开”按钮消失不见了,文本上面空出了大概两行文本高度的空间,我们看下 DOM 结构

不兼容2.png

可以看到“展开”按钮跑到上面去了,但是没有显示出来,再看下我的代码

/* .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属性也不会生效
  • 应用到弹性元素上的floatclear属性将被忽略
  • 爷爷辈容器设置为了弹性盒子,那么该子元素的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 的孙元素,看看效果会如何呢?

兼容float3.png

nice!看似问题解决了,但是这依然只是在我自己手机上的效果,还要测试兼容性,于是我在其他不同版本的手机上测试了一下

6.png

7.png

又傻眼了。。。有的机型上的“...”与文本重叠了,有的虽然“...”显示正常,但是与“展开”按钮的距离太远,显然这种方案依然不行

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;
}

一行时也展示展开按钮的副本.png

可以看到,文本超过两行时已经显示正常了,并且做了兼容性测试,在各种机型上的显示都没有问题,但是很明显又出现了新的问题,当文本没有超过两行时在右下角依然会显示“展开”按钮,那么如何隐藏呢?通常用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;
 }

8.png

可以看到当文本只有一行时“展开”按钮已经被遮住了,且兼容性也很好,但是布局是乎有点别扭。当文本只有一行时由于右下角“展开”按钮的存在,导致文本依然占据两行行高的空间,所以导致文本与他下面的元素的距离变大了,所以这种方案依然不能很好的解决我们的需求,除非文本下面没有其他元素了。

所以最终还是采用了计算文本高度的方式来解决问题,但是首屏时间会延长,不过当文本元素不太多的时候影响可以忽略。

2 需求二

把需求一解决完之后接着又来了一个相似的需求,UI图是一个表格,表格第一列要求是这样的:

  • 基金名称后面可能有图标,也可能没有
  • 基金名称后面没有图标,基金名称超过2行显示“...”
  • 基金名称后面有图标,且基金名称+图标超过两行时显示“... 图标”
  • 基金名称或基金名称+图标没有超过两行时完全显示

image.png

这次的场景比需求一的场景更复杂,因为文本后面可能有图标,且文本+图标是一个整体,当这个整体超过两行时,整体又分开,显示成“文本...” + "图标"

有了需求一的经验积累,在做需求二时可以少走不少弯路,我们当然首先选择用纯 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);
  };
}

至此需求完成,但是由于是通过计算高度方式来实现的,所以依然有首屏时间延长的问题,如果表格行数特别多,且每一行的文本后面都有图片,那这个阻塞首屏的时间会加长,看到这篇文章的你会有更好的解决方案吗?

参考文章:juejin.cn/post/696390…