仿微博文本折叠
概述
列表中的博文、评论等复合型内容组件,在文本行数超过预设阈值时,触发'展开''收起'的功能。内容收起时,如果有用'图片'展示'表情'的功能场景,由于图片出现的位置和大小都不固定,在收起展开时,截止到文字结尾的位置不好判断。
本文将介绍解决这一问题的基本逻辑和解决方案,帮助开发者使用系统自带模块,更简洁地解决问题。
纯文本展开折叠
场景描述
在示例列表中显示纯文本展开和收起功能,文本与按钮的显示变化。
- 文本中只有文字。
- 超出 2 行要能显示'…展开',展开后显示收起。
实现原理
需要计算出'…'前最后一个文字的索引和显示行高,以确定'收起''展开'按钮的位置。计算文本高度,结合按钮和'…'的宽度,计算收起文本最后一个文字的坐标,换算为对应内容索引,截断显示相应的内容。
分别添加'收起''展开'按钮及交互,进行文本截断内容和全部内容展示的切换。
本示例使用 measureTextSize() 方法判断文字的高度,API20 新增 getParagraphs20+ 方法测算文本。getParagraphs() 方法代码量更少,更直观地获取显示行数。
开发步骤
- 获取文本尺寸:
- 使用
this.uiContext.getMeasureUtils().measureTextSize方法计算文本的尺寸。参数包括文本内容、行高、布局宽度和字体大小。 - 结果存储在
titleSize变量中,特别是文本的高度。
- 使用
- 转换高度单位:
- 使用
this.getUIContext().px2vp方法将文本高度从像素单位转换为视口单位。
- 使用
- 判断是否需要处理:
- 如果文本高度小于等于两倍的行高,则不需要进一步处理,将
this.textModifier.needProcess设置为false,并将标题设置为当前文本。 - 否则,将
this.textModifier.needProcess设置为true,表示需要进一步处理。
- 如果文本高度小于等于两倍的行高,则不需要进一步处理,将
- 处理文本展开/折叠:
- 如果
this.expanded为true,则调用this.collapseText()方法折叠文本。 - 否则,调用
this.expandText()方法展开文本。
- 如果
- 测量文本尺寸工具:通过
uiContext?.getMeasureUtils()获取一个测量工具对象。 - 存储高度信息:将测量得到的高度信息存储在变量
minHeight中。如果高度未定义,minHeight将为undefined。 - 初始化文本:
- 使用
TextUtils.getStringFromResource方法从资源中获取文本内容,并将其存储在text变量中。
- 使用
- 二分查找算法:
- 将文本字符串分割成字符数组
textStr,并初始化左右指针leftCursor和rightCursor。 - 使用二分查找算法在文本中查找合适的切割点。每次迭代中,计算中间位置
cursor,并生成临时文本tempTitle。 - 再次调用
uiContext.getMeasureUtils().measureTextSize方法测量tempTitle的尺寸。 - 根据测量结果,调整左右指针的位置,以缩小搜索范围。
- 当左右指针的差值小于等于 1 时,表明找到了合适的切割点,跳出循环。
- 将文本字符串分割成字符数组
- 返回结果:
- 返回切割后的文本字符串,并在末尾添加
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();
}
}
富文本展开折叠
场景描述
在示例列表中显示富文本展开和收起功能,文本与按钮的显示变化。
- 文本中存在表情。
- 文本中文字存在不同颜色、字号。
- 超出 3 行要能显示'…展开',展开后显示收起。
- 关键字有超链接功能。
当前展示内容需要针对整个文本做截断并最终显示…和'展开'字眼。该场景由于文本中有图片和不同字号的限制,使得计算截断文本的位置比较困难。
实现原理
需要计算出'…'前最后一个文字的索引和显示行高,以确定'收起''展开'按钮的位置。使用排版,计算实际需要收起内容的高度,结合按钮和'…'的宽度,计算收起文本最后一个文字的坐标,换算为对应内容索引,截断显示相应的内容。
分别添加'收起''展开'按钮及交互,进行文本截断内容和全部内容展示的切换。
开发步骤
- 引用
graphics.text解析文本、确定内容大小。设置文本解析规则,解析字符串。例如,图片显示位置、大小、文本显示位置、文本颜色、文本字号。- 定义了一个名为
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,用于构建段落。 - 调用
paragraphGraphBuilder.addPlaceholder方法,开始添加占位符。 - 设置占位符的宽度为
item.imgWidth,高度为item.imgHeight。 - 指定占位符的对齐方式为
text.PlaceholderAlignment.BOTTOM_OF_ROW_BOX,这意味着占位符将与行盒的底部对齐。 - 设置占位符的基线为
text.TextBaseline.IDEOGRAPHIC,这通常用于对齐中日文字的基线。 - 设定基线偏移量为
0,表示占位符的基线与其顶部对齐。 - 调用
pushStyle方法,传入一个对象{fontSize: fontSize},该对象指定了文本的字体大小。这一步是为了设置文本的样式。 - 调用
addText方法,传入item.content,该方法将文本内容添加到图形构建器中。这一步是为了在图形构建器中添加实际的文本内容。 - 构建段落图形对象:
- 调用
paragraphGraphBuilder.build()方法,创建一个段落图形对象并赋值给变量paragraph。
- 调用
- 同步布局段落图形:
- 调用
paragraph.layoutSync(textMaxWidth)方法,对刚刚创建的段落图形对象进行同步布局。
- 调用
- 代码首先初始化一个变量
y用于存储总高度。 - 然后通过
for循环遍历textSectionAttribute.maxLines指定的行数。 - 在循环中,通过条件判断
i === textSectionAttribute.maxLines - 1来决定当前行的高度。- 如果是最后一行,则高度为
paragraph.getLineHeight(i) / 2。 - 否则,高度为
paragraph.getLineHeight(i)。
- 如果是最后一行,则高度为
- 每次循环结束时,将计算出的行高度累加到
y上。 - 最终,
y存储了文本段落的总高度。 - 首先,代码通过调用
paragraph.getLineWidth方法获取文本段落的宽度,该宽度与文本段落的最大行数相关。 - 然后,代码将获取到的宽度与额外宽度(
Number(widthMore))相加,并与文本段落的约束宽度进行比较。 - 如果相加的结果大于约束宽度,则将 x 设置为约束宽度减去额外宽度。
- 如果相加的结果不大于约束宽度,则将 x 设置为文本段落的宽度。
- 获取字形位置:
- 调用
paragraph.getGlyphPositionAtCoordinate(x, y)方法,根据提供的坐标 (x, y) 获取字形位置信息,并存储在positionWithAffinity变量中。
- 调用
- 初始化索引:
- 初始化
index变量为 0,用于存储最终的索引值。
- 初始化
- 确定索引值:
- 检查
positionWithAffinity.affinity的值:- 如果
affinity是text.Affinity.UPSTREAM,说明坐标在字符的前面,索引值为positionWithAffinity.position。 - 否则,说明坐标在字符的后面,索引值为
positionWithAffinity.position + 1。
- 如果
- 检查
- 首先检查
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。
- 如果条件满足,则创建一个
- 如果条件满足,则创建一个
Span对象,内容为this.lastSpanAttribute.content[0],并设置其字体颜色为this.lastSpanAttribute.color。 - 如果上述两个条件都不满足,则不执行任何操作。
- 定义了一个名为
添加'展开''收起'按钮。显示展开按钮时使用上面获取的 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);

