Cómo compilar animaciones de expansión y contracción de rendimiento

Stephen McGruer
Stephen McGruer

Resumen

Usa transformaciones a escala cuando animes clips. Para evitar que los elementos secundarios se estiren y se distorsionen durante la animación, puedes realizar un contraajuste.

Anteriormente, publicamos actualizaciones sobre cómo crear efectos de paralaje y desplazamientos infinitos de alto rendimiento. En esta publicación, veremos en qué consiste si quieres animaciones de recorte con buen rendimiento. Si quieres ver una demostración, consulta el repositorio de GitHub de elementos de IU de muestra.

Tomemos como ejemplo un menú desplegable:

Algunas opciones para crearlo son más eficaces que otras.

Incorrecto: Animar el ancho y la altura en un elemento contenedor

Imagina usar un poco de CSS para animar el ancho y la altura del elemento contenedor.

.menu {
  overflow: hidden;
  width: 350px;
  height: 600px;
  transition: width 600ms ease-out, height 600ms ease-out;
}

.menu--collapsed {
  width: 200px;
  height: 60px;
}

El problema inmediato de este enfoque es que requiere animar width y height. Estas propiedades requieren calcular el diseño y pintar los resultados en cada fotograma de la animación, lo cual puede ser muy costoso y suele hacer que pierdas 60 FPS. Si no tienes novedades, lee nuestras guías sobre Rendimiento del procesamiento, en las que puedes obtener más información sobre el funcionamiento del proceso.

Incorrecto: Usar las propiedades de recorte o ruta de recorte de CSS

Una alternativa a animar width y height podría ser usar la propiedad clip (ahora obsoleta) para animar el efecto de expandir y contraer. O, si lo prefieres, puedes usar clip-path. Sin embargo, el uso de clip-path es menos compatible que clip. Sin embargo, clip dejó de estar disponible. ¿Verdad? Pero no desesperes, esta no es la solución que querías.

.menu {
  position: absolute;
  clip: rect(0px 112px 175px 0px);
  transition: clip 600ms ease-out;
}

.menu--collapsed {
  clip: rect(0px 70px 34px 0px);
}

Si bien es mejor que animar width y height del elemento de menú, la desventaja de este enfoque es que aún activa la pintura. Además, la propiedad clip, si eliges esa ruta, requiere que el elemento en el que opera esté ubicado de manera absoluta o fija, lo que puede requerir un poco de derivación adicional.

Buena: escalas de animación

Dado que este efecto implica que algo cada vez es más grande, puedes usar una transformación de escala. Esta es una buena noticia, ya que cambiar transformaciones es algo que no requiere diseño ni pintura y que el navegador puede transferir a la GPU, lo que significa que el efecto se acelera y es mucho más probable que alcance los 60 FPS.

La desventaja de este enfoque, como la mayoría de los aspectos del rendimiento de la renderización, es que requiere un poco de configuración. Pero vale la pena.

Paso 1: Calcula los estados inicial y final

Con un enfoque que usa animaciones a escala, el primer paso es leer los elementos que indican el tamaño que debe tener el menú cuando se contrae y cuando se expande. Es posible que, en algunas situaciones, no puedas obtener ambos fragmentos de información de una sola vez y debas activar o desactivar algunas clases para poder leer los diversos estados del componente. Sin embargo, si necesitas hacerlo, ten cuidado: getBoundingClientRect() (o offsetWidth y offsetHeight) obligan al navegador a ejecutar estilos y pases de diseño si los diseños cambiaron desde la última ejecución.

function calculateCollapsedScale () {
    // The menu title can act as the marker for the collapsed state.
    const collapsed = menuTitle.getBoundingClientRect();

    // Whereas the menu as a whole (title plus items) can act as
    // a proxy for the expanded state.
    const expanded = menu.getBoundingClientRect();
    return {
    x: collapsed.width / expanded.width,
    y: collapsed.height / expanded.height
    };
}

En el caso de algo como un menú, podemos suponer razonablemente que comenzará en su escala natural (1, 1). Esta escala natural representa su estado expandido, lo que significa que tendrás que animar desde una versión reducida (que se calculó anteriormente) hasta esa escala natural.

¡Pero espera! Seguro que esto también ampliaría el contenido del menú, ¿no? Bueno, como puedes ver a continuación, sí.

Entonces, ¿qué puedes hacer al respecto? Puedes aplicar una transformación counter-al contenido, por lo que, por ejemplo, si el contenedor reduce su escala a una quinta parte de su tamaño normal, puedes aumentar 5 veces el contenido para evitar que se aplaste. Debes tener en cuenta dos aspectos al respecto:

  1. La contratransformación también es una operación de escala. Esto es bueno porque también se puede acelerar, al igual que la animación en el contenedor. Es posible que debas asegurarte de que los elementos animados obtengan su propia capa del compositor (que permita que la GPU ayude) y, para ello, puedas agregar will-change: transform al elemento o, si necesitas admitir navegadores más antiguos, backface-visiblity: hidden.

  2. La contratransformación se debe calcular por trama. Aquí es donde las cosas pueden volverse un poco más complicadas, ya que, si suponemos que la animación está en CSS y usa una función de aceleración, la aceleración en sí misma debe contrarponerse cuando se anima la contratransformación. Sin embargo, calcular la curva inversa de, por ejemplo, cubic-bezier(0, 0, 0.3, 1) no es tan obvio.

Puede resultar tentador, entonces, considerar la posibilidad de animar el efecto con JavaScript. Después de todo, podrías usar una ecuación de aceleración para calcular los valores de la escala y el contador de escala por fotograma. La desventaja de cualquier animación basada en JavaScript es lo que sucede cuando el subproceso principal (donde se ejecuta JavaScript) está ocupado con alguna otra tarea. La respuesta corta es que tu animación puede entrecortarse o detenerse por completo, lo que no es muy bueno para UX.

Paso 2: Compila animaciones de CSS sobre la marcha

La solución, que puede parecer extraña al principio, es crear una animación con fotograma clave con nuestra propia función de aceleración de forma dinámica y, luego, insertarla en la página para que la use el menú. (Muchas gracias al ingeniero de Chrome Robert Flack por señalar esto). El beneficio principal de esto es que una animación con fotogramas clave que muta las transformaciones se puede ejecutar en el compositor, lo que significa que no se ve afectada por las tareas del subproceso principal.

Para realizar la animación del fotograma clave, vamos de 0 a 100 y calculamos qué valores de escala serían necesarios para el elemento y su contenido. Luego, se pueden resumir en una string, que se puede insertar en la página como un elemento de estilo. Si insertas los diseños, se pasará la acción Volver a calcular estilos en la página, lo cual es un trabajo adicional que debe realizar el navegador, pero solo lo hará una vez cuando se inicie el componente.

function createKeyframeAnimation () {
    // Figure out the size of the element when collapsed.
    let {x, y} = calculateCollapsedScale();
    let animation = '';
    let inverseAnimation = '';

    for (let step = 0; step <= 100; step++) {
    // Remap the step value to an eased one.
    let easedStep = ease(step / 100);

    // Calculate the scale of the element.
    const xScale = x + (1 - x) * easedStep;
    const yScale = y + (1 - y) * easedStep;

    animation += `${step}% {
        transform: scale(${xScale}, ${yScale});
    }`;

    // And now the inverse for the contents.
    const invXScale = 1 / xScale;
    const invYScale = 1 / yScale;
    inverseAnimation += `${step}% {
        transform: scale(${invXScale}, ${invYScale});
    }`;

    }

    return `
    @keyframes menuAnimation {
    ${animation}
    }

    @keyframes menuContentsAnimation {
    ${inverseAnimation}
    }`;
}

Es posible que los curiosos se pregunten sobre la función ease() dentro del bucle for. Puedes usar algo como esto para asignar valores de 0 a 1 a un equivalente reducido.

function ease (v, pow=4) {
  return 1 - Math.pow(1 - v, pow);
}

También puedes usar la Búsqueda de Google para crear un gráfico de cómo se ve. Práctico. Si necesitas otras ecuaciones de aceleración, consulta Tween.js de Soledad Penadés, que contiene un montón de ellas.

Paso 3: Habilita las animaciones de CSS

Con estas animaciones creadas y preparadas en la página en JavaScript, el último paso es alternar las clases que habilitan las animaciones.

.menu--expanded {
  animation-name: menuAnimation;
  animation-duration: 0.2s;
  animation-timing-function: linear;
}

.menu__contents--expanded {
  animation-name: menuContentsAnimation;
  animation-duration: 0.2s;
  animation-timing-function: linear;
}

Esto hace que se ejecuten las animaciones que se crearon en el paso anterior. Como las animaciones preparadas ya se aceleran, la función de sincronización debe establecerse en linear. De lo contrario, el intercambio de fotogramas clave se verá muy extraño.

Cuando se trata de contraer el elemento nuevamente, hay dos opciones: actualizar la animación CSS para que se ejecute al revés en lugar de hacia adelante. Esto funcionará bien, pero la "sensación" de la animación se revertirá, por lo que si usaste una curva de salida lenta, la inversa se verá adentro, lo que hará que sea lenta. Una solución más adecuada es crear un segundo par de animaciones para contraer el elemento. Se pueden crear de la misma manera que las animaciones de expansión de fotogramas clave, pero con valores de inicio y finalización intercambiados.

const xScale = 1 + (x - 1) * easedStep;
const yScale = 1 + (y - 1) * easedStep;

Una versión más avanzada: revelaciones circulares

También es posible usar esta técnica para crear animaciones circulares de expansión y contracción.

Los principios son en gran medida los mismos que la versión anterior, en la que escalas un elemento y realizas la contraescala de sus elementos secundarios inmediatos. En este caso, el elemento que escala verticalmente tiene un border-radius del 50%, lo que lo hace circular, y está unido por otro elemento que tiene overflow: hidden, lo que significa que no verás que el círculo se expanda fuera de los límites del elemento.

Advertencia sobre esta variante en particular: Chrome tiene texto borroso en pantallas con valores bajos de DPI durante la animación debido a errores de redondeo debido a la escala y la contraescala del texto. Si te interesa conocer los detalles, hay un informe de error que puedes destacar y seguir.

El código para el efecto de expansión circular se puede encontrar en el repositorio de GitHub.

Conclusiones

Es una manera de realizar animaciones de recorte eficaces con transformaciones de escala. En un mundo perfecto, sería genial ver que se aceleren las animaciones de clips (hay un error de Chromium para eso, de Jake Archibald), pero hasta que lleguemos a ese punto, debes tener cuidado cuando animes clip o clip-path y, por supuesto, evita animar width o height.

También sería útil usar Animaciones web para efectos como este, ya que tienen una API de JavaScript, pero se pueden ejecutar en el subproceso del compositor si solo animas transform y opacity. Lamentablemente, la compatibilidad con las animaciones web no es muy buena, aunque puedes usar mejoras progresivas para usarlas si están disponibles.

if ('animate' in HTMLElement.prototype) {
    // Animate with Web Animations.
} else {
    // Fall back to generated CSS Animations or JS.
}

Hasta que eso cambie, si bien puedes usar bibliotecas basadas en JavaScript para realizar la animación, es posible que obtengas un rendimiento más confiable si preparas una animación de CSS y la usas. De la misma manera, si tu app ya depende de JavaScript para sus animaciones, será mejor que seas, al menos, coherente con tu base de código existente.

Si quieres revisar el código de este efecto, consulta el repositorio de GitHub de muestras de elementos de la IU y, como siempre, cuéntanos cómo avanzas en los comentarios.