富文本输入功能可让用户设置所输入内容的格式,包括将字词设为粗体、添加标题、对齐段落或插入突出显示的 @mention。在 Compose 中,您可以在基于状态的文本字段(BasicTextField 和 TextField)之上构建此功能。样式与文本一起存储在 TextFieldState 中,因此它们在修改、选择更改和重组后仍然存在,并在用户输入、删除和粘贴时保持同步。
本页介绍了如何使用 TextFieldBuffer 在 TextFieldState 中应用、更新、查询、提取和移除样式,以及如何通过 TextFieldState.textStyles 驱动格式设置工具栏。
应用内嵌样式和段落样式
您可以在任何 TextFieldBuffer 接收器范围内应用字符级和段落级格式设置,包括 TextFieldState.edit、InputTransformation.transformInput 和 OutputTransformation.transformOutput。
使用 ExpandPolicy 控制边界扩展
调用 addStyle 时,您可以传递 ExpandPolicy,以定义当用户直接在其 start 或 end 边界处插入新字符时,样式化范围的行为方式:
- 在
(start, end)内插入:始终会扩展样式范围。 - 在
start之前插入:始终将start和end偏移量向右移动。 - 直接在
start或end处插入:由您指定的ExpandPolicy控制。
下表介绍了可用的 ExpandPolicy 选项及其典型应用场景:
| 政策 | 边界行为 | 推荐的使用场景 | 示例 |
|---|---|---|---|
ExpandPolicy.InsideOnly |
在 start 或 end 处输入的文本会保留在样式范围之外。 |
离散标记(例如 @mentions、#hashtags 或背景胶囊),在标记旁边输入内容时不应继承标记的背景颜色。 |
![]() |
ExpandPolicy.AtStart |
在 start 处输入的文字会沿用该样式,而在 end 处输入的文字则不会应用该样式。 |
直接在样式化文本前面输入的文本也应采用相同的格式。 | ![]() |
ExpandPolicy.AtEnd |
在 end 处输入的文字会沿用该样式,而在 start 处输入的文字则不会应用该样式。 |
标准富文本编辑器输入,在粗体或斜体字词后继续输入会沿用该格式。 | ![]() |
ExpandPolicy.AtBoth |
在 start 或 end 处输入的文本会扩展范围并继承样式。 |
应从任一边缘扩展的活跃突出显示或连续格式化块。 | ![]() |
使用 SpanStyle 应用字符格式
将 addStyle 与 SpanStyle 搭配使用,可对缓冲区中的特定 TextRange 应用字符级格式设置,例如文本颜色、背景突出显示、FontWeight、FontStyle 或下划线。
SpanStyle 范围的关键行为
- 分层顺序:样式会按照您将其添加到缓冲区的顺序应用。如果多个重叠的
SpanStyle范围使用不同的值设置同一属性,则后添加的样式会呈现在顶部。 - 非空范围要求:您必须传递一个非折叠范围,其中
start<end。对长度为0的收起选区调用addStyle不会产生任何效果,而传递反向或超出边界的索引会抛出IllegalArgumentException。
TextField( state = rememberTextFieldState(), inputTransformation = InputTransformation { for (i in 0 until changes.changeCount) { val range = changes.getRange(i) if (!range.collapsed) { addStyle( spanStyle = SpanStyle( fontWeight = FontWeight.ExtraBold, fontStyle = FontStyle.Italic, color = Color.Red, ), range = range, expandPolicy = ExpandPolicy.AtEnd ) } } } )
使用 ParagraphStyle 应用块格式
将 addStyle 与 ParagraphStyle 搭配使用,可将块级结构格式(例如 textAlign、lineHeight、textDirection 或 textIndent)应用于段落范围。
fun setParagraphAlignment(state: TextFieldState, alignment: TextAlign) { val targetRange = if (state.selection.collapsed) { TextRange(0, state.text.length) } else { state.selection } if (targetRange.collapsed) return state.edit { // 1. Remove existing paragraph styles in this range getParagraphStyles(targetRange).forEach { removeStyle(it) } // 2. Apply the new paragraph alignment addStyle( paragraphStyle = ParagraphStyle(textAlign = alignment), range = targetRange, expandPolicy = ExpandPolicy.InsideOnly ) } }
ParagraphStyle 在左对齐、居中和右对齐之间切换段落对齐方式。使用 ParagraphStyle 时应避免的失误
由于 ParagraphStyle 适用于整个文本块,因此请避免在以下场景中使用它:
- 扩展到相邻段落:请谨慎设置
ExpandPolicy,以免相邻的ParagraphStyle范围在用户修改后发生重叠。例如,如果[0, 5)使用ExpandPolicy.AtEnd,[5, 10)使用ExpandPolicy.AtStart,在索引5处插入文本会将这两种样式扩展为重叠,并抛出IllegalArgumentException。 - 嵌套
ParagraphStyle范围:虽然完全嵌套的范围(例如[0, 10)和[2, 8))在AnnotatedString中有效,但请避免在文本字段中嵌套ParagraphStyle范围。布局会将嵌套范围拆分为单独的段落,后续修改可能会无意中将嵌套范围变为部分重叠。
使用 TrackedRange 就地更新样式
使用 TextRange 和 ExpandPolicy 调用 addStyle 会返回一个 TrackedRange 句柄,该句柄可以是 TrackedRange<SpanStyle> 或 TrackedRange<ParagraphStyle>。TrackedRange 是对 TextFieldBuffer 中样式区间的动态引用,当在其周围插入、删除或替换文本时,它会自动更新其 start 和 end 字符偏移量。
就地可变性
您无需调用 removeStyle 和 addStyle 即可更新现有样式或调整其边界。在 TextFieldBuffer 范围内,TrackedRange 提供了扩展属性,这些属性可就地更新区间,同时保留其相对于其他样式的原始分层顺序:
TrackedRange<SpanStyle>.spanStyle:读取或替换TrackedRange<SpanStyle>上的SpanStyle载荷。TrackedRange<ParagraphStyle>.paragraphStyle:读取或替换TrackedRange<ParagraphStyle>上的ParagraphStyle载荷。TrackedRange<*>.textRange:就地读取或更新当前TextRange边界。设置收起、反向或超出边界的范围会抛出IllegalArgumentException。TrackedRange<*>.expandPolicy:就地读取或更新ExpandPolicy。TrackedRange<*>.isValid:如果样式间隔在缓冲区中仍处于活跃状态,则返回true;如果修改导致其长度缩减为0或已被移除,则返回false。
// Trim an existing bold span in place when the selection overlaps one edge val range = style.textRange if (range.start < selection.min && range.end <= selection.max) { // Selection covers the end of the span - shrink end boundary in place style.textRange = TextRange(range.start, selection.min) } else if (range.start >= selection.min && range.end > selection.max) { // Selection covers the start of the span - shrink start boundary in place style.textRange = TextRange(selection.max, range.end) }
范围限制和生命周期规则
使用 TrackedRange 实例时,请注意以下范围和生命周期规则:
- 仅限于
TextFieldBuffer:TrackedRange扩展属性仅存在于创建或查询句柄的TextFieldBuffer范围内。 - 收起时自动修剪:如果用户修改删除了
TrackedRange涵盖的所有字符,其长度会缩减为0,isValid会返回false,并且样式会从缓冲区中移除。
查询和移除样式
Compose 提供了两种检查有效样式的方法,具体取决于您是要更改缓冲区还是要读取界面的状态:
- 在
TextFieldBuffer代码块内:直接对TextFieldBuffer接收器调用getSpanStyles和getParagraphStyles,以获取可变TrackedRange句柄。 - 在修改块之外:读取
TextFieldState.textStyles,它会返回反映已提交状态的不可变AnnotatedString.Range列表。
查询 TextFieldBuffer 中的相交样式
TextFieldBuffer.getSpanStyles 和 TextFieldBuffer.getParagraphStyles 会返回与给定 TextRange 相交的所有 TrackedRange 实例,并按它们添加到缓冲区的时间排序。
例如,您可以在 state.edit 内查询 getSpanStyles(selection),以检查或更新涵盖用户当前所选内容的样式:
state.edit { if (selection.collapsed) return@edit // Query all bold spans that overlap with the current selection val boldStyles: List<TrackedRange<SpanStyle>> = getSpanStyles(selection).filter { it.spanStyle.fontWeight == FontWeight.Bold } // ... }
样式区间使用半开数学范围 [start, end)。是否返回接触样式取决于您查询的是所选内容还是单个光标点:
- 对于非空选择
[start, end):- 返回:与所选范围重叠、包含在所选范围内或位于所选范围内的任何样式。例如,查询
[5, 15)与[0, 20)、[8, 12)或[0, 10)匹配。 - 不返回:仅触及外边界边缘而不与字符重叠的样式。例如,查询
[5, 15)会排除[0, 5)和[15, 25)。
- 返回:与所选范围重叠、包含在所选范围内或位于所选范围内的任何样式。例如,查询
- 对于收起的游标
[index, index]:- 返回:包含光标或从光标位置开始的任何样式。例如,
10处的游标与[0, 20)和[10, 20)匹配。 - 不返回(接触结束规则):在光标位置结束的任何样式。例如,
10处的游标会排除[0, 10)。
- 返回:包含光标或从光标位置开始的任何样式。例如,
使用 removeStyle 移除或切换样式
将 TrackedRange 句柄传递给 removeStyle,以从 TextFieldBuffer 中删除该特定样式间隔。
如果找到并移除了 TrackedRange,removeStyle 会返回 true;如果 已被移除或属于其他缓冲区,则会返回
false。它只会移除目标 TrackedRange 句柄,而不会影响其他重叠样式。
fun toggleBold(state: TextFieldState) { state.edit { if (selection.collapsed) return@edit // Query all bold spans that overlap with the current selection val boldStyles: List<TrackedRange<SpanStyle>> = getSpanStyles(selection).filter { it.spanStyle.fontWeight == FontWeight.Bold } if (boldStyles.isNotEmpty()) { // Remove or trim existing bold spans covering the selection for (style in boldStyles) { val range = style.textRange if (range.start >= selection.min && range.end <= selection.max) { removeStyle(style) } else if (range.start < selection.min && range.end > selection.max) { val oldEnd = range.end style.textRange = TextRange(range.start, selection.min) addStyle( spanStyle = SpanStyle(fontWeight = FontWeight.Bold), range = TextRange(selection.max, oldEnd), expandPolicy = ExpandPolicy.AtEnd ) } else if (range.start < selection.min) { style.textRange = TextRange(range.start, selection.min) } else { style.textRange = TextRange(selection.max, range.end) } } } else { // No bold spans intersect the selection - apply bold addStyle( spanStyle = SpanStyle(fontWeight = FontWeight.Bold), range = selection, expandPolicy = ExpandPolicy.AtEnd ) } } }
提取或序列化富文本
如需在只读 Text 可组合项中显示格式化输入,或将其序列化到后端服务,请在修改块之外读取 TextFieldState.textStyles。
state.textStyles.getSpanStyles 和 state.textStyles.getParagraphStyles 会返回 List<AnnotatedString.Range<SpanStyle>> 和 List<AnnotatedString.Range<ParagraphStyle>>,您可以将其直接传递给 AnnotatedString 构造函数:
fun TextFieldState.toAnnotatedString(): AnnotatedString { val fullRange = TextRange(0, text.length) return AnnotatedString( text = text.toString(), spanStyles = textStyles.getSpanStyles(fullRange), paragraphStyles = textStyles.getParagraphStyles(fullRange) ) }
Jetchat 中的已完成示例
如需查看结合了内嵌字符样式(粗体、斜体、下划线、删除线和颜色)的完整实现,请参阅 GitHub 上的 Jetchat 示例:
RichTextState.kt:封装TextFieldState样式查询、span 切换、跨选择边界剪裁或拆分等。UserInput.kt:将RichTextState连接到富文本格式工具栏和消息输入字段。



