设置富文本输入样式

富文本输入功能可让用户设置所输入内容的格式,包括将字词设为粗体、添加标题、对齐段落或插入突出显示的 @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.InsideOnly 范围的开头或结尾输入的文字保持无样式。
ExpandPolicy.AtStart 在 start 处输入的文字会沿用该样式,而在 end 处输入的文字则不会应用该样式。 直接在样式化文本前面输入的文本也应采用相同的格式。 动画演示:在 ExpandPolicy.AtStart 范围的开头输入的文本会沿用样式,而在结尾输入的文本则不会沿用样式。
ExpandPolicy.AtEnd 在 end 处输入的文字会沿用该样式,而在 start 处输入的文字则不会应用该样式。 标准富文本编辑器输入,在粗体或斜体字词后继续输入会沿用该格式。 动画:在 ExpandPolicy.AtEnd 范围末尾输入的文本继承了样式,而在开头输入的文本仍未设置样式。
ExpandPolicy.AtBoth 在 start 或 end 处输入的文本会扩展范围并继承样式。 应从任一边缘扩展的活跃突出显示或连续格式化块。 动画展示了在 ExpandPolicy.AtBoth 范围的开头或结尾输入的文字继承了样式。

使用 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
                )
            }
        }
    }
)

一个 TextField,在用户输入时动态应用粗体和斜体红色格式。
图 1. 动态地对用户输入应用红色粗斜体格式。

使用 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
        )
    }
}

一个 TextField,使用 ParagraphStyle 在左对齐、居中和右对齐之间切换段落对齐方式。
图 2. 使用 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
            )
        }
    }
}

一个 TextField,用于为所选字词开启和关闭粗体格式。
图 3. 在文本加粗和取消加粗之间切换。

提取或序列化富文本

如需在只读 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 连接到富文本格式工具栏和消息输入字段。
Jetchat 示例应用,展示了消息输入栏中的富文本格式。
图 4. Jetchat 中的富文本编辑。