迁移到基于状态的文本字段

本页面提供了有关如何将基于值的 TextField 迁移到基于状态的 TextField 的示例。如需了解基于值和基于状态的 TextField 之间的区别,请参阅配置文本字段页面。

基本用法

基于价值

@Composable
fun OldSimpleTextField() {
    var state by rememberSaveable { mutableStateOf("") }
    TextField(
        value = state,
        onValueChange = { state = it },
        singleLine = true,
    )
}

基于状态

@Composable
fun NewSimpleTextField() {
    TextField(
        state = rememberTextFieldState(),
        lineLimits = TextFieldLineLimits.SingleLine
    )
}

  • 将 value, onValueChange 和 remember { mutableStateOf("") } 替换为 rememberTextFieldState()。
  • 将 singleLine = true 替换为 lineLimits = TextFieldLineLimits.SingleLine。

通过 onValueChange 过滤

基于价值

@Composable
fun OldNoLeadingZeroes() {
    var input by rememberSaveable { mutableStateOf("") }
    TextField(
        value = input,
        onValueChange = { newText ->
            input = newText.trimStart { it == '0' }
        }
    )
}

基于状态

@Preview
@Composable
fun NewNoLeadingZeros() {
    TextField(
        state = rememberTextFieldState(),
        inputTransformation = InputTransformation {
            while (length > 0 && charAt(0) == '0') delete(0, 1)
        }
    )
}

  • 将值回调循环替换为 rememberTextFieldState()。
  • 使用 InputTransformation 在 onValueChange 中重新实现过滤逻辑。
  • 使用 InputTransformation 的接收器范围中的 TextFieldBuffer 更新 state。
    • 系统会在检测到用户输入后立即调用 InputTransformation。
    • InputTransformation 通过 TextFieldBuffer 提出的更改会立即应用,从而避免软件键盘与 TextField 之间出现同步问题。

信用卡格式设置工具 TextField

基于价值

@Composable
fun OldTextFieldCreditCardFormatter() {
    var state by remember { mutableStateOf("") }
    TextField(
        value = state,
        onValueChange = { if (it.length <= 16) state = it },
        visualTransformation = VisualTransformation { text ->
            // Making XXXX-XXXX-XXXX-XXXX string.
            var out = ""
            for (i in text.indices) {
                out += text[i]
                if (i % 4 == 3 && i != 15) out += "-"
            }

            TransformedText(
                text = AnnotatedString(out),
                offsetMapping = object : OffsetMapping {
                    override fun originalToTransformed(offset: Int): Int {
                        if (offset <= 3) return offset
                        if (offset <= 7) return offset + 1
                        if (offset <= 11) return offset + 2
                        if (offset <= 16) return offset + 3
                        return 19
                    }

                    override fun transformedToOriginal(offset: Int): Int {
                        if (offset <= 4) return offset
                        if (offset <= 9) return offset - 1
                        if (offset <= 14) return offset - 2
                        if (offset <= 19) return offset - 3
                        return 16
                    }
                }
            )
        }
    )
}

基于状态

@Composable
fun NewTextFieldCreditCardFormatter() {
    val state = rememberTextFieldState()
    TextField(
        state = state,
        inputTransformation = InputTransformation.maxLength(16),
        outputTransformation = OutputTransformation {
            if (length > 4) insert(4, "-")
            if (length > 9) insert(9, "-")
            if (length > 14) insert(14, "-")
        },
    )
}

  • 将 onValueChange 中的过滤替换为 InputTransformation,以设置输入的最大长度。
  • 将 VisualTransformation 替换为 OutputTransformation,以添加短划线。
    • 使用 VisualTransformation 时,您不仅要负责创建带有短划线的新文本,还要负责计算索引在视觉文本和后备状态之间的映射方式。
    • OutputTransformation 会自动处理偏移映射。您只需使用 OutputTransformation.transformOutput 的接收器作用域中的 TextFieldBuffer 在正确的位置添加短划线。

更新状态(简单)

基于价值

@Composable
fun OldTextFieldStateUpdate(userRepository: UserRepository) {
    var username by remember { mutableStateOf("") }
    LaunchedEffect(Unit) {
        username = userRepository.fetchUsername()
    }
    TextField(
        value = username,
        onValueChange = { username = it }
    )
}

基于状态

@Composable
fun NewTextFieldStateUpdate(userRepository: UserRepository) {
    val usernameState = rememberTextFieldState()
    LaunchedEffect(Unit) {
        usernameState.setTextAndPlaceCursorAtEnd(userRepository.fetchUsername())
    }
    TextField(state = usernameState)
}

  • 将值回调循环替换为 rememberTextFieldState()。
  • 使用 TextFieldState.setTextAndPlaceCursorAtEnd 更改值分配。

更新状态(复杂)

基于价值

@Composable
fun OldTextFieldAddMarkdownEmphasis() {
    var markdownState by remember { mutableStateOf(TextFieldValue()) }
    Button(onClick = {
        // add ** decorations around the current selection, also preserve the selection
        markdownState = with(markdownState) {
            copy(
                text = buildString {
                    append(text.take(selection.min))
                    append("**")
                    append(text.substring(selection))
                    append("**")
                    append(text.drop(selection.max))
                },
                selection = TextRange(selection.min + 2, selection.max + 2)
            )
        }
    }) {
        Text("Bold")
    }
    TextField(
        value = markdownState,
        onValueChange = { markdownState = it },
        maxLines = 10
    )
}

基于状态

@Composable
fun NewTextFieldAddMarkdownEmphasis() {
    val markdownState = rememberTextFieldState()
    LaunchedEffect(Unit) {
        // add ** decorations around the current selection
        markdownState.edit {
            insert(originalSelection.max, "**")
            insert(originalSelection.min, "**")
            selection = TextRange(originalSelection.min + 2, originalSelection.max + 2)
        }
    }
    TextField(
        state = markdownState,
        lineLimits = TextFieldLineLimits.MultiLine(1, 10)
    )
}

在此用例中,按钮会添加 Markdown 装饰,使光标或当前所选内容周围的文本变为粗体。它还会在更改后保持选择位置。

  • 将值回调循环替换为 rememberTextFieldState()。
  • 将 maxLines = 10 替换为 lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10)。
  • 更改了通过 TextFieldState.edit 调用计算新 TextFieldValue 的逻辑。
    • 系统会根据当前所选内容拼接现有文本,并在中间插入 Markdown 装饰,从而生成新的 TextFieldValue。
    • 此外,系统还会根据文本的新索引调整所选内容。
    • TextFieldState.edit 采用更自然的方式,通过使用 TextFieldBuffer 来修改当前状态。
    • 该选择明确定义了插入修饰的位置。
    • 然后,调整所选内容,类似于 onValueChange 方法。

ViewModel StateFlow 架构

许多应用都遵循现代应用开发指南,该指南提倡使用 StateFlow 通过一个包含所有信息的不可变类来定义屏幕或组件的界面状态。

在这类应用中,带有文本输入的登录屏幕等表单通常设计如下:

class LoginViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(UiState())
    val uiState: StateFlow<UiState>
        get() = _uiState.asStateFlow()

    fun updateUsername(username: String) = _uiState.update { it.copy(username = username) }

    fun updatePassword(password: String) = _uiState.update { it.copy(password = password) }
}

data class UiState(
    val username: String = "",
    val password: String = ""
)

@Composable
fun LoginForm(
    loginViewModel: LoginViewModel,
    modifier: Modifier = Modifier
) {
    val uiState by loginViewModel.uiState.collectAsStateWithLifecycle()
    Column(modifier) {
        TextField(
            value = uiState.username,
            onValueChange = { loginViewModel.updateUsername(it) }
        )
        TextField(
            value = uiState.password,
            onValueChange = { loginViewModel.updatePassword(it) },
            visualTransformation = PasswordVisualTransformation()
        )
    }
}

这种设计非常适合使用 value, onValueChange 状态提升范式的 TextFields。不过,在文本输入方面,这种方法存在不可预测的缺点。Compose 中 TextField 的有效状态管理博文详细介绍了此方法的深度同步问题。

问题在于,新的 TextFieldState 设计与 StateFlow 支持的 ViewModel 界面状态不直接兼容。将 username: String 和 password: String 替换为 username: TextFieldState 和 password: TextFieldState 看起来可能很奇怪,因为 TextFieldState 本身就是一种可变数据结构。

常见的建议是避免将界面依赖项放入 ViewModel 中。 虽然这通常是一种很好的做法,但有时可能会被误解。 对于纯粹是数据结构且不包含任何界面元素的 Compose 依赖项(例如 TextFieldState),尤其如此。

MutableState 或 TextFieldState 等类是由 Compose 的快照状态系统支持的简单状态容器。它们与 StateFlow 或 RxJava 等依赖项没有区别。因此,我们建议您重新评估如何在代码中应用“ViewModel 中不应存在界面依赖项”原则。在 ViewModel 中保留对 TextFieldState 的引用本身并不是一种不好的做法。

我们建议您从 UiState 中提取 username 或 password 等值,并在 ViewModel 中保留对这些值的单独引用。

class LoginViewModel : ViewModel() {
    val usernameState = TextFieldState()
    val passwordState = TextFieldState()
}

@Composable
fun LoginForm(
    loginViewModel: LoginViewModel,
    modifier: Modifier = Modifier
) {
    Column(modifier) {
        TextField(state = loginViewModel.usernameState,)
        SecureTextField(state = loginViewModel.passwordState)
    }
}

  • 将 MutableStateFlow<UiState> 替换为几个 TextFieldState 值。
  • 将这些 TextFieldState 对象传递给 LoginForm 可组合项中的 TextFields。

合规方法

这些类型的架构更改并不总是那么容易。您可能无法自由进行这些更改,或者时间投入可能超过使用新 TextField 的好处。在这种情况下,您仍然可以使用基于状态的文本字段,只需稍加调整即可。

class LoginViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(UiState())
    val uiState: StateFlow<UiState>
        get() = _uiState.asStateFlow()

    fun updateUsername(username: String) = _uiState.update { it.copy(username = username) }

    fun updatePassword(password: String) = _uiState.update { it.copy(password = password) }
}

data class UiState(
    val username: String = "",
    val password: String = ""
)

@Composable
fun LoginForm(
    loginViewModel: LoginViewModel,
    modifier: Modifier = Modifier
) {
    val initialUiState = remember(loginViewModel) { loginViewModel.uiState.value }
    Column(modifier) {
        val usernameState = rememberTextFieldState(initialUiState.username)
        LaunchedEffect(usernameState) {
            snapshotFlow { usernameState.text.toString() }.collectLatest {
                loginViewModel.updateUsername(it)
            }
        }
        TextField(usernameState)

        val passwordState = rememberTextFieldState(initialUiState.password)
        LaunchedEffect(passwordState) {
            snapshotFlow { passwordState.text.toString() }.collectLatest {
                loginViewModel.updatePassword(it)
            }
        }
        SecureTextField(passwordState)
    }
}

  • 保持 ViewModel 和 UiState 类不变。
  • 不要直接将状态提升到 ViewModel 中并使其成为 TextFields 的可信来源,而是将 ViewModel 转换为简单的数据容器。
    • 为此,请通过在 LaunchedEffect 中收集 snapshotFlow 来观察每个 TextFieldState.text 的变化。
  • 您的 ViewModel 仍将具有界面中的最新值,但其 uiState: StateFlow<UiState> 不会驱动 TextField。
  • 在 ViewModel 中实现的任何其他持久性逻辑都可以保持不变。