高级软件工程师
为了在 Compose 中实现可靠的文本编辑,我们最终完全重写了 BasicTextField。这种向缓冲区驱动型架构的转变彻底消除了 IME 竞态条件。本讲座将深入探讨为何必须淘汰旧版输入同步机制,以及 TextFieldState 如何确保键盘更新保持同步。然后,深入探究格式化编辑,探索 TrackedRange 的机制、编辑块的性能成本以及样式边界背后的实际问题,最后抢先了解 Markdown 渲染。
TextFieldState API 重载取代了 Compose 中基于回调的重载。它会与用户输入同步更新,从而消除软件键盘的时间问题和不必要的重组。TextFieldBuffer 处理文本。您可以在提交修改之前拦截修改,也可以设置显示文本的格式,而无需手动计算偏移映射。TextFieldState 取决于您的架构需求。您可以将其保留在可组合项本地,也可以直接将其存储在 ViewModel 中,以便业务逻辑访问。TextFieldBuffer 添加了格式化文本编辑 API。您可以使用可自定义的 ExpandPolicy 选项应用样式,并且现有样式范围会随着用户编辑而自动更新。转写文稿
大家好!
感谢您参加我们的讲座。
我叫 Anastasia,这位是我的同事 Haoyu。
今天,我们将向您展示如何打造可靠的文本编辑体验。
无论是构建简单的登录界面、边输边搜栏,还是复杂的即时通讯应用,文本字段都是用户与 Android 应用互动的核心。
因此,大多数开发者可能已经在使用 Material 文本字段或轮廓文本字段。
TextField(state = rememberTextFieldState())OutlinedTextField(state = rememberTextFieldState())如果您要构建自己的设计体系,或者需要一个简单的编辑框,也可以使用 BasicTextField。
BasicTextField(state = rememberTextFieldState())此外,还有专为输入密码而设计的 SecureTextField。
SecureTextField(state = rememberTextFieldState())BasicTextField 是一个无样式组件,实际上为 Compose 中的所有其他文本字段提供支持。
您可能已经注意到,在过去几年中,我们同时提供了两种 API 形式:一种接受值和值更改回调,另一种接受 TextFieldState 对象。
@Composable
public fun BasicTextField(
state: TextFieldState,
)您可能想知道我们推荐哪种方法。
我们强烈建议您使用 TextFieldState 重载。
在本次讲座中,我们将讨论它在 API、功能和性能方面的优势。
自 Compose 1.7 起,它已成为稳定版。
至于回调重载,我们将在 Compose 1.13 中对 BasicTextField 和 Material 封装容器进行软弃用。
@Composable
public fun BasicTextField(
value: String,
onValueChange: (String) -> Unit
)
@Composable
public fun BasicTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit这意味着,我们不会再添加任何新功能或修复 bug,除非是关键的安全问题或无障碍问题。
无状态的 value-on-value-change 重载非常适合同步输入。
但一旦将其与异步流(例如 StateFlow)配对,就会导致 IME(即软件键盘)出现时间问题。
因此,这会导致字符丢失、字符重复、光标跳转,如您在此录制内容中所见。
为了从根本上解决此问题,我们引入了 TextFieldState。
当用户输入内容时,它会同步更新,而无需等待这些异步往返,这正是 IME 所需要的。
TextFieldState 除了只读文本之外,还具有其他属性,这些属性会创建一种活跃的编辑会话。
即选择、撤消、重做状态,以及用于格式化文本编辑的 span、span 样式和段落样式,稍后会详细介绍。
TextFieldState 的内部属性由 Compose 快照系统提供支持。
也就是说,当您在 Compose 中的某个位置读取 TextFieldState 对象中的文本或所选内容时,您读取这些内容时所在的特定作用域会订阅更新。
它允许 BasicTextField 仅在布局或绘制阶段读取状态,并与一些其他功能一起消除用户输入时不必要的重组。
再加上其他一些更改,TextFieldState 重载的性能得到了显著提升。
现在,如果 TextFieldState 的文本是只读属性,您该如何以编程方式更改输入字段?
这时,TextFieldBuffer 就派上用场了。
它是一个可变对象,为您提供用于进行更改的 API。
因此,当您想要进行程序化更改时,可以调用 TextFieldState 的修改函数。
在该 lambda 中,您会获得一个 TextFieldBuffer。
TextFieldBuffer 提供了多个函数,可让您根据需要插入、替换和删除字符。
关于修改,有一个小提示:避免连续多次调用它们。
// Often generated by AI agents:
state.edit { insert(5, ",") }
state.edit { delete(12, 13) }
state.edit { replace(0, 5, "goodbye") }
state.edit { selection = TextRange(9, 16) }正确的做法是,将所有操作批量合并到单个修改调用中。
// Batch all operations in a single atomic edit:
state.edit {
insert(5, ",")
delete(12, 13)
replace(0, 5, "goodbye")
selection = TextRange(9, 16)
}这样,Compose 将仅创建 TextFieldBuffer 的单个实例,就地对其进行更改,然后将其与 IME 和快照系统同步一次。
另一个提示是,对于您需要的大多数常见批量操作,我们实际上在 TextFieldState 上提供了扩展函数。
fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}因此,您无需考虑对这些操作进行批处理。
您只需拨打其中一个号码即可。
例如,clearText 和 setTextAndPlaceCursorAtEnd。
fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}那么,如何过滤用户输入呢?
例如,强制执行字符数限制或仅接受数字。
在旧版 API 中,onValueChange 中的过滤操作会在文本提交后运行。
onValueChange { newString ->
// full diffing new String vs old String
}因此,拒绝或更改该输入会与键盘产生冲突。
此外,onValueChange 回调仅提供最终字符串。
因此,为了了解发生了哪些变化,您必须进行全字符串差异比较。
而在新的重载中,我们有输入转换。
@Stable
public fun interface InputTransformation {
public fun TextFieldBuffer.transformInput()
}因此,它会在用户输入到达文本字段状态之前拦截输入,并为您提供相同的 TextFieldBuffer API。
这就是如何使用输入转换实现仅限数字过滤,基本上只需获取更改即可。
// Intercept and filter input during typing
inputTransformation = InputTransformation {
val text = asCharSequence()
if (!text.all { it.isDigit() }) {
// Reject non-digit characters
revertAllChanges()
}
}您需要检查是否存在非数字字符,如果存在,则通过对该 TextFieldBuffer 调用 revertAllChanges 来拒绝输入。
此输入转换的关键功能是 ChangeList。
因此,它不会对完整字符串进行差异比较,而是允许您检查确切的修改范围,然后根据需要就地更改这些范围或完全拒绝输入。
Compose 会自动让键盘和光标保持同步。
最后,如果您想对输入内容进行可视化格式设置,该怎么办?
例如,您想在信用卡号或电话号码中添加括号或短划线。
真的吗?
您也不想将这些数据存储在数据库中,对吧?
之前,您使用的是视觉转换 API。
在这种情况下,您必须实现从原始输入到视觉输出的双向映射,然后再反向映射。
而且很难做到正确无误,这往往会导致运行时异常。
现在,我们有了输出转换,同样,在后台,它由同一个 TextFieldBuffer 提供支持,因此您基本上可以获得相同的 API。
@Stable
public fun interface OutputTransformation {
public fun TextFieldBuffer.transformOutput()
}因此,在这个电话号码示例中,您只需在特定索引处插入这些短划线,只需调用 TextFieldBuffer 的 insert 函数,系统就会为您计算双向偏移映射。
// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
if (length > 6) {
insert(6, "-")
}
if (length > 3) {
insert(3, "-")
}
}这样就无需手动计算,并可显著减少崩溃次数。
现在进入最重要的部分:如何实际迁移,以及在哪里提升 TextFieldState?
在实践中,几乎每个用例都属于我将要讨论的其中一个类别。
如果不是,通常意味着您的架构有些不寻常,但这些示例仍然可以指导您完成迁移。
最简单的情况是将 TextFieldState 保留在文本字段所在的可组合项中。
@Composable
fun PromoCodeInput(onApply: (String) -> Unit) {
// State hoisted locally inside the composable
val state = rememberTextFieldState()
OutlinedTextField(
state = state,
inputTransformation = InputTransformation.maxLength(6).allCaps(Locale.current),
trailingIcon = { Button(onClick = { onApply(state.text.toString()) }) { }
)
}当文本字段是自包含的输入字段(例如促销代码输入字段)时,您只需要在用户提交(例如按下提交按钮)时访问该文本,对吧?
因此,您的视图模型无需在每次更改时都拦截用户输入。
因此,您只需在本地调用 rememberTextFieldState,rememberTextFieldState 还会帮助您在进程终止后恢复状态。
然后,您可以使用输入转换进行过滤,并从该状态(TextFieldState 对象)中获取文本,当用户提交时,您可以通过 lambda 传递该文本。
好的,如果同级界面元素(例如字符计数器或提交按钮)也需要读取或更改文本,该怎么办?
这就引出了一个问题:您需要将 TextFieldState 对象提升到通用可组合函数(即通用祖先可组合函数)。
@Composable
fun PostCommentBar(onSubmit: (String) -> Unit) {
// to share between UI components
val commentState = rememberTextFieldState()
Column {
CommentTextField(commentState)
Row {
Text("${commentState.text.length} / 280")
SubmitButton(commentState)
}
}由于 TextFieldState 由快照系统提供支持,因此通过祖先传递它不会触发父级的重组。
只有实际读取状态文本的位置才会更新。
好的。
因此,对于复杂的界面用例,如果您需要 TextFieldState、输入和输出转换来在内部协调状态,请使用状态容器对象,而不是单独提升它们并尝试在可组合函数中将它们连接在一起。
@Composable
fun PostEditor() {
// Hoisting and wiring related pieces separately in the composable
val textFieldState = rememberTextFieldState()
val tagRanges = remember { mutableStateListOf<TextRange>() }
val inputTransform = remember { TagInputTransformation(tagRanges) }
val outputTransform = remember { TagOutputTransformation(tagRanges) }
BasicTextField(
state = textFieldState,
inputTransformation = inputTransform,
outputTransformation = outputTransform,
)
}假设您要处理内嵌话题标签。
TextFieldState 包含原始文本和光标位置。
输入转换负责检测话题标签,您还可以通过输入转换执行原子标签删除。
输出转换会添加额外的视觉样式,还可以帮助您删除 # 标签。
它们全部打包到一个状态容器对象中,我称之为 TagEditorState。
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {
private val tagRanges = mutableStateListOf<TextRange>()
// Transformations coordinate state internally
val inputTransformation = TagInputTransformation(tagRanges)
val outputTransformation = TagOutputTransformation(tagRanges)
fun addTag(tag: String) { ... }
}然后,它们都可以重复使用中间计算,例如本例中的 tagRanges。
然后,只需将此状态容器对象传递给可组合项即可。
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}
@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
BasicTextField(
state = state.textFieldState,
inputTransformation = state.inputTransformation,
outputTransformation = state.outputTransformation,
)
}好,另一种用例是,当您的业务逻辑确实需要访问该文本以实现边输入边搜索或一些复杂的验证时,请将该文本字段提升到 ViewModel 中。
请务必不要将可变文本字段对象放入不可变数据类界面状态中,因为该对象会通过 copy 方法进行复制,然后在每次更改时发出到 StateFlow 中。
data class FormUiState(val username: TextFieldState)TextFieldState 是一个由快照系统支持的长效可变对象,其引用对象永远不会改变。
因此,您的 StateFlow 甚至不会知道该用户正在输入内容,因为引用保持不变。
更糟糕的是,如果您创建界面状态的新实例并分配新的 TextFieldState,输入字段将丢失其所有状态。
例如,选择内容、光标位置或撤消/重做历史记录。
因此,请改为将 TextFieldState 直接设置为 ViewModel 的属性。
class FormViewModel : ViewModel() {
val username = TextFieldState()
val uiState: StateFlow<FormUiState> = ...
}在这里,您无需担心 ViewModel 中没有界面的规则,因为在这种情况下,TextFieldState 不会引用 Android View 或 Context 对象或 Compose 生命周期。
它只是一个纯粹的快照支持的状态容器对象。
因此,在这种情况下,它在概念上与 StateFlow 没有什么不同。
请注意,ViewModel 没有 rememberSaveable,因此您需要连接 TextFieldState.Saver,以便文本字段在进程终止后继续留存并恢复其状态。
class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
val username: TextFieldState = savedStateHandle.saveable(
key = "username",
saver = TextFieldState.Saver,
init = { TextFieldState() }
)
val uiState: StateFlow<FormUiState> = ...
}现在,如果您的团队有严格的模块规则,ViewModel 只能存在于纯 Kotlin 模块中,并且您无法引入 compose-foundation 依赖项,该怎么办?
或者,您正在迁移复杂的旧版屏幕,但目前无法更新或重构界面状态?
在这种情况下,请使用符合规范的方法。
因此,您需要将 TextFieldState 保留在可组合项中,作为该文本字段的单一事实来源。
然后,您可以使用 LaunchedEffect 和 snapshotFlow 将这些文本更改推送回现有的 ViewModel。
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
LaunchedEffect(usernameState, viewModel) {
snapshotFlow { usernameState.text.toString() }
.drop(1)
.collect { viewModel.onUsernameChanged(it) }
}
BasicTextField(state = usernameState)
}您的 ViewModel 仍不含 Compose,并且由于界面状态属性不会在每次更改时反馈到文本字段,因此您仍然可以避免这些异步往返 bug。
是的,还要注意一个常见陷阱。
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
LaunchedEffect(usernameState, viewModel) {
snapshotFlow { usernameState.text.toString() }
.drop(1) // 👈 CRITICAL: Avoids firing on initial composition or rotation
.collect { viewModel.onUsernameChanged(it) }
}
BasicTextField(state = usernameState)
}在大多数情况下,您需要省略第一个事件。
这样可以防止不必要的重复搜索查询,也可以防止用初始值覆盖 ViewModel。
好的,如果您的逻辑需要将这些更改反馈给状态,例如,当用户按下提交按钮,而您想清除文本时,您不能只是将空字符串设置为 ViewModel 的 ViewModel。
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
...
// 3. Handle discrete ViewModel events (e.g. clear after login)
LaunchedEffect(viewModel) {
viewModel.clearUsernameEvent.collect {
usernameState.clearText()
}
}
BasicTextField(state = usernameState)
}而是需要发出一个清除文本事件,然后包含该 TextFieldState 对象的可组合函数将对该对象调用 clearText。
为了进一步协助您完成过渡,我们编写了本迁移指南。
您可以通过此链接访问该页面。
我们还在开发一些额外的 API,以便更轻松地进行迁移以及观察一般的 TextFieldState 更改,敬请期待。
到目前为止,我们介绍的所有内容,包括 TextFieldState、TextFieldBuffer、输入和输出转换,不仅仅是为了纯文本输入而构建的。
这些是最新一组格式化文本编辑 API 的基础。
现在,请 Haoyu 为大家详细介绍一下。
好了
谢谢。
大家好
接下来,我将介绍我们为格式化文本编辑引入的新 API。
因此,在深入了解新 API 之前,我们先退一步,看看静态文本中的样式设置是如何运作的。
因此,大多数人已经非常熟悉 AnnotatedString。
val styledText = buildAnnotatedString {
append("Jetpack Compose ")
// 1. Blue & Bold on "Jetpack" [0, 7)
addStyle(
style = SpanStyle(
color = Color.Blue,
fontWeight = FontWeight.Bold
),
start = 0,
end = 7
)
}
// Render static formatted text
Text(text = styledText)这是为静态文本设置样式的标准方法。
我们使用 buildAnnotatedString 并调用 add-- 调用 addStyle,以在某些给定范围内应用 SpanStyle 或 ParagraphStyle。
它非常适合静态文本。
因此,在考虑格式化文本编辑时,我们最初的想法是,能否直接让 AnnotatedString 变为可编辑?
但我们很快意识到,这并不简单。
AnnotatedString 是一种不可变的数据结构。
但在编辑场景中,用户会不断输入内容。
而且每次按键,我们都必须分配一个新的 AnnotatedString 对象。
每个 AnnotatedString 上都有很多 AnnotatedString.Range。
因此,这些对象也必须重新创建。
它无法很好地扩缩,并且会产生巨大的分配开销。
请看这个示例。
当我们在中间插入单词时,其后的每个样式都会改变其范围并重新分配。
当您有许多样式时,这显然行不通。
因此,我们在 Anastasia 之前介绍的 TextFieldBuffer 中引入了专用样式 API。
它们与 AnnotatedString 中的样式设置 API 非常相似。
我们来深入了解一下它们的工作原理。
因此,与 AnnotatedString.Builder 一样,TextFieldBuffer 现在也具有 addStyle,可将 SpanStyle 或 ParagraphStyle 应用于某个范围。
fun addStyle(spanStyle: SpanStyle, start: Int, end: Int)
fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int)
fun addStyle(spanStyle: SpanStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<SpanStyle>
fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<ParagraphStyle>您还可以传递 ExpandPolicy 参数,该参数用于控制在样式边界处输入文本时样式的行为。
我们稍后会对此进行介绍。
首先,我们来看看样式跟踪的工作原理。
val state = TextFieldState("The quick fox jumps")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 13
)
insert(0, "Look! ")
insert(16, "brown ")
delete(16, 22)
delete(10, 19)
}向 TextFieldBuffer 添加样式后,其范围会随着文本的编辑而自动更新。
在此示例中,我们首先将一些文字设为粗体。
当您在样式范围之前插入文本时,样式范围会向前移动。
当您在其中插入文本时,文本范围会扩大。
当我们删除范围内的文本时,样式范围也会相应缩小。
如果样式范围内的所有文本都被删除,则样式会收起并自动从缓冲区中移除。
那么,如果文本恰好插入到样式范围的边界处,会发生什么情况?
新插入的文本是否受样式影响?
因此,为了让您能够控制此行为,我们引入了 ExpandPolicy API。
我们来逐一了解一下。
第一种是 ExpandPolicy.InsideOnly。
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.InsideOnly
)
insert(4, "quick ")
insert(length, " jumps")在此模式下,在边界处插入文本不会扩展样式范围。
在这里,我们首先应用粗体范围。
如果我们在粗体范围的开头插入文本,样式范围会移动,但不会扩展。
同样,如果我们在粗体范围的末尾插入文本,样式范围也不会扩展。
下面是一个演示,展示了它在编辑器中的工作方式。
接下来是 ExpandPolicy.AtStart。
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtStart
)
insert(4, "quick ")顾名思义,在范围开头插入文本会扩大样式范围。
因此,当我们在样式范围之前插入文本时,样式范围会扩展到涵盖新插入的文本。
下面是另一个演示,展示了它的工作原理。
第三个是ExpandPolicy.AtEnd。
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtEnd
)
insert(length, " jumps")因此,我们在范围末尾插入文本,以扩展样式范围。
因此,这也是最常见的编辑器设置。
下面通过一个演示来展示其工作原理。
最后是 ExpandPolicy.AtBoth,它会在文本插入到范围的开头和结尾时扩展样式。
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtBoth
)
insert(4, "quick ")
insert(length, " jumps")
}下面再演示一个示例。
到目前为止,我们已经介绍了所有样式应用 API。
除此之外,我们还引入了新的查询 API(例如 getSpanStyles、getParagraphStyles),用于提取当前在给定范围内应用的样式。
// Outside edits. ReadOnly API in TextFieldState.
val textFieldState.styles: TextFieldTextStyles
fun TextFieldTextStyles.getSpanStyle(range: TextRange): List<AnnotatedString.Range<SpanStyle>>
// Inside edits, returned TrackedRange
fun TextFieldBuffer.getSpanStyles(range: TextRange): List<TrackedRange<SpanStyle>>以下是两个版本的查询 API。
在 TextFieldState 上,样式查询 API 是只读的。
它们会返回一个不可变的 AnnotatedString.Range 对象列表。
但在 TextFieldBuffer 中,getSpanStyles 返回了一个新引入的 TrackedRange 对象列表,我稍后会介绍这些对象。
因此,所有样式查询都具有顺序感知能力。
它们会严格按照样式应用顺序返回样式范围。
这样,您就可以像使用 AnnotatedString 一样,在现有样式的基础上应用另一种样式,以覆盖之前的样式。
接下来是 TrackedRange API。
var TrackedRange<*>.textRange: TextRange
var TrackedRange<*>.expandPolicy: ExpandPolicy
var TrackedRange<SpanStyle>.spanStyle: SpanStyle
var TrackedRange<ParagraphStyle>.paragraphStyle: ParagraphStyle
val TrackedRange<*>.isValid: Boolean
fun removeStyle(trackedRange: TrackedRange<*>): BooleanTrackedRange 对象由 TextFieldBuffer 中的 addStyle 或 getSpanStyles API 返回。
您可以将其视为缓冲区中样式范围的实时句柄。
您可以使用它来更新文本范围、ExpandPolicy 或样式本身。
它还具有 isValid 属性,用于检查样式范围是否仍在 TextFieldBuffer 中。
这是因为,如果样式在修改过程中被收起,或者被 removeStyle 故意移除,则样式可能会被移除。
接下来,我们来看看这些 API 如何在实际应用场景中协同工作。
第一个应用场景是构建所见即所得 (WYSIWYG) 编辑器。
在此示例中,我们实现了加粗按钮功能。
点击“粗体”按钮可切换所选文本的粗体样式。
接下来,我们来看看代码。
因此,当用户点按“加粗”按钮时,我们首先对所选内容调用 getSpanStyles,以查找所有重叠的样式。
fun TextFieldBuffer.toggleBoldSelection() {
if (selection.collapsed) return
val boldSpans = getSpanStyles(selection)
if (!isFullyBold(boldSpans, selection)) {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
selection,
ExpandPolicy.AtEnd
)
} else {
unboldSelection(boldSpans.first())
}
}然后,我们检查整个所选内容是否已加粗。
在这里,所选字词未加粗。
由于它不是粗体,因此我们addStyle到所选范围。
我们传递 ExpandPolicy.AtEnd,以便用户可以在此字词后继续输入粗体。
现在,我们来看看如果所选范围已加粗,会发生什么情况。
fun TextFieldBuffer.unboldSelection(
boldSpan: TrackedRange<SpanStyle>
) {
val hasBoldBefore = boldSpan.start < selection.min
val hasBoldAfter = boldSpan.end > selection.max
when {
hasBoldBefore ->
boldSpan.range = TextRange(range.start, selection.min)
hasBoldAfter ->
boldSpan.range = TextRange(selection.max, range.end)
hasBoldBefore && hasBoldAfter -> {
val oldEnd = boldSpan.end
boldSpan.range = TextRange(boldSpan.start, selection.min)
addStyle(
boldSpan.spanStyle,
start = selection.max,
end = oldEnd
)
}
else -> removeStyle(range)
}
}在这种情况下,我们需要清除所选范围内的粗体样式。
在这里,用户选择的字词位于较长的粗体短语中。
由于粗体样式延伸到了所选内容之前,我们需要更新样式范围,使所选字词不加粗。
为此,我们可以使用 TrackedRange API。
对于这个具体情况,我们无需删除此样式并重新创建,只需就地调整粗体 span 范围,使其在索引 10 处的选区之前结束。
当然,这是一种高度简化的实现。
如需查看完整演示,请访问我们的 Compose 示例代码库。
接下来,我们来看看基于图案的样式。
在此演示中,当用户在某个字词两侧输入 Markdown 星号时,编辑器会在用户输入时自动将其转换为粗体文字。
现在,我们来看看如何使用 InputTransformation 实现它。
object MarkdownBoldTransformation : InputTransformation {
val regex = Regex("""**(.*?)**""")
override fun TextFieldBuffer.transformInput() {
val matches = regex.findAll(asCharSequence()).toList()
for (match in matches) {
val range = match.range
val content = match.groupValues[1]
replace(range.first, range.last + 1, content)
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = range.first,
end = range.first + content.length,
policy = ExpandPolicy.atEnd
)
}
}
}在转换输入过滤器中,我们找到按钮上所有匹配的模式。
对于每个匹配项,我们都会提取双星号内的内容。
在此示例中,该字词为“fox”。
接下来,我们移除星号,并将匹配模式替换为其内容。
最后,我们对 addStyle 调用 bold,以将粗体样式应用于内容文本。
请注意,InputTransformation 将提交到最终文本字段状态。
这意味着粗体样式会持续应用于该状态。
最后,我们来看一下搜索突出显示示例。
因此,在此示例中,当用户输入搜索查询时,文本字段中的匹配字词会突出显示,而不会修改文本字段状态。
现在,我们来看看如何在 OutputTransformation 中实现。
突出显示搜索字词时,切勿改变文本状态。
OutputTransformation 是实现此用例的理想平台。
我们来看看实际实现。
我们首先搜索突出显示的字词,并找到所有匹配项。
class SearchHighlightTransformation(
val query: MutableState<String>
) : OutputTransformation {
override fun TextFieldBuffer.transformOutput() {
val search = query.value
if (search.isEmpty()) return
val text = asCharSequence().toString()
var index = text.indexOf(search)
while (index != -1) {
addStyle(
SpanStyle(
background = Color.Yellow,
color = Color.Black
),
start = index,
end = index + search.length
)
index = text.indexOf(search, index + search.length)
}
}
}请注意,查询是一个可变状态。
这是因为 OutputTransformation 能够感知快照。
当此可变状态更新时,系统会自动重新应用 OutputTransformation。
然后,我们遍历每个匹配项,并调用 addStyle 在匹配范围内绘制黄色突出显示。
同样,OutputTransformation 只会更改视觉文本,而原始文本状态保持不变。
好了,以上就是我们所有的应用场景。
提醒一下,前面展示的所有 API 均从 Compose 1.12 开始提供。
TextField(state = rememberTextFieldState())OutlinedTextField(state = rememberTextFieldState())BasicTextField(state = rememberTextFieldState())SecureTextField(state = rememberTextFieldState())@Composable
public fun BasicTextField(
state: TextFieldState,
)@Composable
public fun BasicTextField(
value: String,
onValueChange: (String) -> Unit
)
@Composable
public fun BasicTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit// Often generated by AI agents:
state.edit { insert(5, ",") }
state.edit { delete(12, 13) }
state.edit { replace(0, 5, "goodbye") }
state.edit { selection = TextRange(9, 16) }// Batch all operations in a single atomic edit:
state.edit {
insert(5, ",")
delete(12, 13)
replace(0, 5, "goodbye")
selection = TextRange(9, 16)
}fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}onValueChange { newString ->
// full diffing new String vs old String
}@Stable
public fun interface InputTransformation {
public fun TextFieldBuffer.transformInput()
}// Intercept and filter input during typing
inputTransformation = InputTransformation {
val text = asCharSequence()
if (!text.all { it.isDigit() }) {
// Reject non-digit characters
revertAllChanges()
}
}@Stable
public fun interface OutputTransformation {
public fun TextFieldBuffer.transformOutput()
}// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
if (length > 6) {
insert(6, "-")
}
if (length > 3) {
insert(3, "-")
}
}@Composable
fun PromoCodeInput(onApply: (String) -> Unit) {
// State hoisted locally inside the composable
val state = rememberTextFieldState()
OutlinedTextField(
state = state,
inputTransformation = InputTransformation.maxLength(6).allCaps(Locale.current),
trailingIcon = { Button(onClick = { onApply(state.text.toString()) }) { }
)
}@Composable
fun PostCommentBar(onSubmit: (String) -> Unit) {
// to share between UI components
val commentState = rememberTextFieldState()
Column {
CommentTextField(commentState)
Row {
Text("${commentState.text.length} / 280")
SubmitButton(commentState)
}
}@Composable
fun PostEditor() {
// Hoisting and wiring related pieces separately in the composable
val textFieldState = rememberTextFieldState()
val tagRanges = remember { mutableStateListOf<TextRange>() }
val inputTransform = remember { TagInputTransformation(tagRanges) }
val outputTransform = remember { TagOutputTransformation(tagRanges) }
BasicTextField(
state = textFieldState,
inputTransformation = inputTransform,
outputTransformation = outputTransform,
)
}@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {
private val tagRanges = mutableStateListOf<TextRange>()
// Transformations coordinate state internally
val inputTransformation = TagInputTransformation(tagRanges)
val outputTransformation = TagOutputTransformation(tagRanges)
fun addTag(tag: String) { ... }
}@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}
@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
BasicTextField(
state = state.textFieldState,
inputTransformation = state.inputTransformation,
outputTransformation = state.outputTransformation,
)
}data class FormUiState(val username: TextFieldState)class FormViewModel : ViewModel() {
val username = TextFieldState()
val uiState: StateFlow<FormUiState> = ...
}class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
val username: TextFieldState = savedStateHandle.saveable(
key = "username",
saver = TextFieldState.Saver,
init = { TextFieldState() }
)
val uiState: StateFlow<FormUiState> = ...
}@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
LaunchedEffect(usernameState, viewModel) {
snapshotFlow { usernameState.text.toString() }
.drop(1)
.collect { viewModel.onUsernameChanged(it) }
}
BasicTextField(state = usernameState)
}@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
LaunchedEffect(usernameState, viewModel) {
snapshotFlow { usernameState.text.toString() }
.drop(1) // 👈 CRITICAL: Avoids firing on initial composition or rotation
.collect { viewModel.onUsernameChanged(it) }
}
BasicTextField(state = usernameState)
}@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
...
// 3. Handle discrete ViewModel events (e.g. clear after login)
LaunchedEffect(viewModel) {
viewModel.clearUsernameEvent.collect {
usernameState.clearText()
}
}
BasicTextField(state = usernameState)
}val styledText = buildAnnotatedString {
append("Jetpack Compose ")
// 1. Blue & Bold on "Jetpack" [0, 7)
addStyle(
style = SpanStyle(
color = Color.Blue,
fontWeight = FontWeight.Bold
),
start = 0,
end = 7
)
}
// Render static formatted text
Text(text = styledText)fun addStyle(spanStyle: SpanStyle, start: Int, end: Int)
fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int)
fun addStyle(spanStyle: SpanStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<SpanStyle>
fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<ParagraphStyle>val state = TextFieldState("The quick fox jumps")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 13
)
insert(0, "Look! ")
insert(16, "brown ")
delete(16, 22)
delete(10, 19)
}val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.InsideOnly
)
insert(4, "quick ")
insert(length, " jumps")val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtStart
)
insert(4, "quick ")val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtEnd
)
insert(length, " jumps")val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtBoth
)
insert(4, "quick ")
insert(length, " jumps")
}// Outside edits. ReadOnly API in TextFieldState.
val textFieldState.styles: TextFieldTextStyles
fun TextFieldTextStyles.getSpanStyle(range: TextRange): List<AnnotatedString.Range<SpanStyle>>
// Inside edits, returned TrackedRange
fun TextFieldBuffer.getSpanStyles(range: TextRange): List<TrackedRange<SpanStyle>>var TrackedRange<*>.textRange: TextRange
var TrackedRange<*>.expandPolicy: ExpandPolicy
var TrackedRange<SpanStyle>.spanStyle: SpanStyle
var TrackedRange<ParagraphStyle>.paragraphStyle: ParagraphStyle
val TrackedRange<*>.isValid: Boolean
fun removeStyle(trackedRange: TrackedRange<*>): Booleanfun TextFieldBuffer.toggleBoldSelection() {
if (selection.collapsed) return
val boldSpans = getSpanStyles(selection)
if (!isFullyBold(boldSpans, selection)) {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
selection,
ExpandPolicy.AtEnd
)
} else {
unboldSelection(boldSpans.first())
}
}fun TextFieldBuffer.unboldSelection(
boldSpan: TrackedRange<SpanStyle>
) {
val hasBoldBefore = boldSpan.start < selection.min
val hasBoldAfter = boldSpan.end > selection.max
when {
hasBoldBefore ->
boldSpan.range = TextRange(range.start, selection.min)
hasBoldAfter ->
boldSpan.range = TextRange(selection.max, range.end)
hasBoldBefore && hasBoldAfter -> {
val oldEnd = boldSpan.end
boldSpan.range = TextRange(boldSpan.start, selection.min)
addStyle(
boldSpan.spanStyle,
start = selection.max,
end = oldEnd
)
}
else -> removeStyle(range)
}
}object MarkdownBoldTransformation : InputTransformation {
val regex = Regex("""**(.*?)**""")
override fun TextFieldBuffer.transformInput() {
val matches = regex.findAll(asCharSequence()).toList()
for (match in matches) {
val range = match.range
val content = match.groupValues[1]
replace(range.first, range.last + 1, content)
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = range.first,
end = range.first + content.length,
policy = ExpandPolicy.atEnd
)
}
}
}class SearchHighlightTransformation(
val query: MutableState<String>
) : OutputTransformation {
override fun TextFieldBuffer.transformOutput() {
val search = query.value
if (search.isEmpty()) return
val text = asCharSequence().toString()
var index = text.indexOf(search)
while (index != -1) {
addStyle(
SpanStyle(
background = Color.Yellow,
color = Color.Black
),
start = index,
end = index + search.length
)
index = text.indexOf(search, index + search.length)
}
}
}