Vue3/React 实现多行文本省略展开收起组件

944 阅读4分钟

在前端开发中,多行文本的展开收起功能是一个常见且实用的交互设计。但是实现起来要考虑到情况诸多,符合应用的场景以及浏览器适配都有很多问题。借助CSS 实现多行文本“展开收起”多行文本展开收起是一个很常见的交互, 如下图演示 实现这一类布局和交互难点主要有以下几点 - 掘金 这篇详细分析css实现多行文本“展开/收起”, 在此基础上使用 Vue3 和 TypeScript 实现一个灵活、可复用的多行文本展开收起组件。

实现难点

在开始之前,让我们先了解实现多行文本展开收起功能的主要难点:

  1. 如何精确控制显示的行数
  2. 如何在文本末尾显示"展开"按钮
  3. 如何实现平滑的展开收起动画
  4. 如何处理文本不足指定行数的情况

TextEllipsis 组件实现

我们的 TextEllipsis 组件采用了纯 CSS 方案,结合 Vue3 的组合式 API,实现了一个灵活且易用的解决方案。下面是组件的核心代码:

<script setup lang="ts">
import { ref, computed, defineSlots } from 'vue'

interface Props {
  text?: string
  maxLines?: number
  expandText?: string
  collapseText?: string
  shadowColor?: string
}

const props = withDefaults(defineProps<Props>(), {
  text: undefined,
  maxLines: 3,
  shadowColor: '#ffffff',
  expandText: '展开',
  collapseText: '收起'
})

defineSlots<{
  default?: () => string
  toggle?: (props: { expanded: boolean; toggle: () => void }) => any
}>()

const expanded = ref(false)
const toggleId = ref(`exp-${Math.random().toString(36).slice(2, 11)}`)
const textRef = ref<HTMLElement | null>(null)

const buttonText = computed(() => expanded.value ? props.collapseText : props.expandText)

const lineHeight = 1.5
const maxHeight = computed(() => `${props.maxLines * lineHeight}em`)

const toggle = () => {
  expanded.value = !expanded.value
}


</script>

<template>
  <div class="text-ellipsis-wrapper">
    <input :id="toggleId" class="text-ellipsis-exp" type="checkbox" v-model="expanded">
    <div ref="textRef" class="text-ellipsis-text"
      :style="{ '--max-lines': props.maxLines, '--line-height': lineHeight, '--max-height': maxHeight, '--shadow-color': props.shadowColor }">
      <label class="text-ellipsis-label" :for="toggleId">
        <slot name="toggle" :expanded="expanded" :toggle="toggle">
          <button class="default-btn"  @click="toggle">
            {{ buttonText }}
          </button>
        </slot>
      </label>
      <slot :msg="text">{{ text }}</slot>
    </div>
  </div>
</template>

<style scoped>
.text-ellipsis-wrapper {
  display: flex;
  overflow: hidden;
  border-radius: 8px;
  padding: 15px;
}

.text-ellipsis-text {
  font-size: 16px;
  overflow: hidden;
  text-overflow: ellipsis;
  text-align: justify;
  position: relative;
  line-height: var(--line-height);
  max-height: var(--max-height);
  transition: .3s max-height;
}

.text-ellipsis-text::before {
  content: '';
  height: calc(100% - 24px);
  float: right;
}

.text-ellipsis-text::after {
  content: '';
  width: 999vw;
  height: 999vw;
  position: absolute;
  box-shadow: inset calc(100px - 999vw) calc(30px - 999vw) 0 0 var(--shadow-color);
  margin-left: -100px;
}

.text-ellipsis-label {
  position: relative;
  float: right;
  clear: both;
  margin-left: 20px;
}

.default-btn {
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  cursor: pointer;
  outline: inherit;
  color: #1890ff;
}

.text-ellipsis-label::before {
  content: '...';
  position: absolute;
  left: -5px;
  color: #333;
  transform: translateX(-100%)
}

.text-ellipsis-exp {
  display: none;
}

.text-ellipsis-exp:checked+.text-ellipsis-text {
  max-height: none;
}

.text-ellipsis-exp:checked+.text-ellipsis-text::after {
  visibility: hidden;
}

.text-ellipsis-exp:checked+.text-ellipsis-text .text-ellipsis-label::before {
  visibility: hidden;
}
</style>

React版本

import React, { useState, useRef, useEffect, useCallback } from 'react';
import './text-ellipsis.css';

interface TextEllipsisProps {
  text: string;
  maxLines?: number;
  expandText?: string;
  collapseText?: string;
  shadowColor?: string;
  children?: React.ReactNode;
}

const TextEllipsis: React.FC<TextEllipsisProps> = ({
  text,
  maxLines = 3,
  expandText = '展开',
  collapseText = '收起',
  shadowColor = '#ffffff',
  children,
}) => {
  const [expanded, setExpanded] = useState(false);
  const textRef = useRef<HTMLDivElement>(null);

  const toggleId = `exp-${Math.random().toString(36).slice(2, 11)}`;
  const lineHeight = 1.5;
  const maxHeight = `${maxLines * lineHeight}em`;

  const toggle = () => {
    setExpanded(!expanded);
  };

  const buttonText = expanded ? collapseText : expandText;

  return (
    <div className='text-ellipsis-wrapper'>
      <input
        id={toggleId}
        type='checkbox'
        className='text-ellipsis-exp'
        checked={expanded}
        onChange={() => setExpanded(!expanded)}
      />
      <div
        ref={textRef}
        className='text-ellipsis-text'
        style={
          {
            maxHeight: expanded ? 'none' : maxHeight,
            lineHeight: `${lineHeight}`,
            '--shadow-color': shadowColor,
          } as React.CSSProperties
        }
      >
        <label htmlFor={toggleId} className='text-ellipsis-label'>
          {children ? (
            React.Children.map(children, child => {
              if (React.isValidElement(child)) {
                return React.cloneElement(child, { expanded, toggle } as any);
              }
              return child;
            })
          ) : (
            <button onClick={toggle} className='default-button'>
              {buttonText}
            </button>
          )}
        </label>
        {text}
      </div>
    </div>
  );
};

export default TextEllipsis;
.text-ellipsis-wrapper {

  display: flex;

  overflow: hidden;

  border-radius: 8px;

  padding: 15px;

}

.text-ellipsis-exp {

  display: none;

}

.text-ellipsis-text {

  font-size: 16px;

  overflow: hidden;

  text-overflow: ellipsis;

  text-align: justify;

  position: relative;

  transition: 0.3s max-height;

}

.text-ellipsis-text::before {

  content: '';

  height: calc(100% - 24px);

  float: right;

}

.text-ellipsis-text::after {

  content: '';

  width: 999vw;

  height: 999vw;

  position: absolute;

  box-shadow: inset calc(100px - 999vw) calc(30px - 999vw) 0 0 var(--shadow-color);

  margin-left: -100px;

}

.text-ellipsis-exp:checked ~ .text-ellipsis-text::after {

  visibility: hidden;

}

.text-ellipsis-label {

  position: relative;

  float: right;

  clear: both;

  margin-left: 20px;

}

.text-ellipsis-label::before {

  content: '...';

  position: absolute;

  left: -5px;

  color: #333;

  transform: translateX(-100%);

}

.default-button {

  background: none;

  border: none;

  padding: 0;

  font: inherit;

  cursor: pointer;

  outline: inherit;

  color: #1890ff;

}

关键技术点解析

  1. 动态最大高度: 我们使用计算属性 maxHeight 来动态设置文本容器的最大高度。

    const maxHeight = computed(() => `${props.maxLines * lineHeight}em`)
    
  2. CSS 变量应用: 我们使用 CSS 变量来动态设置样式,提高了组件的灵活性。

    <div
      ref="textRef" class="text-ellipsis-text"
      :style="{ '--max-lines': props.maxLines, '--line-height': lineHeight, '--max-height': maxHeight, '--shadow-color': props.shadowColor }"
    >
    
  3. 展开/收起切换: 使用 v-model 绑定的 checkbox 来控制文本的展开和收起状态。

    <input :id="toggleId" v-model="expanded" class="text-ellipsis-exp" type="checkbox">
    
  4. 自定义插槽: 提供了默认插槽,允许用户自定义展开/收起按钮的样式和行为。

    <slot name="toggle" :expanded="expanded" :toggle="toggle">
      <button class="default-btn" @click="toggle">
        {{ buttonText }}
      </button>
    </slot>
    

CSS 技巧

TextEllipsis 组件的 CSS 部分也包含了一些巧妙的技术:

.text-ellipsis-text::before {
  content: '';
  height: calc(100% - 24px);
  float: right;
}

.text-ellipsis-text::after {
  content: '';
  width: 999vw;
  height: 999vw;
  position: absolute;
  box-shadow: inset calc(100px - 999vw) calc(30px - 999vw) 0 0 var(--shadow-color);
  margin-left: -100px;
}

.text-ellipsis-label::before {
  content: '...';
  position: absolute;
  left: -5px;
  color: #333;
  transform: translateX(-100%)
}
  1. 使用 ::before 伪元素创建一个浮动的空间,用于放置展开/收起按钮。
  2. 使用 ::after 伪元素创建一个巧妙的渐变遮罩效果。
  3. 为标签添加一个 ::before 伪元素,显示省略号。

组件的使用

使用 TextEllipsis 组件非常简单:

  • 直接使用
<template>
  <TextEllipsis :text="longText" />
</template>

<script setup lang="ts">
import TextEllipsis from './components/TextEllipsis.vue'

const longText = '这是一段很长的文本...'
</script>
  • 使用插槽
<template>
  <TextEllipsis :max-lines="3">
      {{ longText }}
    <template #toggle="{ expanded, toggle }">
      <button @click="toggle">
        {{ expanded ? '收起' : '展开' }}
      </button>
    </template>
  </TextEllipsis>
</template>

<script setup lang="ts">
import TextEllipsis from './components/TextEllipsis.vue'

const longText = '这是一段很长的文本...'
</script>

优势与特点

  1. 高度可定制:支持自定义最大行数、展开/收起文本、渐变颜色等。
  2. 灵活的插槽设计:允许用户自定义展开/收起按钮的样式和行为。
  3. 响应式设计:适应不同屏幕尺寸和字体大小。

总结

通过 TextEllipsis 组件,我们巧妙地解决了多行文本展开收起的实现难点。这个组件不仅功能强大,而且具有很高的可复用性和可定制性。在实际项目中,它可以大大提升用户体验,同时简化开发流程。

希望这篇文章能够帮助你更好地理解和实现多行文本展开收起功能。如果你有任何问题或改进建议,欢迎在评论区留言讨论!