24 de octubre de 2022

Animaciones JavaScript

Las animaciones de JavaScript pueden manejar cosas que CSS no puede.

Por ejemplo, moverse a lo largo de una ruta compleja, con una función de sincronización diferente a las curvas de Bézier, o una animación en un canvas.

Usando setInterval

Una animación se puede implementar como una secuencia de frames, generalmente pequeños cambios en las propiedades de HTML/CSS.

Por ejemplo, cambiar style.left de 0px a 100px mueve el elemento. Y si lo aumentamos en setInterval, cambiando en 2px con un pequeño retraso, como 50 veces por segundo, entonces se ve suave. Ese es el mismo principio que en el cine: 24 frames por segundo son suficientes para que se vea suave.

El pseudocódigo puede verse así:

let timer = setInterval(function() {
  if (animation complete) clearInterval(timer);
  else increase style.left by 2px
}, 20); // cambiar en 2px cada 20ms, aproximadamente 50 frames por segundo

Ejemplo más completo de la animación:

let start = Date.now(); // recordar la hora de inicio

let timer = setInterval(function() {
  // ¿Cuánto tiempo pasó desde el principio?
  let timePassed = Date.now() - start;

  if (timePassed >= 2000) {
    clearInterval(timer); // terminar la animación después de 2 segundos
    return;
  }

  // dibujar la animación en el momento timePassed
  draw(timePassed);

}, 20);

// mientras timePassed va de 0 a 2000
// left obtiene valores de 0px a 400px
function draw(timePassed) {
  train.style.left = timePassed / 5 + 'px';
}

Haz clic para ver la demostración:

Resultado
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>

Usando requestAnimationFrame

Imaginemos que tenemos varias animaciones ejecutándose simultáneamente.

Si las ejecutamos por separado, aunque cada una tenga setInterval (..., 20), el navegador tendría que volver a pintar con mucha más frecuencia que cada 20ms.

Eso es porque tienen un tiempo de inicio diferente, por lo que “cada 20ms” difiere entre las diferentes animaciones. Los intervalos no están alineados. Así que tendremos varias ejecuciones independientes dentro de 20ms.

En otras palabras, esto:

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

…Es más ligero que tres llamadas independientes:

setInterval(animate1, 20); // animaciones independientes
setInterval(animate2, 20); // en diferentes lugares del script
setInterval(animate3, 20);

Estos varios redibujos independientes deben agruparse para facilitar el redibujado al navegador y, por lo tanto, cargar menos CPU y verse más fluido.

Hay una cosa más a tener en cuenta. A veces, cuando el CPU está sobrecargado, o hay otras razones para volver a dibujar con menos frecuencia (como cuando la pestaña del navegador está oculta), no deberíamos ejecutarlo cada 20ms.

Pero, ¿cómo sabemos eso en JavaScript? Hay una especificación Sincronización de animación que proporciona la función requestAnimationFrame. Aborda todos estos problemas y aún más.

La sintaxis:

let requestId = requestAnimationFrame(callback)

Eso programa la función callback para que se ejecute en el tiempo más cercano cuando el navegador quiera hacer una animación.

Si hacemos cambios en los elementos dentro de callback, entonces se agruparán con otros callbacks de requestAnimationFrame y con animaciones CSS. Así que habrá un recálculo y repintado de geometría en lugar de muchos.

El valor devuelto requestId se puede utilizar para cancelar la llamada:

// cancelar la ejecución programada del callback
cancelAnimationFrame(requestId);

El callback obtiene un argumento: el tiempo transcurrido desde el inicio de la carga de la página en microsegundos. Este tiempo también se puede obtener llamando a performance.now().

Por lo general, el callback se ejecuta muy pronto, a menos que el CPU esté sobrecargado o la batería de la laptop esté casi descargada, o haya otra razón.

El siguiente código muestra el tiempo entre las primeras 10 ejecuciones de requestAnimationFrame. Por lo general, son 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>

Animación estructurada

Ahora podemos hacer una función de animación más universal basada en requestAnimationFrame:

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

  let start = performance.now();

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

    // calcular el estado actual de la animación
    let progress = timing(timeFraction)

    draw(progress); // dibujar

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

  });
}

La función animate acepta 3 parámetros que básicamente describen la animación:

duration

Tiempo total de animación. Como: 1000.

timing(timeFraction)

Función de sincronización, como la propiedad CSS transition-timing-function que obtiene la fracción de tiempo que pasó (0 al inicio, 1 al final) y devuelve la finalización de la animación (como y en la curva de Bézier).

Por ejemplo, una función lineal significa que la animación continúa uniformemente con la misma velocidad:

function linear(timeFraction) {
  return timeFraction;
}

Su gráfico:

Eso es como transition-timing-function: linear. A continuación se muestran variantes más interesantes.

draw(progress)

La función que toma el estado de finalización de la animación y la dibuja. El valor progress=0 denota el estado inicial de la animación y progress=1 – el estado final.

Esta es la función que realmente dibuja la animación.

Puede mover el elemento:

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

…O hacer cualquier otra cosa, podemos animar cualquier cosa, de cualquier forma.

Vamos a animar el elemento width de 0 a 100% usando nuestra función.

Haz clic en el elemento de la demostración:

Resultado
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>

El código para ello:

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

A diferencia de la animación CSS, aquí podemos hacer cualquier función de sincronización y cualquier función de dibujo. La función de sincronización no está limitada por las curvas de Bézier. Y draw puede ir más allá de las propiedades, crear nuevos elementos para la animación de fuegos artificiales o algo así.

Funciones de sincronización

Vimos arriba la función de sincronización lineal más simple.

Veamos más de ellas. Intentaremos animaciones de movimiento con diferentes funciones de sincronización para ver cómo funcionan.

Potencia de n

Si queremos acelerar la animación, podemos usar progress en la potencia n.

Por ejemplo, una curva parabólica:

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

La gráfica:

Velo en acción (haz clic para activar):

…O la curva cúbica o incluso mayor n. Aumentar la potencia hace que se acelere más rápido.

Aquí está el gráfico de progress en la potencia 5:

En acción:

El arco

Función:

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

La gráfica:

Back: tiro con arco

Esta función realiza el “tiro con arco”. Primero “tiramos de la cuerda del arco”, y luego “disparamos”.

A diferencia de las funciones anteriores, depende de un parámetro adicional x, el “coeficiente de elasticidad”. La distancia de “tirar de la cuerda del arco” está definida por él.

El código:

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

The graph for x = 1.5:

Para la animación lo usamos con un valor específico de x. Ejemplo de x = 1.5:

Rebotar

Imagina que dejamos caer una pelota. Se cae, luego rebota unas cuantas veces y se detiene.

La función bounce hace lo mismo, pero en orden inverso: el “rebote” comienza inmediatamente. Utiliza algunos coeficientes especiales para eso:

function bounce(timeFraction) {
  for (let a = 0, b = 1; 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)
    }
  }
}

En acción:

Animación elástica

Una función “elástica” más que acepta un parámetro adicional x para el “rango inicial”.

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

La gráfica para x=1.5:

En acción para x=1.5:

Inversión: ease*

Entonces tenemos una colección de funciones de sincronización. Su aplicación directa se llama “easyIn”.

A veces necesitamos mostrar la animación en orden inverso. Eso se hace con la transformación “easyOut”.

easeOut

En el modo “easyOut”, la función de sincronización se coloca en un wrapper timingEaseOut:

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

En otras palabras, tenemos una función de “transformación” makeEaseOut que toma una función de sincronización “regular” y devuelve el wrapper envolviéndola:

// acepta una función de sincronización, devuelve la variante transformada
function makeEaseOut(timing) {
  return function(timeFraction) {
    return 1 - timing(1 - timeFraction);
  }
}

Por ejemplo, podemos tomar la función bounce descrita anteriormente y aplicarla:

let bounceEaseOut = makeEaseOut(bounce);

Entonces el rebote no estará al principio, sino al final de la animación. Se ve aún mejor:

Resultado
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; 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>

Aquí podemos ver cómo la transformación cambia el comportamiento de la función:

Si hay un efecto de animación al principio, como rebotar, se mostrará al final.

En el gráfico anterior, el rebote regular tiene el color rojo y el rebote easyOut es azul.

  • Rebote regular: el objeto rebota en la parte inferior y luego, al final, salta bruscamente hacia la parte superior.
  • Después de easyOut – primero salta a la parte superior, luego rebota allí.

easeInOut

También podemos mostrar el efecto tanto al principio como al final de la animación. La transformación se llama “easyInOut”.

Dada la función de tiempo, calculamos el estado de la animación de la siguiente manera:

if (timeFraction <= 0.5) { // primera mitad de la animación
  return timing(2 * timeFraction) / 2;
} else { // segunda mitad de la animación
  return (2 - timing(2 * (1 - timeFraction))) / 2;
}

El código wrapper:

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);

En acción, bounceEaseInOut:

Resultado
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; 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 transformación “easyInOut” une dos gráficos en uno: easyIn (regular) para la primera mitad de la animación y easyOut (invertido) – para la segunda parte.

El efecto se ve claramente si comparamos las gráficas de easyIn, easyOut y easyInOut de la función de sincronización circ:

  • Rojo es la variante regular de circ (easeIn).
  • VerdeeaseOut.
  • AzuleaseInOut.

Como podemos ver, el gráfico de la primera mitad de la animación es el easyIn reducido y la segunda mitad es el easyOut reducido. Como resultado, la animación comienza y termina con el mismo efecto.

“Dibujar” más interesante

En lugar de mover el elemento podemos hacer otra cosa. Todo lo que necesitamos es escribir la función draw adecuada.

Aquí está la escritura de texto animada “rebotando”:

Resultado
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">Tomó su espada vorpal en mano:
Hace mucho tiempo que el enemigo manxome buscaba—
Así descansó junto al árbol Tumtum,
Y se quedó un rato en el pensamiento.
  </textarea>

  <button onclick="animateText(textExample)">¡Ejecuta la escritura animada!</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.slice(0, Math.ceil(result))
        }
      });
    }


    function bounce(timeFraction) {
      for (let a = 0, b = 1; 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>

Resumen

Para animaciones que CSS no puede manejar bien, o aquellas que necesitan un control estricto, JavaScript puede ayudar. Las animaciones de JavaScript deben implementarse a través de requestAnimationFrame. Ese método integrado permite configurar una función callback para que se ejecute cuando el navegador esté preparando un repintado. Por lo general, es muy pronto, pero el tiempo exacto depende del navegador.

Cuando una página está en segundo plano, no se repinta en absoluto, por lo que el callback no se ejecutará: la animación se suspenderá y no consumirá recursos. Eso es genial.

Aquí está la función auxiliar animate para configurar la mayoría de las animaciones:

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

  let start = performance.now();

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

    // calcular el estado actual de la animación
    let progress = timing(timeFraction);

    draw(progress); // dibujar

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

  });
}

Opciones:

  • duration – el tiempo total de animación en ms.
  • timing – la función para calcular el progreso de la animación. Obtiene una fracción de tiempo de 0 a 1, devuelve el progreso de la animación, generalmente de 0 a 1.
  • draw – la función para dibujar la animación.

Seguramente podríamos mejorarlo, agregar más campanas y silbidos, pero las animaciones de JavaScript no se aplican a diario. Se utilizan para hacer algo interesante y no estándar. Por lo tanto, querrás agregar las funciones que necesitas cuando las necesites.

Las animaciones JavaScript pueden utilizar cualquier función de sincronización. Cubrimos muchos ejemplos y transformaciones para hacerlos aún más versátiles. A diferencia de CSS, aquí no estamos limitados a las curvas de Bézier.

Lo mismo ocurre con draw: podemos animar cualquier cosa, no solo propiedades CSS.

Tareas

importancia: 5

Haz una pelota que rebote. Haz clic para ver cómo debería verse:

Abrir un entorno controlado para la tarea.

Para rebotar podemos usar la propiedad CSS top y position:absolute para la pelota dentro del campo con position:relative.

La coordenada inferior del campo es field.clientHeight. La propiedad CSS top se refiere al borde superior de la bola. Por lo tanto, debe ir desde 0 hasta field.clientHeight - ball.clientHeight, que es la posición final más baja del borde superior de la pelota.

Para obtener el efecto de “rebote”, podemos usar la función de sincronización bounce en el modo easeOut.

Aquí está el código final de la animación:

let to = field.clientHeight - ball.clientHeight;

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

Abrir la solución en un entorno controlado.

importancia: 5

Haz que la pelota rebote hacia la derecha. Así:

Escribe el código de la animación. La distancia a la izquierda es 100px.

Toma la solución de la tarea anterior Animar la pelota que rebota como fuente.

En la tarea Animar la pelota que rebota solo teníamos una propiedad para animar. Ahora necesitamos una más: elem.style.left.

La coordenada horizontal cambia por otra ley: no “rebota”, sino que aumenta gradualmente desplazando la pelota hacia la derecha.

Podemos escribir una animate más para ello.

Como función de tiempo podríamos usar linear, pero algo como makeEaseOut(quad) se ve mucho mejor.

El código:

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

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

// animate left (moviéndose a la derecha)
animate({
  duration: 2000,
  timing: makeEaseOut(quad),
  draw: function(progress) {
    ball.style.left = width * progress + "px"
  }
});

Abrir la solución en un entorno controlado.

Mapa del Tutorial