En este tutorial veremos el concepto de estado en Compose, con el fin de reflejar sobre nuestra funciones componibles, el cambio de valores asociados a la lógica de nuestra App.
¿Qué Es Un Estado?
En el contexto de una aplicación Android, el estado se refiere a un valor que puede mutar su contenido a lo largo del tiempo.
Por ejemplo:
- Una colección de datos de interés para el usuario
- El texto de un error
- La bandera que determina si mostrar o no carga
- El texto de los campos de un formulario
- …
Los componibles usan el estado que se especifique para proyectarlo en la pantalla y así plasmar el contenido al usuario.
No obstante, veremos que debido al paradigma declarativo, cada invocación de una función componible solo muestra el estado con que es creado. Por lo que para actualizar el estado en la UI, se debe llamar de nuevo con el nuevo estado (recomposición).
Ejemplo De Estado En Compose
Usaremos como ilustración una App que suma dos números al presionar un botón. El diseño es el siguiente:
La idea es actualizar los estados de ambos campos de texto cuando el usuario escriba y modificar el valor visualizado para el resultado de la suma al presionar el botón. Puedes descargar el código desde el siguiente enlace (módulo p4_estado
):
Almacenar Estado En Compose
Las funciones componibles pueden almacenar en memoria el valor de su estado en la composición inicial. Por lo que cuando se ejecuta una recomposición, este valor es recordado.
Para ello, invoca la función componible remember()
en tu composable, para sostener el valor producido por el argumento entrante. Esta tiene tres formas equivalentes de invocarse:
- Delegación de propiedades
- Asignación común
- Desestructuración de declaración (el segundo componente es del tipo
(T) -> Unit
y te permite modificar el estado)
@Composable
fun MutableStateExample() {
val estado1 by remember { mutableStateOf("") }
var estado2 = remember { mutableStateOf(10) }
var (estado3, setEstado3) = remember { mutableStateOf(true) }
//...
}
Debido a que deseamos observar el estado, usaremos instancias del tipo MutableState<T>
de androidx.compose.runtime
. Esta interfaz representa al portador de estado de un solo valor, cuyas lecturas y escrituras son observadas por Compose.
Para producirlos, invocamos la función mutableStateOf()
junto al valor del estado inicial como lo hicimos en el código anterior con ""
, 10
y true
.
Ejemplo De Estado En TextField
Uno de los usos más claros del estado en Compose, es el manejo del valor del texto de los campos de texto (TextField
).
Por ejemplo, tomemos como entrada un operando para la suma:
@Composable
@Preview
fun TextFieldWithoutState() {
TextField(
value = "",
onValueChange = { },
label = { Text("Número 1") }
)
}
¿Qué sucede cuando ejecutas la App e intentas escribir texto?
El campo de texto no recibirá la entrada desde el teclado. Debido a que el valor visualizado es el parámetro value
. Si este elemento no cambia, entonces la vista no lo reflejará.
¿Como lo resolvemos?
Declaramos el estado del campo de texto y se lo pasamos al parámetro value
para que lo recuerde en cada recomposición.
Y como deseamos que value
cambie cada que el usuario escriba, entonces actualizaremos su valor desde onValueChange
. Este parámetro actúa como el controlador que notifica las entradas de texto y es del tipo función (String) -> Unit
, donde su parámetro String
contiene al texto escrito:
@Composable
fun TextFieldWithState() {
var firstNumber by remember { mutableStateOf("") }
TextField(
value = firstNumber,
onValueChange = { firstNumber = it },
label = { Text("Número 1") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
}
Al ejecutar de nuevo la App, el TextField
recibirá los números que necesitamos:
Lectura: Lee mi tutorial TextField en Compose (todo) para profundizar más en este componible.
Componibles Con Estado
Un componible con estado o stateful composable es un componible que posee una pieza de estado declarada como variable local, con el objetivo de percibir directamente sus cambios a través del tiempo.
Hay dos momentos donde necesitaremos declarar un estado interno para un componible:
- Cuando la interfaz controla autónomamente la variación de su estado
- Cuando deseamos aislar el estado de otro componible (state hoisting).
Por ejemplo:
En nuestro diseño de la suma de dos números tenemos los siguientes estados:
- La entrada del número 1
- La entrada del número 2
- El resultado de la suma
- Color del texto de suma
Creemos una función llamada SumScreenStateful()
y agreguemos los elementos:
// 02StatefulComposable.kt
@Composable
@Preview
fun SumScreenStateful() {
var firstNumber by remember { mutableStateOf("") } // 1
var secondNumber by remember { mutableStateOf("") }
var sum by remember { mutableStateOf(0.0) }
var sumColor by remember { mutableStateOf(Color.Black) }
val onCalculate = { // 2
sum = firstNumber.toDoubleOrZero() + secondNumber.toDoubleOrZero()
sumColor = when {
sum < 10.0 -> Color.Cyan
sum > 10.0 -> Color.Blue
sum == 10.0 -> Color.Magenta
else -> Color.Black
}
}
Column(
Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
TextField(
value = firstNumber, // 3
onValueChange = { firstNumber = it },
label = { Text("Número 1") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
Spacer()
TextField(
value = secondNumber, // 3
onValueChange = { secondNumber = it },
label = { Text("Número 2") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
Spacer()
Text(
text = "Suma = $sum", // 3
fontSize = 30.sp,
color = sumColor // 3
)
Spacer()
Button(onClick = onCalculate) { // 4
Text(text = "CALCULAR")
}
}
}
En el código Kotlin anterior:
- Definimos los cuatro estados
- Declaramos una lambda en una variable llamada
onCalculate
. Sus sentencias serán la operación de la suma con los estados de los campos de texto y la asignación de un color basado en las desigualdades a partir de la suma. - Establecemos los estados para los valores que deseamos cambiar en pantalla. El parámetro
value
paraTextField()
;text
ycolor
paraText()
- Pasamos a
onCalculate
como argumento deonClick
, el manejador de clicks deButton()
.
Al ejecutar la aplicación, verías los siguiente:
Analicemos:
- ¿Qué pasaría si quisiéramos usar esta misma pantalla para proyectar una resta?
- ¿Y si quisiéramos crear una función composible para extraer los campos de texto?
- ¿Cómo permitir al código cliente la personalización de un filtro para el texto entrante en los TextField?
Debido a que tenemos enraizados los estados, el bloque no estaría en las condiciones necesarias para reutilizarlo.
Visto que nos limitan, veamos como aislarlos.
Componibles Sin Estado
Como su nombre lo dice, un componible sin estado (stateless composable) es aquel que no se preocupa del uso de estados internamente, si no que se enfoca en la creación de la jerarquía UI.
Veamos algunas motivaciones del por qué realizar esta separación.
Bucle Para Actualizar UI
Normalmente, la actualización de estados se produce en respuesta a los eventos, es decir, estímulos generados por fuera de nuestra aplicación. Tales como: gestos en la pantalla, servicios del sistema operativo, publicación de datos de APIs de terceros, etc.
Debido a esto, cuando usamos el sistema de views se genera un bucle de actualización de la interfaz gráfica que consiste de:
- Procesar eventos
- Actualizar estados
- Visualizar estados
Por ejemplo, si el usuario hace click en un botón y se actualiza el texto de un contador en pantalla, el flujo anterior podemos representarlo así:
Unidirectional Data Flow
¿Qué sucede con el enfoque anterior?
Requiere una estructura donde se añade el estado a las actividades o fragmentos, con el fin de tenerlos a mano y modificarlos cuando los manejadores de eventos hagan notificaciones. Es decir, nuestra vista aumenta sus responsabilidades y se convierte en portador y modificador de estado.
Por esta razón Google introdujo el componente ViewModel
. El cual se encarga de extraer los estados de la UI y proveer una interfaz para actualizarlos cuando se produzcan los eventos.
Donde cada estado es almacenado en un tipo observable LiveData
, que permitirá notificar el nuevo estado a visualizar.
Aplicando estas colaboraciones, se dice que el estado fluye hacia abajo (UI -> View Model) y los eventos fluyen hacia arriba (View Model -> UI).
A este diseño se le conoce como Flujo de Datos Unidireccional o Unidirectional Data Flow. Y como podrás inferir, nos otorga las siguiente ventajas:
- Testabilidad: Facilita pruebas sobre la actividad y el viewmodel, ya que el estado es desacoplado
- Encapsulación del estado: Reduce la incorporación y propagación de errores debido a que el estado es actualizado en un solo lugar (View Model)
- Consistencia: Las actualizaciones de estado se visualizan inmediatamente, gracias a la observación del tipo que porta al estado
Ahora bien, ¿Cómo llegar a este diseño con el sistema de Compose?
Veamos.
Aplicar Elevación De Estado
La elevación de estado o state hoisting es un patrón de Compose, que consiste en la división de un componible en otros dos componibles. Uno que sólo albergue el estado del original y otro solo con los bloques de UI.
Para aplicar esta transformación, añade los siguientes parámetros al componible con la UI:
value:T
: El valor inicial a visualizaronValueChange : (T) -> Unit
: El evento que solicita el cambio del estado por un nuevo valorT
Ejemplo De State Hoisting
Apliquemos el desacoplamiento del estado de la función SumScreen()
.
Paso 1. Creemos una composable sin estado para los campos de texto llamado OperandTextField()
. Aislaremos su estado y manejo de evento de cambio de texto:
@Composable
fun OperandTextField(
label: String,
number: String,
numberChange: (String) -> Unit
) {
TextField(
value = number,
onValueChange = numberChange,
label = { Text(label) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
}
Paso 2. Ahora, creemos un nuevo composable llamado SumContent()
y convirtamos los estados de los operandos y la suma en parámetros, al igual que con los tres eventos:
@Composable
private fun SumContent(
firstNumber: String,
secondNumber: String,
firstNumberChange: (String) -> Unit,
secondNumberChange: (String) -> Unit,
sum: Double,
onCalculate: () -> Unit
) {
Column(
Modifier
.fillMaxSize()
.padding(vertical = 64.dp, horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
OperandTextField(
label = "Número 1",
number = firstNumber,
numberChange = firstNumberChange
)
Spacer()
OperandTextField(
label = "Número 2",
number = secondNumber,
numberChange = secondNumberChange
)
Spacer()
Text(
text = "Suma = $sum",
fontSize = 30.sp,
color = when {
sum < 10.0 -> Color.Cyan
sum > 10.0 -> Color.Blue
sum == 10.0 -> Color.Red
else -> Color.Black
}
)
Spacer()
Button(onClick = onCalculate) {
Text(text = "CALCULAR")
}
}
}
Paso 3. Así solo nos queda reescribir a SumScreen()
para que invoque a SumContent()
con el estado necesario y las funciones lambda que se ejecutan al ocurrir los eventos:
@Composable
@Preview
fun SumScreen() {
var firstNumber by remember { mutableStateOf("") }
var secondNumber by remember { mutableStateOf("") }
var sum by remember { mutableStateOf(0.0) }
SumContent(
firstNumber,
secondNumber,
firstNumberChange = { firstNumber = it },
secondNumberChange = { secondNumber = it },
sum,
onCalculate = { sum = firstNumber.toDoubleOrZero() + secondNumber.toDoubleOrZero() }
)
}
Con esta interacción, ambas funciones componibles envían flujos unidireccionales como se propuso al inicio:
Cuando el usuario tipea y presiona el botón de suma, SumContent()
hace fluir estos eventos a SumScreen()
. Lo que provoca que los estados observados desde SumScreen()
actualicen la interfaz en SumContent()
.
Retener Estado En Cambios De Configuración
Si necesitas retener el valor del estado ante las recomposiciones, recreaciones de actividades y muerte de procesos, entonces usa la función rememberSaveable()
.
Ahora mismo si ejecutas nuestro ejemplo, realizas una suma y rotas la pantalla, verás como se pierden todos los datos visualizados.
Para retener a nuestros operandos y el resultado, cambiamos las funciones remember()
por remembeSaveable()
que existen en SumScreen()
:
@Composable
@Preview
fun SumScreenWithRetainedState() {
var firstNumber by rememberSaveable { mutableStateOf("") }
var secondNumber by rememberSaveable { mutableStateOf("") }
var sum by rememberSaveable { mutableStateOf(0.0) }
SumContent(
firstNumber,
secondNumber,
firstNumberChange = { firstNumber = it },
secondNumberChange = { secondNumber = it },
sum,
onCalculate = { sum = firstNumber.toDoubleOrZero() + secondNumber.toDoubleOrZero() }
)
}
Esta vez al rotar la pantalla del dispositivo, el estado será restaurado automáticamente:
Anotación Parcelize
La función rememberSaveable()
añade a nuestros estados de tipo básico en un objeto Bundle que es guardado y cargado automáticamente.
Sin embargo, puede que te encuentres con tipos que no se les pueda aplicar este proceso. En ese caso puedes usar la anotación @Parcelize
e implementar Parcelable
sobre la clase asociada. Para incluirla a tu módulo añade el siguiente plugin:
plugins {
id 'kotlin-parcelize'
}
Esta anotación generará todo el código bajo cuerda para que las instancias sean parcelables.
Por ejemplo:
Supongamos que decidimos modelar mejor el dominio y decidimos añadir la siguiente clase de datos para las operaciones:
@Parcelize
data class Sum(
val operand1: Double,
val operand2: Double
) : Parcelable {
val result = operand1 + operand2
}
Al marcarla con @Parcelize
es posible retenerla y restaurarla como un estado completo a través de rememberSaveable()
:
var sum by rememberSaveable {
mutableStateOf(Sum(0.0, 0.0))
}
Nota: Si esta anotación no es suficiente para parcelar tu tipo, entonces puedes hacer uso de MapSaver o ListSaver a fin de crear la estructura adecuada.
Usar Estados Desde ViewModel
Las funciones componibles pueden usar a los ViewModels como portadores de estado sin ningún problema. Para obtener la instancia de ellos usa la función de extensión viewModel()
al interior del componible.
Si los estados en el ViewModel son de tipo LiveData
o StateFlow
, conviértelos a State<T>
con la función observeAsState()
. Con ello, el valor del estado será observado en la composición.
Las anteriores funciones solo estarán disponibles si incorporamos las siguientes dependencias en build.gradle
del módulo:
dependencies {
//...
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:ultima_version'
implementation "androidx.compose.runtime:runtime-livedata:$compose_version"
}
Por ejemplo:
Creemos un nuevo view model llamado SumViewModel
en nuestro proyecto y añadamos los tres estados actuales, además de métodos que procesen los eventos:
class SumViewModel : ViewModel() {
private val _number1 = MutableLiveData("")
val number1: LiveData<String> = _number1
private val _number2 = MutableLiveData("")
val number2: LiveData<String> = _number2
private val _sum = MutableLiveData(0.0)
val sum: LiveData<Double> = _sum
fun onFirstNumberChange(number: String) {
_number1.value = number
}
fun onSecondNumberChange(number: String) {
_number2.value = number
}
fun onCalculate() {
_sum.value = number1.value.toDoubleOrZero() + number2.value.toDoubleOrZero()
}
}
Luego modifiquemos a SumScreen()
para especificarle que el nuevo portador de estados será SumViewHolder
.
@Composable
@Preview
fun SumScreenWithViewModel(sumViewModel: SumViewModel = viewModel()) {
val firstNumber by sumViewModel.number1.observeAsState("")
val secondNumber by sumViewModel.number2.observeAsState("")
val sum by sumViewModel.sum.observeAsState(0.0)
SumContentWithViewModel(
firstNumber,
secondNumber,
firstNumberChange = sumViewModel::onFirstNumberChange,
secondNumberChange = sumViewModel::onSecondNumberChange,
sum,
onCalculate = sumViewModel::onCalculate
)
}
Como ves, accedemos a las propiedades LiveData
de los estados para pasarlos a SumContentWithViewModel()
. Sumándole el paso de las referencias de los métodos de eventos.
Además el ViewModel rentendrá los estados en cambios de configuración y recomposiciones.