Saltar al contenido
K
Jetpack ComposePaso a paso38 min de lectura · 5 min de máquina Intermedio

Más efectos: scope, derivedStateOf y SideEffect

Al terminar: Lanzar trabajo desde un botón con rememberCoroutineScope y derivar estado con derivedStateOf y SideEffect.

Al terminar esta lección, la lista de partidos de Marcador tendrá un botón «↑» que solo aparece cuando has bajado (rememberCoroutineScope y derivedStateOf), y el título de la Activity (su title, el nombre de la ventana, que según la versión de Android se ve en la lista de aplicaciones recientes) llevará el marcador («Marcador 0-0»), publicado con SideEffect. Que se publicó lo comprobarás siempre en Logcat.

Importa porque no todo trabajo de Compose se lanza al entrar en pantalla: a veces lo dispara un toque, a veces solo importa cuando cambia un resultado, y a veces hay que contarle algo a un objeto que Compose no conoce. Temas: rememberCoroutineScope, derivedStateOf y SideEffect.

El estado de la lista

Una LazyColumn recuerda por dentro hasta dónde la has desplazado. Ese estado de la lista se pide con rememberLazyListState() y se le entrega a la lista con state =. Con él puedes preguntar y ordenar:

  • firstVisibleItemIndex: el índice de la primera fila que se ve (la primera fila de la lista es la de índice 0, la quinta es la de índice 4).
  • animateScrollToItem(i): desplaza la lista con animación hasta la fila de índice i.

rememberCoroutineScope: una corrutina desde un onClick

Tienes que hacer que un botón suba la lista al principio con animateScrollToItem(0). Hay dos obstáculos, y son distintos:

  1. animateScrollToItem es una función suspend: solo se puede llamar desde una corrutina.
  2. LaunchedEffect es un composable, y el onClick de un botón no es @Composable: no se puede llamar ahí dentro, sea cual sea lo que haga el efecto.

rememberCoroutineScope() resuelve las dos: da un alcance con la vida del composable, del que puedes hacer alcance.launch { … } desde cualquier callback. Completa la familia de alcances de Android: lifecycleScope (vive con la Activity, en El hilo principal y el ANR) y este (vive con el composable). Y GlobalScope sigue sin ser opción, por la razón de Un alcance propio: nadie lo cancela.

derivedStateOf: avisar solo cuando cambia el resultado

firstVisibleItemIndex (el índice de la primera fila visible, del apartado anterior) cambia cada vez que una fila nueva pasa a ser la primera visible, es decir, muchas veces mientras desplazas. Si el botón «↑» leyera ese número directamente, la pantalla se recompondría en cada cambio, aunque al botón solo le importe una cosa: si la primera fila visible es la de índice 4 o más (la quinta). derivedStateOf { índice > 3 } calcula un booleano y guarda su último resultado para compararlo con el siguiente: solo avisa cuando ese booleano cambia, dos veces en todo el recorrido (al cruzar el índice 4 hacia abajo y al volver).

Va siempre dentro de remember { derivedStateOf { … } }: sin remember, cada recomposición crearía un derivedStateOf nuevo, sin el último resultado guardado, y perdería justo lo que lo hace útil.

Tres piezas de pantalla que aparecen en el ejemplo:

  • Box { … }: apila sus hijos uno sobre otro (lo viste con Row y Column en Compose: composables y modifiers); aquí, la lista debajo y el botón encima.
  • SmallFloatingActionButton: un botón pequeño y redondo de Material 3 que «flota» sobre el contenido.
  • Modifier.align(Alignment.BottomEnd): dentro de un Box, coloca ese hijo en una esquina (aquí, abajo a la derecha).

Todo junto, en una lista de canciones inventada: un botón «Arriba» que aparece cuando la primera fila visible es la de índice 4 o más (la quinta) y sube la lista con animación.

ejemplo mínimo: derivedStateOf y rememberCoroutineScope (con comentarios)
@Composable
fun ListaDeCanciones(canciones: List<String>) {
    val estadoDeLista = rememberLazyListState()                  // el estado de la lista: recuerda el scroll de la LazyColumn
    val verArriba by remember {                                  // remember: el derivedStateOf se crea una sola vez
        derivedStateOf { estadoDeLista.firstVisibleItemIndex > 3 }  // índice 4 o más; avisa solo cuando el Boolean cambia
    }
    val alcance = rememberCoroutineScope()                       // alcance que muere con este composable
 
    Box {                                                        // apila: lista debajo, botón encima
        LazyColumn(state = estadoDeLista) {                      // state =: la lista usa ese estado de scroll
            items(canciones, key = { it }) { Text(it) }
        }
        if (verArriba) {
            SmallFloatingActionButton(                           // botón redondo pequeño que flota
                onClick = { alcance.launch { estadoDeLista.animateScrollToItem(0) } },  // onClick no es suspend: se lanza una corrutina
                modifier = Modifier.align(Alignment.BottomEnd),  // esquina inferior derecha del Box
            ) { Text("Arriba") }
        }
    }
}

Lo que ves en pantalla: la lista de canciones y, cuando la primera fila visible es la de índice 4 o más, un botón «Arriba» abajo a la derecha que sube la lista y desaparece.

Aunque firstVisibleItemIndex cambie unas cuarenta veces al bajar de la fila 0 a la 20 y volver, ListaDeCanciones se recompone dos veces por culpa de verArriba: una al aparecer el botón y otra al desaparecer. derivedStateOf compara con el último resultado que guardó y avisa solo cuando pasa de false a true o al revés. El ejercicio de abajo te lo deja medir.

SideEffect: publicar hacia fuera

«Correcta» quiere decir completada, como viste en Efectos en Compose: LaunchedEffect: una pasada que llegó hasta el final y que Compose usa para dibujar, no una abandonada a medias.

SideEffect { … } no tiene claves, no se cancela y tampoco ofrece una forma de deshacer lo publicado (eso lo hace DisposableEffect, en la lección siguiente): corre tras cada pasada completada. Sirve para copiar estado de Compose a algo que Compose no conoce. Un ejemplo con una tienda inventada: un objeto Estadisticas, de fuera de Compose, que anota la última pantalla vista.

ejemplo mínimo: publicar el carrito hacia fuera (con comentarios)
object Estadisticas { var ultimaPantalla = "" }              // algo que Compose no conoce
 
@Composable
fun Carrito() {
    var productos by remember { mutableIntStateOf(0) }       // el estado que cambia con cada pulsación
    SideEffect {                                              // tras CADA pasada completada
        Estadisticas.ultimaPantalla = "carrito con $productos"
        Log.d("Tienda", "publicado: carrito con $productos")  // Logcat, como en El ciclo de vida de la Activity
        // pulsas «Añadir» tres veces: salen cuatro líneas «publicado» (la primera composición y una por pulsación)
    }
    Column {
        Text("Carrito: $productos productos")
        Button(onClick = { productos++ }) { Text("Añadir") }
    }
}

Lo que ves: Carrito: 0 productos en pantalla y, en Logcat, publicado: carrito con 0; tras tres pulsaciones, cuatro líneas en total. Corre aunque lo que publica no haya cambiado: no compara con lo anterior.

Marcador usa el mismo mecanismo para publicar el marcador como título de la Activity:

ejemplo mínimo: el título de la Activity (con comentarios)
@Composable
fun TituloDeLaActividad(marcador: Marcador) {
    val actividad = LocalActivity.current        // la Activity que aloja la composición; puede ser null
    SideEffect {
        actividad?.title = "Marcador $marcador"  // publica el marcador fuera de Compose
    }
}

LocalActivity es una CompositionLocal, como la que usa MaterialTheme en Compose: Scaffold, slots y tema: un valor que Compose deja disponible a todo el árbol sin pasarlo por parámetro, y .current lo lee. Vive en androidx.activity.compose, la librería de la Activity para Compose, que :app ya usa; title es el título de la ventana de la Activity. El ?. significa «si actividad no es null, asigna; si lo es, no hagas nada»: LocalActivity.current puede devolver null, por ejemplo en una vista previa, donde no hay Activity. Este composable, igual que el reloj, no pone nada en pantalla.

Un detalle importante: corre aunque el marcador no haya cambiado, cada vez que TituloDeLaActividad se recompone. En Marcador lo verás en Logcat: con el reloj en marcha, aparece una línea título publicado: Marcador 0-0 por cada recomposición, todas con el mismo marcador.

Por qué se recompone si el marcador no cambia: Marcador vive en :torneo, un módulo que no usa el compilador de Compose, así que Compose lo trata como un tipo inestable y, para decidir si puede saltarse TituloDeLaActividad, compara su parámetro con === (¿es el mismo objeto?) en lugar de con == (¿tiene el mismo contenido?), como se explica en Recomposición. PantallaMarcador construye un Marcador nuevo en cada pasada, aunque los números sean los mismos, y esa instancia nueva basta para que el composable no se salte y el SideEffect corra. Es barato aquí; para algo caro tendrías que comparar antes con el valor anterior.

Necesito…Uso
Una corrutina que arranque al entrar y dependa de valoresLaunchedEffect(claves)
Que un efecto largo vea siempre la última lambdarememberUpdatedState
Una corrutina lanzada por un toque o un callbackrememberCoroutineScope()
Un estado calculado que avise solo si cambia su resultadoderivedStateOf
Publicar estado a un objeto que Compose no conoceSideEffect

Reto · Ejercicio: la lista con atajo

Lo vas a medir tú, en la vista previa interactiva (la de Estado en Compose). SideEffect no entra aquí: no escribe en Logcat desde el modo interactivo, y lo practica el paso de abajo.

En la carpeta ejercicios (la que creaste en La rotación y la muerte del proceso): clic derecho sobre com.sebastiannarvaez.marcador → New → Package → escribe .ejercicios.lista_con_atajo. Luego clic derecho sobre esa carpeta → New → Kotlin Class/File → File → ListaConAtajo.

Escribe un composable ListaConAtajo que reciba una lista de paradas y un modificador opcional. Arriba lleva una fila con un botón «Ir al final», que desplaza la lista con animación hasta la última parada, y un texto que diga «Primera mitad» o «Más de la mitad» según la primera fila visible. Ese texto se calcula con un estado derivado que solo avisa cuando cambia el resultado: la mitad es el número de paradas dividido entre dos (con 30 paradas, 30 entre 2 es 15, y el índice 15 es la fila «Parada 16», porque la primera fila es la de índice 0). Debajo, «Pasadas de la pantalla: N», contadas con la caja que no es estado (la de Recomposición), y la lista perezosa de paradas.

Para la vista previa, dale 30 paradas («Parada 1» … «Parada 30», con un rango y map) y un alto fijo de 400 dp para que la lista tenga dónde desplazarse: se pide con heightDp = 400 dentro de @Preview.

En el modo interactivo, desplaza la lista despacio hasta abajo y vuelve arriba (con el ratón: mantén pulsado sobre la lista y arrastra hacia arriba o hacia abajo, como con el dedo en el móvil). Después pulsa «Ir al final». Debes ver «Primera mitad» y «Pasadas de la pantalla: 1» al abrir; al desplazarte el número no se mueve hasta que «Parada 16» queda arriba, y entonces sale «Más de la mitad» y «2». Al volver arriba, «Primera mitad» y «3». «Ir al final» baja la lista con animación y cambia el texto, con una pasada más.

Ver solución
app/src/main/java/com/sebastiannarvaez/marcador/ejercicios/lista_con_atajo/ListaConAtajo.kt
package com.sebastiannarvaez.marcador.ejercicios.lista_con_atajo
 
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
 
@Composable
fun ListaConAtajo(paradas: List<String>, modifier: Modifier = Modifier) {
    val estadoDeLista = rememberLazyListState()
    val pasadaLaMitad by remember {
        derivedStateOf { estadoDeLista.firstVisibleItemIndex >= paradas.size / 2 }   // solo avisa al cruzar la mitad
    }
    val alcance = rememberCoroutineScope()
    val pasadas = remember { intArrayOf(0) }                   // cuenta las pasadas de ListaConAtajo, sin ser estado
    pasadas[0]++
 
    Column(modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
        Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            Button(onClick = { alcance.launch { estadoDeLista.animateScrollToItem(paradas.size - 1) } }) {
                Text("Ir al final")
            }
            Text(if (pasadaLaMitad) "Más de la mitad" else "Primera mitad")
        }
        Text("Pasadas de la pantalla: ${pasadas[0]}")
        LazyColumn(Modifier.fillMaxWidth(), state = estadoDeLista) {
            items(paradas, key = { it }) { parada -> Text(parada, Modifier.padding(vertical = 8.dp)) }
        }
    }
}
 
@Preview(showBackground = true, heightDp = 400)
@Composable
private fun ListaConAtajoPreview() {
    MaterialTheme { ListaConAtajo((1..30).map { "Parada $it" }) }
}

El número de pasadas solo sube cuando el resultado del derivedStateOf cambia, no con cada fila que pasa.

Paso · el botón «↑» y el título de la Activity

Esta lección no instala nada. rememberCoroutineScope, derivedStateOf y SideEffect vienen con las librerías de Compose que tienes.

Objetivo. En «Partidos», un botón «↑» que aparece cuando la primera fila visible es la de índice 4 o más (la quinta); y el título de la Activity con el marcador. Al terminar, Logcat (tag:Efectos) escribe título publicado: Marcador 0-0.

app
kotlin+java → com.sebastiannarvaez.marcador
feature
partidos
ui
Listas.ktse modifica
marcador
ui
Efectos.ktse modifica
PantallaMarcador.ktse modifica
Tres ficheros que se modifican, en la vista Android.

Punto de partida. Para abrir un fichero, en el panel Project (con el selector en Android) despliega app → kotlin+java → com.sebastiannarvaez.marcador → feature y haz doble clic: Listas.kt está en partidos → ui, y Efectos.kt y PantallaMarcador.kt en marcador → ui. Tras la lección anterior, Efectos.kt tiene MINUTOS_DEL_PARTIDO, RelojDelPartido y PanelDelCronometro.

Dónde va cada trozo.

  • Listas.kt: los imports nuevos se mezclan con los existentes, en orden alfabético. Sustituye PantallaPartidos entera, con su @Composable, por la de abajo.
  • Efectos.kt: los imports nuevos, en su sitio alfabético; ETIQUETA justo antes de MINUTOS_DEL_PARTIDO; y TituloDeLaActividad entre RelojDelPartido y PanelDelCronometro (no al final del fichero).
  • PantallaMarcador.kt: la llamada a TituloDeLaActividad(…) va justo después de RelojDelPartido(…) { … } y antes de Scaffold(.

Piezas nuevas de este paso:

  • rememberCoroutineScope(), derivedStateOf { … }, SideEffect { … } y LocalActivity.current: arriba.
  • rememberLazyListState(), Box, SmallFloatingActionButton y Modifier.align(…): explicados antes del primer ejemplo.
  • Log.d(tag, mensaje) ya lo conoces de El ciclo de vida de la Activity.

Intenta escribirlo tú, en este orden:

  1. En «Partidos», un botón flotante pequeño «↑» en la esquina de abajo a la derecha, que aparece solo cuando la primera fila visible es la de índice 4 o más (la quinta) y que, al pulsarlo, sube con animación al principio.
  2. En Efectos.kt, una constante privada ETIQUETA con el texto «Efectos», el nombre con el que buscarás en Logcat.
  3. TituloDeLaActividad, un composable que tras cada recomposición pone como título de la Activity «Marcador» seguido del marcador que recibe, y lo anota en Logcat.
  4. En la pantalla del marcador, la llamada a TituloDeLaActividad, que recibe el marcador del estado.

Debes ver aparecer el botón al desplazar la lista en «Partidos» y, en Logcat, el título publicado.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código

Imports nuevos de Listas.kt (cada línea va en su sitio alfabético):

app/src/main/java/com/sebastiannarvaez/marcador/feature/partidos/ui/Listas.kt
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch

PantallaPartidos (sustituye a la anterior; el resto del fichero no cambia):

app/src/main/java/com/sebastiannarvaez/marcador/feature/partidos/ui/Listas.kt
@Composable
fun PantallaPartidos(partidos: List<PartidoDeLista>, modifier: Modifier = Modifier) {
    val estadoDeLista = rememberLazyListState()
    val mostrarBotonSubir by remember { derivedStateOf { estadoDeLista.firstVisibleItemIndex > 3 } }
 
    val alcance = rememberCoroutineScope()
 
    Box(modifier) {
        LazyColumn(
            state = estadoDeLista,
            contentPadding = PaddingValues(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            items(partidos, key = { it.id }) { item ->
                FilaDePartido(item.partido.local.nombre, item.partido.visitante.nombre, item.partido.marcador().toString())
            }
        }
        if (mostrarBotonSubir) {
            SmallFloatingActionButton(
                onClick = { alcance.launch { estadoDeLista.animateScrollToItem(0) } },
                modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
            ) { Text("↑") }
        }
    }
}

Imports nuevos de Efectos.kt, mezclados con los que ya tiene y en su sitio alfabético:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/Efectos.kt
import android.util.Log
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.SideEffect
import com.sebastiannarvaez.marcador.torneo.Marcador

La constante de la etiqueta de Logcat, antes de MINUTOS_DEL_PARTIDO:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/Efectos.kt
private const val ETIQUETA = "Efectos"

La función, en Efectos.kt entre RelojDelPartido y PanelDelCronometro:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/Efectos.kt
@Composable
fun TituloDeLaActividad(marcador: Marcador) {
    val actividad = LocalActivity.current
    SideEffect {
        actividad?.title = "Marcador $marcador"
        Log.d(ETIQUETA, "título publicado: Marcador $marcador")
    }
}

La llamada en PantallaMarcador.kt, justo después de RelojDelPartido(…) { … } y antes de Scaffold(:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
    TituloDeLaActividad(estado.marcador)

Ejecútalo y míralo

Ejecuta la app con Run (configuración app, variante gratisDebug como en Gradle: flavors y variantes, emulador arrancado como en La Activity).

  1. El botón «↑». En Partidos, arrastra la lista hacia abajo: cuando la quinta fila (índice 4) pasa a ser la primera que ves, aparece «↑»; al pulsarlo sube al principio y desaparece.
  2. El título. Abre View → Tool Windows → Logcat y escribe tag:Efectos en el campo de búsqueda de arriba. Al abrir la app sale título publicado: Marcador 0-0. Pulsa Empezar (el cronómetro de Efectos en Compose: LaunchedEffect): cada minuto del reloj recompone la pantalla, y con él aparece otra línea título publicado: Marcador 0-0, siempre con el mismo marcador. Es el detalle de SideEffect que viste arriba.
  3. Un gol. Pulsa el botón Gol Rayo FC de la tarjeta del marcador. El marcador pasa a 1-0 y Logcat escribe título publicado: Marcador 1-0. Después abre la lista de aplicaciones recientes del emulador: en algunas versiones de Android el título de la Activity (Marcador 1-0) sale ahí y en otras no; no depende de tu código, y Logcat ya demuestra que se publicó.

Punto de control

  • La app compila y se ejecuta sin errores en rojo, con TituloDeLaActividad escrita por ti.
  • En «Partidos» aparece «↑» solo cuando la primera fila visible es la de índice 4 o más (la quinta), y al pulsarlo la lista sube.
  • En Logcat (tag:Efectos) aparece título publicado: Marcador 0-0 al abrir la app.
  • Con el reloj en marcha, aparece una línea título publicado: … por cada recomposición, aunque el marcador no cambie; al pulsar Gol Rayo FC, las siguientes dicen Marcador 1-0.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.El botón «↑» solo debe aparecer cuando la primera fila visible es la de índice 4 o más. ¿Por qué se usa derivedStateOf { índice > 3 } en lugar de leer firstVisibleItemIndex directamente?

  2. 2.Quieres que un botón, al pulsarse, ejecute animateScrollToItem(0) (una función suspend). ¿Qué usas?

  3. 3.Quieres poner actividad?.title = "Marcador $marcador" en el cuerpo de un composable. ¿Por qué se hace dentro de un SideEffect?

  4. 4.¿Por qué un derivedStateOf se escribe dentro de remember { … }?

  5. 5.¿Cuándo se ejecuta el bloque de un SideEffect?

Faltan 5 de 5