本文是两篇文章 BasicTextField2: A TextField of Dreams 1/2 和 BasicTextField2: A TextField of Dreams 2/2 的翻译,译者合并为了一篇。本文探索了正在进行中的 BasicTextField2,聊了很多相关的新特性。
翻译整体采用机翻+人工处理的方式,为了阅读连贯,重写了其中部分内容、调整了部分格式,整体花费的时间还是相当长的。觉得不错,欢迎点个赞~
原文开始
省流:Compose 文本团队正在构建下一代的
TextField
API,现在可以开始尝试了。BasicTextField2
**在最新的 foundation 1.6.0 版本(现在还在 beta)**的 text 包中已经可以试用了。你也可以通过文章最后描述的各种渠道向团队提供反馈。
注意Compose 是分层构建的:Material -> Foundations -> UI。
TextField
和OutlinedTextField
是 Material 组件,它们在 foundations 层上添加了样式,底层基于BasicTextField
。在本文中,我们将描述BasicTextField2
,它旨在取代BasicTextField
。 请注意 ,BasicTextField2
在 API 开发过程中是一个临时名称。
Compose 的各个层级及每个 API 的位置
回望一下
咱们大多数估计是通过下面的代码接触到 Jetpack Compose 中的 TextField
(或 BasicTextField
)API 的:
kotlin
var textField by rememberSaveable { mutableStateOf("") }
TextField(
value = textField,
onValueChange = { textField = it },
)
这个简单的 API 存在一系列缺点,其中最明显的是:
- 使用
onValueChange
回调更新BasicTextField
状态时,很容易引入异步行为,导致不可预测的行为。这个问题非常复杂,在下面这篇博文中有详细描述: VisualTransformation
也容易令人困惑( 并造成 bug )。以电话号码格式化为例,通常,您希望在输入电话号码时通过添加空格、破折号、括号来修改它。要使用VisualTransformation
API 实现这一点,您需要指定初始字符和转换后字符之间的映射关系。
Visual Transformations 中的手动映射
而编写这些映射关系并不容易。
扩展 VisualTransformation 时格式化电话号码
kotlin
private val phoneNumberFilter = VisualTransformation { text ->
val trimmed = if (text.text.length >= 10) text.text.substring(0..9) else text.text
val filled = trimmed + "_".repeat(10 - trimmed.length)
val res = "(" + filled.substring(0..2) + ") " + filled.substring(3..5) + "-" + filled.substring(6..9)
TransformedText(AnnotatedString(text = res), phoneNumberOffsetTranslator(text.text))
}
于是, ValidatingOffsetMapping
类 诞生 了 ,它的目的是验证 VisualTransformation
,并在映射错误时抛出有意义的异常信息。它被设计为向开发者提供更多信息,以更好地理解 crash 并进行调试。在此之前,如果碰到崩溃,那几乎没有信息能帮助找到问题。然而,这个改变并没有从解决上根本问题。
整体的 TextField
API 也可以改进。例如,配置单行非常简单:我们只需定义 singleLine = true
。然而,像下面这样写最终是什么结果,就不是很清楚:
kotlin
TextField(
value = textField,
onValueChange = { textField = it },
singleLine = true,
minLines = 2,
maxLines = 4
)
此外,当前的 API 很难确定编辑过程中的确切更改,只能把这个工作留给开发者。想象一下,您希望在文档协作工具中显示每个用户所做的更改列表。
kotlin
TextField(
value = text,
// 这里到底发生了什么变化?你只有旧值和新值。
onValueChange = { newFullText -> /***/ }
)
考虑到所有这些以及其他一些限制,Compose 团队聚集在一起,探讨了典型的 Text Field 使用情况,并想象了理想的 TextField API 是什么样的。
下面的部分是对这些想法如何付诸实践并最终成为 BasicTextField2
的探索。
看看新的
定义状态
我们有一个注册界面。对于"用户名"字段,在之前我们可能会这样定义 TextField :
kotlin
var username by rememberSaveable { mutableStateOf("") }
BasicTextField(
value = username,
onValueChange = { username = it },
)
而新的 API BasicTextField2
的使用方式如下:
kotlin
val username = rememberTextFieldState()
BasicTextField2(state = username)
我们不再需要回调函数,这样可以避免之前提到的引入异步行为的错误。
使用 rememberTextFieldState
来定义类型为 TextFieldState
的状态变量。您可以方便地配置 initialText
,以及初始选区和光标位置。使用这个 API 定义的状态在重组、Configuration 变化时都能保留。 使用 rememberTextFieldState
形式上其实也蛮熟悉,就类似于 rememberLazyListState
来定义 LazyListState
或者使用 rememberScrollState
来定义 ScrollState
一样。
如果您需要对状态应用业务规则,或者需要将状态提升到 ViewModel 中,那么可以像这样定义一个类型为 TextFieldState
的变量:
kotlin
// ViewModel
val username = TextFieldState()
// Compose
BasicTextField2(state = viewModel.username)
样式
BasicTextField2
的 Material 封装还未准备好( BasicTextField2
位于 foundations 层)。您可以使用 TextStyle
块来修改 color
、 fontSize
、 lineHeight
等样式,通过使用各种 Modifier,特别是 border
来实现上面例子中的样式。或者可以使用 decorator
参数来进一步自定义 TextField 容器的外观(就像 OutlinedTextField
在 decorationBox
中实现的那样)。
kotlin
val username = rememberTextFieldState()
BasicTextField2(state = username,
textStyle = textStyleBodyLarge,
modifier = modifier.border(...),
decorator = @Composable {}
)
行限制
新的 API 通过提供类型为 TextFieldLineLimits
的 lineLimits
参数消除了配置单行/多行的不明确性:
kotlin
BasicTextField2(
/***/
lineLimits = TextFieldLineLimits.SingleLine
)
BasicTextField2(
/***/
lineLimits = TextFieldLineLimits.Multiline(5, 10)
)
SingleLine
: TextField 始终为单行,忽略换行符,并在文本溢出时水平滚动。
Multiline
:定义最小和最大行数。文本开始时的高度为 minHeightInLines
,当达到字段末尾时,文本会换到下一行。随着继续输入,文本会增长到高度为 maxHeightInLines
。如果继续输入超出范围,将纵向滚动。
状态观察
让我们看看如何观察用户名状态并进行一些验证,并对其应用业务上的规则。
API:
textAsFlow
假设我们希望在选定的用户名不可用时显示错误信息。
代码可能如下所示:
kotlin
// ViewModel
// 将 TextField 状态提升到 ViewModel 中
val username = TextFieldState()
// 观察用户名 TextField 状态的变化
val userNameHasError: StateFlow<Boolean> =
username.textAsFlow()
.debounce(500)
.mapLatest { signUpRepository.isUsernameAvailable(it.toString()) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = false
)
- 我们定义了一个变量
userNameHasError
,它根据对username
运行的验证结果为 true 或 false。 - 我们可以使用
snapshotFlow
API 来观察TextFieldState
中可变状态的文本,对于每个新值,我们可以调用异步函数验证。
kotlin
snapshotFlow { username.text }
- 这种使用
snapshotFlow
观察组合状态的方式非常常见,因此BTF2
API 提供了一个名为textAsFlow
的方法,它以简单的方式完成相同的操作,您只需调用该扩展函数即可。 - 我们使用
Flow
API 中的debounce
来等待一段时间后触发验证,给用户一些缓冲时间来完成输入。 - 然后我们使用
mapLatest
在每次输入新字符时调用验证。最后,stateIn
将此状态转换为StateFlow
。
然后在界面上读取 userNameHasError
。如果该状态为 true,则在用户名下方显示一个 Text
。
kotlin
// Compose
if (signUpViewModel.userNameHasError) {
// 显示错误标签
}
API:
forEachTextValue
在每个字符输入上执行异步操作的另一种便捷方法是使用 textAsFlow().collectLatest(block)
并触发异步验证。还有一个名为 forEachTextValue
的扩展函数,它为我们提供了一个挂起函数作用域,以在每个值更改时进行请求。
我们可以重新编写之前的示例,定义一个挂起方法 validateUsername
。然后对于每个新的文本值,我们进行异步请求并设置 userNameHasError
的值。这在 ViewModel 中完成。在 Compose 中,我们定义一个 LaunchedEffect
来观察输入事件,并观察 userNameHasError
状态来修改视图。
代码可能如下所示:
kotlin
// ViewModel
var userNameHasError by mutableStateOf(false)
suspend fun validateUsername() {
username.forEachTextValue {
userNameHasError = signUpRepository.isUsernameAvailable(it.toString())
}
}
// Compose
LaunchedEffect(Unit) {
signUpViewModel.validateUsername()
}
if (signUpViewModel.userNameHasError) {
// 显示错误标签
Text(
text = "用户名不可用,请选择一个不同的用户名。"
)
}
onValueChange
重写
让我们再看一种观察状态变化的方法。 BTF2
允许以以下方式定义状态:
kotlin
var username by rememberSaveable { mutableStateOf("") }
BasicTextField2(
value = username,
onValueChange = {
username = it
}
)
这与当前 API BasicTextField
v1 的形式完全相同。在这两个 API 之间进行过渡时,对于只有一个字符串的简单情况来说,这种形式更为熟悉。你可能会想:这不是还会出现之前提到的相同错误吗(在编辑过程中出现意外的异步延迟),那它有什么改进之处?虽然这个 API 在 BTF2
和 BTF1
中看起来完全一样,但在底层它们的行为非常不同。
kotlin
BasicTextField2(
// 当 BTF2 被 focus 时,下面这一行会被忽略
value = username,
onValueChange = {
// *** 可能存在的异步耗时任务 *** //
username = it
}
)
在此API中,无论发生什么,lambda 内部的代码总是会被调用。因此,如果您在 lambda 内部进行异步调用,这些调用将会被执行。然而,在 TextField 获得焦点时,你的修改不会导致更新状态,它只会接受来自用户输入的内容。这会导致,用户在界面上不会看到任何在 lambda 内部进行的更改。该字段始终得到控制,使界面响应快速、并避免了异步问题。只有当 TextField 失去焦点时,您所做的最后一次更改才会被应用。这种内部机制确保了编辑过程中 TextField 状态的完整性,因为它始终保持了一个可信数据源(用户通过软键盘或开发者设置的),在必要的情况下忽略程序的更改。相反,如果您期望您的更改在特定时间点反映在UI上,那么您可能会感到失望。
为避免歧义,此段落附上原文 (Whatever happens inside the lambda is always called, so if you're making async calls those will be fired. However, the text field won't update the state with your programmatic changes while the field is in focus , it respects only the input coming from typing events. The result is that the user won't see any changes applied inside lambda reflected in the UI. The field has the control at all times making it snappy and responsive, avoiding the async issues. Only when your field loses focus, any programatic changes you have done last will be applied. This internal mechanism guarantees the integrity of the text field state during the editing process, because it keeps one source of truth at all times (either user through the software keyboard (IME) or the developer), ignoring programatic changes if it needs to. The counterpart is that you might expect your changes to be reflected in the UI at a certain point in time but they won't be.)
因此,建议只在最简单的情况下使用此 API 形式,例如仅需要一个字符串来表示状态,并且不需要在 TextField 获得焦点时做操作,如修改文本、选区或光标位置等。对于更复杂的情况,请使用我们已经介绍过的带有 TextFieldState 的API形式。
用代码编辑文本
在许多情况下,我们需要或希望手动操作 TextField 的内容。最简单的例子是添加一个清除按钮来删除 TextField 的内容。
为了访问 Edit Session 以对 TextField 内容进行更改,新引入了一个名为 TextFieldBuffer
的 API。
kotlin
class TextFieldBuffer : Appendable {
val length: Int
val hasSelection: Boolean
var selectionInChars: TextRange
fun replace(...)
fun append(...)
fun insert(...)
fun delete(...)
fun selectAll(...)
fun selectCharsIn(...)
fun placeCursorAfterCharAt(...)
}
这个类保存了有关 TextField 的信息,比如长度和选区(如果有),还有一些方法可以显式地修改文本,比如替换、追加、插入和删除,以及更改选择和光标位置。
为了实现清除按钮,我们可以这样写:
kotlin
// ViewModel
val username = TextFieldState()
fun clearField() {
username.edit {
// 我们在 TextFieldBuffer 的 scope 中
delete(0, length)
}
}
注意,我们使用 edit
函数来访问 TextFieldBuffer
,从而可以访问上面描述的所有方法。在上面的例子中,我们删除 TextField State 的内容。这个方法非常常见,所以 BTF2
提供了一个名为 clearText
的扩展函数,可以完全实现相同的功能。所以代码可以简化为:
kotlin
// ViewModel
val username = TextFieldState()
fun clearField() {
username.clearText()
}
另一个例子,假设您有一个 Markdown 文本编辑器(例如 Github 的 PR 描述),您想要在选择的文本周围添加 "**"
字符,以表示该文本是粗体的:
kotlin
// Compose
val markdownText = rememberTextFieldState()
BasicTextField2(
state = markdownText
)
// ...
Button(
onClick = {
markdownText.edit {
if (selectionInChars.length > 0) {
insert(selectionInChars.start, "**")
insert(selectionInChars.end, "**")
}
}
},
) {
Text(text = "B", fontWeight = Bold)
}
有关如何为
TextFieldBuffer
编写 Markdown 编辑器的进一步示例,请参见 此代码片段。
或者在出现错误时选择所有文本:
kotlin
// ViewModel
val username = TextFieldState()
val userNameHasError: StateFlow<Boolean> =
username.textAsFlow()
.mapLatest {
val hasError = signUpRepository.isUsernameAvailable(it.toString())
if (hasError) highlight()
return@mapLatest hasError
}
.stateIn(...)
fun highlight() {
username.edit { selectAll() }
}
此时,您可能会意识到这种方式命令 UI 进行文本
edit
与声明式 UI 的范例相悖。确实。为了防止状态同步问题,TextField 的状态更新完全交给内部组件处理,没有真正的状态观察来更新 UI。这是 TextField 的设计选择,就像在ScrollableState
中的animateScrollTo
方法一样,您可以命令 UI 做某事。
过滤 | 转换输入
假设我们要实现一个接受数字验证码的 TextField:
为了过滤用户的输入,例如仅接受数字或者去掉特殊字符,你需要定义一个 InputTransformation
。这将在保存到 TextField 状态之前修改用户输入。这是一个不可逆的操作,所以你会丢失不符合你转换规则的输入。这就是为什么我们把它们称为"过滤器"。
InputTransformation
API 的形式如下:
kotlin
fun interface InputTransformation {
val keyboardOptions: KeyboardOptions? get() = null
fun transformInput(
originalValue: TextFieldCharSequence,
valueWithChanges: TextFieldBuffer
)
}
transformInput
方法包含了原始输入文本和带有更改的值,以 TextFieldBuffer
的形式描述,该类在上面的部分中有介绍。 TextFieldBuffer
API 提供了一个 ChangeList
类型的更改列表。
kotlin
class TextFieldBuffer {
// 其他字段和方法
val changes: ChangeList get()
interface ChangeList {
val changeCount: Int
fun getRange(changeIndex: Int): TextRange
fun getOriginalRange(changeIndex: Int): TextRange
}
}
你可以对更改做任何操作,包括丢弃它们。
kotlin
object DigitsOnlyTransformation : InputTransformation {
override val keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
override fun transformInput(
originalValue: TextFieldCharSequence,
valueWithChanges: TextFieldBuffer
) {
if (!valueWithChanges.asCharSequence().isDigitsOnly()) {
valueWithChanges.revertAllChanges()
}
}
}
// Compose
BasicTextField2(
state = state,
inputTransformation = DigitsOnlyFilter
)
我们定义了一个实现了 InputTransformation
接口的对象。首先,我们需要实现 transformInput
方法。在我们的例子中,我们检查来自 TextFieldBuffer
的更改。如果它们只包含数字,我们保留这些更改。如果字符不是数字,我们撤销这些更改。这非常简单,因为差异是由内部为我们处理的。 请注意,我们还设置了相应的键盘类型为 Number
。
由于 InputTransformation
是一个函数式接口,你可以直接向 BasicTextField2
组合中传递 lambda 以描述你的转换,如下所示:
kotlin
BasicTextField2(
state = state,
inputTransformation = { originalValue, valueWithChanges ->
if (!valueWithChanges.asCharSequence().isDigitsOnly()) {
valueWithChanges.revertAllChanges()
}
},
// in this case pass the keyboardOptions to the BFT2 directly, which
// overrides the one from the inputTransformation
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number
)
如果你只有一个需要特定 Transformation 的 TextField ,那么这样做就可以了;如果有多个 TextField 需要这种特定的转换,那么提取成 object 更好。
接下来,我们需要构建一个最大长度为 6 个字符且全部大写的验证码字段。
对于这种常见情况,我们有一些内置的输入转换: maxLengthInChars
用于限制字段长度, allCaps
用于将文本转换为大写。 我们可以这样编写验证码:
kotlin
BasicTextField2(
state = state,
inputTransformation = InputTransformation.maxLengthInChars(6)
.then(InputTransformation.allCaps(Locale.current)),
)
我们使用 then
来链接输入转换,过滤器按顺序依次应用。
Visual transformation | OutputTransformation ⚠️
截至 2023 年 11 月初:⚠️ OutputTransformation API 正在建设中。您可以在 此处 查看进展。
在我们的验证码 TextField 示例中,现在我们希望用点替换用户尚未输入的字符,并将它们分组成三个一组,在其中添加一个空格,如下所示:
为了解决这个问题以及其他需要格式化 TextField 内容的情况(比如格式化电话号码或信用卡号)您可以定义一个 OutputTransformation
。它将在显示 UI 时格式化内部状态。请注意,与 InputTransformation
不同,应用 OutputTransformation
的结果不会保存到 TextField 状态中。
OutputTransformation
API 的形式如下:
kotlin
fun interface OutputTransformation {
fun transformOutput(buffer: TextFieldBuffer)
}
新 API 的一个巨大好处是,我们无需提供原始原始文本和转换后文本之间的偏移映射。 TextField 会隐式地为我们处理这一点。 在这个示例中,我们定义一个实现了 OutputTransformation
接口的对象,并实现 transformOutput
:
kotlin
object VerificationCodeOutputTransformation : OutputTransformation {
override fun transformOutput(buffer: TextFieldBuffer) {
// 如果长度不足,则使用占位符字符填充文本
// ··· ···
val padCount = 6 - buffer.length
repeat(padCount) {
buffer.append('·')
}
// 123 456
if (buffer.length > 3) buffer.insert(3, " ")
}
}
首先,我们在 TextField Buffer上调用 append 方法,为任何尚未输入的字符插入"点"。然后我们调用 insert
方法在中间添加一个空格。完事儿,没有旧 API 导致的混乱和崩溃。很不错,对吧?
SecureTextField
让我们在注册界面中加上 Password Field 。编写 Password Field 是一个非常常见的用例,Jetpack Compose 为此专门提供了一个新的组合控件,基于 BasicTextField2
构建的 BasicSecureTextField
:
kotlin
val password = rememberTextFieldState()
BasicSecureTextField(
state = password,
textObfuscationMode = TextObfuscationMode.RevealLastTyped
)
textObfuscationMode
有 3 种有用的模式。 RevealLastTyped
是默认模式,与在 View 中将输入类型配置为 textPassword
时的 EditText
的行为相匹配。采用这种行为,您可以在超时之前或输入下一个字符之前短暂地看到最后一个输入的字符。然后是 Hidden
,在这种情况下,您永远看不到输入的字符,还有 Visible
,用于暂时使密码值可见。
将 BasicSecureTextField
作为一个独立的组合控件非常强大。它允许团队在内部进行安全优化,确保字段内容在内存中不会比应该保存的时间更长,避免诸如内存欺骗之类的问题。它具有自带小眼睛的 UI 和 textObfuscationModes
,以及与之关联的明确行为,例如对 Text Toolbar 的修改(您无法剪切或复制 Password Field 的内容)。
您无法剪切或复制 Password Field 的内容。
还有更多...
有很多东西可供讨论,但我将只在这儿说其中的3个亮点。
新的 BasicTextField2
允许您访问内部滚动状态。像对其他可滚动组合一样(如 LazyLayout
),将滚动状态提升,然后将其传递给 BasicTextField2
,现在您可以将另一个 Composable(例如 Vertical Slider
) 作为 TextField 的滚动条,以用代码控制滚动:
kotlin
val scrollState = rememberScrollState()
BasicTextField2(
state = state,
scrollState = scrollState,
// ...
)
Slider(
value = scrollState.value.toFloat(),
onValueChange = {
coroutineScope.launch { scrollState.scrollTo(it.roundToInt()) }
},
valueRange = 0f..scrollState.maxValue.toFloat()
)
控制 TextField 的滚动
团队增加了对更多手势的支持,例如双击选择单词。
最后, TextFieldState
为您提供了对 UndoState
类的访问。这个类保存状态的历史值,并提供了 undo
或 redo
编辑更改的有用方法,建立在 TextFieldBuffer
的 ChangeList
之上。您可以用非常少的代码实现撤销/重做支持,就像这样:
kotlin
val state: TextFieldState = rememberTextFieldState()
Button(
onClick = { state.undoState.undo() },
enabled = state.undoState.canUndo
) {
Text("撤销")
}
Button(
onClick = { state.undoState.clearHistory() },
enabled = state.undoState.canUndo || state.undoState.canRedo
) {
Text("清除历史记录")
}
非常强大的 API,而且一切都是开箱即用的 😊🎉
欲了解更多,请查看 Zach 的演讲,他是该项目的主要工程师之一,负责谷歌的 Compose Text 项目,讲述了这个项目的起源以及当前的进展情况。
我们已经看到如何:
- 使用
InputTransformation
应用过滤器- 使用
OutputTransformation
进行视觉转换- 用
BasicSecureTextField
编写 Password Field新的 API 结构还将允许进行一些当前不可能的操作,比如识别编辑过程中的显式更改并访问 TextField 的滚动状态。
未来
BasicTextField2
目前正在建设中。欢迎在最新的 Compose 1.6.0 中尝试这个 API,并向团队提供反馈。思考一下您正在处理的复杂编辑用例,您还希望 API 支持什么,以及当前的结构是否能够满足您的需求。
在尝试之后,您可以提供反馈!🙏
- 在 Google 的问题跟踪器上提交功能请求或 bug @ issuetracker.google.com
- 在 Kotlin Lang Slack 上讨论,频道 #compose
- 在社交媒体上联系 Compose Text 团队的成员:
- Zach : androiddev.social/@zachklipp | Mastodon
- Halil : androiddev.social/@halil | Mastodon
- Anastasia : Anastasia Soboleva @ kotlinlang.org | Slack
BasicTextField2
在一个名为 text2
的单独包中,因此它是明确可区分的。这只是一个临时包, BasicTextField2
是一个临时名称,API 正在稳定化过程中。
接下来,团队即将完成 OutputTransformation
API。此外,多文本样式编辑(Multi Text Style Editing)是一个备受期待的功能,而使用这种新的 API 结构使其变得可能。当然还有 Material 封装,以便它可以像任何其他 Material 可组合一样正常工作,并具有正确的样式。
敬请关注 BasicTextField2
更多的发布信息。
您可以在 Github playground repo 中找到本文中使用的所有代码。
原文结束
译者:我还没有实际用过 BasicTextField2,但看起来不错,不过感觉还是有一些迁移工作要做。目前,Jetpack Compose 1.6 仍然没有发布正式版,我也在持续关注它的更新。当它发布时,我会尽快写一篇文章介绍的。感兴趣的欢迎关注,下次见 ~