Cómo migrar a campos de texto basados en el estado

En esta página, se proporcionan ejemplos de cómo puedes migrar TextField basados en valores a TextField basados en el estado. Consulta la página Configurar campos de texto para obtener información sobre las diferencias entre los TextField basados en valores y en el estado.

Uso básico

Basadas en el valor

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

Basado en el estado

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

  • Reemplaza value, onValueChange y remember { mutableStateOf("") } por rememberTextFieldState().
  • Reemplaza singleLine = true con lineLimits = TextFieldLineLimits.SingleLine.

Filtrado a través de onValueChange

Basadas en el valor

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

Basado en el estado

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

  • Reemplaza el bucle de devolución de llamada de valor por rememberTextFieldState().
  • Vuelve a implementar la lógica de filtrado en onValueChange con InputTransformation.
  • Usa TextFieldBuffer desde el alcance del receptor de InputTransformation para actualizar state.
    • Se llama a InputTransformation justo después de que se detecta la entrada del usuario.
    • Los cambios que propone InputTransformation a través de TextFieldBuffer se aplican de inmediato, lo que evita un problema de sincronización entre el teclado en software y TextField.

Formateador de tarjetas de crédito TextField

Basadas en el valor

@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
                    }
                }
            )
        }
    )
}

Basado en el estado

@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, "-")
        },
    )
}

  • Reemplaza el filtrado en onValueChange por un InputTransformation para establecer la longitud máxima de la entrada.
  • Reemplaza VisualTransformation por OutputTransformation para agregar guiones.
    • Con VisualTransformation, eres responsable de crear el texto nuevo con los guiones y de calcular cómo se asignan los índices entre el texto visual y el estado de copia de seguridad.
    • OutputTransformation se encarga de la asignación de offset automáticamente. Solo debes agregar los guiones en los lugares correctos con el TextFieldBuffer del alcance del receptor de OutputTransformation.transformOutput.

Actualiza el estado (simple)

Basadas en el valor

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

Basado en el estado

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

  • Reemplaza el bucle de devolución de llamada de valor por rememberTextFieldState().
  • Cambia la asignación de valor con TextFieldState.setTextAndPlaceCursorAtEnd.

Actualiza el estado (complejo)

Basadas en el valor

@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
    )
}

Basado en el estado

@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)
    )
}

En este caso de uso, un botón agrega las decoraciones de Markdown para que el texto esté en negrita alrededor del cursor o la selección actual. También mantiene la posición de selección después de los cambios.

  • Reemplaza el bucle de devolución de llamada de valor por rememberTextFieldState().
  • Reemplaza maxLines = 10 con lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10).
  • Cambia la lógica de calcular un TextFieldValue nuevo con una llamada a TextFieldState.edit.
    • Para generar un TextFieldValue nuevo, se une el texto existente en función de la selección actual y se insertan las decoraciones de Markdown en el medio.
    • Además, la selección se ajusta según los nuevos índices del texto.
    • TextFieldState.edit tiene una forma más natural de editar el estado actual con el uso de TextFieldBuffer.
    • La selección define de forma explícita dónde insertar las decoraciones.
    • Luego, ajusta la selección, de manera similar al enfoque de onValueChange.

Arquitectura de StateFlow de ViewModel

Muchas aplicaciones siguen los Lineamientos de desarrollo de apps modernas, que recomiendan usar un StateFlow para definir el estado de la IU de una pantalla o un componente a través de una sola clase inmutable que contiene toda la información.

En estos tipos de aplicaciones, un formulario como una pantalla de acceso con entrada de texto suele diseñarse de la siguiente manera:

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()
        )
    }
}

Este diseño se ajusta perfectamente a los TextFields que usan el paradigma de elevación de estado value, onValueChange. Sin embargo, este enfoque tiene inconvenientes imprevisibles cuando se trata de la entrada de texto. Los problemas de sincronización profunda con este enfoque se explican en detalle en la entrada de blog Administración eficaz del estado para TextField en Compose.

El problema es que el nuevo diseño de TextFieldState no es directamente compatible con el estado de la IU de ViewModel respaldado por StateFlow. Puede parecer extraño reemplazar username: String y password: String por username: TextFieldState y password: TextFieldState, ya que TextFieldState es una estructura de datos inherentemente mutable.

Una recomendación común es evitar colocar dependencias de la IU en un ViewModel. Si bien esta es una práctica recomendada, a veces se puede malinterpretar. Esto es especialmente cierto para las dependencias de Compose que son solo estructuras de datos y no llevan ningún elemento de la IU, como TextFieldState.

Clases como MutableState o TextFieldState son contenedores de estado simples que están respaldados por el sistema de estado de Snapshot de Compose. No son diferentes de las dependencias como StateFlow o RxJava. Por lo tanto,te recomendamos que reevalúes cómo aplicas el principio "Sin dependencias de IU en ViewModel" en tu código. Mantener una referencia a un TextFieldState dentro de tu ViewModel no es una práctica inherentemente mala.

Te recomendamos que extraigas valores como username o password de UiState y que mantengas una referencia independiente para ellos en 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)
    }
}

  • Reemplaza MutableStateFlow<UiState> por un par de valores TextFieldState.
  • Pasa esos objetos TextFieldState a TextFields en el elemento LoginForm componible.

Enfoque de conformidad

Estos tipos de cambios arquitectónicos no siempre son fáciles. Es posible que no tengas la libertad de realizar estos cambios, o bien que la inversión de tiempo pueda superar los beneficios de usar los nuevos TextField. En este caso, puedes seguir usando campos de texto basados en el estado con algunos ajustes.

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(usernameState) {
            snapshotFlow { usernameState.text.toString() }.collectLatest {
                loginViewModel.updatePassword(it)
            }
        }
        SecureTextField(passwordState)
    }
}

  • Mantén las clases ViewModel y UiState iguales.
  • En lugar de elevar el estado directamente a ViewModel y convertirlo en la fuente de información de TextFields, convierte ViewModel en un contenedor de datos simple.
    • Para ello, observa los cambios en cada TextFieldState.text recopilando un snapshotFlow en un LaunchedEffect.
  • Tu ViewModel seguirá teniendo los valores más recientes de la IU, pero su uiState: StateFlow<UiState> no controlará los TextField.
  • Cualquier otra lógica de persistencia implementada en tu ViewModel puede permanecer igual.