22º aprile 2021

Animazioni JavaScript

Le animazioni JavaScript consentono di gestire cose che con il CSS non è possibile gestire.

Ad esempio, definire movimenti che seguono un percorso complesso, con funzioni di temporizzazione diverse da curve di Bezier, è possibile animare anche oggetti all’interno di un canvas.

Utilizzo di setInterval

Un animazione può essere implementata come una sequenza di frame, solitamente sfruttando delle piccole modifiche alle proprietà HTML/CSS.

Ad esempio, modificando style.left da 0px a 100px per spostare l’elemento. Se lo incrementiamo in setInterval, applicando incrementi di 2px con un piccolo ritardo, ad esempio 50 volte per secondo, allora otterremo un’animazione molto fluida. Questo è lo stesso principio applicato nel cinema: 24 frame per secondo sono sufficienti per far si che le immagini appaiano fluide.

Il pseudo codice è qualcosa del genere:

let timer = setInterval(function() {
  if (animation complete) clearInterval(timer);
  else increase style.left by 2px
}, 20); // cambia di 2px ogni 20ms, circa 50 frame per secondo

Un esempio più completo dell’animazione:

let start = Date.now(); // memorizziamo il momento di partenza

let timer = setInterval(function() {
  // quanto tempo è passato dall'inizio?
  let timePassed = Date.now() - start;

  if (timePassed >= 2000) {
    clearInterval(timer); // completiamo l'animazione dopo 2 secondi
    return;
  }

  // tracciamo l'animazione all'istante timePassed
  draw(timePassed);

}, 20);

// via via che timePassed va da 0 a 2000
// left assume valori che variano tra 0px e 400px
function draw(timePassed) {
  train.style.left = timePassed / 5 + 'px';
}

Cliccate per visualizzare la dimostrazione:

Risultato
index.html
<!DOCTYPE HTML>
<html>

<head>
  <style>
    #train {
      position: relative;
      cursor: pointer;
    }
  </style>
</head>

<body>

  <img id="train" src="https://js.cx/clipart/train.gif">


  <script>
    train.onclick = function() {
      let start = Date.now();

      let timer = setInterval(function() {
        let timePassed = Date.now() - start;

        train.style.left = timePassed / 5 + 'px';

        if (timePassed > 2000) clearInterval(timer);

      }, 20);
    }
  </script>


</body>

</html>

Utilizzo di requestAnimationFrame

Immaginiamo di avere diverse animazioni in esecuzione contemporaneamente.

Se le eseguissimo separatamente, ed ognuna di esse avesse setInterval(..., 20), allora il browser dovrebbe effettuare operazioni di repaint con molta più frequenza di una ogni 20ms.

Questo perché le animazioni hanno degli istanti di inizio differenti, quindi “ogni 20ms” è differente per ogni singola animazione. Gli intervalli non sono allineati. Quindi abbiamo molte animazioni indipendenti che vengono eseguite in 20ms.

In altre parole, questo:

setInterval(function() {
  animate1();
  animate2();
  animate3();
}, 20)

…E’ molto più leggero rispetto a 3 invocazioni differenti:

setInterval(animate1, 20); // animazioni indipendenti
setInterval(animate2, 20); // in posti diversi dello script
setInterval(animate3, 20);

Questa serie di operazioni di repaint dovrebbero essere raggruppate, in modo tale da rendere il repaint più semplice per il browser, portare meno carico alla CPU e rendere il tutto più fluido.

C’è un ulteriore cosa a cui prestare attenzione. Talvolta la CPU potrebbe essere sovraccarica, oppure potrebbero esserci altri motivi per cui potremmo effettuare il repaint con minore frequenza (ad esempio quando la tab del browser non è visibile), quindi non è necessaria l’esecuzione ogni 20ms.

Ma come facciamo ad avere controllo su questo utilizzando JavaScript? Abbiamo a disposizione Animation timing definita nelle specifiche, che ci fornisce la funzione requestAnimationFrame. Questa ha lo scopo di aiutarci a risolvere questo tipo di problemi.

La sintassi:

let requestId = requestAnimationFrame(callback)

In questo modo pianifichiamo la funzione callback in modo tale che venga eseguita appena il browser vorrà eseguire animazioni.

Se facciamo modifiche agli elementi nella callback, allora questi verranno raggruppati con le altre callbacks in requestAnimationFrame e con le animazioni CSS. In questo modo avremo un solo ricalcolo geometrico ed un repaint, piuttosto di averne molti.

Il valore ritornato, requestId, può essere utilizzato per annullare l’invocazione:

// annulla l'esecuzione programmata per una specifica callback
cancelAnimationFrame(requestId);

La callback riceve un solo argomento, il tempo trascorso dall’inizio del caricamento della pagina, in microsecondi. Possiamo ottenere questa informazione anche invocando performance.now().

Solitamente callback viene eseguita molto presto, a meno che la CPU non sia in uno stato di sovraccarico, la batteria del portatile non sia quasi scarica, o per altri motivi.

Il codice sotto mostra il tempo trascorso tra le prime 10 esecuzioni di requestAnimationFrame. Solitamente è circa 10-20ms:

<script>
  let prev = performance.now();
  let times = 0;

  requestAnimationFrame(function measure(time) {
    document.body.insertAdjacentHTML("beforeEnd", Math.floor(time - prev) + " ");
    prev = time;

    if (times++ < 10) requestAnimationFrame(measure);
  })
</script>

Animazione strutturata

Ora possiamo definire una funzione di animazione universale basata su requestAnimationFrame:

function animate({timing, draw, duration}) {

  let start = performance.now();

  requestAnimationFrame(function animate(time) {
    // timeFraction va da  0 a 1
    let timeFraction = (time - start) / duration;
    if (timeFraction > 1) timeFraction = 1;

    // calcola lo stato corrente dell'animazione
    let progress = timing(timeFraction)

    draw(progress); // la esegue

    if (timeFraction < 1) {
      requestAnimationFrame(animate);
    }

  });
}

La funzione animate accetta 3 parametri che descrivono l’animazione:

duration

Durata totale dell’animazione. Ad esempio, 1000.

timing(timeFraction)

Funzione di temporizzazione, proprio come la proprietà CSS transition-timing-function che prende come input la frazione di tempo passato (0 all’inizio, 1 alla fine) e ritorna lo stato di completamento dell’animazione (ad esempio y nelle curve di Bezier).

Ad esempio, una funzione lineare significa che l’animazione procede uniformemente con la stessa velocità:

function linear(timeFraction) {
  return timeFraction;
}

La curva corrispondente:

Proprio come transition-timing-function: linear. Vengono mostrare altre varianti sotto.

draw(progress)

La funzione che accetta come input lo stato di completamento dell’animazione e la esegue. Il valore progress=0 indica lo stato iniziale dell’animazione, mentre progress=1 lo stato finale.

Questa è la funzione che si occupa di eseguire l’animazione.

Può spostare l’elemento:

function draw(progress) {
  train.style.left = progress + 'px';
}

…O fare altro, possiamo animare qualunque cosa, in qualunque modo.

Proviamo ad animare la width dell’elemento da 0 a 100%, utilizzando la nostra funzione.

Cliccate sull’elemento per visualizzare la dimostrazione:

Risultato
animate.js
index.html
function animate({duration, draw, timing}) {

  let start = performance.now();

  requestAnimationFrame(function animate(time) {
    let timeFraction = (time - start) / duration;
    if (timeFraction > 1) timeFraction = 1;

    let progress = timing(timeFraction)

    draw(progress);

    if (timeFraction < 1) {
      requestAnimationFrame(animate);
    }

  });
}
<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <style>
    progress {
      width: 5%;
    }
  </style>
  <script src="animate.js"></script>
</head>

<body>


  <progress id="elem"></progress>

  <script>
    elem.onclick = function() {
      animate({
        duration: 1000,
        timing: function(timeFraction) {
          return timeFraction;
        },
        draw: function(progress) {
          elem.style.width = progress * 100 + '%';
        }
      });
    };
  </script>


</body>

</html>

Il codice corrispondente:

animate({
  duration: 1000,
  timing(timeFraction) {
    return timeFraction;
  },
  draw(progress) {
    elem.style.width = progress * 100 + '%';
  }
});

A differenza dell’animazione CSS, possiamo definire qualsiasi funzione di temporizzazione e di animazione. La funzione di temporizzazione non è limitata alle curve di Bezier. Mentre draw può andare oltre le proprietà, creando nuovi elementi per animare fuochi d’artificio o qualunque altra cosa.

Funzioni di temporizzazione

Sopra abbiamo visto la più semplice delle funzioni di temporizzazione, quella lineare.

Vediamone altre. Proveremo a definre animazioni con diverse funzioni di temporizzazione in modo da capirne il funzionamento.

Potenza di n

Se vogliamo velocizzare l’animazione, possiamo fornire come progress una potenza di n.

Ad esempio, una parabola:

function quad(timeFraction) {
  return Math.pow(timeFraction, 2)
}

La curva corrispondente:

Vediamola in azione (cliccate per attivare):

…Oppure una curva di grado tre o maggiore. L’incremento del grado della curva renderà l’animazione più veloce.

Qui vediamo la curva progress con una potenza di grado 5:

In azione:

L’arco

Funzione:

function circ(timeFraction) {
  return 1 - Math.sin(Math.acos(timeFraction));
}

Il grafico:

Indietro: tiro con l’arco

Questa funzione simula il “tiro con l’arco”. Prima “tendiamo l’arco” e poi “spariamo”.

A differenza delle funzioni precedenti, abbiamo una dipendenza sul parametro addizionale x, il “coefficiente di elasticità”. Ovvero la distanza di “tensione dell’arco”, definita appunto dal parametro.

Il codice:

function back(x, timeFraction) {
  return Math.pow(timeFraction, 2) * ((x + 1) * timeFraction - x)
}

La curva relativa a x = 1.5:

Per eseguire l’animazione utilizzeremo un valore specifico per x. Ad esempio x = 1.5:

Rimbalzo

Immaginiamo di star facendo cadere una palla. Prima cade a terra, poi rimbalza un paio di volte e infine si ferma.

La funzione bounce simula questo comportamento, ma nell’ordine inverso: il “rimbalzo” inizia immediatamente. Utilizza un paio di coefficienti per farlo:

function bounce(timeFraction) {
  for (let a = 0, b = 1, result; 1; a += b, b /= 2) {
    if (timeFraction >= (7 - 4 * a) / 11) {
      return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2)
    }
  }
}

In azione:

Animazione elastica

Un ulteriore funzione “elastica” che accetta un parametro addizionale x come “intervallo iniziale”.

function elastic(x, timeFraction) {
  return Math.pow(2, 10 * (timeFraction - 1)) * Math.cos(20 * Math.PI * x / 3 * timeFraction)
}

The graph for x=1.5:

In azione con x=1.5:

Inversione: ease*

Abbiamo visto una serie di funzioni di temporizzazione. La loro diretta applicazione viene chiamata “easeIn”.

Talvolta abbiamo però bisogno di mostrare l’animazione nell’ordine inverso. Possiamo farlo con la trasformazione “easeOut”.

easeOut

Nella modalità “easeOut” la funzione di timing (funzione di temporizzazione) viene posta in un contenitore timingEaseOut:

timingEaseOut(timeFraction) = 1 - timing(1 - timeFraction)

In altre parole, abbiamo una funzione di “trasformazione” makeEaseOut, la quale riceve come input una funzione di temporizzazione “normale” e ne ritorna una versione racchiusa in un contenitore:

// accetta in input una funzione di temporizzazione, e ne ritorna una variante trasformata
function makeEaseOut(timing) {
  return function(timeFraction) {
    return 1 - timing(1 - timeFraction);
  }
}

Ad esempio, possiamo prendere la funzione bounce, descritta poco sopra, ed applicarci makeEaseOut:

let bounceEaseOut = makeEaseOut(bounce);

In questo modo il “rimbalzo” non avverrà all’inizio dell’animazione, ma alla fine. Sarà più carina:

Risultato
style.css
index.html
#brick {
  width: 40px;
  height: 20px;
  background: #EE6B47;
  position: relative;
  cursor: pointer;
}

#path {
  outline: 1px solid #E8C48E;
  width: 540px;
  height: 20px;
}
<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="style.css">
  <script src="https://js.cx/libs/animate.js"></script>
</head>

<body>


  <div id="path">
    <div id="brick"></div>
  </div>

  <script>
    function makeEaseOut(timing) {
      return function(timeFraction) {
        return 1 - timing(1 - timeFraction);
      }
    }

    function bounce(timeFraction) {
      for (let a = 0, b = 1, result; 1; a += b, b /= 2) {
        if (timeFraction >= (7 - 4 * a) / 11) {
          return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2)
        }
      }
    }

    let bounceEaseOut = makeEaseOut(bounce);

    brick.onclick = function() {
      animate({
        duration: 3000,
        timing: bounceEaseOut,
        draw: function(progress) {
          brick.style.left = progress * 500 + 'px';
        }
      });
    };
  </script>


</body>

</html>

Qui possiamo vedere come la funzione di “trasformazione” ne cambia il comportamento:

Se abbiamo un animazione all’inizio, come il rimbalzo, questa verrà mostrata alla fine.

Nel grafico sopra il rimbalzo normale è identificato dal colore rosso, mentre il rimbalzo easeOut è di colore blue.

  • Rimbalzo normale: l’oggetto rimbalza verso basso, poi alla fine rimbalza nettamente verso l’alto.
  • Rimbalzo easeOut: rimbalza verso l’alto, fino a fermarsi.

easeInOut

Possiamo anche decidere di mostrare l’effetto sia all’inizio che al termine dell’animazione. La trasformazione viene chiamata “easeInOut”.

Data la funzione di temporizzazione, calcoliamo lo stato dell’animazione in questo modo:

if (timeFraction <= 0.5) { // prima
  return timing(2 * timeFraction) / 2;
} else { // seconda metà dell'animazione
  return (2 - timing(2 * (1 - timeFraction))) / 2;
}

Il codice che esegue la trasformazione:

function makeEaseInOut(timing) {
  return function(timeFraction) {
    if (timeFraction < .5)
      return timing(2 * timeFraction) / 2;
    else
      return (2 - timing(2 * (1 - timeFraction))) / 2;
  }
}

bounceEaseInOut = makeEaseInOut(bounce);

In azione, bounceEaseInOut:

Risultato
style.css
index.html
#brick {
  width: 40px;
  height: 20px;
  background: #EE6B47;
  position: relative;
  cursor: pointer;
}

#path {
  outline: 1px solid #E8C48E;
  width: 540px;
  height: 20px;
}
<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="style.css">
  <script src="https://js.cx/libs/animate.js"></script>
</head>

<body>


  <div id="path">
    <div id="brick"></div>
  </div>

  <script>
    function makeEaseInOut(timing) {
      return function(timeFraction) {
        if (timeFraction < .5)
          return timing(2 * timeFraction) / 2;
        else
          return (2 - timing(2 * (1 - timeFraction))) / 2;
      }
    }


    function bounce(timeFraction) {
      for (let a = 0, b = 1, result; 1; a += b, b /= 2) {
        if (timeFraction >= (7 - 4 * a) / 11) {
          return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2)
        }
      }
    }

    let bounceEaseInOut = makeEaseInOut(bounce);

    brick.onclick = function() {
      animate({
        duration: 3000,
        timing: bounceEaseInOut,
        draw: function(progress) {
          brick.style.left = progress * 500 + 'px';
        }
      });
    };
  </script>


</body>

</html>

La trasformazione “easeInOut” unisce due grafici in uno: easeIn (normale) per la prima metà dell’animazione, easeOut (inverso) epr la seconda metà.

L’effetto è chiaramente visibile se compariamo i grafici di easeIn, easeOut e easeInOut della funzione di temporizzazione di circ:

  • Rosso è la variante normale di circ (easeIn).
  • Verde, easeOut.
  • Blu, easeInOut.

Come possiamo vedere, il grafico della prima metà di animazione è una versione ridimensionata di easeIn, mentre la seconda metà è una versione ridimensionata di easeOut. Il risultato è che l’animazione inizia e termina con la stessa animazione.

“Effetti” più interessanti

Piuttosto di limitarci a muovere un elemento, possiamo fare altro. Tutto ciò che dobbiamo fare è scrivere una funzione di draw.

Qui vediamo l’animazione di scrittura con “rimbalzo”:

Risultato
style.css
index.html
textarea {
  display: block;
  border: 1px solid #BBB;
  color: #444;
  font-size: 110%;
}

button {
  margin-top: 10px;
}
<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="style.css">
  <script src="https://js.cx/libs/animate.js"></script>
</head>

<body>


  <textarea id="textExample" rows="5" cols="60">He took his vorpal sword in hand:
Long time the manxome foe he sought—
So rested he by the Tumtum tree,
And stood awhile in thought.
  </textarea>

  <button onclick="animateText(textExample)">Run the animated typing!</button>

  <script>
    function animateText(textArea) {
      let text = textArea.value;
      let to = text.length,
        from = 0;

      animate({
        duration: 5000,
        timing: bounce,
        draw: function(progress) {
          let result = (to - from) * progress + from;
          textArea.value = text.substr(0, Math.ceil(result))
        }
      });
    }


    function bounce(timeFraction) {
      for (let a = 0, b = 1, result; 1; a += b, b /= 2) {
        if (timeFraction >= (7 - 4 * a) / 11) {
          return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2)
        }
      }
    }
  </script>


</body>

</html>

Riepilogo

Per le animazione che il CSS non è in grado di gestire molto bene, o per quelle in cui è richiesto un controllo preciso, JavaScript può aiutare. Le animazioni JavaScript dovrebbero essere implementate via requestAnimationFrame. Questo metodo integrato ci consente di impostare le funzione di callback in modo tale che vengano eseguite nel momento in cui il browser effettua il repaint. Solitamente questo intervallo di tempo è breve, ma dipende molto dal browser.

Quando la pagina è in background, non si ha alcun repaint, quindi le callback non verranno invocate: le animazioni vengono sospese, e non avremo alcuno spreco di risorse. Questo è grandioso.

Qui vediamo la funzione animate che può aiutare nell’impostare la maggior parte delle animazioni:

function animate({timing, draw, duration}) {

  let start = performance.now();

  requestAnimationFrame(function animate(time) {
    // timeFraction va da 0 a 1
    let timeFraction = (time - start) / duration;
    if (timeFraction > 1) timeFraction = 1;

    // calcola lo stato attuale dell'animazione
    let progress = timing(timeFraction);

    draw(progress); // la esegue

    if (timeFraction < 1) {
      requestAnimationFrame(animate);
    }

  });
}

Opzioni:

  • duration: la durata totale dell’animazione in ms.
  • timing: la funzione per calcolare lo stato dell’animazione. Accetta in input una frazione di tempo che va da 0 a 1, e ritorna il progresso dell’animazione, solitamente da 0 a 1.
  • draw: la funzione per disegnare l’animazione.

Ovviamente potremmo migliorarla aggiungendo più opzioni, ma le animazioni JavaScript non vengono utilizzate quotidianamente. Vengono piuttosto utilizzate per costruire qualcosa di più interessante e non standard. Quindi potrete aggiungere più funzionalità nel momento in cui ne avrete bisogno.

Le animazioni JavaScript possono utilizzare qualsiasi funzione di temporizzazione. Abbiamo visto molti esempi e trasformazioni che le rendono molto versatili. A differenza del CSS, non siamo limitati alle sole curve di Bezier.

Lo stesso vale per draw: possiamo animare qualsiasi cosa, non solamente le proprietà CSS.

Esercizi

importanza: 5

Fate rimbalzare la palla. Clicca per vedere come dovrebbe apparire l’animazione:

Apri una sandbox per l'esercizio.

Per il rimbalzo potete utilizzare la proprietà CSS top e position:absolute sulla palla dentro il campo con position:relative.

Le coordinate del fondo del campo sono field.clientHeight. La proprietà CSS top fa riferimento al bordo alto della palla. Quindi dovrebbe andare da 0 fino a field.clientHeight - ball.clientHeight, questa è la posizione finale, quella più bassa rispetto al bordo alto della palla.

Per dare l’effetto di “rimbalzo” possiamo utilizzare la funzione di temporizzazione bounce in modalità easeOut.

Here’s the final code for the animation:

let to = field.clientHeight - ball.clientHeight;

animate({
  duration: 2000,
  timing: makeEaseOut(bounce),
  draw(progress) {
    ball.style.top = to * progress + 'px'
  }
});

Apri la soluzione in una sandbox.

importanza: 5

Fate rimbalzare la palla verso destra. Come nell’esempio:

Scrivete il codice relativo all’animazione. La distanza da sinistra è 100px.

Prendete la soluzione dell’esercizio precedente Fate rimbalzare la palla come punto di partenza.

Nell’esercizio Fate rimbalzare la palla era richiesto di animare una sola proprietà. Ora dovete animarne una in più: elem.style.left.

La coordinata orizzontale varia secondo un’altra regola: non deve limitarsi a rimbalzare, ma deve anche scorrere verso destra.

Potete scrivere un ulteriore animate per questo.

Potreste utilizzare la funzione di temporizzazione linear, ma qualcosa come makeEaseOut(quad) renderà l’animazione migliore.

Il codice:

let height = field.clientHeight - ball.clientHeight;
let width = 100;

// anima top (rimbalzo)
animate({
  duration: 2000,
  timing: makeEaseOut(bounce),
  draw: function(progress) {
    ball.style.top = height * progress + 'px'
  }
});

// anima left (sposta verso destra)
animate({
  duration: 2000,
  timing: makeEaseOut(quad),
  draw: function(progress) {
    ball.style.left = width * progress + "px"
  }
});

Apri la soluzione in una sandbox.

Mappa del tutorial

Commenti

leggi questo prima di lasciare un commento…
  • Per qualsiasi suggerimento - per favore, apri una issue su GitHub o una pull request, piuttosto di lasciare un commento.
  • Se non riesci a comprendere quanto scitto nell'articolo – ti preghiamo di fornire una spiegazione chiara.
  • Per inserire delle righe di codice utilizza il tag <code>, per molte righe – includile nel tag <pre>, per più di 10 righe – utilizza una sandbox (plnkr, jsbin, codepen…)