¿Crear una ruta de movimiento CSS responsiva? ¡Seguro que podemos!
Recientemente hubo una discusión sobre Animation at Work Slack: ¿cómo se puede hacer que una ruta de movimiento CSS responda? ¿Qué técnicas funcionarían? Esto me hizo pensar.
Una ruta de movimiento CSS nos permite animar elementos a lo largo de rutas personalizadas definidas por el usuario. Esas rutas siguen la misma estructura que las rutas SVG. Definimos una ruta para un elemento usando offset-path
.
.block { offset-path: path('M20,20 C20,100 200,0 200,100');}
Estos valores parecen relativos al principio y lo sería si estuviéramos usando SVG. Pero, cuando se usan en un archivo offset-path
, se comportan como unidades px. Este es exactamente el problema. Las unidades de píxeles no responden realmente. Este camino no se flexionará a medida que el elemento en el que se encuentra se haga más pequeño o más grande. Resolvamos esto.
Para preparar el escenario, la offset-distance
propiedad dicta dónde debe estar un elemento en esa ruta:
No solo podemos definir la distancia que se encuentra un elemento a lo largo de una ruta, sino que también podemos definir la rotación de un elemento con desplazamiento-rotación. El valor predeterminado es automático, lo que hace que nuestro elemento siga la ruta. Consulte el artículo del almanaque de la propiedad para conocer más valores.
Para animar un elemento a lo largo del camino, animamos offset-distance
:
Bien, eso se pone al día con los elementos en movimiento a lo largo de un camino. Ahora tenemos que responder…
¿Podemos hacer caminos responsivos?
El punto conflictivo con las rutas de movimiento CSS es la naturaleza codificada. No es flexible. Estamos atacados en la codificación de rutas para dimensiones y tamaños de ventanas gráficas particulares. Una ruta que anima un elemento de 600 px animará ese elemento de 600 px independientemente de si la ventana gráfica tiene 300 px o 3440 px de ancho.
Esto difiere de lo que nos conocemos cuando usamos rutas SVG. Se escalará con el tamaño del cuadro de visualización SVG.
Intento cambiar el tamaño de la siguiente demostración a continuación y verá:
- El SVG se escalará con el tamaño de la ventana gráfica al igual que la ruta contenida.
- La ruta de desplazamiento no se escala y el elemento se desvía de su curso.
Esto podría estar bien para caminos más simples. Pero una vez que nuestros caminos se vuelvan más complicados, será difícil mantenerlos. Especialmente si deseamos utilizar rutas que hemos creado en aplicaciones de dibujo vectorial.
Por ejemplo, considere la ruta con la que trabajamos anteriormente:
.element { --path: 'M20,20 C20,100 200,0 200,100'; offset-path: path(var(--path));}
Para ampliar eso a un tamaño de contenedor diferente, necesitaríamos calcular la ruta nosotros mismos y luego aplicarla en diferentes puntos de interrupción. Pero incluso con esta ruta “simple”, ¿se trata de multiplicar todos los valores de la ruta? ¿Eso nos dará la escala correcta?
@media(min-width: 768px) { .element { --path: 'M40,40 C40,200 400,0 400,200'; // ???? }}
Una ruta más compleja, como la dibujada en una aplicación vectorial, será más complicada de mantener. Será necesario que el desarrollador abra la aplicación, cambie la escala de la ruta, la exporte y la integre con el CSS. Esto deberá suceder para todas las variaciones de tamaño de contenedor. No es la peor solución, pero requiere un nivel de mantenimiento en el que quizás no querríamos meternos.
.element { --path: 'M40,228.75L55.729166666666664,197.29166666666666C71.45833333333333,165.83333333333334,102.91666666666667,102.91666666666667,134.375,102.91666666666667C165.83333333333334,102.91666666666667,197.29166666666666,165.83333333333334,228.75,228.75C260.2083333333333,291.6666666666667,291.6666666666667,354.5833333333333,323.125,354.5833333333333C354.5833333333333,354.5833333333333,386.0416666666667,291.6666666666667,401.7708333333333,260.2083333333333L417.5,228.75'; offset-path: path(var(--path));}
@media(min-width: 768px) { .element { --path: 'M40,223.875L55.322916666666664,193.22916666666666C70.64583333333333,162.58333333333334,101.29166666666667,101.29166666666667,131.9375,101.29166666666667C162.58333333333334,101.29166666666667,193.22916666666666,162.58333333333334,223.875,223.875C254.52083333333334,285.1666666666667,285.1666666666667,346.4583333333333,315.8125,346.4583333333333C346.4583333333333,346.4583333333333,377.1041666666667,285.1666666666667,392.4270833333333,254.52083333333334L407.75,223.875'; }}
@media(min-width: 992px) { .element { --path: 'M40,221.625L55.135416666666664,191.35416666666666C70.27083333333333,161.08333333333334,100.54166666666667,100.54166666666667,130.8125,100.54166666666667C161.08333333333334,100.54166666666667,191.35416666666666,161.08333333333334,221.625,221.625C251.89583333333334,282.1666666666667,282.1666666666667,342.7083333333333,312.4375,342.7083333333333C342.7083333333333,342.7083333333333,372.9791666666667,282.1666666666667,388.1145833333333,251.89583333333334L403.25,221.625'; }}
Parece que una solución JavaScript tiene sentido aquí. GreenSock es mi primer pensamiento porque su complemento MotionPath puede escalar rutas SVG. ¿Pero qué pasa si queremos animar fuera de un SVG? ¿Podríamos escribir una función que escale los caminos por nosotros? Podríamos, pero no será sencillo.
Probar diferentes enfoques
¿Qué herramienta nos permite definir un camino de alguna manera sin la sobrecarga mental? ¡Una biblioteca de gráficos! Algo como D3.js nos permite pasar un conjunto de coordenadas y recibir una cadena de ruta generada. Ese cordel lo podemos adaptar a nuestras necesidades con diferentes curvas, tamaños, etc.
Con algunos retoques, podemos crear una función que escala una ruta basada en un sistema de coordenadas definido:
Esto definitivamente funciona, pero tampoco es ideal porque es poco probable que declaremos rutas SVG usando conjuntos de coordenadas. Lo que queremos hacer es tomar un camino sacado directamente de una aplicación de dibujo vectorial, optimizarlo y colocarlo en una página. De esa manera, podemos invocar alguna función de JavaScript y dejar que ella haga el trabajo pesado.
Entonces eso es exactamente lo que vamos a hacer.
Primero, necesitamos crear un camino. Este se creó rápidamente en Inkscape. Hay otras herramientas de dibujo vectorial disponibles.
A continuación, optimicemos el SVG. Después de guardar el archivo SVG, lo ejecutaremos a través de la brillante herramienta SVGOMG de Jake Archibald. Eso nos da algo como esto:
svg viewBox="0 0 79.375 79.375"path d="M10.362 18.996s-6.046 21.453 1.47 25.329c10.158 5.238 18.033-21.308 29.039-18.23 13.125 3.672 18.325 36.55 18.325 36.55l12.031-47.544" fill="none" stroke="#000" stroke-width=".265"//svg
Las piezas que nos interesan son path
y viewBox
.
Ampliando la solución JavaScript
Ahora podemos crear una función de JavaScript para encargarse del resto. Anteriormente, creamos una función que toma un conjunto de puntos de datos y los convierte en una ruta SVG escalable. Pero ahora queremos ir un paso más allá y tomar la cadena de ruta y calcular el conjunto de datos. De esta manera, nuestros usuarios nunca tendrán que preocuparse por intentar convertir sus rutas en conjuntos de datos.
Hay una advertencia para nuestra función: además de la cadena de ruta, también necesitamos algunos límites para escalar la ruta. Es probable que estos límites sean el tercer y cuarto valor del atributo viewBox en nuestro SVG optimizado.
const path ="M10.362 18.996s-6.046 21.453 1.47 25.329c10.158 5.238 18.033-21.308 29.039-18.23 13.125 3.672 18.325 36.55 18.325 36.55l12.031-47.544";const height = 79.375 // equivalent to viewbox y2const width = 79.375 // equivalent to viewbox x2
const motionPath = new ResponsiveMotionPath({ height, width, path,});
No repasaremos esta función línea por línea. ¡Puedes comprobarlo en la demostración! Pero destacaremos los pasos importantes que lo hacen posible.
Primero, estamos convirtiendo una cadena de ruta en un conjunto de datos.
La mayor parte para hacer esto posible es poder leer los segmentos de ruta. Esto es totalmente posible gracias a la API SVGGeometryElement. Comenzamos creando un elemento SVG con una ruta y asignando la cadena de ruta a su d
atributo.
// To convert the path data to points, we need an SVG path element.const svgContainer = document.createElement('div');// To create one though, a quick way is to use innerHTMLsvgContainer.innerHTML = ` svg path d="${path}" stroke-width="${strokeWidth}"/ /svg`;const pathElement = svgContainer.querySelector('path');
Luego podemos usar la API SVGGeometryElement en ese elemento de ruta. Todo lo que tenemos que hacer es iterar sobre la longitud total de la ruta y devolver el punto en cada longitud de la ruta.
convertPathToData = path = { // To convert the path data to points, we need an SVG path element. const svgContainer = document.createElement('div'); // To create one though, a quick way is to use innerHTML svgContainer.innerHTML = `svg path d="${path}"/ /svg`; const pathElement = svgContainer.querySelector('path'); // Now to gather up the path points. const DATA = []; // Iterate over the total length of the path pushing the x and y into // a data set for d3 to handle for (let p = 0; p pathElement.getTotalLength(); p++) { const { x, y } = pathElement.getPointAtLength(p); DATA.push([x, y]); } return DATA;}
A continuación, generamos proporciones de escala.
¿Recuerda que dijimos que necesitaríamos algunos límites probablemente definidos por viewBox
? Esta es la razón por. Necesitamos alguna forma de calcular la relación entre la trayectoria del movimiento y su contenedor. Esta relación será igual a la del camino contra el SVG viewBox
. Luego los usaremos con escalas D3.js.
Tenemos dos funciones: una para tomar los valores más grandes x
y y
, y otra para calcular las proporciones en relación con el viewBox
.
getMaximums = data = { const X_POINTS = data.map(point = point[0]) const Y_POINTS = data.map(point = point[1]) return [ Math.max(...X_POINTS), // x2 Math.max(...Y_POINTS), // y2 ]}getRatios = (maxs, width, height) = [maxs[0] / width, maxs[1] / height]
Ahora necesitamos generar la ruta.
La última pieza del rompecabezas es generar el camino para nuestro elemento. Aquí es donde realmente entra en juego D3.js. No se preocupe si no lo ha usado antes porque solo usamos un par de funciones. Específicamente, usaremos D3 para generar una cadena de ruta con el conjunto de datos que generamos anteriormente.
Para crear una línea con nuestro conjunto de datos, hacemos esto:
d3.line()(data); // M10.362000465393066,18.996000289916992L10.107386589050293, etc.
El problema es que esos puntos no están adaptados a nuestro contenedor. Lo bueno de D3 es que ofrece la posibilidad de crear escalas. Estos actúan como funciones de interpolación. ¿Ves a dónde va esto? Podemos escribir un conjunto de coordenadas y luego hacer que D3 recalcule la ruta. Podemos hacer esto según el tamaño de nuestro contenedor usando las proporciones que generamos.
Por ejemplo, aquí está la escala de nuestras x
coordenadas:
const xScale = d3 .scaleLinear() .domain([ 0, maxWidth, ]) .range([0, width * widthRatio]);
El dominio va de 0 a nuestro x
valor más alto. En la mayoría de los casos, el rango irá de 0 al ancho del contenedor multiplicado por nuestra relación de ancho.
Hay ocasiones en las que nuestro rango puede diferir y necesitamos ampliarlo. Esto es cuando la relación de aspecto de nuestro contenedor no coincide con la de nuestro camino. Por ejemplo, considere una ruta en un SVG con una viewBox
extensión de 0 0 100 200
. Esa es una relación de aspecto de 1:2. Pero si luego dibujamos esto en un contenedor que tiene una altura y un ancho de 20 vmin, la relación de aspecto del contenedor es 1:1. Necesitamos rellenar el rango de ancho para mantener el camino centrado y mantener la relación de aspecto.
Lo que podemos hacer en estos casos es calcular un desplazamiento para que nuestra ruta siga centrada en nuestro contenedor.
const widthRatio = (height - width) / heightconst widthOffset = (ratio * containerWidth) / 2const xScale = d3 .scaleLinear() .domain([0, maxWidth]) .range([widthOffset, containerWidth * widthRatio - widthOffset])
Una vez que tengamos dos escalas, podemos mapear nuestros puntos de datos usando las escalas y generar una nueva línea.
const SCALED_POINTS = data.map(POINT = [ xScale(POINT[0]), yScale(POINT[1]),]);d3.line()(SCALED_POINTS); // Scaled path string that is scaled to our container
Podemos aplicar esa ruta a nuestro elemento pasándola en línea a través de una propiedad CSS
ELEMENT.style.setProperty('--path', `"${newPath}"`);
Entonces es nuestra responsabilidad decidir cuándo queremos generar y aplicar una nueva ruta escalada. Aquí hay una posible solución:
const setPath = () = { const scaledPath = responsivePath.generatePath( CONTAINER.offsetWidth, CONTAINER.offsetHeight ) ELEMENT.style.setProperty('--path', `"${scaledPath}"`)}const SizeObserver = new ResizeObserver(setPath)SizeObserver.observe(CONTAINER)
Esta demostración (que se ve mejor en pantalla completa) muestra tres versiones del elemento utilizando una ruta de movimiento. Los caminos están presentes para ver más fácilmente la escala. La primera versión es el SVG sin escala. El segundo es un contenedor de escala que ilustra cómo la ruta no escala. El tercero es utilizar nuestra solución JavaScript para escalar la ruta.
¡Uf, lo logramos!
¡Este fue un desafío realmente genial y definitivamente aprendí mucho de él! Aquí hay un par de demostraciones que utilizan la solución.
¡Debería funcionar como prueba de concepto y parece prometedor! ¡Siéntete libre de incluir tus propios archivos SVG optimizados en esta demostración para probarlos! – debería captar la mayoría de las relaciones de aspecto.
Creé un paquete llamado "Meanderer" en GitHub y npm. También puedes bajarlo con unpkg CDN para jugar con él en CodePen, si quieres probarlo.
Espero ver hacia dónde podría llegar esto y espero que podamos ver alguna forma nativa de manejar esto en el futuro.
Deja un comentario