En Introducción a Jetpack Compose y cómo diseñar interfaces con Jetpack Compose vimos los principios fundamentales de Jetpack Compose y sus principales actores, así como una guía básica para la creación de interfaces, pero todo desde una perspectiva completamente estática.

En este nuevo post hablaremos sobre cómo trabajar con Compose de una manera más realista y cercana al objetivo final que tendremos como desarrolladores, aprendiendo a utilizar datos dinámicos y redibujando las vistas cuando sea necesario.

Estado y Composición

Hasta ahora, la jerarquía de vistas de Android se ha representado como un árbol de widgets de IU. Cuando la app cambiaba de estado (por interacciones del usuario, por ejemplo) se debía actualizar la jerarquía de la IU para mostrar los datos actuales.

La forma más común de actualizar la IU es recorrer el árbol con funciones como findViewById y cambiar los nodos mediante llamadas a métodos como setText o addView, los cuales cambian el estado interno del componente. Este sistema de manipulación de vistas “manual” es proclive a errores, ya que se puede perder la actualización de una de las vistas o se producen estados ilegales (intentar actualizar una vista que ya no está presente, por ejemplo).

La tendencia ha comenzado a migrar a un modelo de IU declarativa, lo que simplifica mucho la ingeniería relacionada con la compilación y la actualización de interfaces de usuario. Esto se consigue mediante la regeneración conceptual de toda la pantalla desde cero y la posterior aplicación de solamente los cambios necesarios. Ese enfoque evita la complejidad de actualizar de forma manual una jerarquía de vistas con estado. Para mitigar el coste que plantea el regenerar toda la pantalla, Compose elige de manera inteligente qué partes de la IU deben volver a dibujarse y en qué momento. Esto tiene algunas consecuencias en la forma de diseñar los componentes de tu IU, como explicaremos a lo largo de este post.

De momento tengamos claro que Compose es declarativo, por lo tanto, la única manera de actualizarlo es llamar al mismo elemento que admite composición con argumentos nuevos. De esta forma. cada vez que se actualiza un estado, se produce una recomposición. Elementos como el TextField (antiguo EditText), no se actualizan automáticamente de la misma manera que en las vistas imperativas, las cuales se basan en XML, sino que se le debe informar de manera explícita del estado nuevo para que se modifique según corresponda.

Vamos a definir los conceptos clave para poder abordar este tema:

Remember y MutableState

Ahora que hemos introducido los conceptos básicos de composición, vamos a ver de qué manera podemos incluir un estado en un elemento de Compose. Para ello vamos a crear un componente muy básico con una etiqueta y un campo de introducción de texto.

    @Composable
    fun EditTextWithLabel() {
        Column(modifier = Modifier.padding(16.dp)) {
            OutlinedTextField(
                value = "",
                modifier = Modifier.padding(top = 8.dp, bottom = 8.dp),
                onValueChange = { },
                label = { Text("Good ‘ol EditText") }
            )
        }
    }
Etiqueta e introducción de texto de Jetpack  compose.

Los componentes de nuestro widget son un elemento de tipo Text, el encargado de mostrar el label, y de tipo OutlinedTextField, el que más nos interesa para nuestro caso. Al crear este último, tenemos que definir dos parámetros obligatoriamente:

Para ejecutar el código anterior, vamos a crear una Activity de prueba y a inicializar el componente mediante setContent:

class SampleActivity: ComponentActivity() {
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            PlaygroundTheme {
                EditTextWithLabel()
            }
        }
    }

    @Composable
    fun EditTextWithLabel() {…}
}

Como habrás podido comprobar, el componente se dibuja correctamente, pero no cambia su valor al introducir texto con el teclado, y esto se debe a que TextField no se actualiza a sí mismo, sino que lo hace cuando cambia su parámetro value, es decir, su estado.

Las funciones de componibilidad pueden usar la API de remember para almacenar un objeto en la memoria. Un valor calculado por remember se almacena en la composición durante la composición inicial, y el valor almacenado se muestra durante la recomposición. Se puede usar remember para almacenar tanto objetos mutables como inmutables. En nuestro caso, vamos a utilizarlo para actualizar el valor del TextField acorde a lo que se vaya introduciendo por teclado.

Para ello vamos a crear una variable content y a inicializarla mediante la API remember utilizando una variable de tipo MutableState con un texto vacío. Tras ello, se la asignaremos como valor al TextField. Para ello utilizaremos mutableStateOf, el cual crea un MutableState<T> observable: un tipo observable integrado en el entorno de ejecución de Compose.

interface MutableState<T> : State<T> {
    override var value: T
}

Cualquier cambio en value ejecutará la recomposición de las funciones composables que estén utilizando dicho valor. Puede declararse de tres maneras:

En principio no existen diferencias entre ellas, pero puede resultar más cómodo utilizar la inicialización por delegación mediante el by, pudiendo acceder directamente al tipo del valor almacenado dentro de la variable MutableState, ahorrándonos de esta forma pasar por el value.

Una vez dicho esto, nuestro código ahora será más similar a esto:

@Composable
    fun EditTextWithLabel() {
        Column(modifier = Modifier.padding(16.dp)) {
            var content by remember {
                mutableStateOf("")
            }
            OutlinedTextField(
                value = content,
                modifier = Modifier.padding(top = 8.dp, bottom = 8.dp),
                onValueChange = { input -> content = input },
                label = { Text("Good ‘ol EditText") }
            )
        }
    }

Cuando ejecutamos el código, podemos ver que efectivamente el valor del TextField se actualiza con el texto introducido por teclado gracias a que hemos actualizado el estado del componente OutlinedEditText.

Gestión de los Estados y buenas prácticas

Ahora que hemos visto cómo actúa el estado sobre los componentes, vamos a ver cómo gestionarlo y cuál es la arquitectura más adecuada para ello. En ocasiones queremos que nuestros componentes tengan un estado y en otras no, por lo que la manera más cómoda de hacer que un mismo componente pueda comportarse de ambas formas es extrayendo o elevando el estado asociado al componente.

El patrón general en Compose para ello consiste en reemplazar la variable de estado con tres parámetros:

Para nuestro ejemplo, vamos a extraer tanto el value principal del TextField como su evento onValueChanged y a suministrarlos desde el mismo punto de acceso, centralizando así la gestión de los estados en un solo método desde el que inicializamos todos los componentes presentes en la pantalla. Vamos a llamar a ese método sampleScreen:

@Composable
fun SampleScreen() {
   var content by rememberSaveable {
       mutableStateOf("")
   }
   EditTextWithLabel(content = content, onContentChanged = { input -> content = input })
}

@Composable
fun EditTextWithLabel(content: String, onContentChanged: (String) -> Unit) {
   Column(modifier = Modifier.padding(16.dp)) {
       OutlinedTextField(
           value = content,
           modifier = Modifier.padding(top = 8.dp, bottom = 8.dp),
           onValueChange = onContentChanged,
           label = { Text("Good 'ol EditText") }
       )
   }
}

Ahora tenemos centralizado el estado y el evento de cambio de estado relacionados con el componente que hemos creado. Si hubiese más componentes componibles, los instanciaríamos desde la misma función para tener todos los estados en el mismo lugar y controlarlos desde ahí. Como podemos observar, hemos utilizado una sintaxis distinta para el remember, rememberSaveable, la cual retiene el estado entre cambios de configuración.

ObserveAsState

Por último, vamos a ver cómo terminan los estados de encajar con la arquitectura de ViewModels promovida por Android. Y es que no solo podemos hacer funcionar los estados mediante objetos del tipo MutableState, sino que Android provee los mecanismos necesarios para crear objetos State<T> a a partir de otros tipos observables más comunes como LiveData, Flow o RxJava2.

En nuestro ejemplo, crearemos la clase SampleViewModel que extiende de ViewModel y dentro contiene tanto el LiveData que retendrá el valor del contenido del TextField como el método que se invocará para actualizar dicho valor. Por último, solo tenemos que invocar el método observeAsState sobre el LiveData declarado dentro del ViewModel y pasárselo como parámetro al método encargado de crear el componente:

class SampleViewModel: ViewModel() {

   val textFieldState = MutableLiveData("")

   fun onTextChanged(input: String) {
       textFieldState.value = input
   }
}
@Composable
fun SampleScreen() {
   val content = sampleViewModel.textFieldState.observeAsState("")
   EditTextWithLabel(content = content.value, onContentChanged = { input -> sampleViewModel.onTextChanged(input) })
}

Ahora ya sabemos todas las formas posibles de controlar el estado de nuestros componentes, por lo que ya disponemos de todos los conocimientos que nos permitirán crear una nueva app con Compose.

Pero la cosa no acaba aquí. Compose tiene un montón de mejoras y funcionalidades que nos permitirán reducir la cantidad de código generado de forma increíble. En próximos posts veremos cómo crear un listado simple de widgets más complejos aprovechando toda la potencia que nos ofrece Compose.

¡Espero que os haya sido de ayuda!

Referencias

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.

Suscríbete

Estamos comprometidos.

Tecnología, personas e impacto positivo.