La barra del partido en juego
Al terminar: Mostrar en todas las pantallas una barra con el marcador en vivo, que lleva al partido y se cierra al terminar.
Al terminar esta lección, Marcador tendrá una barra fija encima de las pestañas, en todas las pantallas, con el partido que se está jugando: «Rayo FC 2–1 Toros · 34'». Al tocarla se abre ese partido (y si ya estaba en la pila, vuelves hasta él). Cuando el partido termina, la barra dice «Final · Rayo FC 3–2 Toros» y trae una X para cerrarla. Mientras se juega, no se puede cerrar. Y dos tests más, hasta 38.
Importa porque es la primera pieza de la app que no es de ninguna pantalla. Hasta hoy, cada dato lo pintaba la pantalla que lo pedía; esta barra se ve en todas, y por eso la dibuja quien está por encima de todas: la raíz de la app. Temas: leer el estado de toda la app en la raíz, una barra encima de otra, los huecos del sistema y cómo se lee una «✕» en voz alta.
Esta lección continúa donde terminó Solo un partido a la vez: el partido ya vive fuera de su pantalla, solo puede haber uno en juego y hay un aviso para ir a él. Pero nadie lo enseña fuera del partido. Hoy sí.
Quién dibuja lo que no es de ninguna pantalla
Piensa en el sitio donde está cada cosa en AppMarcador:
| Pieza | Dónde se dibuja | De quién es |
|---|---|---|
| La lista, el marcador, las tablas | Dentro de NavDisplay, una pantalla cada vez | De su pantalla |
Las pestañas (NavigationBar) | En el bottomBar del Scaffold de fuera | De la raíz |
| La barra del partido en juego | También en el bottomBar de fuera | De la raíz |
Una pantalla no puede dibujar la barra: se vería solo cuando esa pantalla estuviera abierta, y la barra tiene que verse en todas. La raíz sí: el Scaffold de AppMarcador rodea a todas las pantallas y no cambia al navegar. Lo que le falta a la raíz es leer el partido en juego. ¿Por qué no pedírselo a Hilt directamente, con @Inject? Porque Hilt rellena el constructor de las clases que él construye (un ViewModel, o una clase con @Inject constructor), y AppMarcador es una función composable: no la construye nadie. Los composables reciben las piezas de la app a través de un ViewModel, que es lo que se pide con hiltViewModel().
Pero ¿no tienen ya las pantallas el suyo? Sí, y no sirve. Un ViewModel está atado a un dueño, y el dueño decide cuándo nace y cuándo muere:
Un ViewModelStoreOwner es, sin tecnicismos, «el dueño del almacén donde se guarda el ViewModel»: la Activity, o cada entrada de la pila si la navegación las trata como dueñas. «El más cercano» es la clave. Dentro de una entrada de NavDisplay, el dueño más cercano es esa entrada (por el decorador de la lección de navegación). Fuera de NavDisplay, en el cuerpo de AppMarcador, no hay ninguna entrada: el dueño más cercano es la Activity, que vive mientras la app está abierta y sobrevive a girar el móvil.
Así que el truco es dónde escribes la línea: hiltViewModel() fuera de NavDisplay da el ViewModel de la Activity; la misma línea dentro de una entrada da el de la entrada. Es una pauta habitual en las apps de Android: un ViewModel que vive en la Activity y que la raíz lee para decidir cosas de toda la app (por ejemplo, qué tema de colores poner). Aquí hace lo mismo con el partido en juego.
Un ViewModel casi vacío
AppViewModel no calcula nada. Recibe PartidoEnJuego (Hilt se lo da: es @Singleton) y expone dos cosas: lo que se ve y lo que se puede hacer.
Si lo que se ve ya es un StateFlow, no hace falta transformarlo. En UiState en Marcador: el ViewModel usaste stateIn para convertir un Flow en un StateFlow con valor inicial. Aquí PartidoEnJuego.actual ya es un StateFlow, de solo lectura y con valor inicial (null): se reenvía tal cual. La única acción de la barra es cerrar, y cerrar() ya existe en PartidoEnJuego: ya sabe que solo funciona con el partido terminado.
Cuatro cosas que se llaman «barra»
En esta lección sale la palabra «barra» con cuatro sentidos, y conviene no mezclarlos:
| Nombre | Qué es | Quién la dibuja |
|---|---|---|
| La barra del partido | La franja nueva con el marcador en vivo | Tú, en esta lección |
| Las pestañas | NavigationBar: «Partidos», «Tablas», «Recomp.» | Tú, ya la tenías |
| La barra de gestos | La franja del móvil, abajo del todo, que dibuja Android (la línea para deslizar, o los tres botones). La documentación de Google la llama «barra de navegación» | El sistema |
| El hueco del sistema | El margen que dejas para no pisar la barra de gestos: navigationBarsPadding() | Tú, con un modificador |
Un aviso para no confundirse: navigationBarsPadding() deja hueco para la barra de gestos del sistema, no para las pestañas.
Una barra encima de otra
El Scaffold de fuera tiene un hueco de abajo, bottomBar, y solo admite un composable. Hay dos cosas que poner (la barra del partido y las pestañas), así que se meten en una Column: una encima de la otra, la del partido primero. Cada una va dentro de un AnimatedVisibility (el de la lección de navegación, que ya mostraba u ocultaba las pestañas):
- La barra del partido entra y sale con
expandVertically()yshrinkVertically(): se despliega hacia arriba y se pliega, empujando el contenido. UnslideInVertically, como el de las pestañas, mueve la barra pero no cambia el hueco que ocupa;expandyshrinksí hacen crecer y encoger el hueco, y el contenido de arriba se acomoda poco a poco. - Las pestañas siguen como estaban.
La barra se ve cuando hay un partido en juego y la pantalla de arriba no es ese mismo partido (allí ya se ve todo). PartidoKey es una data class, y dos data class con los mismos valores son iguales: PartidoKey(1) == PartidoKey(1), así que basta comparar la clave de la pantalla actual con la del partido en juego.
Un alto mínimo para el dedo
La barra tiene un alto de al menos 56 dp (un dp es una unidad de tamaño de pantalla que no depende de la densidad del móvil: así se ve igual de grande en todos; lo viste en Compose: composables y modifiers): cómodo para pulsarla. Se escribe con Modifier.heightIn(min = 56.dp): «el alto mínimo es 56 dp; si el contenido pide más, la barra crece». Es distinto de height(56.dp), que la dejaría fija y cortaría un texto largo.
Los huecos del sistema
En las pantallas con pestañas, NavigationBar ya reserva el hueco de la barra de gestos del móvil, abajo del todo. (Esto ocurre porque Marcador se dibuja «de borde a borde»: la app ocupa toda la pantalla, también por debajo de las barras del sistema, y a cada pieza le toca dejar su hueco.) Pero en una pantalla sin pestañas (la plantilla de un equipo, el partido número 2 con su aviso…) la NavigationBar está oculta, y la barra del partido pasa a ser lo último de la pantalla: sin hueco, quedaría pegada a la barra de gestos.
La solución es Modifier.navigationBarsPadding(): añade un margen del alto de la barra de gestos. Pero solo cuando la barra del partido está sola (ultimaDeLaPantalla, que vale true si la pantalla actual no es una pestaña). Con pestañas debajo, el margen se sumaría dos veces.
Una «✕» que se lee «Cerrar»
La X de cerrar es un IconButton: un botón redondo y pequeño pensado para un dibujo. Dentro va un Text("✕"), un carácter y no un icono: la app no tiene ninguna librería de iconos, y añadir una sería otra instalación para un solo símbolo.
Un lector de pantalla (TalkBack, el de Android) leería «✕» como no sabe qué: un cuadrado, una cruz, un carácter raro. Hace falta una etiqueta que diga qué hace el botón.
contentDescription es esa etiqueta: el texto que se lee en voz alta, no el que se ve. Un Text ya aporta su propio texto al lector, así que habría que decirle «ignora lo que traías y lee esto otro». Eso hace Modifier.clearAndSetSemantics { contentDescription = "Cerrar" }: borra la semántica que el elemento tenía (lo que el lector sabe de él) y pone la tuya. Así queda el botón entero, un IconButton con un Text dentro (el onCerrar es la función que se llama al pulsar):
IconButton(onClick = onCerrar) { // el botón redondo y pequeño
Text( // dentro, un texto: el carácter «✕»
"✕",
Modifier.clearAndSetSemantics { contentDescription = "Cerrar" }, // lo que se LEE en voz alta
)
}Y la barra entera, que se toca para abrir el partido, lleva clickable(onClickLabel = "Abrir el partido"): la misma etiqueta de clic que ya pusiste en Una fila que se toca: la plantilla. Con TalkBack, la pista de la barra será «abrir el partido», y la de la X, «Cerrar».
Un ejemplo, con una cocina
Un ejemplo para leer, con datos ajenos a Marcador: una app de cocina con un temporizador que sigue corriendo aunque cambies de pestaña. Es la misma forma que usarás en Marcador: una pieza de toda la app, un ViewModel de la raíz que la reenvía y un bottomBar con una Column.
// El dato de toda la app: lo que se está cocinando y los segundos que quedan
data class Temporizador(val plato: String, val segundosRestantes: Int)
// Una pieza que vive lo que la app: @Singleton = una sola copia. Es la dueña del temporizador.
// Aquí @Singleton va en la propia clase, con `@Inject constructor()`; en Marcador, `PartidoEnJuego`
// no lleva etiquetas y su copia única se declara en la receta de `DatosModule`. Las dos formas valen.
@Singleton
class Cocina @Inject constructor() {
private val poniendo = MutableStateFlow<Temporizador?>(null) // privado: solo Cocina lo cambia (null = no hay ninguno)
val temporizador: StateFlow<Temporizador?> = poniendo.asStateFlow() // hacia fuera, solo lectura
fun empezar(plato: String, segundos: Int) { poniendo.value = Temporizador(plato, segundos) }
fun terminar() { poniendo.value = null }
}
// El ViewModel de la raíz: no es de ninguna pantalla, solo reenvía lo que se va a pintar
@HiltViewModel
class RaizViewModel @Inject constructor(cocina: Cocina) : ViewModel() {
val temporizador: StateFlow<Temporizador?> = cocina.temporizador // ya es StateFlow: sin stateIn
}
@Composable
fun RaizDeCocina() {
// Fuera de cualquier NavDisplay: es el ViewModel de la Activity
val viewModel: RaizViewModel = hiltViewModel()
val temporizador by viewModel.temporizador.collectAsStateWithLifecycle()
Scaffold(
bottomBar = {
Column { // una encima de la otra
AnimatedVisibility(
visible = temporizador != null, // solo si hay temporizador
enter = expandVertically(), // se despliega empujando
exit = shrinkVertically(), // se pliega
) {
temporizador?.let { t -> // let: el bloque solo corre si no es null
Surface(color = MaterialTheme.colorScheme.secondaryContainer, modifier = Modifier.fillMaxWidth()) {
Text("${t.plato} · ${t.segundosRestantes} s", Modifier.padding(16.dp))
}
}
}
NavigationBar {
NavigationBarItem(selected = true, onClick = {}, icon = {}, label = { Text("Recetas") })
NavigationBarItem(selected = false, onClick = {}, icon = {}, label = { Text("Compra") })
}
}
},
) { interior -> Text("Contenido de la pestaña", Modifier.padding(interior)) }
}Con temporizador a null se ven solo las pestañas «Recetas» y «Compra». En cuanto alguien llama a cocina.empezar("Pasta", 510) (por ejemplo, el botón de una receta), aparece encima de las pestañas una franja de color con «Pasta · 510 s», y el contenido de arriba se encoge para hacerle sitio. Con cocina.terminar(), el valor vuelve a null y la franja se pliega. Surface es un contenedor con color de fondo; secondaryContainer es uno de los colores del tema, pensado para zonas destacadas sin gritar.
Fíjate en lo que no hay: ninguna pantalla sabe que el temporizador existe, y RaizDeCocina no lo calcula. Lee un dato que otro guarda.
Reto · Ejercicio: la barra de música
En tu carpeta ejercicios (la que creaste en Rotación y muerte del proceso), crea la subcarpeta barra_musica: clic derecho sobre com.sebastiannarvaez.marcador → New → Package → añade .ejercicios.barra_musica al final del nombre que aparece ya escrito. Después, clic derecho sobre esa carpeta → New → Kotlin Class/File → File → BarraDeMusica. La primera línea debe ser package com.sebastiannarvaez.marcador.ejercicios.barra_musica.
Escribe en él dos piezas (Android Studio te propondrá los import con Option+Enter, o Alt+Enter en Windows y Linux):
-
textoDeLaBarraDeMusica: recibe el título de la canción, el segundo en que va y si ya terminó, y devuelve el texto de la barra. Con la canción sonando, el título, un punto medio y el tiempo en minutos y segundos (los segundos siempre con dos cifras); si terminó, «Terminó · » y el título:Título Segundo Terminó Texto Imagine 75 no Imagine · 1:15Imagine 65 no Imagine · 1:05Imagine 75 sí Terminó · Imagine -
BarraDeMusica: recibe título, segundo, si terminó y qué hacer al cerrar. Dibuja una franja a todo lo ancho con el colorsecondaryContainerdel tema y al menos 56 dp de alto (Modifier.heightIn(min = 56.dp), de arriba), con el texto a la izquierda ocupando el sitio que quede (Modifier.weight(1f), de Listas en Compose). Solo si ya terminó, a la derecha va un botón con «✕» que un lector de pantalla leerá como «Cerrar».
Para la vista previa no hace falta que escribas la raíz: aquí la tienes hecha. Es un Scaffold con la barra en el bottomBar, dentro de una Column encima de una NavigationBar con dos pestañas, como en el ejemplo de la cocina. Pégala en el mismo fichero (con sus import) y añade tú dos vistas previas que la llamen: una con la canción sonando (terminada = false) y otra terminada (terminada = true).
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
@Composable
private fun RaizConBarra(terminada: Boolean) {
Scaffold(
bottomBar = {
Column {
BarraDeMusica("Imagine", segundo = 75, terminada = terminada, onCerrar = {})
NavigationBar {
NavigationBarItem(selected = true, onClick = {}, icon = {}, label = { Text("Inicio") })
NavigationBarItem(selected = false, onClick = {}, icon = {}, label = { Text("Buscar") })
}
}
},
) { interior -> Text("Contenido", Modifier.padding(interior).padding(16.dp)) }
}Lo que debes ver: en la primera, la franja con «Imagine · 1:15», sin X, sobre las dos pestañas; en la segunda, «Terminó · Imagine» con la X a la derecha.
Pistas, todo de lo visto: "%02d".format(n) rellena con ceros hasta dos cifras (Extensiones); / y % dan los minutos y el resto de segundos; la forma de la barra, en el ejemplo de la cocina y en el del botón «✕» de arriba.
Ver solución
package com.sebastiannarvaez.marcador.ejercicios.barra_musica
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
fun textoDeLaBarraDeMusica(titulo: String, segundo: Int, terminada: Boolean): String =
if (terminada) "Terminó · $titulo" else "$titulo · ${segundo / 60}:${"%02d".format(segundo % 60)}"
@Composable
fun BarraDeMusica(titulo: String, segundo: Int, terminada: Boolean, onCerrar: () -> Unit) {
Surface(color = MaterialTheme.colorScheme.secondaryContainer, modifier = Modifier.fillMaxWidth()) {
Row(Modifier.heightIn(min = 56.dp).padding(start = 16.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Text(textoDeLaBarraDeMusica(titulo, segundo, terminada), Modifier.weight(1f))
if (terminada) {
IconButton(onClick = onCerrar) { Text("✕", Modifier.clearAndSetSemantics { contentDescription = "Cerrar" }) }
}
}
}
}
@Composable
private fun RaizConBarra(terminada: Boolean) {
Scaffold(
bottomBar = {
Column {
BarraDeMusica("Imagine", segundo = 75, terminada = terminada, onCerrar = {})
NavigationBar {
NavigationBarItem(selected = true, onClick = {}, icon = {}, label = { Text("Inicio") })
NavigationBarItem(selected = false, onClick = {}, icon = {}, label = { Text("Buscar") })
}
}
},
) { interior -> Text("Contenido", Modifier.padding(interior).padding(16.dp)) }
}
@Preview(showBackground = true)
@Composable
private fun SonandoPreview() { MaterialTheme { RaizConBarra(terminada = false) } }
@Preview(showBackground = true)
@Composable
private fun TerminadaPreview() { MaterialTheme { RaizConBarra(terminada = true) } }textoDeLaBarraDeMusica es una función normal, sin nada de Compose: por eso se puede probar sin pintar. BarraDeMusica solo pinta lo que recibe, y la X solo existe cuando terminada es true. En las vistas previas, el Scaffold con la Column (la RaizConBarra que te dimos) es la misma estructura que tendrá AppMarcador.
Paso · La barra en Marcador
Objetivo. Dibujar en la raíz la barra del partido en juego. Al terminar, empiezas un partido, sales a la lista y ves «Rayo FC … Toros · 34'» encima de las pestañas; al tocarla vuelves al partido, y cuando termina sale «Final · …» con su X.
Crea los ficheros. Con el selector en Android, despliega app → kotlin+java → com.sebastiannarvaez.marcador. Clic derecho sobre ui (la de la raíz, donde ya está AppMarcador; no la de ninguna feature) → New → Kotlin Class/File → File → AppViewModel, y lo mismo con BarraDelPartidoEnJuego. Van ahí porque la barra y el ViewModel de la app no son de ninguna feature: son la cáscara que las junta. El test copia esa carpeta: clic derecho sobre com.sebastiannarvaez.marcador (test) (el que lleva (test) detrás) → New → Package → añade .ui al final del nombre que aparece ya escrito, y sobre ese ui → New → Kotlin Class/File → File → BarraDelPartidoEnJuegoTest. Cuando Android Studio marque algo en rojo, pulsa Option+Enter (Alt+Enter en Windows y Linux) sobre el nombre y elige el import.
Piezas nuevas de este paso:
SurfaceeIconButton, yModifier.heightIn(min = 56.dp)(un alto mínimo: la barra crece si el texto lo pide, pero nunca baja de 56 dp): los acabas de usar en la barra de música.Modifier.clearAndSetSemantics { contentDescription = "Cerrar" }: la etiqueta que lee el lector de pantalla.expandVertically()yshrinkVertically(): se despliega y se pliega empujando el contenido.navigationBarsPadding(): el hueco de la barra del sistema, solo cuando la barra del partido está sola.appViewModel::cerrar: una referencia a una función, como elviewModel::alEventoque le pasas a la pantalla del partido (UiState en Marcador: el ViewModel): «cuando haga falta, llama a esa función».
Intenta escribirlo tú, en este orden:
AppViewModel: un@HiltViewModelque recibePartidoEnJuego, expone suactualcon el nombreenJuego(sinstateIn) y tienecerrar(). Con un comentario que diga por qué existe.BarraDelPartidoEnJuego.kt:textoDeLaBarra(enJuego), que devuelve «Rayo FC 2–1 Toros · 34'» o, si terminó, «Final · Rayo FC 3–2 Toros» (el guion del marcador es el largo, «–»: cópialo de aquí, o en Mac escríbelo con Option + guion); y el composableBarraDelPartidoEnJuego, como la de música:SurfacedesecondaryContainer, toda la barra tocable con la etiqueta «Abrir el partido», y la X con la etiqueta «Cerrar» solo al terminar. ConultimaDeLaPantallapara el hueco del sistema, y dos vistas previas, en juego y final.- En
AppMarcador: pide elAppViewModelfuera deNavDisplay, recogeenJuegoconcollectAsStateWithLifecycley pon en elbottomBarunaColumncon la barra del partido y, debajo, las pestañas que ya tenías. - Los dos tests del texto: escribe tú el primero y copia el segundo.
Debes ver el partido en la barra en todas las pantallas menos en el propio partido, y 38 tests que pasan.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
package com.sebastiannarvaez.marcador.ui
import androidx.lifecycle.ViewModel
import com.sebastiannarvaez.marcador.feature.marcador.domain.PartidoEnJuego
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
// EL VIEWMODEL DE LA RAIZ.
//
// La barra del partido en juego no es de ninguna pantalla: se ve encima de las pestanas, en
// todas. Quien la pinta es `AppMarcador`, la raiz, y lo que lee es estado de toda la app.
// Este ViewModel lo pide FUERA de `NavDisplay`, asi que su almacen es el de la Activity:
// vive mientras la app esta abierta y sobrevive a girar la pantalla.
//
// No guarda nada suyo: reenvia el `StateFlow` del partido en juego (ya es de solo lectura,
// no hace falta `stateIn`) y la unica accion que tiene la barra, cerrarlo.
@HiltViewModel
class AppViewModel @Inject constructor(private val partidoEnJuego: PartidoEnJuego) : ViewModel() {
val enJuego: StateFlow<PartidoEnJuego.EnJuego?> = partidoEnJuego.actual
fun cerrar() = partidoEnJuego.cerrar()
}package com.sebastiannarvaez.marcador.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.sebastiannarvaez.marcador.feature.marcador.domain.PartidoEnJuego
import com.sebastiannarvaez.marcador.torneo.Marcador
// LA BARRA DEL PARTIDO EN JUEGO: abajo, encima de las pestanas, en todas las pantallas menos
// la del propio partido. Al tocarla se abre el partido; al terminar, una X la cierra.
// El texto, en una funcion aparte: se prueba y se previsualiza sin pintar nada.
// en juego -> «Rayo FC 2–1 Toros · 34'»
// al final -> «Final · Rayo FC 3–2 Toros»
fun textoDeLaBarra(enJuego: PartidoEnJuego.EnJuego): String {
val resultado = "${enJuego.local} ${enJuego.marcador.local}–${enJuego.marcador.visitante} ${enJuego.visitante}"
return if (enJuego.terminado) "Final · $resultado" else "$resultado · ${enJuego.minuto}'"
}
@Composable
fun BarraDelPartidoEnJuego(
enJuego: PartidoEnJuego.EnJuego,
onAbrir: () -> Unit,
onCerrar: () -> Unit,
modifier: Modifier = Modifier,
// Sin pestanas debajo, la barra es lo ultimo de la pantalla: deja el hueco de la barra del sistema.
ultimaDeLaPantalla: Boolean = false,
) {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = modifier.fillMaxWidth().clickable(onClickLabel = "Abrir el partido", onClick = onAbrir),
) {
Row(
(if (ultimaDeLaPantalla) Modifier.navigationBarsPadding() else Modifier)
.heightIn(min = 56.dp)
.padding(start = 16.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(textoDeLaBarra(enJuego), Modifier.weight(1f), style = MaterialTheme.typography.titleSmall)
// Mientras se juega no se puede cerrar: la X solo sale al final.
if (enJuego.terminado) {
IconButton(onClick = onCerrar) {
// TalkBack no lee «✕»: lee «Cerrar».
Text("✕", Modifier.clearAndSetSemantics { contentDescription = "Cerrar" })
}
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun BarraEnJuegoPreview() {
MaterialTheme {
BarraDelPartidoEnJuego(
PartidoEnJuego.EnJuego(1, "Rayo FC", "Toros", Marcador(2, 1), minuto = 34, terminado = false),
onAbrir = {}, onCerrar = {},
)
}
}
@Preview(showBackground = true)
@Composable
private fun BarraFinalPreview() {
MaterialTheme {
BarraDelPartidoEnJuego(
PartidoEnJuego.EnJuego(1, "Rayo FC", "Toros", Marcador(3, 2), minuto = 90, terminado = true),
onAbrir = {}, onCerrar = {},
)
}
}En AppMarcador.kt, tres import nuevos, cada uno junto a sus vecinos (el orden alfabético es el de Android Studio):
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.ColumnDentro de AppMarcador, justo debajo de val actual = pila.lastOrNull():
// ESTADO DE TODA LA APP, LEIDO EN LA RAIZ. Este ViewModel se pide aqui, FUERA de NavDisplay:
// es el de la Activity, no el de una pantalla (mira AppViewModel).
val appViewModel: AppViewModel = hiltViewModel()
val enJuego by appViewModel.enJuego.collectAsStateWithLifecycle()Y el bottomBar del Scaffold de fuera, que antes era solo el AnimatedVisibility de las pestañas, pasa a ser esto (el AnimatedVisibility de las pestañas es el mismo, ahora dentro de la Column):
bottomBar = {
// La barra del partido en juego va ENCIMA de las pestanas. No sale en la pantalla
// del propio partido (alli ya se ve todo).
Column {
AnimatedVisibility(
visible = enJuego != null && actual != enJuego?.let { PartidoKey(it.partidoId) },
enter = expandVertically(),
exit = shrinkVertically(),
) {
enJuego?.let { partido ->
BarraDelPartidoEnJuego(
partido,
onAbrir = { irAlPartido(pila, partido.partidoId) },
onCerrar = appViewModel::cerrar,
ultimaDeLaPantalla = actual !in PESTANAS,
)
}
}
AnimatedVisibility(
visible = actual in PESTANAS,
enter = slideInVertically { it },
exit = slideOutVertically { it },
) {
NavigationBar {
listOf(ListaKey to "Partidos", TablasKey to "Tablas", RecomposicionKey to "Recomp.")
.forEach { (clave, titulo) ->
NavigationBarItem(
selected = actual == clave,
onClick = { irAPestana(pila, clave) },
icon = {},
label = { Text(titulo) },
)
}
}
}
}
},Los tests:
package com.sebastiannarvaez.marcador.ui
import com.sebastiannarvaez.marcador.feature.marcador.domain.PartidoEnJuego
import com.sebastiannarvaez.marcador.torneo.Marcador
import kotlin.test.Test
import kotlin.test.assertEquals
// El texto de la barra es una funcion normal: se prueba en la JVM, sin pintar nada.
class BarraDelPartidoEnJuegoTest {
@Test
fun enJuegoMuestraElMinuto() {
val enJuego = PartidoEnJuego.EnJuego(1, "Rayo FC", "Toros", Marcador(2, 1), minuto = 34, terminado = false)
assertEquals("Rayo FC 2–1 Toros · 34'", textoDeLaBarra(enJuego))
}
@Test
fun alTerminarMuestraFinal() {
val final = PartidoEnJuego.EnJuego(1, "Rayo FC", "Toros", Marcador(3, 2), minuto = 90, terminado = true)
assertEquals("Final · Rayo FC 3–2 Toros", textoDeLaBarra(final))
}
}Cuatro líneas que pueden extrañar.
(if (ultimaDeLaPantalla) Modifier.navigationBarsPadding() else Modifier).heightIn(…)…: unifen medio de una cadena de modificadores. Comoifda un valor, da o el modificador con el margen o elModifiervacío, y a ese resultado se le encadenan los demás.actual != enJuego?.let { PartidoKey(it.partidoId) }: si hay partido en juego, construye su clave y la compara con la pantalla de arriba; si no hay,letdanull. ComoenJuego != nullva delante en el&&, esenullnunca decide nada.enJuego?.let { partido -> … }dentro delAnimatedVisibility: el bloque solo corre sienJuegono esnull. Si lo es, la animación termina de plegar la franja con el contenido ya vacío; dura muy poco y en el emulador no se nota.val enJuego by appViewModel.enJuego.collectAsStateWithLifecycle(): el mismobyy el mismocollectAsStateWithLifecycle()de todas las pantallas. Cada vez que el minuto o el marcador cambian,enJuegocambia y la barra se vuelve a pintar sola.
Qué comprueban los dos tests. Como textoDeLaBarra es una función normal (no un composable), se prueba en la JVM, sin emulador y sin Turbine:
| Test | Qué comprueba |
|---|---|
enJuegoMuestraElMinuto | Con el partido en el minuto 34 y 2–1, el texto es «Rayo FC 2–1 Toros · 34'» |
alTerminarMuestraFinal | Con el partido terminado y 3–2, el texto es «Final · Rayo FC 3–2 Toros», sin minuto |
Probarlo. Ejecuta la app. En Partidos, abre el primer partido y pulsa Empezar partido. Vuelve atrás.
- Encima de las pestañas sale la barra: «Rayo FC … Toros · N'», con el minuto en el que va el partido. Cambia de pestaña a Tablas: la barra sigue.
- Tócala: se abre el partido, en el minuto en que va, sin barra (ya estás en él). Atrás vuelve a Tablas.
- Gira el móvil con la barra a la vista: la barra se queda donde estaba.
- Espera a que el partido termine (los 90 minutos pasan en unos 23 segundos). La barra pasa a «Final · …» y sale la X a la derecha; antes, no. Pulsa la X: la barra desaparece.
- Empieza el primer partido, sal y abre el segundo: sale «Ya hay un partido en juego» con el botón Ir al partido, y la barra abajo, con su hueco de la barra del sistema (esta pantalla no tiene pestañas). Pulsa Ir al partido: se abre el primero, y atrás vuelve al segundo.
Punto de control
- Con un partido en juego, la barra «Rayo FC … Toros · N'» aparece encima de las pestañas en Partidos, Tablas y Recomp., y no aparece en la pantalla del propio partido.
- Al tocarla se abre el partido en el minuto en que va; atrás vuelve a la pantalla de antes.
- Con el partido terminado, la barra dice «Final · …» y trae una X que la quita. Mientras se juega, no hay X.
- Con otro partido abierto sale el aviso «Ya hay un partido en juego», la barra se ve debajo, y Ir al partido abre el primero.
- Al girar el móvil, la barra y su X se conservan.
./gradlew :app:testGratisDebugUnitTestcuenta 38 tests (36 de antes y los 2 deBarraDelPartidoEnJuegoTest), o 41 si hiciste el ejercicio del temporizador de Tests de un ViewModel: fakes, setMain y Turbine. El ejercicio de esta lección no añade tests. 0 fallos.
Con esto, el partido en juego es de toda la app: lo empieza una pieza que vive lo que la app, lo mira el partido y lo mira la barra. La misma barra existe en iOS, con otras piezas, en La barra del partido en juego, en iOS, al final del módulo de arquitectura. Con esto termina el módulo de Android Jetpack. Sigue Swift: Tu primer paquete de Swift.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.En
AppMarcador, la líneaval appViewModel: AppViewModel = hiltViewModel()está escrita fuera deNavDisplay. ¿De quién es elViewModelque se obtiene?2.
AppViewModelexponeval enJuego = partidoEnJuego.actualsin usarstateIn. ¿Por qué no hace falta?3.Hay dos cosas que poner en el
bottomBardelScaffoldde fuera: la barra del partido y las pestañas. ¿Cómo se resuelve?4.La X para cerrar la barra es un
Text("✕")conModifier.clearAndSetSemantics { contentDescription = "Cerrar" }. ¿Qué consigue ese modificador?5.La barra recibe
ultimaDeLaPantalla = actual !in PESTANASy, si valetrue, añadenavigationBarsPadding(). ¿Por qué solo en ese caso?