Creare uno slideshow di immagini con Angular
Con questo articolo si conclude la serie dedicata alla creazione di uno slideshow di immagini con tecnologie diverse. Dopo CSS, JavaScript puro, Vue.js e React, affrontiamo lo stesso problema con Angular, nella sua forma moderna: componenti standalone, signal per lo stato reattivo, le funzioni input() e output() per comunicare con il componente padre, il nuovo control flow nei template e la dependency injection per condividere servizi. Vedremo come i signal avvicinino Angular al modello reattivo di Vue, mentre la dependency injection e la struttura dei file mantengono quell'impostazione architetturale che distingue il framework.
Gli esempi fanno riferimento ad Angular 20 o successivo e seguono le convenzioni della guida di stile aggiornata, che ha eliminato i suffissi come .component dai nomi dei file.
Creazione del progetto
Creiamo il progetto con Angular CLI e generiamo il componente e il servizio che ci servono.
npm install -g @angular/cli
ng new slideshow-angular --style=css --ssr=false
cd slideshow-angular
ng generate component image-slideshow
ng generate service browser-environment
ng serve
Al termine avremo questa struttura, a cui aggiungeremo un file per i tipi condivisi.
src/app/
browser-environment.ts
slide-image.ts
image-slideshow/
image-slideshow.ts
image-slideshow.html
image-slideshow.css
app.ts
I signal in breve
Un signal è un contenitore di un valore che notifica chi lo legge quando il valore cambia. Si crea con signal(), si legge chiamandolo come una funzione e si modifica con set() o update(). Un computed() deriva un valore da altri signal e si ricalcola automaticamente quando le sue dipendenze cambiano; un effect() esegue un effetto collaterale ogni volta che i signal letti al suo interno cambiano. Il template di un componente è a sua volta un consumatore di signal: Angular sa esattamente quali parti della vista dipendono da quali signal e le aggiorna in modo mirato.
Chi ha letto l'articolo su Vue riconoscerà i concetti: signal corrisponde a ref, computed a computed, effect a watchEffect. La differenza principale è sintattica: in Angular un signal si legge chiamandolo, currentIndex(), invece di accedere a .value.
Il tipo delle immagini
Iniziamo dai tipi. Il file slide-image.ts definisce la forma di un'immagine e quella dell'evento emesso al cambio di slide.
// src/app/slide-image.ts
export interface SlideImage {
src: string;
alt: string;
caption?: string;
}
export interface SlideChange {
index: number;
previousIndex: number;
}
export type SlideDirection = 'next' | 'prev';
Un servizio per l'ambiente del browser
Nelle versioni precedenti la preferenza per il movimento ridotto e la visibilità della pagina venivano lette all'interno dello slideshow. Si tratta però di informazioni che riguardano l'intera applicazione: un video, un'animazione decorativa o un ticker di notizie avrebbero bisogno degli stessi dati. In Angular la soluzione naturale è un servizio iniettabile, fornito a livello di applicazione, che espone queste informazioni come signal.
// src/app/browser-environment.ts
import { Injectable, PLATFORM_ID, Signal, inject, signal } from '@angular/core';
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { toSignal } from '@angular/core/rxjs-interop';
import { fromEvent, map } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class BrowserEnvironment {
private readonly document = inject(DOCUMENT);
readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
readonly prefersReducedMotion: Signal<boolean> = this.isBrowser
? this.createMediaQuerySignal('(prefers-reduced-motion: reduce)')
: signal(false).asReadonly();
readonly isPageVisible: Signal<boolean> = this.isBrowser
? toSignal(
fromEvent(this.document, 'visibilitychange').pipe(
map(() => this.document.visibilityState === 'visible')
),
{ initialValue: this.document.visibilityState === 'visible' }
)
: signal(true).asReadonly();
private createMediaQuerySignal(query: string): Signal<boolean> {
const mediaQuery = this.document.defaultView!.matchMedia(query);
// Il valore iniziale è letto subito, i cambiamenti arrivano dall'evento
return toSignal(
fromEvent<MediaQueryListEvent>(mediaQuery, 'change').pipe(map((event) => event.matches)),
{ initialValue: mediaQuery.matches }
);
}
}
La funzione toSignal converte un Observable di RxJS in un signal. L'Observable creato con fromEvent emette un valore a ogni evento, e il signal riflette l'ultimo valore emesso; l'opzione initialValue fornisce il valore da usare prima del primo evento. Poiché il servizio è fornito in root, esiste un'unica istanza per tutta l'applicazione, e la sottoscrizione resta attiva per tutta la sua durata.
Il servizio non accede mai direttamente agli oggetti globali window e document: ottiene il documento tramite il token di injection DOCUMENT e la finestra tramite defaultView. Insieme al controllo isPlatformBrowser, questo rende il servizio sicuro anche con il rendering lato server, dove le API del browser non esistono e il servizio restituisce semplicemente dei valori predefiniti. Inoltre, nei test è possibile sostituire il servizio con una versione simulata, ad esempio per verificare il comportamento dello slideshow quando l'utente preferisce un movimento ridotto.
Il componente: input, output e stato
Il componente ImageSlideshow dichiara i propri input con la funzione input(), che restituisce un signal in sola lettura. Le funzioni di trasformazione booleanAttribute e numberAttribute permettono di usare il componente anche con attributi statici, ad esempio scrivendo loop="false" o interval="4000" senza parentesi quadre.
// src/app/image-slideshow/image-slideshow.ts
import {
Component,
booleanAttribute,
computed,
effect,
inject,
input,
linkedSignal,
numberAttribute,
output,
signal
} from '@angular/core';
import { BrowserEnvironment } from '../browser-environment';
import { SlideChange, SlideDirection, SlideImage } from '../slide-image';
const SWIPE_THRESHOLD = 50;
@Component({
selector: 'app-image-slideshow',
templateUrl: './image-slideshow.html',
styleUrl: './image-slideshow.css'
})
export class ImageSlideshow {
private readonly environment = inject(BrowserEnvironment);
// Input del componente
readonly images = input.required<SlideImage[]>();
readonly autoplay = input(true, { transform: booleanAttribute });
readonly interval = input(5000, { transform: numberAttribute });
readonly loop = input(true, { transform: booleanAttribute });
readonly label = input('Galleria di immagini');
// Output del componente
readonly slideChange = output<SlideChange>();
readonly total = computed(() => this.images().length);
// L'indice si riallinea automaticamente se il numero di immagini cambia
readonly currentIndex = linkedSignal<number, number>({
source: this.total,
computation: (total, previous) => Math.min(previous?.value ?? 0, Math.max(total - 1, 0))
});
readonly direction = signal<SlideDirection>('next');
// Pausa esplicita richiesta dall'utente
readonly isPaused = signal(false);
// Pausa temporanea durante hover o focus
readonly isInteracting = signal(false);
readonly statusMessage = signal('');
private pointerStartX: number | null = null;
// ... continua
}
Il signal più interessante è currentIndex, creato con linkedSignal. Un linked signal è un signal scrivibile, che possiamo modificare con set() come qualunque altro, ma che viene anche ricalcolato automaticamente quando cambia la sua sorgente. Qui la sorgente è il numero totale di immagini: se l'elenco si riduce e l'indice corrente non è più valido, la funzione computation lo riporta all'ultima posizione disponibile, partendo dal valore precedente. Nelle altre versioni avevamo ottenuto lo stesso risultato con un watcher in Vue e con un'azione dedicata del reducer in React; in Angular è una proprietà dichiarativa del signal stesso.
Lo stato è distribuito in signal distinti, ciascuno con un significato preciso, esattamente come nella versione Vue. Il valore pointerStartX, invece, è un semplice campo della classe: non influenza la vista e non ha bisogno di essere reattivo.
I valori derivati
I valori derivati si esprimono con computed. Il più importante è isRunning, che decide se la riproduzione automatica debba essere attiva e combina lo stato del componente con i signal del servizio BrowserEnvironment.
readonly canGoPrev = computed(() => this.loop() || this.currentIndex() > 0);
readonly canGoNext = computed(() => this.loop() || this.currentIndex() < this.total() - 1);
readonly isRunning = computed(() =>
this.environment.isBrowser
&& this.autoplay()
&& !this.isPaused()
&& !this.isInteracting()
&& !this.environment.prefersReducedMotion()
&& this.environment.isPageVisible()
&& this.total() > 1
&& this.canGoNext()
);
Un computed traccia dinamicamente le proprie dipendenze. Le espressioni combinate con && si interrompono al primo valore falso, quindi i signal successivi non vengono letti e non diventano dipendenze in quel momento; se le condizioni cambiano, Angular aggiorna l'insieme delle dipendenze alla valutazione successiva. Il risultato è sempre corretto, e il ricalcolo avviene solo quando serve.
La navigazione
Come in tutte le versioni precedenti, ogni cambio di slide passa da un unico metodo, che normalizza l'indice, calcola la direzione, aggiorna lo stato, emette l'output e, se richiesto, prepara l'annuncio per gli screen reader.
goTo(index: number, announce = true): void {
const total = this.total();
if (total === 0) {
return;
}
const previousIndex = this.currentIndex();
const target = this.loop()
? ((index % total) + total) % total
: Math.min(Math.max(index, 0), total - 1);
if (target === previousIndex) {
return;
}
// Si confronta l'indice richiesto, non quello normalizzato
this.direction.set(index > previousIndex ? 'next' : 'prev');
this.currentIndex.set(target);
this.slideChange.emit({ index: target, previousIndex });
if (announce) {
this.statusMessage.set(`Immagine ${target + 1} di ${total}`);
}
}
next(): void {
this.goTo(this.currentIndex() + 1);
}
prev(): void {
this.goTo(this.currentIndex() - 1);
}
togglePause(): void {
this.isPaused.update((paused) => !paused);
}
A differenza di React, dove il nuovo stato era disponibile solo al rendering successivo, in Angular un signal aggiornato con set() restituisce subito il nuovo valore alla lettura successiva. Il comportamento è quindi simile a quello di Vue e il codice risulta lineare.
L'output viene emesso direttamente nel metodo goTo, invece che in un effetto che osserva l'indice. Le linee guida di Angular raccomandano di non usare gli effetti per propagare cambiamenti di stato: quando l'origine di un cambiamento è nota, come in questo caso, è più semplice e prevedibile reagire nel punto in cui avviene.
La riproduzione automatica con effect
La riproduzione automatica, invece, è proprio il caso d'uso per cui gli effetti esistono: sincronizzare lo stato con qualcosa di esterno al mondo dei signal, in questo caso un timer del browser. L'effetto viene creato nel costruttore, che in Angular è un contesto di injection, e viene distrutto automaticamente insieme al componente.
constructor() {
effect((onCleanup) => {
if (!this.isRunning()) {
return;
}
// Leggere l'indice lo rende una dipendenza: ogni cambio riavvia il conteggio
const index = this.currentIndex();
const delay = Math.max(1000, this.interval());
const timeoutId = setTimeout(() => {
// Il cambio automatico non viene annunciato
this.goTo(index + 1, false);
}, delay);
// Eseguita prima della successiva esecuzione dell'effetto o alla distruzione
onCleanup(() => clearTimeout(timeoutId));
});
// Precarica l'immagine successiva
effect(() => {
if (!this.environment.isBrowser) {
return;
}
const images = this.images();
const nextImage = images[(this.currentIndex() + 1) % images.length];
if (nextImage) {
new Image().src = nextImage.src;
}
});
}
Il primo effetto legge isRunning, currentIndex e interval: quando uno qualunque di questi signal cambia, Angular esegue la funzione di pulizia registrata con onCleanup, che cancella il timeout pendente, e poi esegue di nuovo l'effetto, che ne programma uno nuovo. Il risultato è lo stesso del watcher di Vue e del resetKey del hook useInterval di React: qualunque cambio di slide, manuale o automatico, fa ripartire il conteggio, e qualunque condizione di pausa ferma il timer.
Il codice eseguito dentro la callback di setTimeout non viene tracciato, perché viene eseguito in un secondo momento, fuori dall'effetto. La chiamata a goTo legge e scrive signal senza creare nuove dipendenze, ed è esattamente ciò che vogliamo.
Le interazioni dell'utente
I gestori delle interazioni sono metodi della classe, collegati agli eventi nel template.
onKeydown(event: KeyboardEvent): void {
const actions: Record<string, () => void> = {
ArrowLeft: () => this.prev(),
ArrowRight: () => this.next(),
Home: () => this.goTo(0),
End: () => this.goTo(this.total() - 1)
};
const action = actions[event.key];
if (action) {
event.preventDefault();
action();
}
}
onFocusOut(event: FocusEvent): void {
const container = event.currentTarget as HTMLElement;
// Riprendi solo se il focus esce davvero dal componente
if (!container.contains(event.relatedTarget as Node | null)) {
this.isInteracting.set(false);
}
}
onPointerDown(event: PointerEvent): void {
if (event.isPrimary && event.button === 0) {
this.pointerStartX = event.clientX;
}
}
onPointerUp(event: PointerEvent): void {
if (this.pointerStartX === null) {
return;
}
const deltaX = event.clientX - this.pointerStartX;
this.pointerStartX = null;
if (Math.abs(deltaX) >= SWIPE_THRESHOLD) {
if (deltaX < 0) {
this.next();
} else {
this.prev();
}
}
}
resetPointer(): void {
this.pointerStartX = null;
}
TypeScript ci obbliga a essere espliciti sui tipi: currentTarget e relatedTarget sono dichiarati in modo generico nelle definizioni del DOM e vanno convertiti nei tipi che ci aspettiamo. La mappa dei tasti è tipizzata come Record<string, () => void>, in modo che l'accesso con una chiave arbitraria sia consentito e restituisca una funzione oppure undefined.
Il template
Il template usa il control flow integrato introdotto con Angular 17: i blocchi @for e @if sostituiscono le direttive strutturali *ngFor e *ngIf, non richiedono importazioni e offrono prestazioni migliori.
<!-- src/app/image-slideshow/image-slideshow.html -->
@if (total() > 0) {
<figure
class="slideshow"
aria-roledescription="carousel"
[attr.aria-label]="label()"
[attr.data-direction]="direction()"
(mouseenter)="isInteracting.set(true)"
(mouseleave)="isInteracting.set(false)"
(focusin)="isInteracting.set(true)"
(focusout)="onFocusOut($event)"
(keydown)="onKeydown($event)"
>
<ul
class="slideshow-slides"
(pointerdown)="onPointerDown($event)"
(pointerup)="onPointerUp($event)"
(pointercancel)="resetPointer()"
>
@for (image of images(); track image.src; let i = $index) {
@let isActive = i === currentIndex();
<li
class="slideshow-slide"
[class.is-active]="isActive"
aria-roledescription="slide"
[attr.aria-label]="(i + 1) + ' di ' + total()"
[attr.aria-hidden]="!isActive"
[attr.inert]="isActive ? null : ''"
>
<img
[src]="image.src"
[alt]="image.alt"
draggable="false"
[attr.loading]="i === 0 ? 'eager' : 'lazy'"
>
@if (image.caption) {
<p class="slideshow-caption">{{ image.caption }}</p>
}
</li>
}
</ul>
<button
type="button"
class="slideshow-arrow slideshow-prev"
aria-label="Immagine precedente"
[disabled]="!canGoPrev()"
(click)="prev()"
>‹</button>
<button
type="button"
class="slideshow-arrow slideshow-next"
aria-label="Immagine successiva"
[disabled]="!canGoNext()"
(click)="next()"
>›</button>
@if (autoplay()) {
<button
type="button"
class="slideshow-toggle"
[attr.aria-pressed]="isPaused()"
(click)="togglePause()"
>{{ isPaused() ? 'Riprendi' : 'Pausa' }}</button>
}
<nav class="slideshow-dots" aria-label="Seleziona immagine">
@for (image of images(); track image.src; let i = $index) {
<button
type="button"
class="slideshow-dot"
[attr.aria-label]="'Vai all\'immagine ' + (i + 1)"
[attr.aria-current]="i === currentIndex() ? 'true' : null"
(click)="goTo(i)"
></button>
}
</nav>
<p class="slideshow-status visually-hidden" aria-live="polite">{{ statusMessage() }}</p>
</figure>
}
Il blocco @for richiede obbligatoriamente un'espressione track, che svolge lo stesso ruolo dell'attributo key in Vue e React: identifica ogni elemento in modo stabile, così che Angular possa riutilizzare i nodi del DOM quando l'elenco cambia. Usiamo l'URL dell'immagine, che è unico. La variabile implicita $index fornisce la posizione corrente.
Il blocco @let, disponibile da Angular 18.1, dichiara una variabile locale al template. Evita di ripetere il confronto i === currentIndex() in quattro punti diversi e rende il markup più leggibile.
Angular distingue tra il binding di proprietà, come [disabled] e [src], che imposta una proprietà dell'oggetto DOM, e il binding di attributo, come [attr.aria-current], che imposta un attributo HTML. Gli attributi ARIA e data-* non hanno una proprietà DOM corrispondente nello schema di Angular e richiedono il prefisso attr.. Lo stesso vale per inert: impostando l'attributo a una stringa vuota lo si attiva, impostandolo a null lo si rimuove. In generale, un binding di attributo con valore null rimuove l'attributo, ed è il meccanismo che usiamo anche per aria-current.
Gli eventi del DOM si collegano con la sintassi tra parentesi tonde, e la variabile $event contiene l'oggetto evento nativo. Nel template possiamo anche chiamare direttamente i metodi dei signal, come in isInteracting.set(true): è comodo per le assegnazioni semplici, mentre la logica più articolata resta nei metodi della classe.
Gli stili
Come nella versione React, tutte le slide restano nel DOM e la slide attiva entra con un'animazione CSS il cui verso dipende dall'attributo data-direction. Gli stili di un componente Angular sono incapsulati per impostazione predefinita: Angular aggiunge agli elementi del template attributi univoci e riscrive i selettori in modo che si applichino soltanto a quel componente.
/* src/app/image-slideshow/image-slideshow.css */
:host {
display: block;
}
.slideshow {
position: relative;
max-width: 960px;
margin: 0 auto;
overflow: hidden;
border-radius: 8px;
background: #111;
}
.slideshow-slides {
position: relative;
aspect-ratio: 16 / 9;
margin: 0;
padding: 0;
list-style: none;
touch-action: pan-y;
}
.slideshow-slide {
position: absolute;
inset: 0;
visibility: hidden;
}
.slideshow-slide.is-active {
visibility: visible;
z-index: 1;
animation: slide-in-next 0.5s ease both;
}
.slideshow[data-direction="prev"] .slideshow-slide.is-active {
animation-name: slide-in-prev;
}
@keyframes slide-in-next {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes slide-in-prev {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
.slideshow-slide img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
user-select: none;
}
.slideshow-caption {
position: absolute;
left: 0;
right: 0;
bottom: 0;
margin: 0;
padding: 2.5rem 1.25rem 1rem;
color: #fff;
background: linear-gradient(transparent, rgb(0 0 0 / 0.7));
}
.slideshow-arrow {
position: absolute;
top: 50%;
z-index: 2;
translate: 0 -50%;
width: 3rem;
height: 3rem;
border: 0;
border-radius: 50%;
font-size: 2rem;
line-height: 1;
color: #fff;
background: rgb(0 0 0 / 0.45);
cursor: pointer;
}
.slideshow-arrow:disabled {
opacity: 0.3;
cursor: default;
}
.slideshow-prev {
left: 1rem;
}
.slideshow-next {
right: 1rem;
}
.slideshow-toggle {
position: absolute;
top: 1rem;
right: 1rem;
z-index: 2;
padding: 0.4rem 0.8rem;
border: 0;
border-radius: 4px;
color: #fff;
background: rgb(0 0 0 / 0.55);
cursor: pointer;
}
.slideshow-dots {
position: absolute;
top: 1rem;
left: 50%;
z-index: 2;
translate: -50% 0;
display: flex;
gap: 0.5rem;
}
.slideshow-dot {
width: 0.75rem;
height: 0.75rem;
padding: 0;
border: 2px solid #fff;
border-radius: 50%;
background: transparent;
cursor: pointer;
}
.slideshow-dot[aria-current="true"] {
background: #fff;
}
.slideshow button:focus-visible {
outline: 3px solid #4da3ff;
outline-offset: 3px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.slideshow-slide.is-active {
animation: none;
}
}
Il selettore :host si riferisce all'elemento ospite del componente, cioè il tag <app-image-slideshow> inserito nel template del padre. Gli elementi personalizzati sono di tipo inline per impostazione predefinita; dichiararlo block evita problemi di layout. Anche i nomi delle animazioni definite con @keyframes vengono resi univoci dall'incapsulamento, quindi non possono entrare in conflitto con animazioni omonime definite altrove.
L'utilizzo nel componente radice
Il componente è standalone, quindi per usarlo basta importarlo nell'array imports del componente che lo contiene.
// src/app/app.ts
import { Component } from '@angular/core';
import { ImageSlideshow } from './image-slideshow/image-slideshow';
import { SlideChange, SlideImage } from './slide-image';
@Component({
selector: 'app-root',
imports: [ImageSlideshow],
template: `
<app-image-slideshow
[images]="images"
interval="4000"
label="Galleria di viaggio"
(slideChange)="onSlideChange($event)"
/>
`
})
export class App {
protected readonly images: SlideImage[] = [
{ src: '/images/mountains.jpg', alt: "Catena montuosa all'alba", caption: "Le montagne all'alba" },
{ src: '/images/lake.jpg', alt: 'Lago alpino circondato da abeti', caption: 'Un lago alpino' },
{ src: '/images/coast.jpg', alt: 'Costa rocciosa al tramonto', caption: 'La costa al tramonto' }
];
protected onSlideChange({ index, previousIndex }: SlideChange): void {
console.log(`Slide cambiata: da ${previousIndex + 1} a ${index + 1}`);
}
}
L'input interval è passato come attributo statico, senza parentesi quadre: il valore arriva come stringa "4000" e la funzione numberAttribute lo converte in numero. Nei progetti generati con Angular CLI le immagini vanno inserite nella cartella public, i cui file vengono serviti dalla radice del sito.
Un'ultima osservazione riguarda il rilevamento delle modifiche. Le applicazioni Angular più recenti possono funzionare senza Zone.js, in modalità zoneless, affidandosi ai signal per sapere quando aggiornare la vista. Il nostro componente è già pronto per questa modalità: tutto lo stato che influenza il template è conservato in signal, e anche gli aggiornamenti eseguiti dentro un setTimeout vengono notificati correttamente al framework.
Possibili estensioni
La funzione model() permetterebbe di trasformare l'indice corrente in un input bidirezionale, utilizzabile dal padre con la sintassi [(index)]. I metodi pubblici next(), prev() e goTo() sono già accessibili a un componente padre che ottiene un riferimento allo slideshow tramite viewChild(). Infine, il servizio BrowserEnvironment può essere sostituito nei test con una versione simulata, registrandola nei providers di TestBed, per verificare il comportamento dello slideshow con la preferenza di movimento ridotto attiva o con la pagina nascosta.
Conclusioni
La versione Angular dello slideshow mostra quanto il framework sia cambiato negli ultimi anni. Con i signal lo stato reattivo è diventato semplice ed esplicito, con un modello molto vicino a quello di Vue; linkedSignal ha permesso di esprimere in modo dichiarativo il riallineamento dell'indice e effect con onCleanup ha gestito la riproduzione automatica in poche righe. La dependency injection, tratto distintivo di Angular, ci ha consentito di estrarre le informazioni sull'ambiente del browser in un servizio condiviso, sicuro con il rendering lato server e facilmente sostituibile nei test.
Ripercorrendo l'intera serie, emerge un filo conduttore. In tutte e cinque le versioni lo slideshow si basa sugli stessi principi: uno stato minimo, un unico punto in cui lo stato cambia, la riproduzione automatica come conseguenza di una singola condizione e un'attenzione costante all'accessibilità. Il CSS custodisce lo stato in elementi nativi, JavaScript lo sincronizza a mano con il DOM, mentre Vue, React e Angular lo rendono dichiarativo con strumenti diversi: la reattività basata su proxy, il rendering come funzione dello stato e i signal. Scegliere uno strumento significa scegliere il modo in cui esprimere queste idee, non le idee stesse.