La forma más nueva: TextFieldState y SecureTextField
Al terminar: Conocer TextFieldState y SecureTextField, la forma más nueva de guardar el texto de un campo y de ocultar una contraseña.
Al terminar esta lección conocerás la segunda forma de escribir un campo de texto en Compose, la que Google recomienda hoy: en lugar de un String suelto y un aviso por cada tecla, el campo recibe un objeto que guarda su texto, TextFieldState. También sabrás ocultar una contraseña, de la forma clásica (la de value y onValueChange, la que has usado hasta ahora) y con SecureTextField, y por qué Marcador sigue con la forma clásica. No cambia ningún fichero de la app: el trabajo es leer, comparar y escribir en tu carpeta ejercicios una pantalla de acceso con correo y contraseña, de las dos formas.
Importa porque verás las dos formas en el mundo real: los proyectos y los ejemplos que ya existen usan casi siempre value y onValueChange, y los nuevos, cada vez más, TextFieldState. Si solo conoces una, la otra se lee como un idioma distinto. Temas: rememberTextFieldState, lineLimits, state.text, PasswordVisualTransformation, SecureTextField y textObfuscationMode.
Dos formas de guardar el texto
Un campo de texto necesita guardar en algún sitio lo que el usuario ha escrito. La diferencia entre las dos formas es quién lo guarda.
Forma clásica (value) | Forma nueva (state) | |
|---|---|---|
| Dónde está el texto | En un String tuyo, que bajas al campo | En un objeto TextFieldState |
| Cómo llega cada tecla | onValueChange te avisa y tú actualizas el String | El campo escribe en el objeto; no hay aviso |
| Para que sobreviva al giro | rememberSaveable | rememberTextFieldState() ya lo guarda |
| Una sola línea | singleLine = true | lineLimits = TextFieldLineLimits.SingleLine |
| Contraseña | visualTransformation con un botón propio | SecureTextField |
Esto no contradice lo que viste en Formularios: el estado y las reglas: allí, con la forma clásica, el texto va en un mutableStateOf, que es lo que encaja con value y onValueChange. La forma nueva es otra, con su propio objeto, y es la que Google recomienda para los campos que se escriben hoy.
Un «cambio de configuración» es, por ejemplo, girar el móvil, como viste en Rotación y muerte del proceso. De ahí que no haga falta rememberSaveable: rememberTextFieldState() ya recuerda el objeto y lo guarda para restaurarlo.
El estado como objeto
Cuatro piezas y ya se puede leer cualquier campo de la forma nueva:
rememberTextFieldState()crea el objeto y lo recuerda entre pasadas. Admite un texto inicial:rememberTextFieldState(initialText = "Ana").state = …sustituye avalueyonValueChangeen el campo. El campo ya no necesita que le bajes el texto ni que le subas las teclas.lineLimits = TextFieldLineLimits.SingleLinedice cuántas líneas admite: una sola, o varias.state.textes el texto de ahora. Si lo lees dentro de una función@Composable, esa función se vuelve a pintar cada vez que el texto cambia, igual que con unmutableStateOf. Es un texto de tipoCharSequence(una secuencia de caracteres):isNotBlank(),lengthycontainsfuncionan, ytoString()lo convierte en unStringcuando hace falta uno, por ejemplo paratoIntOrNull().
Un ejemplo para leer, con datos ajenos a Marcador: el alta de un socio de la lección anterior, con la forma nueva. No hace falta escribirlo; el ejercicio de más abajo usa las mismas piezas.
@Composable
fun AltaDeSocioNueva() {
// El estado de cada campo es un OBJETO que se recuerda; ya no hay un String suelto
val correo = rememberTextFieldState()
val edad = rememberTextFieldState()
// Leer `.text` aquí hace que la pantalla se vuelva a pintar cuando el texto cambia
val sePuedeAnotar = correo.text.contains("@") && edad.text.toString().toIntOrNull() != null
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
state = correo, // el objeto, no `value`
label = { Text("Correo") },
lineLimits = TextFieldLineLimits.SingleLine, // en lugar de singleLine = true
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
state = edad,
label = { Text("Edad") },
lineLimits = TextFieldLineLimits.SingleLine,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), // igual que antes
modifier = Modifier.fillMaxWidth(),
)
Button(onClick = {}, enabled = sePuedeAnotar, modifier = Modifier.fillMaxWidth()) {
Text("Anotar socio")
}
Text("Has escrito: ${correo.text}")
}
}Lo que se vería: dos campos, Correo y Edad, el botón Anotar socio gris y, debajo, «Has escrito: » sin nada. Si escribieras ana@correo.es, la línea de abajo se llenaría letra a letra, sin que ninguna función haga nada. El botón seguiría gris hasta que la edad fuera un número: con 18, se activaría. Y si giraras el emulador, lo escrito seguiría ahí.
Fíjate en lo que no hay: ni var … by, ni onValueChange, ni rememberSaveable. Y lo que no cambia: label, modifier y keyboardOptions se escriben igual que antes.
Contraseñas, de las dos formas
La forma clásica. Un campo de contraseña tiene que pintar puntos en lugar de letras, pero guardar las letras de verdad. Eso lo hace visualTransformation: una transformación que cambia cómo se ve el texto sin cambiar el texto. PasswordVisualTransformation() pinta cada carácter como un punto, y VisualTransformation.None lo deja como está. Con una bandera que alterna entre las dos y un botón al final del campo sale el «Ver / Ocultar» que tienen muchas apps. Dos piezas nuevas: trailingIcon es el hueco al final del campo (admite cualquier composable) y TextButton es un botón sin relleno, solo texto. Y KeyboardType.Password pide al teclado su modo de contraseñas.
@Composable
fun CodigoDeTaquilla() {
var codigo by rememberSaveable { mutableStateOf("") } // lo escrito (las letras de verdad)
var verCodigo by rememberSaveable { mutableStateOf(false) } // ¿se ve o se oculta?
OutlinedTextField(
value = codigo,
onValueChange = { codigo = it },
label = { Text("Código de la taquilla") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), // teclado de contraseñas
// Un botón de texto al final del campo que da la vuelta a la bandera
trailingIcon = {
TextButton(onClick = { verCodigo = !verCodigo }) {
Text(if (verCodigo) "Ocultar" else "Ver")
}
},
// Oculto: cada carácter se pinta como un punto. Visible: el texto tal cual
visualTransformation = if (verCodigo) VisualTransformation.None else PasswordVisualTransformation(),
)
}Escribe «4821» con Ver pulsado y se lee 4821; púlsalo otra vez y se ve ••••. El texto guardado es el mismo en los dos casos: solo cambia el dibujo.
La forma nueva. Necesitaba tres piezas: una transformación, una bandera y un botón. Con TextFieldState hay un campo hecho para eso: SecureTextField (y OutlinedSecureTextField, con borde). Recibe un TextFieldState y oculta lo que escribes sin que le digas nada.
@Composable
fun PinDeTaquilla() {
val pin = rememberTextFieldState() // el estado, como en cualquier campo nuevo
OutlinedSecureTextField(
state = pin,
label = { Text("PIN de la taquilla") },
// Nada más: sin visualTransformation, sin bandera y sin botón. Si quisieras otro modo:
// textObfuscationMode = TextObfuscationMode.Hidden (oculta todo, incluso el último carácter)
modifier = Modifier.fillMaxWidth(),
)
}textObfuscationMode es el parámetro que decide cómo se oculta el texto: Hidden lo oculta todo, RevealLastTyped deja ver el último carácter un instante (lo justo para comprobar que has pulsado bien) y Visible lo enseña entero. Si no lo pones, usa RevealLastTyped: al escribir «4821» ves un instante el último dígito y enseguida todo en puntos. Un botón Ver / Ocultar sería alternar ese parámetro entre dos valores.
¿Y por qué Marcador no lo usa?
Google recomienda la forma nueva, y aun así el formulario de Marcador usa la clásica. Hay dos razones, y las dos son de esta app, no un defecto de TextFieldState.
- El «tocado». El formulario de añadir jugador marca un campo como tocado en el momento en que llega una tecla:
alEscribirNombrehacecopy(nombre = texto, nombreTocado = true). ConTextFieldStateno llega ninguna tecla a tu código: el campo escribe directamente en el objeto. Para enterarte de que el usuario ha escrito habría que vigilar el objeto con bastante más código, y es más de lo que el formulario necesita. - Los datos normales. El formulario de Marcador es una
data classcon reglas que se prueban con textos normales y corrientes, y que pueden viajar tal cual a otro sitio: si algún día esas reglas se compartieran con otra plataforma, como iOS, unStringdentro de unadata classlo entienden las dos. UnTextFieldStatees un objeto de Compose y solo lo entiende Android.
Esto es lo que conviene llevarse: no es mejor ni peor, es una decisión. Para un campo suelto de una app solo Android, usa la forma nueva. Para un formulario cuyas reglas ya viven en una clase de datos, o que se comparte con otra plataforma, la clásica encaja mejor.
Reto · Ejercicio: el acceso con correo y contraseña, de las dos formas
Fuera del formulario de Marcador, en tu carpeta ejercicios (la que creaste en Rotación y muerte del proceso). Dos partes: la misma pantalla de acceso con la forma clásica y con la nueva.
Parte 1, la forma clásica. Crea la subcarpeta acceso: clic derecho sobre com.sebastiannarvaez.marcador → New → Package → añade .ejercicios.acceso al final del nombre que aparece ya escrito. Después, clic derecho sobre esa carpeta → New → Kotlin Class/File → File → Acceso. La primera línea debe ser package com.sebastiannarvaez.marcador.ejercicios.acceso. Escribe dos piezas, como en el ejercicio del termostato de State hoisting:
AccesoContent, sin estado propio. Recibe el correo, la contraseña, si la contraseña se ve, si se puede entrar, un aviso por cada cambio de texto, un aviso para el botón Ver / Ocultar, un aviso para Entrar y un modificador opcional. Dibuja:- un campo Correo, de una línea, con teclado de correo y la tecla «Siguiente»;
- un campo Contraseña, de una línea, con teclado de contraseña y la tecla «Hecho», que se ve como puntos salvo que se pida verla. Al final del campo, un botón de texto que dice Ver (con la contraseña oculta) u Ocultar (con la contraseña visible);
- un botón Entrar, activo solo si se puede entrar. La tecla «Hecho» hace lo mismo que el botón, pero solo si está activo.
Acceso, el dueño del estado: correo, contraseña y «¿se ve?», los tres sobreviven al giro. Es él quien calcula si se puede entrar: el correo no está vacío (isNotBlank()) y la contraseña tiene 6 o más caracteres (contrasena.length >= 6). Entrar todavía no hace nada.
Añade dos vistas previas: AccesoContent con el correo ana@correo.es, la contraseña 123456 (sin ver) y «se puede entrar», y Acceso tal cual. En la primera debes ver seis puntos en el campo de contraseña, Ver a la derecha y Entrar activo.
Parte 2, la forma nueva. Con la subcarpeta acceso_nuevo (.ejercicios.acceso_nuevo) y el fichero AccesoNuevo, escribe un único composable, AccesoNuevo, que guarda el estado él mismo y dibuja lo mismo sin la tecla «Hecho» en la contraseña (queda el teclado normal) y sin ningún botón Ver: el campo de la contraseña se oculta solo, y el que lleva Secure en el nombre es el que necesitas. El correo sí conserva su teclado de correo y la tecla «Siguiente». Pistas: rememberTextFieldState para el estado de cada campo, lineLimits para la sola línea y .text para la regla del botón. Añade una vista previa que dibuje AccesoNuevo.
En las vistas previas de Acceso y AccesoNuevo, en el modo interactivo (Estado en Compose), debe verse:
| Escribes | Debe verse |
|---|---|
| Nada | Entrar gris |
Correo ana@correo.es y contraseña 12345 | Cinco puntos (en la forma nueva, el último carácter puede verse un instante mientras escribes) y Entrar gris |
Contraseña 123456 | Seis puntos y Entrar activo |
| Ver (solo en la parte 1) | 123456 en lugar de los puntos, y el botón dice Ocultar |
Ver solución
package com.sebastiannarvaez.marcador.ejercicios.acceso
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
private const val MINIMO = 6
@Composable
fun AccesoContent(
correo: String,
contrasena: String,
verContrasena: Boolean,
puedeEntrar: Boolean,
onCorreo: (String) -> Unit,
onContrasena: (String) -> Unit,
onVer: () -> Unit,
onEntrar: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = correo,
onValueChange = onCorreo,
label = { Text("Correo") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = contrasena,
onValueChange = onContrasena,
label = { Text("Contraseña") },
singleLine = true,
trailingIcon = {
TextButton(onClick = onVer) { Text(if (verContrasena) "Ocultar" else "Ver") }
},
visualTransformation = if (verContrasena) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { if (puedeEntrar) onEntrar() }),
modifier = Modifier.fillMaxWidth(),
)
Button(onClick = onEntrar, enabled = puedeEntrar, modifier = Modifier.fillMaxWidth()) {
Text("Entrar")
}
}
}
@Composable
fun Acceso() {
var correo by rememberSaveable { mutableStateOf("") }
var contrasena by rememberSaveable { mutableStateOf("") }
var ver by rememberSaveable { mutableStateOf(false) }
// Se calcula en cada pasada a partir del texto: no se guarda, así no puede quedarse viejo
val puedeEntrar = correo.isNotBlank() && contrasena.length >= MINIMO
AccesoContent(
correo = correo,
contrasena = contrasena,
verContrasena = ver,
puedeEntrar = puedeEntrar,
onCorreo = { correo = it },
onContrasena = { contrasena = it },
onVer = { ver = !ver },
onEntrar = {},
)
}
@Preview(showBackground = true)
@Composable
private fun AccesoOcultoPreview() {
MaterialTheme {
AccesoContent(
correo = "ana@correo.es",
contrasena = "123456",
verContrasena = false,
puedeEntrar = true,
onCorreo = {},
onContrasena = {},
onVer = {},
onEntrar = {},
)
}
}
@Preview(showBackground = true)
@Composable
private fun AccesoPreview() {
MaterialTheme { Acceso() }
}puedeEntrar lo calcula Acceso, el dueño, y AccesoContent solo lo recibe: lo lee en dos sitios, el botón y la tecla «Hecho», y por eso la tecla no puede saltarse la regla.
package com.sebastiannarvaez.marcador.ejercicios.acceso_nuevo
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedSecureTextField
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
private const val MINIMO = 6
@Composable
fun AccesoNuevo() {
val correo = rememberTextFieldState()
val contrasena = rememberTextFieldState()
val puedeEntrar = correo.text.isNotBlank() && contrasena.text.length >= MINIMO
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
state = correo,
label = { Text("Correo") },
lineLimits = TextFieldLineLimits.SingleLine,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth(),
)
OutlinedSecureTextField(
state = contrasena,
label = { Text("Contraseña") },
modifier = Modifier.fillMaxWidth(),
)
Button(onClick = {}, enabled = puedeEntrar, modifier = Modifier.fillMaxWidth()) {
Text("Entrar")
}
}
}
@Preview(showBackground = true)
@Composable
private fun AccesoNuevoPreview() {
MaterialTheme { AccesoNuevo() }
}Compara las dos: en la forma nueva desaparecen las tres variables con rememberSaveable, el botón Ver / Ocultar y los avisos onCorreo y onContrasena. Lo que la forma nueva da menos hecho es la vista previa con datos: para ver el formulario ya relleno hay que escribir en el estado antes de pintarlo (rememberTextFieldState(initialText = "ana@correo.es")), mientras que con AccesoContent bastaba pasarle un texto.
Punto de control
- La vista previa de
Accesomuestra los dos campos y Entrar gris; conana@correo.esy123456, Entrar se activa, la contraseña se ve como puntos y Ver la enseña. - La vista previa de
AccesoNuevohace lo mismo sin el botón Ver. - La app no ha cambiado:
./gradlew :app:testGratisDebugUnitTestcuenta los mismos 27 tests (30 si hiciste el ejercicio del temporizador de Tests de un ViewModel: fakes, setMain y Turbine), 0 fallos.
Con esto el formulario de Marcador queda completo: ya guarda jugadores de verdad. Falta una última idea de Android, que usarás en toda la app y que se reparte en cuatro lecciones, la primera de ellas Estado de toda la app: el partido en juego. El mismo formulario en iOS lo escribirás cuando Swift y SwiftUI estén aprendidos, en Equipos y plantillas en iOS.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.Un campo con
state = correo(unTextFieldState). ¿Cómo lees lo que el usuario lleva escrito?2.En la forma clásica, un campo de una sola línea lleva
singleLine = true. ¿Qué escribes para lo mismo en un campo constate?3.El texto de un campo con
rememberTextFieldState()sobrevive a girar el móvil sin que escribasrememberSaveable. ¿Por qué?4.En el ejercicio, el campo de contraseña es un
OutlinedSecureTextField. ¿Qué haces para que oculte lo escrito?5.Marcador sigue con
valueyonValueChangeen su formulario. ¿Cuál es una razón real de esa decisión?