多行文本“展开收起”只用CSS就能实现?

1,293 阅读4分钟

首先声明,本文的思路并非原创,参考自:CSS 实现多行文本“展开收起”

上周看到公司同事发了一篇阅文前端团队的文章,讲述纯CSS实现多行文本的展开收起功能(果然阅文都是CSS大佬),正巧这周就有需求用到这个功能,遂在此基础上进行一些封装改造,应用于公司内部项目。

最终效果:

gif.gif

业务背景

本次要做一个外部长链转可以唤起app内对应webview的deeplink,同时生成一个短链,并用Table组件展示历史转换记录(该项目使用的是ViewUI)。面对长度动辄接近100的字符串,为了美观,产品希望加上一个展开收起的功能。

功能清单

要用纯CSS实现多行文本展开收起,需要实现以下功能:

  • 多行文本截断
  • 位于右下角的“展开收起”按钮(环绕效果)
  • 控制展开收起状态
  • 可以组件化应用于Table组件中

多行文本截断

传统的css方案相信大家都已经很熟悉了:

.text{
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

但是我们这里并不打算使用这种方式去处理文本截断,如果这样操作,可以预想到的将会“啪”的一下就从收起状态变成了展开状态,这样一点也不丝滑一点也不酷。为了有一个过渡动画,我们决定采用改变文本容器的高度+transition的方案。(当然还有一个重要的原因是:无法处理浏览器兼容问题。可以在原文中找到。)

这里有一个问题:CSS3的transition动画需要被改变状态的初始值和结束值,但是我们并不知道文本有长,更不要说高度了,难度要等到组件mount之后再调用Element.getboundingclientrect()去动态获取高度吗?不!别忘了我们的标题是:只用CSS!还有一个属性也可以达到这样的效果,没错他就是max-height

虽然我们不知道文本一共有多少行,高度是多少。但是,初始状态我们可以通过line-height去控制,结束状态只要给一个足够大的max-height,可以达到一样的效果。(当然数值也不要太大,会影响动画效果。文本容器的高度并不会从初始限制的行数变为max-height那么高,但是动画是按照(结束状态max-height - 初始状态max-height) / time的速度去执行的)。

相关代码如下:

// 初始状态
.text {
    overflow: hidden;
    text-overflow: ellipsis;
    text-align: justify;
    position: relative;
    line-height: 1.5em;
    max-height: 3em; // 限制2transition: 0.3s max-height ease-in-out;
  }

// 展开状态
.text.open {
  max-height: 999px;
}

位于右下角的“展开收起”按钮(环绕效果)

这个就比较简单了,实现的方式有很多种,比如:放置一个<button />按钮,自身clear: both + 伪元素flot: right实现文本右侧的环绕效果;伪元素width: 0; height: calc(100% - button高度)实现按钮处于文本的右下角。代码这里就不放了,可以在最终的完整代码中查看。

控制展开收起状态

原文的精髓我认为就在这一步:使用<input type="checkbox" />去关联展开收起两种状态,使用伪类content去处理两种状态的文本。两个字,优秀!

结合上一步,把<button />替换为<label />,相关代码如下:

<template>
  <div class="wrap">
    <input type="checkbox" id="exp">
    <div class="text">
      <label class="btn" for="exp">展开</label>
      浮动元素是如何定位的
    正如我们前面提到的那样,当一个元素浮动之后,它会被移出正常的文档流,然后向左或者向右平移,一直平移直到碰到了所处的容器的边框,或者碰到另外一个浮动的元素。
    </div>
  </div>
</template>
<style lang="less">
.btn::after{
  content:'展开' /*采用content生成*/
}
  
.exp:checked+.text .btn::after{
  content:'收起'
}
</style>

可以组件化应用于Table组件中

想要以组件的形式应用于项目中,有两个最基础的问题要处理:

  • 可配置初始限制的行数
  • 一个<label />for对应一个<input />

按照思路,如果要配置初始行数,就是要控制初始的max-height。由于我们设置了line-height 是1.5em,再通过props接受一个参数,去动态计算并绑定max-height值。

<div class="text" :id="'text' + id" :style="{'max-height': `${line * 1.5}em`}">
<script>
export default {
  props: {
    line: {
      type: Number,
      default: 2
    }
  }
}
</script>

写完之后跑起来,发现并没有生效

gif2.gif

打开控制台,发现是由于行内绑定的max-height优先级高于<style>内的样式,所以max-height的值并没有改变,也就是说transition动画的结束状态的值是没有改变的。

561622457344_.pic.jpg

这个简单,!important安排一下!(虽说不建议使用!important,但是我们严格按照BEM规范书写样式,加上这个属性只控制了transition动画,并不会造成全局样式污染,我只能说,问题不大~)

.exp:checked + .text {
  max-height: 999px !important;
}

完美运行!

接着,处理一个<label />for对应一个<input />的问题,这个更简单,一般每条数据都有一个唯一id,我们给<input />动态生成一个id,将<label />的for属性也按照同样的规则赋值。

<input :id="'exp' + id" class="exp" type="checkbox" />
<div class="text" :id="'text' + id" :style="{'max-height': `${line * 1.5}em`}">
	<label class="btn" :for="'exp' + id"></label>
	{{ text }}
</div>

再看一下:

gif3.gif

又出现了新的问题:由于在文本不足以截断时,使用了文本容器的:after伪类生成了一个很大的box-shadow去遮盖住展开收起按钮

{
    box-shadow: inset calc(100px - 999vw) calc(30px - 999vw) 0 0 #fff;
}

而这个背景色和ViewUI自带的hover效果不兼容。

翻看ViewUI源码,给文本容器的:after伪类添加同样的过渡动画,搞定!

// 兼容ivew hover&highlight样式
.text::after{
  transiton: .2s box-shadow ease-in-out
}
.ivu-table-row-hover,.ivu-table-row-highlight {
  .CollapseTextViewWrapper {
    .text::after {
      box-shadow: inset calc(100px - 999vw) calc(30px - 999vw) 0 0 #ebf7ff;
    }
  }
}

gif4.gif

最后,多添加几条数据看一下最终效果:

git5.gif

附上完整代码:

<template>
  <div class="CollapseTextViewWrapper">
    <input :id="'exp' + id" class="exp" type="checkbox" />
    <div class="text" :id="'text' + id" :style="{'max-height': `${line * 1.5}em`}">
      <label class="btn" :for="'exp' + id"></label>
      {{ text }}
    </div>
  </div>
</template>
<script>
export default {
  name: 'CollapseView',
  props: {
    text: {
      type: String,
      default: '',
    },
    id: {
      type: Number,
    },
    line: {
      type: Number,
      default: 2,
    },
  },
};
</script>
<style lang="less">
.CollapseTextViewWrapper {
  display: flex;
  overflow: hidden;
  padding: 5px 0; /** hack for iView's table cell strange blank in vertical */
  .text {
    overflow: hidden;
    text-overflow: ellipsis;
    text-align: justify;
    position: relative;
    line-height: 1.5em;
    transition: 0.3s max-height ease-in-out;
  }
  .text::before {
    content: '';
    height: calc(100% - 18px);
    float: right;
  }
  .text::after {
    content: '';
    width: 999vw;
    height: 999vw;
    position: absolute;
    box-shadow: inset calc(100px - 999vw) calc(30px - 999vw) 0 0 #fff;
    transition: .2s box-shadow ease-in-out;
    margin-left: -100px;
  }
  .btn {
    position: relative;
    float: right;
    clear: both;
    margin-left: 20px;
    color: rgb(0, 110, 255);
    cursor: pointer;
  }
  .btn::after {
    content: '展开';
  }
  .exp {
    display: none;
  }
  .exp:checked + .text {
    max-height: 999px;
  }
  .exp:checked + .text::after {
    visibility: hidden;
  }
  .exp:checked + .text .btn::before {
    visibility: hidden;
  }
  .exp:checked + .text .btn::after {
    content: '收起';
  }
  .btn::before {
    content: '...';
    position: absolute;
    left: -5px;
    color: #333;
    transform: translateX(-100%);
  }
}
// 兼容ivew hover&highlight样式
.ivu-table-row-hover,.ivu-table-row-highlight {
  .CollapseTextViewWrapper {
    .text::after {
      box-shadow: inset calc(100px - 999vw) calc(30px - 999vw) 0 0 #ebf7ff;
    }
  }
}
</style>