首先声明,本文的思路并非原创,参考自:CSS 实现多行文本“展开收起”
上周看到公司同事发了一篇阅文前端团队的文章,讲述纯CSS实现多行文本的展开收起功能(果然阅文都是CSS大佬),正巧这周就有需求用到这个功能,遂在此基础上进行一些封装改造,应用于公司内部项目。
最终效果:
业务背景
本次要做一个外部长链转可以唤起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; // 限制2行
transition: 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>
写完之后跑起来,发现并没有生效
打开控制台,发现是由于行内绑定的max-height优先级高于<style>内的样式,所以max-height的值并没有改变,也就是说transition动画的结束状态的值是没有改变的。
这个简单,!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>
再看一下:
又出现了新的问题:由于在文本不足以截断时,使用了文本容器的: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;
}
}
}
最后,多添加几条数据看一下最终效果:
附上完整代码:
<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>