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

Stephen McGruer
Stephen McGruer

A modo de resumen

Usa transformaciones de escalamiento cuando animes clips. Para evitar que los elementos secundarios se estiren y deformen durante la animación, puedes aplicarles una escala inversa.

Anteriormente, publicamos actualizaciones sobre cómo crear efectos de paralaje y desplazamientos infinitos de alto rendimiento. En esta publicación, analizaremos de qué se trata si quieres animaciones de clips con buen rendimiento. Si quieres ver una demo, consulta el repositorio de GitHub de Sample UI Elements.

Por ejemplo, un menú desplegable:

Algunas opciones para compilar esto tienen un mejor rendimiento que otras.

Mala: Animar el ancho y la altura en un elemento de contenedor

Puedes imaginarte usar un poco de CSS para animar el ancho y la altura del elemento del 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 con 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 que puede ser muy costoso y, por lo general, te hará perder 60 fps. Si eso te parece bien, lee nuestras guías sobre el rendimiento de la renderización, en las que puedes obtener más información sobre el funcionamiento del proceso.

Mala: Usa las propiedades clip o clip-path de CSS

Una alternativa para animar width y height podría ser usar la propiedad clip (ahora obsoleta) para animar el efecto de expansión y contracción. O bien, si lo prefieres, puedes usar clip-path en su lugar. Sin embargo, el uso de clip-path tiene menos compatibilidad que clip. Pero clip dejó de estar disponible. ¿No? Pero no te desesperes, esta no es la solución que querías de todos modos.

.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 los 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 se opera esté en una posición absoluta o fija, lo que puede requerir un poco de transformación adicional.

Bueno: Animación de escalas

Dado que este efecto implica que algo se hace más grande y más pequeño, puedes usar una transformación de escala. Esta es una gran noticia, ya que cambiar las 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 de inicio y finalización

Con un enfoque en el que se usan animaciones de escala, el primer paso es leer elementos que te 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 datos de información de una sola vez y que debas activar o desactivar algunas clases para poder leer los diferentes estados del componente. Sin embargo, si necesitas hacerlo, ten cuidado: getBoundingClientRect() (o offsetWidth y offsetHeight) obliga al navegador a ejecutar estilos y pases de diseño si los estilos cambiaron desde la última vez que se ejecutaron.

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 un menú, podemos suponer de manera razonable que comenzará en su escala natural (1, 1). Esta escala natural representa su estado expandido, lo que significa que deberás animar desde una versión reducida (que se calculó anteriormente) hasta esa escala natural.

¡Pero espera! Seguramente esto también escalaría el contenido del menú, ¿no? Sí, como puedes ver a continuación.

Entonces, ¿qué puedes hacer al respecto? Puedes aplicar una transformación counter- a los contenidos, por lo tanto, por ejemplo, si el contenedor se reduce a 1/5 de su tamaño normal, puedes escalar el contenido hacia arriba 5 veces para evitar que el contenido se aplaque. Hay dos aspectos que debes tener en cuenta:

  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 que se animan tengan su propia capa del compositor (lo que permitirá que la GPU ayude) y, para eso, puedes 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 fotograma. Aquí es donde las cosas pueden complicarse un poco, ya que, si suponemos que la animación está en CSS y usa una función de suavización, se debe contrarrestar la suavización cuando se anima la contratransformación. Sin embargo, calcular la curva inversa para, digamos, cubic-bezier(0, 0, 0.3, 1) no es tan obvio.

Por lo tanto, puede ser tentador considerar animar el efecto con JavaScript. Después de todo, podrías usar una ecuación de suavización para calcular los valores de escala y contraescala por fotograma. La desventaja de cualquier animación basada en JavaScript es lo que sucede cuando el subproceso principal (donde se ejecuta tu código JavaScript) está ocupado con alguna otra tarea. La respuesta corta es que tu animación puede tartamudear o detenerse por completo, lo que no es bueno para la 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 fotogramas clave con nuestra propia función de suavizació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 se puede ejecutar en el compositor una animación con fotogramas clave que muta las transformaciones, lo que significa que no se ve afectada por las tareas del subproceso principal.

Para crear la animación de fotogramas clave, avanzamos de 0 a 100 y calculamos qué valores de escala se necesitarían para el elemento y su contenido. Luego, se pueden reducir a una cadena, que se puede insertar en la página como un elemento de diseño. La inserción de los estilos provocará que se pase Recalculate Styles en la página, que es un trabajo adicional que el navegador debe realizar, pero lo hará solo 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 usuarios con curiosidad constante 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 suavizado.

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

También puedes usar la Búsqueda de Google para ver cómo se ve. ¡Muy útil! Si necesitas otras ecuaciones de suavización, consulta Tween.js de Soledad Penadés, que contiene una gran cantidad de ellas.

Paso 3: Habilita las animaciones de CSS

Con estas animaciones creadas y compiladas en la página en JavaScript, el último paso es activar o desactivar las clases para habilitar 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. Debido a que las animaciones renderizadas ya están suavizadas, la función de tiempo debe establecerse en linear. De lo contrario, se suavizará entre cada fotograma clave, lo que se verá muy extraño.

Cuando se trata de contraer el elemento, hay dos opciones: actualizar la animación de CSS para que se ejecute de forma inversa en lugar de hacia adelante. Esto funcionará bien, pero la “sensación” de la animación se invertirá, por lo que, si usaste una curva de salida lenta, la inversa se sentirá más lenta, lo que hará que se sienta lenta. Una solución más apropiada es crear un segundo par de animaciones para contraer el elemento. Se pueden crear de la misma manera que las animaciones de fotogramas clave de expansión, pero con los valores de inicio y fin 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 de expansión y contracción circulares.

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

Ten en cuenta que, en esta variante en particular, Chrome tiene texto desenfocado en pantallas de baja densidad de píxeles durante la animación debido a errores de redondeo debido a la escala y la contraescala del texto. Si te interesan los detalles, se presentó un error que puedes destacar y seguir.

Puedes encontrar el código del efecto de expansión circular en el repositorio de GitHub.

Conclusiones

Ya lo tienes, una forma de realizar animaciones de clips de alto rendimiento con transformaciones de escala. En un mundo ideal, sería genial ver que se aceleren las animaciones de clips (hay un error de Chromium para eso creado por Jake Archibald), pero hasta que lleguemos allí, debes tener cuidado cuando animes clip o clip-path, y 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 pueden ejecutarse 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 la mejora progresiva 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 compilas una animación de CSS y la usas en su lugar. Del mismo modo, si tu app ya usa JavaScript para sus animaciones, quizás te convenga ser al menos coherente con tu base de código existente.

Si quieres revisar el código de este efecto, echa un vistazo al repositorio de GitHub de muestras de elementos de la IU y, como siempre, cuéntanos cómo te va en los comentarios.