鸿蒙6.0应用开发——仿微博文本折叠
列表中的博文、评论等复合型内容组件,在文本行数超过预设阈值时,触发“展开”“收起”的功能。内容收起时,如果有用“图片”展示“表情”的功能场景,由于图片出现的位置和大小都不固定,在收起展开时,截止到文字结尾的位置不好判断。
【高心星出品】
文章目录
- 仿微博文本折叠
-
- 概述
- 纯文本展开折叠
-
- 场景描述
- 实现原理
- 开发步骤
- 富文本展开折叠
-
- 场景描述
- 实现原理
- 开发步骤
仿微博文本折叠
概述
列表中的博文、评论等复合型内容组件,在文本行数超过预设阈值时,触发“展开”“收起”的功能。内容收起时,如果有用“图片”展示“表情”的功能场景,由于图片出现的位置和大小都不固定,在收起展开时,截止到文字结尾的位置不好判断。
本文将介绍解决这一问题的基本逻辑和解决方案,帮助开发者使用系统自带模块,更简洁的解决问题。

纯文本展开折叠
场景描述
在示例列表中显示纯文本展开和收起功能,文本与按钮的显示变化。
- 文本中只有文字。
- 超出2行要能显示"…展开",展开后显示收起。

实现原理
需要计算出“…”前最后一个文字的索引和显示行高,以确定“收起”“展开”按钮的位置,其原理如图所示:

计算文本高度,结合按钮和“…”的宽度,计算收起文本最后一个文字的坐标,换算为对应内容索引,截断显示相应的内容。
分别添加“收起”“展开”按钮及交互,进行文本截断内容和全部内容展示的切换。
本示例使用measureTextSize()方法判断文字的高度,API20新增getParagraphs20+方法测算文本。getParagraphs()方法代码量更少,更直观的获取显示行数。
开发步骤
-
计算原始文本高度 。
使用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(); } }代码逻辑走读:
-
获取文本尺寸:
- 使用
this.uiContext.getMeasureUtils().measureTextSize方法计算文本的尺寸。参数包括文本内容、行高、布局宽度和字体大小。 - 结果存储在
titleSize变量中,特别是文本的高度。
- 使用
-
转换高度单位:
- 使用
this.getUIContext().px2vp方法将文本高度从像素单位转换为视口单位。
- 使用
-
判断是否需要处理:
- 如果文本高度小于等于两倍的行高,则不需要进一步处理,将
this.textModifier.needProcess设置为false,并将标题设置为当前文本。 - 否则,将
this.textModifier.needProcess设置为true,表示需要进一步处理。
- 如果文本高度小于等于两倍的行高,则不需要进一步处理,将
-
处理文本展开/折叠:
-
如果
this.expanded为true,则调用this.collapseText()方法折叠文本。 -
否则,调用
this.expandText()方法展开文本。
-
-
-
计算文本收起高度(示例代码与步骤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;代码逻辑走读:
-
获取文本尺寸工具:通过
uiContext?.getMeasureUtils()获取一个测量工具对象。 -
测量文本尺寸
调用measureTextSize方法,传入一系列参数来测量文本的尺寸。
textContent:要测量的文本内容。fontSize:文本的字体大小。maxLines:文本的最大行数。wordBreak:文本的换行规则,这里设置为BREAK_ALL,表示允许文本在任意位置断行。constraintWidth:文本的限制宽度。
-
存储高度信息:将测量得到的高度信息存储在变量
minHeight中。如果高度未定义,minHeight将为undefined。
-
-
获取收起文本,显示收起展开按钮。
减少接收文字字符数。当接收文字高度小于指定行数高度时,使文字显示两行收起。
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; }代码逻辑走读:
-
初始化文本:
- 使用
TextUtils.getStringFromResource方法从资源中获取文本内容,并将其存储在text变量中。
- 使用
-
测量文本尺寸:
- 调用
uiContext.getMeasureUtils().measureTextSize方法测量文本的尺寸,包括字体大小、最大行数、宽度限制等参数。 - 存储测量结果的
minHeight,如果测量失败(即minHeight为undefined),则返回空字符串。
- 调用
-
二分查找算法:
- 将文本字符串分割成字符数组
textStr,并初始化左右指针leftCursor和rightCursor。 - 使用二分查找算法在文本中查找合适的切割点。每次迭代中,计算中间位置
cursor,并生成临时文本tempTitle。 - 再次调用
uiContext.getMeasureUtils().measureTextSize方法测量tempTitle的尺寸。 - 根据测量结果,调整左右指针的位置,以缩小搜索范围。
- 当左右指针的差值小于等于1时,表明找到了合适的切割点,跳出循环。
- 将文本字符串分割成字符数组
-
返回结果:
- 返回切割后的文本字符串,并在末尾添加
suffix和lastSpan。
- 返回切割后的文本字符串,并在末尾添加
-
富文本展开折叠
场景描述
在示例列表中显示富文本展开和收起功能,文本与按钮的显示变化。
- 文本中存在表情。
- 文本中文字存在不同颜色、字号。
- 超出3行要能显示"…展开",展开后显示收起。
- 关键字有超链接功能。
当前展示内容需要针对整个文本做截断并最终显示…和"展开"字眼,例如图片中的文本就比较长,需要在"潮声与你"的位置截断。该场景由于文本中有图片和不同字号的限制,使得计算截断文本的位置比较困难。

实现原理
需要计算出“…”前最后一个文字的索引和显示行高,以确定“收起”“展开”按钮的位置,其原理如图所示:

使用排版,计算实际需要收起内容的高度,结合按钮和“…”的宽度,计算收起文本最后一个文字的坐标,换算为对应内容索引,截断显示相应的内容。
分别添加“收起”“展开”按钮及交互,进行文本截断内容和全部内容展示的切换。
开发步骤
-
引用graphics.text解析文本、确定内容大小。
设置文本解析规则,解析字符串。例如,图片显示位置、大小、文本显示位置、文本颜色、文本字号。
-
设置段落排版。
创建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);代码逻辑走读:
- 定义了一个名为
myTextStyle的TextStyle对象,设置了字体大小。字体大小通过uiContext?.fp2px(fontSize)进行转换,确保在不同设备上的一致性显示。 - 定义了一个名为myParagraphStyle的ParagraphStyle对象,配置了文本样式、对齐方式、最大行数、换行策略和单词断裂策略。
textStyle属性设置为myTextStyle,应用了之前定义的文本样式。align属性设置为text.TextAlign.START,使文本左对齐。maxLines属性设置为 300,定义了文本最多显示的行数,以防止过多的内容溢出显示区域。breakStrategy属性设置为text.BreakStrategy.GREEDY,定义了文本在达到最大行数时如何进行换行。wordBreak属性设置为text.WordBreak.BREAK_WORD,允许在单词中进行换行。
- 创建了一个空的
FontCollection对象,用于管理字体集合。 - 创建了一个
ParagraphBuilder对象,传入了myParagraphStyle和fontCollection,用于构建段落。
- 定义了一个名为
-
添加占位符,指定样式。
根据第一步骤解析出来的内容,如果是图片的话,就用addPlaceholder(),添加一张图片占位符,需要指定这张图片的大小(单位px),旁边的文字排版方式,文字基线位置等信息。
paragraphGraphBuilder.addPlaceholder({ width: item.imgWidth, height: item.imgHeight, align: text.PlaceholderAlignment.BOTTOM_OF_ROW_BOX, baseline: text.TextBaseline.IDEOGRAPHIC, baselineOffset: 0 });代码逻辑走读:
- 调用
paragraphGraphBuilder.addPlaceholder方法,开始添加占位符。 - 设置占位符的宽度为
item.imgWidth,高度为item.imgHeight。 - 指定占位符的对齐方式为
text.PlaceholderAlignment.BOTTOM_OF_ROW_BOX,这意味着占位符将与行盒的底部对齐。 - 设置占位符的基线为
text.TextBaseline.IDEOGRAPHIC,这通常用于对齐中日文字的基线。 - 设定基线偏移量为
0,表示占位符的基线与其顶部对齐。
如果是文字的话,就使用addText(),添加一段文本,添加这段文本之前可以重新通过pushStyle()方法指定这段文本的字体大小和颜色。
paragraphGraphBuilder.pushStyle({ fontSize: fontSize, }); paragraphGraphBuilder.addText(item.content);代码逻辑走读:
- 调用
pushStyle方法,传入一个对象{fontSize: fontSize},该对象指定了文本的字体大小。这一步是为了设置文本的样式。 - 调用
addText方法,传入item.content,该方法将文本内容添加到图形构建器中。这一步是为了在图形构建器中添加实际的文本内容。
如果需要使用之前的文本样式,可以通过popStyle()把当前样式pop出去。
上面添加的文字大小和图片占位符的大小要同Text控件展示的时候的大小一致,否则会导致计算不准确。
- 调用
-
预排版。
全部添加完成之后,使用paragraph的layoutSync()方法预先排版,传递的大小单位也为px。这个layoutSync()传递宽度要同展示的时候的Text文本宽度一致,否则计算出来的和展示的时候肯定不一致。
let paragraph = paragraphGraphBuilder.build(); paragraph.layoutSync(textMaxWidth);代码逻辑走读:
- 构建段落图形对象:
- 调用
paragraphGraphBuilder.build()方法,创建一个段落图形对象并赋值给变量paragraph。这个方法的具体实现细节不在此代码片段中展示,但其作用是生成一个段落图形对象。
- 调用
- 同步布局段落图形:
- 调用
paragraph.layoutSync(textMaxWidth)方法,对刚刚创建的段落图形对象进行同步布局。textMaxWidth是一个参数,表示文本的最大宽度,布局过程会根据这个宽度来调整段落图形的显示。
- 调用
- 构建段落图形对象:
-
计算截断坐标。
计算三个点之前的最后一个文字的坐标。设这个字符变量为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); }代码逻辑走读:
-
代码首先初始化一个变量
y用于存储总高度。 -
然后通过
for循环遍历textSectionAttribute.maxLines指定的行数。 -
在循环中,通过条件判断
i === textSectionAttribute.maxLines - 1来决定当前行的高度。
- 如果是最后一行,则高度为
paragraph.getLineHeight(i) / 2。 - 否则,高度为
paragraph.getLineHeight(i)。
- 如果是最后一行,则高度为
-
每次循环结束时,将计算出的行高度累加到
y上。 -
最终,
y存储了文本段落的总高度。
-
-
计算lastWord的X坐标。
if (paragraph.getLineWidth(textSectionAttribute.maxLines - 1) + Number(widthMore) > textSectionAttribute.constraintWidth) { x = textSectionAttribute.constraintWidth - Number(widthMore); } else { x = paragraph.getLineWidth(textSectionAttribute.maxLines - 1) }代码逻辑走读:
- 首先,代码通过调用
paragraph.getLineWidth方法获取文本段落的宽度,该宽度与文本段落的最大行数相关。 - 然后,代码将获取到的宽度与额外宽度(
Number(widthMore))相加,并与文本段落的约束宽度进行比较。 - 如果相加的结果大于约束宽度,则将x设置为约束宽度减去额外宽度。
- 如果相加的结果不大于约束宽度,则将x设置为文本段落的宽度。
- 首先,代码通过调用
-
转换坐标对应索引。
计算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; }代码逻辑走读:
-
获取字形位置:
- 调用
paragraph.getGlyphPositionAtCoordinate(x, y)方法,根据提供的坐标 (x, y) 获取字形位置信息,并存储在positionWithAffinity变量中。
- 调用
-
初始化索引:
- 初始化
index变量为 0,用于存储最终的索引值。
- 初始化
-
确定索引值:
-
检查
positionWithAffinity.affinity的值:- 如果
affinity是text.Affinity.UPSTREAM,说明坐标在字符的前面,索引值为positionWithAffinity.position。 - 否则,说明坐标在字符的后面,索引值为
positionWithAffinity.position + 1。
- 如果
-
-
-
添加“展开”“收起”按钮。
显示展开按钮时使用上面获取的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) }代码逻辑走读:
-
首先检查
this.textModifier.needProcess是否为真且this.textModifier.exceedOneLine是否为假。- 如果条件满足,则创建一个
Span对象,内容为this.lastSpanAttribute.content[0],并设置其字体颜色为this.lastSpanAttribute.color。
- 如果条件满足,则创建一个
-
如果上述条件不满足,则检查
this.textModifier.exceedOneLine是否为真。- 如果条件满足,则创建一个
Span对象,内容为this.lastSpanAttribute.content[1],并设置其字体颜色为this.lastSpanAttribute.color。
- 如果条件满足,则创建一个
this.textModifier.needProcess是否为真且this.textModifier.exceedOneLine是否为假。- 如果条件满足,则创建一个
Span对象,内容为this.lastSpanAttribute.content[0],并设置其字体颜色为this.lastSpanAttribute.color。
-
如果上述条件不满足,则检查
this.textModifier.exceedOneLine是否为真。- 如果条件满足,则创建一个
Span对象,内容为this.lastSpanAttribute.content[1],并设置其字体颜色为this.lastSpanAttribute.color。
- 如果条件满足,则创建一个
-
如果上述两个条件都不满足,则不执行任何操作。
-
更多推荐




所有评论(0)