Saltar al contenido
K
Jetpack y arquitecturaPaso a paso30 min Intermedio

Una fila que se toca: la plantilla

Al terminar: Abrir la plantilla de un equipo al tocar su fila en Tablas.

Al terminar esta lección, en la pestaña Tablas cada fila de equipo se puede tocar. Al tocar «1. Rayo FC» se abre una pantalla nueva, «Plantilla de Rayo FC», con una fila por jugador (Ana #9, Luis -, Marta #1), y el botón atrás vuelve a Tablas. Los datos salen de la tabla jugador que llenaste en la lección anterior.

Importa porque «tocar una fila para ver su detalle» es el patrón más repetido de una app: una lista, un toque, una pantalla con un argumento. Y porque es la segunda vez que un ViewModel recibe un dato que solo conoce la pantalla (un texto, esta vez, y no un número). Temas: Modifier.clickable con onClickLabel, una clave de navegación con un String y la inyección asistida con un texto.

Una fila que responde al toque

Hasta ahora, las filas de Tablas eran texto que se mira. Para que una fila se toque, se le añade el modifier clickable (ya lo usaste en SavedStateHandle): recibe una función, y la ejecuta cada vez que alguien toca el elemento. Se pone sobre la fila entera (Row) y no solo sobre un texto, por dos razones: una fila entera es un blanco fácil para el dedo, y una persona que usa un lector de pantalla recibe una sola cosa que activar, no dos textos sueltos.

clickable tiene un parámetro opcional que importa: onClickLabel, una etiqueta que describe qué hace el toque. Un lector de pantalla es una función de accesibilidad que lee en voz alta lo que hay en pantalla; el de Android se llama TalkBack. Sin etiqueta, TalkBack dice algo genérico; con ella, dice qué va a pasar.

Esto no hace falta para seguir. Si quieres probarlo, activa TalkBack en los ajustes de accesibilidad del emulador; con él encendido el emulador se maneja de otra forma (un toque selecciona y dos toques activan), así que cuando termines vuelve a esos mismos ajustes y apágalo. La etiqueta de Marcador, "Ver la plantilla", está escrita para que la pista tenga sentido.

Un ejemplo ajeno, con un recetario. Una lista de recetas donde cada una se toca, y una clave de navegación con el nombre de la receta:

ejemplo mínimo: una lista de recetas que se toca (con comentarios)
// La clave de la pantalla de una receta: lleva el nombre como argumento
@Serializable
data class RecetaKey(val nombre: String) : NavKey
 
// Sin estado propio: recibe las recetas y avisa de la que se toca
@Composable
fun ListaDeRecetas(recetas: List<String>, onElegirReceta: (String) -> Unit, modifier: Modifier = Modifier) {
    Column(modifier) {
        recetas.forEach { receta ->
            Text(
                receta,
                Modifier
                    .fillMaxWidth()                                                        // toda la fila, no solo las letras
                    .clickable(onClickLabel = "Ver la receta") { onElegirReceta(receta) }  // avisa de cuál se tocó
                    .padding(16.dp),                                                       // después de clickable: el margen también responde
            )
        }
    }
}
 
// Donde se arma la navegación, el aviso apila la clave de la receta tocada:
//     ListaDeRecetas(listOf("Tortilla", "Arepa", "Gazpacho"), onElegirReceta = { nombre -> pila.add(RecetaKey(nombre)) })

Lo que se ve: tres filas, «Tortilla», «Arepa» y «Gazpacho», que responden al toque en todo su ancho. Al tocar «Tortilla», la pila de pantallas pasa de [RecetasKey] (la pantalla de la lista, otra clave como esta) a [RecetasKey, RecetaKey(nombre=Tortilla)], y la pantalla de la receta recibe «Tortilla» desde la clave.

El composable no sabe nada de la navegación: solo avisa. Quien lo coloca decide qué pasa al tocar. Es el mismo reparto de State hoisting: el estado y las decisiones suben; la pieza solo pinta y avisa.

Ejercicio: elegir una receta

Reto · Ejercicio: elegir una receta

En tu carpeta ejercicios crea la subcarpeta recetas (clic derecho sobre com.sebastiannarvaez.marcador → New → Package → añade .ejercicios.recetas al final del nombre que aparece ya escrito) y, dentro, un fichero Recetas.kt con la primera línea package com.sebastiannarvaez.marcador.ejercicios.recetas.

Escribe dos piezas:

  1. ListaDeRecetas, sin estado propio: recibe la lista de nombres, avisa con el nombre de la que se toca y acepta un modificador opcional. Cada nombre es una fila que responde al toque en todo su ancho, con el margen también tocable, y con la etiqueta de acción «Ver la receta».
  2. Recetas, el dueño del estado: dibuja ListaDeRecetas con «Tortilla», «Arepa» y «Gazpacho» y, debajo, un texto con la receta elegida. La elegida sobrevive al giro del móvil.

Añade una vista previa de Recetas.

Lo que debes ver: tres filas y, debajo, «Toca una receta». En el modo interactivo (Estado en Compose), toca «Arepa» y el texto pasa a «Elegiste: Arepa»; toca «Gazpacho» y pasa a «Elegiste: Gazpacho». Después ejecuta la vista previa en el emulador (como en Estado en Compose), toca una receta y gira el móvil: la receta elegida sigue ahí. Cuando termines, elige otra vez app en el selector de arriba.

Pistas, solo con lo visto: la lista del ejemplo de arriba; para «todavía no se ha elegido ninguna», el estado puede valer null, y se declara con el tipo entre <>: mutableStateOf<String?>(null) (String? es un texto que puede faltar); el estado, como en Estado en Compose; y la vista previa, como en State hoisting.

Ver solución
app/src/main/java/com/sebastiannarvaez/marcador/ejercicios/recetas/Recetas.kt
package com.sebastiannarvaez.marcador.ejercicios.recetas
 
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
 
@Composable
fun ListaDeRecetas(recetas: List<String>, onElegirReceta: (String) -> Unit, modifier: Modifier = Modifier) {
    Column(modifier) {
        recetas.forEach { receta ->
            Text(
                receta,
                Modifier
                    .fillMaxWidth()
                    .clickable(onClickLabel = "Ver la receta") { onElegirReceta(receta) }
                    .padding(16.dp),
            )
        }
    }
}
 
@Composable
fun Recetas() {
    // null = todavía no se ha elegido ninguna; rememberSaveable la conserva al girar
    var elegida by rememberSaveable { mutableStateOf<String?>(null) }
    Column {
        ListaDeRecetas(listOf("Tortilla", "Arepa", "Gazpacho"), onElegirReceta = { elegida = it })
        Text(elegida?.let { "Elegiste: $it" } ?: "Toca una receta", Modifier.padding(16.dp))
    }
}
 
@Preview(showBackground = true)
@Composable
private fun RecetasPreview() {
    MaterialTheme { Recetas() }
}

ListaDeRecetas no guarda nada: avisa con el nombre. Recetas es la que lo guarda, en un estado que puede ser null (ninguna elegida). Con clickable antes de padding, el margen de 16 también responde al toque.

Una clave con un texto y un ViewModel asistido

La pantalla de la plantilla necesita saber de qué equipo es. Es el mismo problema que el id del partido, con otro tipo, y se resuelve igual:

  • La clave PlantillaKey(equipo: String) lleva el nombre del equipo. El nombre es lo que guarda la base en las tablas partido, gol y jugador, así que sirve de identificador. Un String cabe en una clave @Serializable sin más.
  • El ViewModel PlantillaViewModel recibe el repositorio por Hilt y el nombre por @Assisted. Pide al repositorio observarPlantilla(equipo), que ya existe, y la convierte en un StateFlow con stateIn (stateIn, shareIn y los flujos calientes).
  • La pantalla PantallaPlantilla no tiene estado: pinta la lista que recibe, una fila por jugador.

Una pantalla de detalle como esta sale en la pila sin la barra de pestañas: la barra solo sale en las pestañas de primer nivel (PESTANAS), y la plantilla no es una de ellas. No hay que escribir nada para que pase.

Paso · tocar un equipo y ver su plantilla

Objetivo. Hacer tocables las filas de la tabla de posiciones y abrir la plantilla del equipo tocado. Debes ver: en Tablas, un «›» al final de cada fila de equipo; al tocar «1. Rayo FC», la pantalla «Plantilla de Rayo FC»; y el botón atrás vuelve a Tablas.

app → kotlin+java → com.sebastiannarvaez.marcador
feature
partidos
ui
Listas.ktse modifica
plantillacarpeta nueva
ui
PlantillaViewModel.ktse crea
PantallaPlantilla.ktse crea
ui
AppMarcador.ktse modifica
Dos ficheros nuevos, en una carpeta nueva, y dos que se modifican, en la vista Android.

Crea la carpeta y los ficheros. Con el selector en Android, clic derecho sobre com.sebastiannarvaez.marcador → New → Package → añade .feature.plantilla.ui al final del nombre que aparece ya escrito y pulsa Enter. Después, clic derecho sobre ui (el nuevo, el que cuelga de plantilla) → New → Kotlin Class/File → File → PlantillaViewModel, y lo mismo con PantallaPlantilla. Si el IDE te enseña plantilla.ui en una sola línea, es lo mismo: junta las carpetas que solo tienen una dentro.

Piezas nuevas de este paso: la etiqueta onClickLabel de clickable y la clave con un String, las dos del ejemplo de arriba. El ViewModel asistido con un texto no es nueva: es la misma receta que PartidoViewModel, con un String en lugar de un Int. El «›» al final del texto es solo un indicio visual de que la fila se toca.

Ya vistas: Jugador.dorsalOGuion(), que devuelve #9 o - si no hay dorsal (Clases y objetos); @HiltViewModel(assistedFactory = …), @AssistedInject, @Assisted y @AssistedFactory (El id del partido: inyección asistida); stateIn con WhileSubscribed(5_000) (ListaViewModel); LazyColumn con items y key (Listas con LazyColumn); Scaffold con CenterAlignedTopAppBar y WindowInsets(0) (Compose: Scaffold, slots y tema).

Intenta escribirlo tú, en este orden:

  1. PlantillaViewModel: inyección asistida con el nombre del equipo (@Assisted val equipo: String) y una propiedad jugadores, un StateFlow de lista de Jugador que sale de observarPlantilla(equipo). Se parece a PartidoViewModel, con un StateFlow por stateIn como ListaViewModel.
  2. PantallaPlantilla: recibe el nombre del equipo y la lista de jugadores. Un Scaffold con título «Plantilla de» y el nombre, y una LazyColumn con una fila por jugador: el nombre a la izquierda y dorsalOGuion() a la derecha. La vista previa usa Ejemplo.rayo.
  3. Listas.kt: PantallaTablas recibe un parámetro más, onElegirEquipo, una función que recibe el nombre del equipo. La fila de cada equipo responde al toque en todo su ancho, con la etiqueta «Ver la plantilla», y su texto termina con « ›». Su vista previa pasa onElegirEquipo = {}.
  4. AppMarcador.kt: los dos import de PlantillaViewModel y PantallaPlantilla, la clave PlantillaKey, la llamada a PantallaTablas con onElegirEquipo (que apila la clave) y el destino de PlantillaKey con hiltViewModel, como el de CronicaKey.

Compila con ./gradlew :app:assembleGratisDebug cuando termines: entre el paso 3 y el 4 el proyecto estará en rojo, porque PantallaTablas pide un parámetro que AppMarcador aún no le da.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
app/src/main/java/com/sebastiannarvaez/marcador/feature/plantilla/ui/PlantillaViewModel.kt
package com.sebastiannarvaez.marcador.feature.plantilla.ui
 
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sebastiannarvaez.marcador.feature.partidos.domain.PartidosRepository
import com.sebastiannarvaez.marcador.torneo.Jugador
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
 
// La plantilla de UN equipo, observada desde el repositorio (la tabla `jugador`).
// Inyeccion asistida, como en PartidoViewModel: el nombre del equipo sale de la PlantillaKey.
@HiltViewModel(assistedFactory = PlantillaViewModel.Fabrica::class)
class PlantillaViewModel @AssistedInject constructor(
    repositorio: PartidosRepository,
    @Assisted val equipo: String,
) : ViewModel() {
 
    @AssistedFactory
    interface Fabrica {
        fun crear(equipo: String): PlantillaViewModel
    }
 
    val jugadores: StateFlow<List<Jugador>> = repositorio.observarPlantilla(equipo)
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
}
app/src/main/java/com/sebastiannarvaez/marcador/feature/plantilla/ui/PantallaPlantilla.kt
package com.sebastiannarvaez.marcador.feature.plantilla.ui
 
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
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.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.sebastiannarvaez.marcador.torneo.Ejemplo
import com.sebastiannarvaez.marcador.torneo.Jugador
 
// La plantilla de un equipo: una fila por jugador, con su dorsal o un guion. Sin estado propio.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PantallaPlantilla(equipo: String, jugadores: List<Jugador>, modifier: Modifier = Modifier) {
    Scaffold(
        modifier = modifier,
        contentWindowInsets = WindowInsets(0),
        topBar = { CenterAlignedTopAppBar(title = { Text("Plantilla de $equipo") }) },
    ) { interior ->
        LazyColumn(Modifier.padding(interior), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
            // En un equipo no hay dos jugadores con el mismo nombre: sirve de key.
            items(jugadores, key = { it.nombre }) { jugador ->
                Row(Modifier.fillMaxWidth().padding(vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween) {
                    Text(jugador.nombre)
                    Text(jugador.dorsalOGuion())
                }
            }
        }
    }
}
 
@Preview(showBackground = true)
@Composable
private fun PantallaPlantillaPreview() {
    MaterialTheme { PantallaPlantilla(Ejemplo.rayo.nombre, Ejemplo.rayo.plantilla) }
}

En Listas.kt, la cabecera de PantallaTablas gana un parámetro (el resto del fichero no cambia; clickable ya está importado de antes):

app/src/main/java/com/sebastiannarvaez/marcador/feature/partidos/ui/Listas.kt
fun PantallaTablas(
    tabla: List<FilaDePosicion>,
    goleadores: List<Pair<Jugador, Int>>,
    onElegirEquipo: (String) -> Unit,
    modifier: Modifier = Modifier,
) {

Dentro de itemsIndexed, la Row de cada equipo cambia así (son nuevos el .clickable(…) y el › al final del segundo Text):

app/src/main/java/com/sebastiannarvaez/marcador/feature/partidos/ui/Listas.kt
            Row(
                Modifier.fillMaxWidth()
                    .clickable(onClickLabel = "Ver la plantilla") { onElegirEquipo(fila.equipo.nombre) }
                    .padding(vertical = 4.dp),
                horizontalArrangement = Arrangement.SpaceBetween,
            ) {
                Text("${indice + 1}. ${fila.equipo.nombre}", Modifier.weight(1f))
                Text("${fila.jugados} PJ  ${fila.diferencia} DG  ${fila.puntos} pts  ›")
            }

Y la vista previa de la tabla:

app/src/main/java/com/sebastiannarvaez/marcador/feature/partidos/ui/Listas.kt
    MaterialTheme { PantallaTablas(torneo.tablaDePosiciones(), torneo.goleadoresConGoles(), onElegirEquipo = {}) }

AppMarcador.kt usa dos piezas de feature → plantilla → ui, otra carpeta, así que necesita sus import (junto a los demás del proyecto; Option+Enter sobre cada nombre en rojo también los añade):

app/src/main/java/com/sebastiannarvaez/marcador/ui/AppMarcador.kt
import com.sebastiannarvaez.marcador.feature.plantilla.ui.PantallaPlantilla
import com.sebastiannarvaez.marcador.feature.plantilla.ui.PlantillaViewModel

En AppMarcador.kt, la clave nueva va debajo de CronicaKey:

app/src/main/java/com/sebastiannarvaez/marcador/ui/AppMarcador.kt
// La plantilla de un equipo: el argumento es su nombre (el mismo que guarda la base).
@Serializable
data class PlantillaKey(val equipo: String) : NavKey

En el destino de TablasKey, la llamada a PantallaTablas cambia así (pila es la pila de pantallas que ya usas en los demás destinos):

app/src/main/java/com/sebastiannarvaez/marcador/ui/AppMarcador.kt
                        PantallaTablas(
                            torneo.tablaDePosiciones(),
                            torneo.goleadoresConGoles(),
                            onElegirEquipo = { equipo -> pila.add(PlantillaKey(equipo)) },
                            modifier = Modifier.padding(interior),
                        )

Y el destino nuevo, junto a los demás entry, por ejemplo entre ArbitrosKey y CronicaKey:

app/src/main/java/com/sebastiannarvaez/marcador/ui/AppMarcador.kt
                entry<PlantillaKey> { clave ->
                    val vm = hiltViewModel<PlantillaViewModel, PlantillaViewModel.Fabrica> { fabrica ->
                        fabrica.crear(clave.equipo)
                    }
                    val jugadores by vm.jugadores.collectAsStateWithLifecycle()
                    PantallaPlantilla(clave.equipo, jugadores)
                }

Fíjate en dos detalles de PantallaPlantilla. La key de cada fila es el nombre del jugador, porque LazyColumn necesita un identificador estable por fila (Listas con LazyColumn); hoy es cierto que en un equipo no hay dos jugadores con el mismo nombre porque los de ejemplo son distintos, y en la lección de formularios añadirás la regla que lo garantiza. Y la pantalla no tiene estado propio: la lista llega ya hecha.

Probarlo. Ejecuta la app (Run, variante gratisDebug).

  1. En Tablas, cada fila de equipo termina en «›». Toca «1. Rayo FC»: se abre «Plantilla de Rayo FC» con Ana #9, Luis - y Marta #1, y sin la barra de pestañas.
  2. Pulsa atrás: vuelves a Tablas, con la barra de pestañas otra vez.
  3. Toca otro equipo: la plantilla es la suya.

Punto de control

  • ./gradlew :app:assembleGratisDebug termina en BUILD SUCCESSFUL.
  • Tablas → «1. Rayo FC» abre «Plantilla de Rayo FC» con Ana #9, Luis - y Marta #1. La barra de pestañas se oculta en esa pantalla, y el botón atrás vuelve a Tablas.
  • ./gradlew :app:testGratisDebugUnitTest cuenta 16 tests (19 si hiciste el ejercicio del temporizador de Tests de un ViewModel: fakes, setMain y Turbine) y 0 fallos (esta lección no añade tests).

En Formularios: el estado y las reglas preparas lo que falta para añadir un jugador a esta plantilla.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.En Modifier.fillMaxWidth().clickable(onClickLabel = "Ver la plantilla") { … }, ¿para qué sirve onClickLabel?

  2. 2.Una fila lleva .clickable { … }.padding(vertical = 4.dp). ¿Qué pasa con el margen vertical de 4 dp?

  3. 3.Para abrir la plantilla de un equipo, Marcador usa PlantillaKey(val equipo: String). ¿Qué papel tiene ese String?

  4. 4.PlantillaViewModel recibe el repositorio y el nombre del equipo. ¿Cuál de los dos pone Hilt por su cuenta?

  5. 5.La pantalla de la plantilla no muestra la barra de pestañas. ¿Por qué?

Faltan 5 de 5