Animaciones expandibles de alto rendimiento: creación de fotogramas clave sobre la marcha
Las animaciones han recorrido un largo camino y brindan continuamente a los desarrolladores mejores herramientas. Las animaciones CSS , en particular, han definido la base para resolver la mayoría de los casos de uso. Sin embargo, hay algunas animaciones que requieren un poco más de trabajo.
Probablemente sepas que las animaciones deben ejecutarse en la capa compuesta. (No me extenderé más aquí, pero si quieres saber más, consulta este artículo). Eso significa animación transform
o opacity
propiedades que no activan el diseño o las capas de pintura. Animar propiedades como height
y width
es un gran no-no, ya que activan esas capas, lo que obliga al navegador a recalcular los estilos.
Además de eso, incluso al animar transform
propiedades, si realmente quieres alcanzar animaciones de 60 FPS, probablemente deberías obtener un poco de ayuda de JavaScript, utilizando la técnica FLIP para obtener animaciones más fluidas.
Sin embargo, el problema de usar transform
animaciones expandibles es que la scale
función no es exactamente la misma que animar width
/ height
propiedades. Crea un efecto sesgado en el contenido, ya que todos los elementos se estiran (al ampliar) o se comprimen (al reducir).
Por eso, mi solución preferida ha sido (y probablemente sigue siendo, por razones que detallaré más adelante) la técnica n.° 3 del artículo de Brandon Smith. Esto todavía tiene una transición activada height
, pero usa Javascript para calcular el tamaño del contenido y forzar una transición usando requestAnimationFrame
. En OutSystems, utilizamos esto para crear la animación del patrón de acordeón de la interfaz de usuario de OutSystems.
Generando fotogramas clave con JavaScript
Recientemente, me topé con otro gran artículo de Paul Lewis, que detalla una nueva solución para expandir y contraer animaciones, lo que me motivó a escribir este artículo y difundir esta técnica.
Según sus palabras, la idea principal consiste en generar fotogramas claves dinámicas, stepping…
[…] de 0 a 100 y calcule qué valores de escala serán necesarios para el elemento y su contenido. Luego, estos se pueden reducir a una cadena, que se puede inyectar en la página como un elemento de estilo.
Para lograrlo, hay tres pasos principales.
Paso 1: Calcular los estados inicial y final
Necesitamos calcular el valor de escala correcto para ambos estados. Eso significa que usamos getBoundingClientRect()
el elemento que servirá como proxy para el estado inicial y lo dividimos con el valor del estado final. Debería ser algo como esto:
function calculateStartScale () { const start= startElement.getBoundingClientRect(); const end= endElement.getBoundingClientRect(); return { x: start.width / end.width, y: start.height / end.height };}
Paso 2: generar los fotogramas clave
Ahora, necesitamos ejecutar un for
bucle, usando la cantidad de fotogramas necesarios como longitud. (En realidad, no debería ser inferior a 60 para garantizar una animación fluida). Luego, en cada iteración, calculamos el valor de aceleración correcto, usando una ease
función:
function ease (v, pow=4) { return 1 - Math.pow(1 - v, pow);}let easedStep = ease(i / frame);
Con ese valor, obtendremos la escala del elemento en el paso actual, usando las siguientes matemáticas:
const xScale = x + (1 - x) * easedStep;const yScale = y + (1 - y) * easedStep;
Y luego agregamos el paso a la cadena de animación:
animation += `${step}% { transform: scale(${xScale}, ${yScale});}`;
Para evitar que el contenido se estire o sesgue, debemos realizar una animación de contador, usando los valores invertidos:
const invXScale = 1 / xScale;const invYScale = 1 / yScale;inverseAnimation += `${step}% { transform: scale(${invXScale}, ${invYScale});}`;
Finalmente, podemos devolver las animaciones completadas o inyectarlas directamente en una etiqueta de estilo recién creada.
Paso 3: habilita las animaciones CSS
En el lado de CSS, necesitamos habilitar las animaciones en los elementos correctos:
.element--expanded { animation-name: animation; animation-duration: 300ms; animation-timing-function: step-end;}.element-contents--expanded { animation-name: inverseAnimation ; animation-duration: 300ms; animation-timing-function: step-end;}
Puede consultar el ejemplo de un Menú del artículo de Paul Lewis, en Codepen (cortesía de Chris):
Construyendo una sección expandible
Después de comprender estos conceptos básicos, quería comprobar si podía aplicar esta técnica en un caso de uso diferente, como una sección ampliable.
Sólo necesitamos animar la altura en este caso, específicamente en la función para calcular escalas. Obtenemos el Y
valor del título de la sección, para que sirva como estado contraído, y toda la sección para representar el estado expandido:
_calculateScales () { var collapsed = this._sectionItemTitle.getBoundingClientRect(); var expanded = this._section.getBoundingClientRect(); // create css variable with collapsed height, to apply on the wrapper this._sectionWrapper.style.setProperty('--title-height', collapsed.height + 'px'); this._collapsed = { y: collapsed.height / expanded.height } }
Como queremos que la sección expandida tenga absolute
posicionamiento (para evitar que ocupe espacio cuando esté colapsada), estamos configurando la variable CSS con la altura colapsada, aplicada en el envoltorio. Ese será el único elemento con posicionamiento relativo.
Luego viene la función para crear los fotogramas clave: _createEaseAnimations()
. Esto no difiere mucho de lo explicado anteriormente. Para este caso de uso, necesitamos crear cuatro animaciones:
- La animación para expandir el contenedor.
- La animación contraexpandida sobre el contenido.
- La animación para colapsar el contenedor.
- La animación de contra-colapso del contenido.
Seguimos el mismo enfoque que antes, ejecutando un for
bucle con una longitud de 60 (para obtener una animación fluida de 60 FPS) y creamos un porcentaje de fotogramas clave, basado en el paso simplificado. Luego, lo llevamos a las cadenas de animaciones finales:
outerAnimation.push(` ${percentage}% { transform: scaleY(${yScale}); }`); innerAnimation.push(` ${percentage}% { transform: scaleY(${invScaleY}); }`);
Comenzamos creando una etiqueta de estilo para contener las animaciones terminadas. Como está construido como un constructor, para poder agregar fácilmente múltiples patrones, queremos tener todas estas animaciones generadas en la misma hoja de estilo. Entonces, primero validamos si el elemento existe. De lo contrario, lo creamos y agregamos un nombre de clase significativa. De lo contrario, terminarías con una hoja de estilo expandible para cada sección, lo cual no es ideal.
var sectionEase = document.querySelector('.section-animations'); if (!sectionEase) { sectionEase = document.createElement('style'); sectionEase.classList.add('section-animations'); }
Hablando de eso, es posible que ya te estés preguntando: “Hmm, si tenemos varias secciones expandibles, ¿no seguirían usando la animación con el mismo nombre, con valores posiblemente incorrectos para su contenido?”
¡Estás absolutamente en lo correcto! Entonces, para evitar eso, también estamos generando nombres de animaciones dinámicas. ¿Guay, verdad?
Hacemos uso del índice pasado al constructor desde el for
bucle al realizar el querySelectorAll('.section')
proceso para agregar un elemento único al nombre:
var sectionExpandAnimationName = "sectionExpandAnimation" + index;var sectionExpandContentsAnimationName = "sectionExpandContentsAnimation" + index;
Luego usamos este nombre para establecer una variable CSS en la sección expandible actual. Como esta variable solo está en este alcance, solo necesitamos configurar la animación en la nueva variable en el CSS, y cada patrón obtendrá su animation-name
valor respectivo.
.section.is--expanded { animation-name: var(--sectionExpandAnimation);}.is--expanded .section-item { animation-name: var(--sectionExpandContentsAnimation);}.section.is--collapsed { animation-name: var(--sectionCollapseAnimation);}.is--collapsed .section-item { animation-name: var(--sectionCollapseContentsAnimation);}
El resto del script está relacionado con la adición de detectores de eventos, funciones para alternar el estado de contraer/expandir y algunas mejoras de accesibilidad.
Acerca de HTML y CSS: se necesita un poco de trabajo adicional para que funcione la funcionalidad ampliable. Necesitamos un contenedor adicional para que sea el elemento relativo que no se anima. Los niños expandibles tienen una absolute
posición para que no ocupen espacio cuando están plegados.
Recuerde, dado que necesitamos hacer animaciones de contador, las hacemos escalar a tamaño completo para evitar un efecto sesgado en el contenido.
.section-item-wrapper { min-height: var(--title-height); position: relative;}.section { animation-duration: 300ms; animation-timing-function: step-end; contain: content; left: 0; position: absolute; top: 0; transform-origin: top left; will-change: transform;}.section-item { animation-duration: 300ms; animation-timing-function: step-end; contain: content; transform-origin: top left; will-change: transform; }
Me gustaría resaltar la importancia de la animation-timing-function
propiedad. Debe establecerse en linear
o step-end
para evitar la relajación entre cada fotograma clave.
La will-change
propiedad, como probablemente sepa, permitirá la aceleración de GPU para la animación de transformación para una experiencia aún más fluida. Y usar la contains
propiedad, con un valor de contents
, ayudará al navegador a tratar el elemento independientemente del resto del árbol DOM, limitando el área antes de volver a calcular las propiedades de diseño, estilo, pintura y tamaño.
Usamos visibility
y opacity
para ocultar el contenido y evitar que los lectores de pantalla accedan a él cuando esté colapsado.
.section-item-content { opacity: 1; transition: opacity 500ms ease;}.is--collapsed .section-item-content { opacity: 0; visibility: hidden;}
¡Y por fin tenemos nuestra sección ampliable! Aquí está el código completo y la demostración para que pueda verificar:
control de rendimiento
Cada vez que trabajamos con animaciones, el rendimiento debe estar en el fondo de nuestra mente. Entonces, utilizamos herramientas de desarrollo para verificar si todo este trabajo valió la pena en términos de rendimiento. Usando la pestaña Rendimiento (estoy usando Chrome DevTools), podemos analizar los FPS y el uso de la CPU durante las animaciones.
¡Y los resultados son geniales!
Usando la herramienta de medidor de FPS para comprobar los valores con mayor detalle, podemos ver que constantemente alcanza la marca de 60 FPS, incluso con un uso abusivo.
Consideraciones finales
Entonces, ¿cuál es el veredicto? ¿Esto reemplaza a todos los demás métodos? ¿Es ésta la solución del “Santo Grial”?
En mi opinión, no.
Pero… ¡está bien, de verdad! Es otra solución en la lista. Y, como ocurre con cualquier otro método, se debe analizar si es el mejor enfoque para el caso de uso.
Esta técnica definitivamente tiene sus ventajas. Como dice Paul Lewis, preparar esto requiere mucho trabajo. Pero, por otro lado, sólo necesitamos hacerlo una vez, cuando se carga la página. Durante las interacciones, simplemente alternamos clases (y atributos en algunos casos, por accesibilidad).
Sin embargo, esto trae algunas limitaciones a la interfaz de usuario de los elementos. Como puede ver en el elemento de sección expandible, la contraescala lo hace mucho más confiable para elementos absolutos y fuera del lienzo, como acciones flotantes o menús. También es difícil aplicar estilos a los bordes porque se utiliza overflow: hidden
.
Sin embargo, creo que este enfoque tiene muchísimo potencial. ¡Déjame saber lo que piensas!
Deja un comentario