跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客我的书AI学习GitHub 精选镜像AI 生图工具UI配色美学关于
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
TypeScript大前端

鸿蒙应用开发:仿微博文本折叠功能实现

鸿蒙应用开发中,列表内容常需实现文本展开与收起功能。纯文本和富文本两种场景下的实现方案。通过 measureTextSize 计算文本高度,利用二分查找确定截断位置;对于富文本,使用 ParagraphBuilder 进行预排版,结合坐标获取字形索引以精准截断。最终实现带“展开/收起”按钮的交互效果,解决表情和图片导致的高度计算难题。

雪落无声发布于 2026/2/25更新于 2026/10/1794 浏览
鸿蒙应用开发:仿微博文本折叠功能实现

仿微博文本折叠

概述

列表中的博文、评论等复合型内容组件,在文本行数超过预设阈值时,触发'展开''收起'的功能。内容收起时,如果有用'图片'展示'表情'的功能场景,由于图片出现的位置和大小都不固定,在收起展开时,截止到文字结尾的位置不好判断。

本文将介绍解决这一问题的基本逻辑和解决方案,帮助开发者使用系统自带模块,更简洁地解决问题。

纯文本展开折叠

场景描述

在示例列表中显示纯文本展开和收起功能,文本与按钮的显示变化。

  1. 文本中只有文字。
  2. 超出 2 行要能显示'…展开',展开后显示收起。
实现原理

需要计算出'…'前最后一个文字的索引和显示行高,以确定'收起''展开'按钮的位置。计算文本高度,结合按钮和'…'的宽度,计算收起文本最后一个文字的坐标,换算为对应内容索引,截断显示相应的内容。

分别添加'收起''展开'按钮及交互,进行文本截断内容和全部内容展示的切换。

本示例使用 measureTextSize() 方法判断文字的高度,API20 新增 getParagraphs20+ 方法测算文本。getParagraphs() 方法代码量更少,更直观地获取显示行数。

开发步骤
  1. 获取文本尺寸:
    • 使用 this.uiContext.getMeasureUtils().measureTextSize 方法计算文本的尺寸。参数包括文本内容、行高、布局宽度和字体大小。
    • 结果存储在 titleSize 变量中,特别是文本的高度。
  2. 转换高度单位:
    • 使用 this.getUIContext().px2vp 方法将文本高度从像素单位转换为视口单位。
  3. 判断是否需要处理:
    • 如果文本高度小于等于两倍的行高,则不需要进一步处理,将 this.textModifier.needProcess 设置为 false,并将标题设置为当前文本。
    • 否则,将 this.textModifier.needProcess 设置为 true,表示需要进一步处理。
  4. 处理文本展开/折叠:
    • 如果 this.expanded 为 true,则调用 this.collapseText() 方法折叠文本。
    • 否则,调用 this.expandText() 方法展开文本。
  5. 测量文本尺寸工具:通过 uiContext?.getMeasureUtils() 获取一个测量工具对象。
  6. 存储高度信息:将测量得到的高度信息存储在变量 minHeight 中。如果高度未定义,minHeight 将为 undefined。
  7. 初始化文本:
    • 使用 TextUtils.getStringFromResource 方法从资源中获取文本内容,并将其存储在 text 变量中。
  8. 二分查找算法:
    • 将文本字符串分割成字符数组 textStr,并初始化左右指针 leftCursor 和 rightCursor。
    • 使用二分查找算法在文本中查找合适的切割点。每次迭代中,计算中间位置 cursor,并生成临时文本 tempTitle。
    • 再次调用 uiContext.getMeasureUtils().measureTextSize 方法测量 tempTitle 的尺寸。
    • 根据测量结果,调整左右指针的位置,以缩小搜索范围。
    • 当左右指针的差值小于等于 1 时,表明找到了合适的切割点,跳出循环。
  9. 返回结果:
    • 返回切割后的文本字符串,并在末尾添加 suffix 和 lastSpan。
public static getShortText(textSectionAttribute: TextSectionAttribute, lastSpan: string): string {
    let text = TextUtils.getStringFromResource(textSectionAttribute.title);
    const minLinesTextSize: SizeOptions | undefined = uiContext?.getMeasureUtils().measureTextSize({ 
        textContent: text, 
        fontSize: textSectionAttribute.fontSize, 
        maxLines: textSectionAttribute.maxLines, 
        wordBreak: WordBreak.BREAK_ALL, 
        constraintWidth: textSectionAttribute.constraintWidth 
    });
    const minHeight: Length | undefined = minLinesTextSize?.height;
    if (minHeight === undefined) {
        return '';
    }
    // Use the dichotomy to find strings that are exactly two lines in length
    let textStr: string[] = Array.from(text); // Split the string to avoid special characters and inconsistent sizes
    let leftCursor: number = 0;
    let rightCursor: number = textStr.length;
    let cursor: number = Math.floor(rightCursor / 2);
    let tempTitle: string = '';
    while (true) {
        tempTitle = text.substring(0, cursor) + suffix + lastSpan;
        const currentLinesTextSize: SizeOptions | undefined = uiContext?.getMeasureUtils().measureTextSize({ 
            textContent: tempTitle, 
            fontSize: textSectionAttribute.fontSize, 
            wordBreak: WordBreak.BREAK_ALL, 
            constraintWidth: textSectionAttribute.constraintWidth 
        });
        const currentLineHeight: Length | undefined = currentLinesTextSize?.height;
        if (currentLineHeight === undefined) {
            return '';
        }
        if (currentLineHeight > minHeight) {
            // The current character has exceeded two lines, continue to look to the left
            rightCursor = cursor;
            cursor = leftCursor + Math.floor((cursor - leftCursor) / 2);
        } else {
            // The current character is less than two lines, it may be OK, but you still need to look to the right
            leftCursor = cursor;
            cursor += Math.floor((rightCursor - cursor) / 2);
        }
        if (Math.abs(rightCursor - leftCursor) <= 1) {
            // The two pointers basically coincide, which means that they have been found
            break;
        }
    }
    return text.substring(0, cursor) + suffix;
}

获取收起文本,显示收起展开按钮。减少接收文字字符数。当接收文字高度小于指定行数高度时,使文字显示两行收起。

计算文本收起高度(示例代码与步骤 3 同源)。使用 measureTextSize() 方法来判断两行文字的高度,当前为两行文字的高度。

const minLinesTextSize: SizeOptions = uiContext?.getMeasureUtils().measureTextSize({ 
    textContent: text, 
    fontSize: fontSize, 
    maxLines: maxLines, 
    wordBreak: WordBreak.BREAK_ALL, 
    constraintWidth: textWidth 
});
const minHeight: Length | undefined = minLinesTextSize.height;

计算原始文本高度。使用 measureTextSize() 方法来判断总体文字的高度。

getIsExpanded() {
    let titleSize: SizeOptions = this.uiContext.getMeasureUtils().measureTextSize({ 
        textContent: this.textSectionAttribute.title, // The text content is calculated 
        lineHeight: this.textSectionAttribute.lineHeight, 
        constraintWidth: this.textSectionAttribute.constraintWidth, // The text layout width is calculated 
        fontSize: this.textSectionAttribute.fontSize // The text font size is calculated 
    });
    let height = this.getUIContext().px2vp(Number(titleSize.height));
    if (height <= this.textSectionAttribute.lineHeight * 2) {
        this.textModifier.needProcess = false;
        this.textModifier.title = this.textSectionAttribute.title;
        return;
    } else {
        this.textModifier.needProcess = true;
    }
    if (this.expanded) {
        this.collapseText();
    } else {
        this.expandText();
    }
}

富文本展开折叠

场景描述

在示例列表中显示富文本展开和收起功能,文本与按钮的显示变化。

  1. 文本中存在表情。
  2. 文本中文字存在不同颜色、字号。
  3. 超出 3 行要能显示'…展开',展开后显示收起。
  4. 关键字有超链接功能。

当前展示内容需要针对整个文本做截断并最终显示…和'展开'字眼。该场景由于文本中有图片和不同字号的限制,使得计算截断文本的位置比较困难。

实现原理

需要计算出'…'前最后一个文字的索引和显示行高,以确定'收起''展开'按钮的位置。使用排版,计算实际需要收起内容的高度,结合按钮和'…'的宽度,计算收起文本最后一个文字的坐标,换算为对应内容索引,截断显示相应的内容。

分别添加'收起''展开'按钮及交互,进行文本截断内容和全部内容展示的切换。

开发步骤
  1. 引用 graphics.text 解析文本、确定内容大小。设置文本解析规则,解析字符串。例如,图片显示位置、大小、文本显示位置、文本颜色、文本字号。
    1. 定义了一个名为 myTextStyle 的 TextStyle 对象,设置了字体大小。字体大小通过 uiContext?.fp2px(fontSize) 进行转换,确保在不同设备上的一致性显示。
    2. 定义了一个名为 myParagraphStyle 的 ParagraphStyle 对象,配置了文本样式、对齐方式、最大行数、换行策略和单词断裂策略。
      • textStyle 属性设置为 myTextStyle,应用了之前定义的文本样式。
      • align 属性设置为 text.TextAlign.START,使文本左对齐。
      • maxLines 属性设置为 300,定义了文本最多显示的行数,以防止过多的内容溢出显示区域。
      • breakStrategy 属性设置为 text.BreakStrategy.GREEDY,定义了文本在达到最大行数时如何进行换行。
      • wordBreak 属性设置为 text.WordBreak.BREAK_WORD,允许在单词中进行换行。
    3. 创建了一个空的 FontCollection 对象,用于管理字体集合。
    4. 创建了一个 ParagraphBuilder 对象,传入了 myParagraphStyle 和 fontCollection,用于构建段落。
    5. 调用 paragraphGraphBuilder.addPlaceholder 方法,开始添加占位符。
    6. 设置占位符的宽度为 item.imgWidth,高度为 item.imgHeight。
    7. 指定占位符的对齐方式为 text.PlaceholderAlignment.BOTTOM_OF_ROW_BOX,这意味着占位符将与行盒的底部对齐。
    8. 设置占位符的基线为 text.TextBaseline.IDEOGRAPHIC,这通常用于对齐中日文字的基线。
    9. 设定基线偏移量为 0,表示占位符的基线与其顶部对齐。
    10. 调用 pushStyle 方法,传入一个对象 {fontSize: fontSize},该对象指定了文本的字体大小。这一步是为了设置文本的样式。
    11. 调用 addText 方法,传入 item.content,该方法将文本内容添加到图形构建器中。这一步是为了在图形构建器中添加实际的文本内容。
    12. 构建段落图形对象:
      • 调用 paragraphGraphBuilder.build() 方法,创建一个段落图形对象并赋值给变量 paragraph。
    13. 同步布局段落图形:
      • 调用 paragraph.layoutSync(textMaxWidth) 方法,对刚刚创建的段落图形对象进行同步布局。
    14. 代码首先初始化一个变量 y 用于存储总高度。
    15. 然后通过 for 循环遍历 textSectionAttribute.maxLines 指定的行数。
    16. 在循环中,通过条件判断 i === textSectionAttribute.maxLines - 1 来决定当前行的高度。
      • 如果是最后一行,则高度为 paragraph.getLineHeight(i) / 2。
      • 否则,高度为 paragraph.getLineHeight(i)。
    17. 每次循环结束时,将计算出的行高度累加到 y 上。
    18. 最终,y 存储了文本段落的总高度。
    19. 首先,代码通过调用 paragraph.getLineWidth 方法获取文本段落的宽度,该宽度与文本段落的最大行数相关。
    20. 然后,代码将获取到的宽度与额外宽度(Number(widthMore))相加,并与文本段落的约束宽度进行比较。
    21. 如果相加的结果大于约束宽度,则将 x 设置为约束宽度减去额外宽度。
    22. 如果相加的结果不大于约束宽度,则将 x 设置为文本段落的宽度。
    23. 获取字形位置:
      • 调用 paragraph.getGlyphPositionAtCoordinate(x, y) 方法,根据提供的坐标 (x, y) 获取字形位置信息,并存储在 positionWithAffinity 变量中。
    24. 初始化索引:
      • 初始化 index 变量为 0,用于存储最终的索引值。
    25. 确定索引值:
      • 检查 positionWithAffinity.affinity 的值:
        • 如果 affinity 是 text.Affinity.UPSTREAM,说明坐标在字符的前面,索引值为 positionWithAffinity.position。
        • 否则,说明坐标在字符的后面,索引值为 positionWithAffinity.position + 1。
    26. 首先检查 this.textModifier.needProcess 是否为真且 this.textModifier.exceedOneLine 是否为假。
      • 如果条件满足,则创建一个 Span 对象,内容为 this.lastSpanAttribute.content[0],并设置其字体颜色为 this.lastSpanAttribute.color。
    27. 如果上述条件不满足,则检查 this.textModifier.exceedOneLine 是否为真。
      • 如果条件满足,则创建一个 Span 对象,内容为 this.lastSpanAttribute.content[1],并设置其字体颜色为 this.lastSpanAttribute.color。
    28. 如果条件满足,则创建一个 Span 对象,内容为 this.lastSpanAttribute.content[0],并设置其字体颜色为 this.lastSpanAttribute.color。
    29. 如果上述两个条件都不满足,则不执行任何操作。

添加'展开''收起'按钮。显示展开按钮时使用上面获取的 shortContentArray 数组数据来渲染,完全展示的时候使用 RichTextModel 里的 textArray 数组数据来渲染。判断两种情况分别显示按钮。

if (this.textModifier.needProcess && !this.textModifier.exceedOneLine) {
    Span(this.lastSpanAttribute.content[0]).fontColor(this.lastSpanAttribute.color)
} else if (this.textModifier.exceedOneLine) {
    Span(this.lastSpanAttribute.content[1]).fontColor(this.lastSpanAttribute.color)
}

转换坐标对应索引。计算 lastWord 的展示索引位置。拿到 lastWord 的 x 与 y 坐标之后,通过 getGlyphPositionAtCoordinate() 拿到这个坐标的文字所在段落的索引,这个就是最终文字展示的索引。

// The conversion coordinates correspond to the index
let positionWithAffinity = paragraph.getGlyphPositionAtCoordinate(x, y);
let index = 0;
if (positionWithAffinity.affinity === text.Affinity.UPSTREAM) {
    index = positionWithAffinity.position;
} else {
    index = positionWithAffinity.position + 1;
}

计算 lastWord 的 X 坐标。

if (paragraph.getLineWidth(textSectionAttribute.maxLines - 1) + Number(widthMore) > textSectionAttribute.constraintWidth) {
    x = textSectionAttribute.constraintWidth - Number(widthMore);
} else {
    x = paragraph.getLineWidth(textSectionAttribute.maxLines - 1);
}

计算截断坐标。计算三个点之前的最后一个文字的坐标。设这个字符变量为 lastWord。经过 paragraph 的排版之后,就可以得到这段文本真实的渲染数据了。先通过 paragraph.getLineCount(),计算出来一共排版了多少行,如果超过了自己要设定的行数,或者 getLineCount() 的行数和自己设定的 maxLine 一致,但是最后一行的宽度 + 之前计算出来的 widthMore,超过了第四步骤设定的最大宽度,则说明需要截断。计算 lastWord 文字的 Y 坐标,通过 getLineHeight() 获取每一行的高度加起来,其中最后一行高度需要加一半的高度。

for (let i = 0; i < textSectionAttribute.maxLines; i++) {
    y += i === textSectionAttribute.maxLines - 1 ? paragraph.getLineHeight(i) / 2 : paragraph.getLineHeight(i);
}

预排版。全部添加完成之后,使用 paragraph 的 layoutSync() 方法预先排版,传递的大小单位也为 px。这个 layoutSync() 传递宽度要同展示的时候的 Text 文本宽度一致,否则计算出来的和展示的时候肯定不一致。

let paragraph = paragraphGraphBuilder.build();
paragraph.layoutSync(textMaxWidth);

添加占位符,指定样式。根据第一步骤解析出来的内容,如果是图片的话,就用 addPlaceholder(),添加一张图片占位符,需要指定这张图片的大小(单位 px),旁边的文字排版方式,文字基线位置等信息。

paragraphGraphBuilder.addPlaceholder({ 
    width: item.imgWidth, 
    height: item.imgHeight, 
    align: text.PlaceholderAlignment.BOTTOM_OF_ROW_BOX, 
    baseline: text.TextBaseline.IDEOGRAPHIC, 
    baselineOffset: 0 
});

代码逻辑走读:如果是文字的话,就使用 addText(),添加一段文本,添加这段文本之前可以重新通过 pushStyle() 方法指定这段文本的字体大小和颜色。

paragraphGraphBuilder.pushStyle({ fontSize: fontSize, });
paragraphGraphBuilder.addText(item.content);

设置段落排版。创建 ParagraphBuilder,初始化文本样式,指定文本大小和文本颜色。注意文本大小这里是传的 px,需要用 fp2px 转换一下(转换时需要考虑字体设置的最大缩放比例和系统字体缩放比例,即选择 min(sysFontScale,maxCustomFontScale))。

let myTextStyle: text.TextStyle = { fontSize: uiContext?.fp2px(fontSize) };
let myParagraphStyle: text.ParagraphStyle = {
    textStyle: myTextStyle,
    align: text.TextAlign.START,
    maxLines: 300, // Just specify a large enough number of rows
    breakStrategy: text.BreakStrategy.GREEDY,
    wordBreak: text.WordBreak.BREAK_WORD
};
let fontCollection = new text.FontCollection();
let paragraphGraphBuilder = new text.ParagraphBuilder(myParagraphStyle, fontCollection);

目录

  1. 仿微博文本折叠
  2. 概述
  3. 纯文本展开折叠
  4. 场景描述
  5. 实现原理
  6. 开发步骤
  7. 富文本展开折叠
  8. 场景描述
  9. 实现原理
  10. 开发步骤

更多推荐文章

查看全部
  • VSCode Copilot 配置使用 DeepSeek
  • C++ 模拟实现 list:双向链表构建与解析
  • C 语言程序调试常用方法与技巧
  • 存储扇区分配表:NAND Flash 与 SD NAND 架构差异
  • Python EXE 逆向分析与代码提取方法
  • SHA512 算法详解
  • Golang 构建豆包大模型 2.0 企业级 Agent 架构与成本优化
  • Google Antigravity:Agent 优先的 AI IDE 实战评测
  • STL 哈希表原理与模拟实现
  • 前端缓存策略最佳实践:LocalStorage 与 Service Worker
  • 13 款程序员常用开发工具及其用途详解
  • JavaScript 文档对象(Document)核心属性实战解析
  • 技术实力的本质:从解决问题到持续学习
  • Linux 系统 Git 原理与使用深入剖析 (上)
  • AI 绘画模型加载报错修复指南
  • OpenClaw Windows 部署指南:Node.js 22、Kimi 与飞书集成
  • CPU+GPU+FPGA 异构计算 AI 系统调优:协同计算资源调度策略
  • 鸿蒙 HarmonyOS 6 应用开发实战指南
  • Android 自定义 ViewGroup 九宫格布局完整实现
  • OpenClaw 快速上手:从 0 到 1 技术指南

相关免费在线工具

  • Base64 字符串编码/解码

    将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online

  • Base64 文件转换器

    将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online

  • Markdown转HTML

    将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online

  • HTML转Markdown

    将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online

  • JSON 压缩

    通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online

  • JSON美化和格式化

    将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online