鸿蒙学习实战之路:HarmonyOS 文本展开折叠组件实现指南

HarmonyOS 文本展开折叠组件实现指南

文本展开折叠是现代应用中常见的交互模式,能够在有限空间内展示长文本内容,提升用户体验

关于本文

本文将详细介绍在 HarmonyOS 5.0 中实现文本展开折叠功能的完整解决方案,从纯文本到富文本,帮助开发者轻松实现类似朋友圈、新闻列表等场景的文本交互效果。

官方文档是个好东西!

官方文档是个好东西!

官方文档是个好东西!

重要的内容说三遍

参考文档

环境要求

软件/环境 版本要求
HarmonyOS 5.0+
API Level 12+
DevEco Studio 4.0+

文本展开折叠基础概念

什么是文本展开折叠?

文本展开折叠功能允许将长文本内容默认只显示部分(通常是几行),用户可以通过点击操作展开查看完整内容,再次点击则收起恢复到默认显示状态。

主要应用场景:

  • 朋友圈或社交动态列表
  • 新闻文章摘要展示
  • 商品详情描述
  • 评论区内容展示

实现原理

文本展开折叠功能的核心实现原理包括以下几个步骤:

  1. 文本测量:计算完整文本和限制行数的高度
  2. 需求判断:对比两种高度,确定是否需要折叠处理
  3. 文本截断:通过二分查找算法找到最佳的折叠点
  4. 状态管理:管理展开/折叠的状态切换

纯文本展开折叠实现

基础常量定义

// 完整的示例文本
const FULL_TEXT: string =
  "君不见黄河之水天上来,奔流到海不复回。君不见高堂明镜悲白发,朝如青丝暮成雪。人生得意须尽欢,莫使金樽空对月。天生我材必有用,千金散尽还复来。烹羊宰牛且为乐,会须一饮三百杯。岑夫子,丹丘生,将进酒,杯莫停。与君歌一曲,请君为我倾耳听。钟鼓馔玉不足贵,但愿长醉不愿醒。古来圣贤皆寂寞,惟有饮者留其名。陈王昔时宴平乐,斗酒十千恣欢谑。主人何为言少钱,径须沽取对君酌。五花马,千金裘,呼儿将出换美酒,与尔同销万古愁。";
const TEXT_WIDTH: number = 300; // 文本容器宽度
const COLLAPSE_LINES: number = 2; // 折叠时显示的行数
const ELLIPSIS: string = "..."; // 省略号
const EXPAND_STR: string = "展开"; // 展开按钮文本
const COLLAPSE_STR: string = "收起"; // 收起按钮文本

核心组件实现

import { Text, TextAlign, Flex, FlexDirection, Alignment } from '@ohos.arkui.component';
import { measureTextSize } from '@ohos.measure';

@Entry
@Component
struct TextExpandCollapseExample {
  @State isExpanded: boolean = false; // 展开状态
  @State displayText: string = ""; // 显示的文本内容
  @State needExpand: boolean = false; // 是否需要展开功能

  onPageShow() {
    this.checkNeedExpand();
  }

  // 检查是否需要展开功能
  checkNeedExpand() {
    // 测量完整文本高度
    const fullSize = measureTextSize({
      textContent: FULL_TEXT,
      fontSize: 16,
      fontFamily: 'HarmonyOS Sans',
      constraintWidth: TEXT_WIDTH
    });

    // 测量限制行数的文本高度
    const collapsedSize = measureTextSize({
      textContent: this.getLinesText(COLLAPSE_LINES),
      fontSize: 16,
      fontFamily: 'HarmonyOS Sans',
      constraintWidth: TEXT_WIDTH
    });

    // 判断是否需要展开功能
    this.needExpand = fullSize.height > collapsedSize.height;

    // 设置初始显示文本
    if (this.needExpand) {
      this.displayText = this.getCollapsedText();
    } else {
      this.displayText = FULL_TEXT;
    }
  }

  // 获取指定行数的测试文本
  getLinesText(lines: number): string {
    let result = "";
    for (let i = 0; i < lines; i++) {
      result += "测试文本\n";
    }
    return result.trim();
  }

  // 获取折叠后的文本
  getCollapsedText(): string {
    // 使用二分查找算法找到最佳截断位置
    let left = 0;
    let right = FULL_TEXT.length;
    let result = FULL_TEXT;

    while (left <= right) {
      const mid = Math.floor((left + right) / 2);
      const testText = FULL_TEXT.substring(0, mid) + ELLIPSIS + EXPAND_STR;

      const testSize = measureTextSize({
        textContent: testText,
        fontSize: 16,
        fontFamily: 'HarmonyOS Sans',
        constraintWidth: TEXT_WIDTH
      });

      const maxHeight = this.getMaxHeight();

      if (testSize.height <= maxHeight) {
        result = FULL_TEXT.substring(0, mid) + ELLIPSIS;
        left = mid + 1;
      } else {
        right = mid - 1;
      }
    }

    return result;
  }

  // 获取最大允许高度
  getMaxHeight(): number {
    const lineHeight = 24; // 估计行高
    return lineHeight * COLLAPSE_LINES;
  }

  // 切换展开/折叠状态
  toggleExpand() {
    this.isExpanded = !this.isExpanded;
    this.displayText = this.isExpanded ? FULL_TEXT : this.getCollapsedText();
  }

  build() {
    Column() {
      // 文本内容
      Text(this.displayText)
        .fontSize(16)
        .width(TEXT_WIDTH)
        .textAlign(TextAlign.Start)
        .margin({ bottom: 10 })

      // 展开/收起按钮
      if (this.needExpand) {
        Flex({ direction: FlexDirection.Row, justifyContent: FlexAlign.End })
          .width(TEXT_WIDTH)
          .padding({ right: 5 })
        {
          Text(this.isExpanded ? COLLAPSE_STR : EXPAND_STR)
            .fontColor('#007DFF')
            .fontSize(14)
            .onClick(() => {
              this.toggleExpand();
            })
        }
      }
    }
    .padding(20)
    .width('100%')
  }
}

组件化封装

为了提高代码复用性,我们可以将文本展开折叠功能封装成一个独立的组件:

TextExpandView 组件

import { Text, TextAlign, Flex, FlexAlign, Prop } from '@ohos.arkui.component';
import { measureTextSize } from '@ohos.measure';

@Component
struct TextExpandView {
  @Prop text: string; // 文本内容
  @Prop maxLines: number = 2; // 最大显示行数
  @Prop fontSize: number = 16; // 字体大小
  @Prop lineHeight: number = 24; // 行高
  @Prop constraintWidth: number = 300; // 文本容器宽度
  @Prop expandText: string = "展开"; // 展开按钮文本
  @Prop collapseText: string = "收起"; // 收起按钮文本
  @Prop ellipsis: string = "..."; // 省略号

  @State isExpanded: boolean = false; // 展开状态
  @State displayText: string = ""; // 显示的文本内容
  @State needExpand: boolean = false; // 是否需要展开功能

  aboutToAppear() {
    this.checkNeedExpand();
  }

  // 检查是否需要展开功能
  checkNeedExpand() {
    // 测量完整文本高度
    const fullSize = measureTextSize({
      textContent: this.text,
      fontSize: this.fontSize,
      constraintWidth: this.constraintWidth
    });

    // 计算最大允许高度
    const maxHeight = this.lineHeight * this.maxLines;

    // 判断是否需要展开功能
    this.needExpand = fullSize.height > maxHeight;

    // 设置初始显示文本
    if (this.needExpand) {
      this.displayText = this.getCollapsedText();
    } else {
      this.displayText = this.text;
    }
  }

  // 获取折叠后的文本
  getCollapsedText(): string {
    let left = 0;
    let right = this.text.length;
    let result = this.text;

    while (left <= right) {
      const mid = Math.floor((left + right) / 2);
      const testText = this.text.substring(0, mid) + this.ellipsis + this.expandText;

      const testSize = measureTextSize({
        textContent: testText,
        fontSize: this.fontSize,
        constraintWidth: this.constraintWidth
      });

      const maxHeight = this.lineHeight * this.maxLines;

      if (testSize.height <= maxHeight) {
        result = this.text.substring(0, mid) + this.ellipsis;
        left = mid + 1;
      } else {
        right = mid - 1;
      }
    }

    return result;
  }

  // 切换展开/折叠状态
  toggleExpand() {
    this.isExpanded = !this.isExpanded;
    this.displayText = this.isExpanded ? this.text : this.getCollapsedText();
  }

  build() {
    Column() {
      Text(this.displayText)
        .fontSize(this.fontSize)
        .width(this.constraintWidth)
        .textAlign(TextAlign.Start)
        .margin({ bottom: 5 })

      if (this.needExpand) {
        Flex({ justifyContent: FlexAlign.End })
          .width(this.constraintWidth)
        {
          Text(this.isExpanded ? this.collapseText : this.expandText)
            .fontColor('#007DFF')
            .fontSize(this.fontSize - 2)
            .onClick(() => {
              this.toggleExpand();
            })
        }
      }
    }
  }
}

使用示例

import { Column } from '@ohos.arkui.component';
import TextExpandView from './TextExpandView'; // 导入自定义组件

@Entry
@Component
struct TextExpandExample {
  private longText: string = "这是一段很长很长的文本内容,在实际应用中,我们经常会遇到需要展示长文本的场景。默认情况下,我们希望只显示几行文本,当用户需要查看更多内容时,可以点击展开按钮查看完整内容。这种交互方式可以有效节省屏幕空间,提升用户体验。";

  build() {
    Column() {
      Text('默认展开折叠示例')
        .fontSize(18)
        .margin({ bottom: 20 })

      // 使用封装的文本展开折叠组件
      TextExpandView({
        text: this.longText,
        maxLines: 3,
        fontSize: 16,
        constraintWidth: 350
      })
        .margin({ bottom: 30 })

      Text('自定义配置示例')
        .fontSize(18)
        .margin({ bottom: 20 })

      // 自定义配置的文本展开折叠组件
      TextExpandView({
        text: this.longText,
        maxLines: 2,
        fontSize: 18,
        lineHeight: 28,
        constraintWidth: 320,
        expandText: '查看更多',
        collapseText: '收起'
      })
    }
    .padding(30)
    .width('100%')
    .height('100%')
  }
}

高级实现:图文混排展开折叠

在实际应用中,我们经常会遇到图文混排的情况,这时候需要更复杂的处理逻辑:

图文混排组件实现

import { Row, Column, Text, Image, Flex, FlexAlign } from '@ohos.arkui.component';
import { measureTextSize } from '@ohos.measure';

@Component
struct RichTextExpandView {
  @Prop text: string; // 文本内容
  @Prop imageSrc?: string; // 图片资源路径
  @Prop maxLines: number; // 最大显示行数
  @Prop constraintWidth: number; // 容器宽度

  @State isExpanded: boolean = false;
  @State displayText: string = "";
  @State needExpand: boolean = false;

  aboutToAppear() {
    this.checkNeedExpand();
  }

  checkNeedExpand() {
    // 测量文本高度
    const textSize = measureTextSize({
      textContent: this.text,
      fontSize: 16,
      constraintWidth: this.constraintWidth
    });

    // 计算最大允许高度(包含图片空间)
    const imageHeight = this.imageSrc ? 100 : 0; // 假设图片高度为100
    const maxTextHeight = 24 * this.maxLines;
    const maxTotalHeight = imageHeight + maxTextHeight;

    // 判断是否需要展开
    this.needExpand = textSize.height > maxTextHeight;

    if (this.needExpand && !this.isExpanded) {
      this.displayText = this.getCollapsedText();
    } else {
      this.displayText = this.text;
    }
  }

  getCollapsedText(): string {
    // 类似前面的二分查找算法,略...
    // 这里需要考虑图片占用的空间进行调整
    return this.text.substring(0, 50) + "...";
  }

  toggleExpand() {
    this.isExpanded = !this.isExpanded;
    this.displayText = this.isExpanded ? this.text : this.getCollapsedText();
  }

  build() {
    Column() {
      Row() {
        // 左侧图片
        if (this.imageSrc) {
          Image(this.imageSrc)
            .width(80)
            .height(80)
            .borderRadius(8)
            .margin({ right: 12 })
        }

        // 右侧文本
        Column()
          .width(this.imageSrc ? this.constraintWidth - 100 : this.constraintWidth)
        {
          Text(this.displayText)
            .fontSize(16)
            .margin({ bottom: 5 })

          // 展开/收起按钮
          if (this.needExpand) {
            Flex({ justifyContent: FlexAlign.End })
            {
              Text(this.isExpanded ? "收起" : "展开")
                .fontColor('#007DFF')
                .fontSize(14)
                .onClick(() => {
                  this.toggleExpand();
                })
            }
          }
        }
      }
    }
  }
}

实际应用场景

下面是一个模拟朋友圈列表的完整示例:

import { List, ListItem, Column, Text, Image, Flex, FlexAlign } from '@ohos.arkui.component';

// 朋友圈数据
interface PostData {
  id: string;
  avatar: string;
  nickname: string;
  content: string;
  image?: string;
  likes: number;
  comments: number;
}

@Entry
@Component
struct MomentsExample {
  @State posts: PostData[] = [
    {
      id: '1',
      avatar: '/common/avatar1.png',
      nickname: '小明',
      content: '今天天气真好,出去走走感觉整个人都精神了!分享一下沿途的风景,希望大家也有好心情~#自然风光 #周末愉快',
      image: '/common/scenery1.png',
      likes: 15,
      comments: 3
    },
    {
      id: '2',
      avatar: '/common/avatar2.png',
      nickname: '小红',
      content: '今天尝试了新的烘焙食谱,做了一个巧克力蛋糕!味道超级棒,分享一下制作过程和成品图。材料:巧克力100克,鸡蛋3个,面粉80克,糖50克,黄油40克。步骤:1. 巧克力和黄油隔水融化;2. 鸡蛋打散加糖打发;3. 加入融化的巧克力黄油糊;4. 筛入面粉拌匀;5. 倒入模具,烤箱180度烤30分钟。',
      likes: 28,
      comments: 7
    }
  ]

  build() {
    Column() {
      Text('朋友圈')
        .fontSize(22)
        .fontWeight(FontWeight.Bold)
        .margin({ bottom: 20 })

      List() {
        ForEach(this.posts, (post) => {
          ListItem() {
            Column() {
              // 用户信息
              Row() {
                Image(post.avatar)
                  .width(40)
                  .height(40)
                  .borderRadius(20)
                  .margin({ right: 12 })

                Column()
                  .alignItems(HorizontalAlign.Start)
                {
                  Text(post.nickname)
                    .fontSize(16)
                    .fontWeight(FontWeight.Medium)
                }
              }
              .margin({ bottom: 10 })

              // 帖子内容(使用文本展开折叠组件)
              RichTextExpandView({
                text: post.content,
                imageSrc: post.image,
                maxLines: 3,
                constraintWidth: 350
              })
              .margin({ bottom: 10 })

              // 互动栏
              Row() {
                Text(`❤️ ${post.likes}`)
                  .fontSize(14)
                  .fontColor('#999')
                  .margin({ right: 20 })

                Text(`💬 ${post.comments}`)
                  .fontSize(14)
                  .fontColor('#999')
              }
              .margin({ top: 10 })
            }
            .padding(20)
            .borderBottom({ width: 1, color: '#f0f0f0' })
          }
        }, (post) => post.id)
      }
    }
    .padding(20)
    .width('100%')
    .height('100%')
  }
}

性能优化

在实现文本展开折叠功能时,需要注意以下性能优化点:

1. 避免重复测量

// 优化前
@State textMeasureCount: number = 0;

checkNeedExpand() {
  // 每次都重新测量
  const fullSize = measureTextSize({...});
  this.textMeasureCount++;
}

// 优化后
@State cachedTextSize: number = 0;

checkNeedExpand() {
  // 只有在文本内容变化时才重新测量
  if (this.cachedTextSize === 0) {
    const fullSize = measureTextSize({...});
    this.cachedTextSize = fullSize.height;
  }
}

2. 使用防抖处理状态切换

private debounceTimer: number | null = null;

@State isExpanded: boolean = false;

toggleExpand() {
  // 清除之前的定时器
  if (this.debounceTimer !== null) {
    clearTimeout(this.debounceTimer);
  }

  // 设置新的定时器
  this.debounceTimer = setTimeout(() => {
    this.isExpanded = !this.isExpanded;
    this.debounceTimer = null;
  }, 200); // 200ms防抖延迟
}

注意事项与最佳实践

⚠️ 重要提示

  • 版本兼容性:本文所有示例基于 HarmonyOS 5.0+和 API Level 12+,在低版本系统上可能需要调整
  • 文本测量精度:不同字体和字号下,文本测量结果可能略有差异,需要进行适当的校准
  • 性能考量:在列表中使用文本展开折叠时,需要特别注意性能优化,避免卡顿
  • 无障碍支持:为展开/收起按钮添加适当的 aria 属性,确保良好的无障碍体验

最佳实践建议

  1. 组件化封装:将文本展开折叠功能封装成独立组件,提高代码复用性
  2. 合理设置默认行数:根据具体场景设置合适的默认显示行数,通常为 2-3 行
  3. 视觉反馈:点击展开/收起时提供适当的视觉反馈,如动画效果
  4. 自适应宽度:组件应支持自适应容器宽度,提高通用性
  5. 缓存优化:对文本测量结果进行缓存,避免重复计算

常见问题解答

Q: 如何处理不同屏幕宽度下的文本展开折叠?
A: 可以监听容器尺寸变化,动态调整文本容器宽度和重新计算折叠文本。

Q: 如何为展开/收起操作添加动画效果?
A: 可以使用 HarmonyOS 的 animateTo API 实现平滑的过渡动画:

animateTo(
  {
    duration: 300,
    curve: Curve.EaseInOut,
  },
  () => {
    // 更新文本内容和状态
  }
);

Q: 如何处理富文本内容的展开折叠?
A: 对于富文本内容,需要使用 Web 组件或者自定义富文本解析器,并实现相应的高度计算逻辑。

Q: 文本展开折叠在性能敏感的场景中如何优化?
A: 可以考虑以下优化策略:

  • 使用虚拟化列表
  • 延迟加载不在视口内的内容
  • 预计算并缓存折叠文本
  • 避免在滚动过程中进行文本测量

总结

文本展开折叠是提升用户体验的重要交互模式,通过本文的学习,你应该已经掌握了在 HarmonyOS 应用中实现这一功能的完整解决方案:

  • 纯文本展开折叠的核心实现原理
  • 组件化封装的最佳实践
  • 图文混排场景的处理方法
  • 性能优化的关键技巧
  • 实际应用场景的完整示例

💡 提示:实践是最好的学习方式,建议你动手尝试上述示例代码,并根据自己的应用需求进行扩展和优化!

祝你开发顺利!

posted @ 2025-12-15 20:47  时间煮鱼  阅读(1)  评论(0)    收藏  举报