Estado En Compose

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:

Ejemplo de Estado en Compose

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:

@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:

  1. Definimos los cuatro estados
  2. 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.
  3. Establecemos los estados para los valores que deseamos cambiar en pantalla. El parámetro value para TextField(); text y color para Text()
  4. Pasamos a onCalculate como argumento de onClick, el manejador de clicks de Button().

Al ejecutar la aplicación, verías los siguiente:

Suma de dos números en Android con Compose

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:

  1. Procesar eventos
  2. Actualizar estados
  3. 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 visualizar
  • onValueChange : (T) -> Unit: El evento que solicita el cambio del estado por un nuevo valor T

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:

Ejemplo rememberSaveable() en Compose

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 SumViewModelen 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.

Únete Al Discord De Develou

Si tienes problemas con el código de este tutorial, preguntas, recomendaciones o solo deseas discutir sobre desarrollo Android conmigo y otros desarrolladores, únete a la comunidad de Discord de Develou y siéntete libre de participar como gustes. ¡Te espero!