Señalizando animaciones en Angular

Alejandro Cuba Ruiz
ngconf
Published in
7 min readMay 27, 2023

--

Captura de pantalla de las casillas animadas, después de resolver la sopa de letras del ejemplo.

El nuevo modelo de programación reactiva añadido como ‘Developer Preview’ a la API pública de Angular 16 ha generado cierta sensación de urgencia para revisar nuestros enfoques tradicionales de detección de cambios y gestión de estado, con el fin de incorporar Angular Signals a nuestra práctica como desarrolladores web.

Después de las discusiones en el documento RFC, Angular Signals ha llegado para quedarse. Por el camino hacia la estabilidad total en la versión 17, este modelo también puede impactar cómo podríamos gestionar las animaciones en Angular.

Una sopa de letras para cocinar Animations con Signals

La integración de ambas funcionalidades puede mejorar significativamente el rendimiento de representación en el navegador web, principalmente cuando las animaciones de Angular dependen de valores de estado parametrizados de carácter reactivo. Angular Signals puede resultar útil cuando necesitemos activar animaciones en respuesta a un cambio de estado a nivel de componente o de aplicación.

Para experimentar un poco con esto, creemos un simple tablero del popular juego de sopa de letras en un componente independiente. El objetivo es poder animar cada casilla cuando termine la partida. Tener una única secuencia de letras ganadora va a simplificar las reglas y nos llevará directamente a la conclusión del juego.

A medida que lees el artículo, es buena idea tener a mano un clon de la rama en español del repositorio en GitHub https://github.com/alejandrocuba/signaling-angular-animations/tree/spanish-version para que puedas armar rápidamente el proyecto de Angular en tu entorno local y jugar con él.

Aquí tienes un extracto de la declaración del componente.

@Component({
selector: 'app-sopa-de-letras',
standalone: true,
imports: [CommonModule]
// declaración de la plantilla y los estilos
)}

export class SopaDeLetrasComponent implements OnInit {
tablero: { letra?: string, estado: string }[][] = [];
tamañoDelTablero = 7; // 7x7 board
secuenciaDeLetras = 'SEÑALES';
indiceDeSecuenciaDeLetras = 0;
secuenciaDeCasillas: { fila: number, columna: number }[] = [];
ultimaCasillaSeleccionada: { fila: number, columna: number } | null = null;
numeroDeCasillasSeleccionadas = signal(0);
esFinalDelJuego = signal(false);
ngOnInit(): void {
this.inicializarTablero();
}
}

Puedes notar que hay dos señales iniciadas como propiedades del componente. La denominada numeroDeCasillasSeleccionadas seguirá la actividad del jugador para usar el número resultante como un parámetro para las animaciones.

Las propiedades indiceDeSecuenciaDeLetras y ultimaCasillaSeleccionada garantizarán que el jugador solo pueda seleccionar letras en casillas adyacentes. Como regla, permitiremos seleccionar una letra correcta de la secuencia si la casilla actual se encuentra en una posición adyacente a la letra anteriormente seleccionada.

Inicializando el tablero

Vamos a añadir un poco de código TypeScript para almacenar los datos de cada casilla en una matriz bidimensional y rellenar el tablero de letras. Para simplificar las cosas, haremos que la secuencia “SEÑALES” se represente desde la esquina superior izquierda en una cuadrícula de 7x7 para que coincida con la longitud de la palabra.

crearTablero(): void {
this.tablero = Array.from({ length: this.tamañoDelTablero }, (x, i) =>
Array.from({ length: this.tamañoDelTablero }, (x, j) => this.inicializarCasilla(i, j))
);
}

rellenarTablero(): void {
for (let i = 0; i < this.tamañoDelTablero; i++) {
for (let j = 0; j < this.tamañoDelTablero; j++) {
this.tablero[i][j] = this.inicializarCasilla(i, j);
}
}
}

inicializarCasilla(i: number, j: number): { letra?: string, estado: string } {
let casilla = { letra: '', estado: 'inicial' };
if (i === j) {
casilla.letra = this.secuenciaDeLetras[i];
} else {
casilla.letra = String.fromCharCode(65 + Math.floor(Math.random() * 26));
}
return casilla;
}

La siguiente lógica de plantilla va a iterar sobre la matriz 2D para crear una estructura HTML tabular, representando una cuadrícula con letras aleatorias por cada casilla.

<article>
<table class="tablero">
<tr *ngFor="let fila of tablero; let x = index">
<td *ngFor="let casilla of fila; let y = index">
{{casilla.letra}}
</td>
</tr>
</table>
</article>

Definiendo estados animados

Para poder configurar las capacidades de animación del navegador requeridas desde el paquete @angular/platform-browser, importemos el proveedor correspondiente en el fichero app.config.ts.

import { ApplicationConfig, importProvidersFrom } from '@angular/core';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';

export const appConfig: ApplicationConfig = {
providers: [
importProvidersFrom([BrowserAnimationsModule])
]
};

En la declaración de inicializarCasilla() te habrás percatado de que cada casilla almacena información de su "estado". Cuando se seleccionan, las casillas pueden pasar del estado inicial al estado correcto (cuando la letra seleccionada es parte de la secuencia) o al estado incorrecto (cuando la letra no es parte de ella).

Vamos ahora a declarar los metadatos de animación dentro del decorador del componente.

animations: [
trigger('estadoDeCasilla', [
state('correcto', style({
backgroundColor: 'var(--color-viejo-oscuro)',
color: 'var(--color-casilla)'
})),
state('incorrecto', style({
backgroundColor: 'var(--color-casilla-incorrecta)',
color: 'var(--color-casilla)'
})),
transition('inicial <=> correcto', animate(300)),
transition('inicial <=> incorrecto', animate(300)),
transition('incorrecto => correcto', animate(600))
]),
trigger('animacionFinal', [
transition('* => true', [
animate('5s', keyframes([
style({ transform: 'translateX({{x1}}px) translateY({{y1}}px)', offset: 0.2 }),
style({ transform: 'translateX({{x2}}px) translateY({{y2}}px)', offset: 0.4 }),
style({ transform: 'translateX({{x3}}px) translateY({{y3}}px)', offset: 0.6 }),
style({ transform: 'translateX({{x4}}px) translateY({{y4}}px)', offset: 0.8 }),
style({ transform: 'translateX(0) translateY(0)', offset: 1.0 }),
]))
])
])
]

Podemos referirnos a esos trigger -o desencadenantes- en la plantilla HTML y asociarlos con expresiones que resuelven uno de los estados de animación establecidos.

<td
<!-- código existente de la plantilla -->
[@cellState]="{ value: casilla.estado }"
[@winAnimation]="generarPosicionAleatoria()"
(click)="comprobarLetra(x, y)"
>

Observa que también hemos añadido un enlace de evento (event binding) para invocar comprobarLetra() al hacer clic en la casilla correspondiente. El estado de una celda se basará en si su letra corresponde a la posición correcta en la secuencia de palabras ganadora.

comprobarLetra(fila: number, columna: number): void {
if (this.esFinalDelJuego() || this.tablero[fila][columna].estado === "correcto") {
return;
}

this.numeroDeCasillasSeleccionadas.update(total => total + 1);

if (this.esCasillaAdyacente(fila, columna) &&
this.tablero[fila][columna].letra === this.secuenciaDeLetras[this.indiceDeSecuenciaDeLetras]) {
this.indiceDeSecuenciaDeLetras++;
this.tablero[fila][columna].estado = 'correcto';
this.ultimaCasillaSeleccionada = { fila: fila, columna: columna };
this.secuenciaDeCasillas.push({ fila: fila, columna: columna });

if (this.indiceDeSecuenciaDeLetras === this.secuenciaDeLetras.length) {
this.esFinalDelJuego.set(true);
}
} else {
this.tablero[fila][columna].estado = 'incorrecto';
}
}

Desencadenando la animación final para celebrar la partida

En cada clic, la señal numeroDeCeldasSeleccionadas se actualiza en función de su valor actual. Si se hace clic en la última letra de la secuencia, la señal esFinalDelJuego pasa a tomar el valor booleano verdadero. Al escuchar esta señal dentro de la lógica de generarPosicionAleatoria(), se desencadena la @animacionFinal.

generarPosicionAleatoria() {
let posicion: Record<string, number> = {};
const valorMaximo = 300;

this.factorAleatorio = computed(() => this.secuenciaDeLetras.length * valorMaximo / (this.numeroDeCasillasSeleccionadas() || 1)) // se comprueba de que no hay division entre cero

for (let i = 0; i < 8; i++) {
let eje = i < 4 ? 'x' : 'y';
let indice = (i % 4) + 1;
posicion[`${eje}${indice}`] = (Math.random() * 2 - 1) * this.factorAleatorio(); // permitir valores negativos
}

return {
value: this.esFinalDelJuego(),
params: posicion,
};
}

La propiedad factorAleatorio utiliza una "señal computada" que actualiza su valor al existir cualquier cambio en la señal numeroDeCasillasSeleccionadas.

Para lograr cierto dinamismo basado en el rendimiento del jugador durante el juego, las fichas del tablero se dispersarán hacia puntos aleatorios en la pantalla siguiendo las instrucciones de fotogramas clave de la @animacionFinal parametrizada. El valor de la posición es inversamente proporcional al número de clics, por lo que cuanto menos casillas se seleccionen, mayor será el número aleatorio resultante.

Completar la secuencia correcta desencadena la @animacionFinal.

Cada propiedad en el objeto posicion puede oscilar entre los valores 300 y -300, interpretados como píxeles durante la traslación via CSS por los ejes cartesianos.

El segundo párrafo debajo del tablero nos proporciona información sobre la cantidad máxima de píxeles utilizados en las animaciones de CSS translateX y translateY después de que se haya alcanzado la cantidad óptima de clics.

En el ejemplo de código proporcionado en Github puedes comprobar la lógica resultante de inicializarTablero() para reinicializar el estado del componente, permitiendo al jugador volver a hacer la sopa de letras pulsando el botón “Reiniciar Tablero”.

Próximos pasos animados

La integración de señales en este sencillo tablero solo demuestra cómo estos valores reactivos se integran de manera sencilla con las animaciones tradicionales de Angular.

Te sugiero experimentar con los eventos de desencadenamiento de la Animación en la propia plantilla, basados en el valor de una señal, como adelanto en el siguiente bloque de código:

<td
<!-- código existente de la plantilla -->
(@winAnimation.done)="isGameFinished() && this.resetCrossword()"
>

También imagina emplear señales como expresiones en enlaces de estilo único o múltiple. Esto podría crear una abanico de posibilidades para desencadenar animaciones con buenas métricas de rendimiento en el navegador web.

<td
<!-- código existente de la plantilla -->
[style.property.px]="señalCambiante()"
>
<td
<!-- código existente de la plantilla -->
[style]="expresionQueContieneSeñalesComputadas()"
>

Angular Signals tiene la capacidad de simplificar el proceso de compartir estados de animación entre componentes, creando la ilusión de una interfaz de usuario interconectada.

Espero que este artículo encienda tu curiosidad para experimentar con este tema, descubriendo nuevas técnicas en el camino. No olvides compartir tu progreso, pues podría inspirar a otros y contribuir a la remodelación del módulo de animaciones en las próximas versiones de Angular.

--

--

Alejandro Cuba Ruiz
ngconf

<front-end web engineer />, Angular GDE, traveler, reader, writer, human being.