Einen responsiven CSS-Bewegungspfad erstellen? Klar können wir das!

Avatar of Jhey Tompkins
Jhey Tompkins am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Es gab kürzlich eine Diskussion im Animation at Work Slack: *Wie kann man einen CSS-Bewegungspfad responsiv gestalten?* Welche Techniken wären dafür geeignet? Das hat mich zum Nachdenken gebracht.

Ein CSS-Bewegungspfad ermöglicht es uns, Elemente entlang benutzerdefinierter Pfade zu animieren. Diese Pfade folgen derselben Struktur wie SVG-Pfade. Wir definieren einen Pfad für ein Element mit offset-path.

.block {
  offset-path: path('M20,20 C20,100 200,0 200,100');
}

Diese Werte erscheinen zunächst relativ und wären es auch, wenn wir SVG verwenden würden. Aber bei der Verwendung von offset-path verhalten sie sich wie px-Einheiten. Das ist genau das Problem. Pixel-Einheiten sind nicht wirklich responsiv. Dieser Pfad passt sich nicht an, wenn das Element, in dem er sich befindet, kleiner oder größer wird. Lassen Sie uns das herausfinden.

Um die Bühne zu bereiten: Die Eigenschaft offset-distance bestimmt, wo sich ein Element auf diesem Pfad befinden soll.

Wir können nicht nur den Abstand eines Elements entlang eines Pfades definieren, sondern auch die Drehung eines Elements mit offset-rotate. Der Standardwert ist auto, was dazu führt, dass unser Element dem Pfad folgt. Schauen Sie sich den Almanach-Artikel zu dieser Eigenschaft für weitere Werte an.

Um ein Element entlang des Pfades zu animieren, animieren wir die offset-distance.

Okay, damit sind wir auf dem neuesten Stand der Bewegung von Elementen entlang eines Pfades. Jetzt müssen wir die Frage beantworten...

Können wir responsive Pfade erstellen?

Der Knackpunkt bei CSS-Bewegungspfaden ist die hartcodierte Natur. Sie ist nicht flexibel. Wir sind gezwungen, Pfade für bestimmte Dimensionen und Ansichtsfenstergrößen hart zu kodieren. Ein Pfad, der ein Element 600 Pixel animiert, wird dieses Element unabhängig davon, ob das Ansichtsfenster 300 Pixel oder 3440 Pixel breit ist, 600 Pixel animieren.

Das unterscheidet sich von dem, was wir von SVG-Pfaden kennen. Sie skalieren mit der Größe des SVG-Viewports.

Probieren Sie, die folgende Demo zu verkleinern, und Sie werden sehen.

  • Das SVG skaliert mit der Ansichtsfenstergröße, ebenso der enthaltene Pfad.
  • Der offset-path skaliert nicht und das Element gerät vom Kurs ab.

Das mag für einfachere Pfade in Ordnung sein. Aber sobald unsere Pfade komplizierter werden, wird es schwierig, sie zu warten. Insbesondere wenn wir Pfade verwenden möchten, die wir in Vektorgrafikanwendungen erstellt haben.

Betrachten Sie zum Beispiel den Pfad, mit dem wir zuvor gearbeitet haben.

.element {
  --path: 'M20,20 C20,100 200,0 200,100';
  offset-path: path(var(--path));
}

Um ihn auf eine andere Containergröße zu skalieren, müssten wir den Pfad selbst neu berechnen und ihn dann bei verschiedenen Breakpoints anwenden. Aber selbst bei diesem „einfachen“ Pfad stellt sich die Frage, ob wir alle Pfadwerte multiplizieren müssen? Bringt uns das die richtige Skalierung?

@media(min-width: 768px) {
  .element {
    --path: 'M40,40 C40,200 400,0 400,200'; // ????
  }
}

Ein komplexerer Pfad, wie z. B. einer, der in einer Vektoranwendung gezeichnet wurde, ist schwieriger zu warten. Der Entwickler müsste die Anwendung öffnen, den Pfad skalieren, ihn exportieren und in CSS integrieren. Dies müsste für alle Containergrößenvarianten geschehen. Es ist keine schlechte Lösung, aber sie erfordert ein Maß an Wartung, in das wir uns vielleicht nicht begeben wollen.

.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';
  }
}

Es scheint, dass hier eine JavaScript-Lösung sinnvoll ist. GreenSock ist mein erster Gedanke, denn sein MotionPath-Plugin kann SVG-Pfade skalieren. Aber was, wenn wir außerhalb eines SVG animieren wollen? Könnten wir eine Funktion schreiben, die die Pfade für uns skaliert? Das könnten wir, aber es ist nicht einfach.

Versuch verschiedener Ansätze

Welches Werkzeug ermöglicht uns, einen Pfad auf eine Weise zu definieren, ohne den mentalen Aufwand? Eine Charting-Bibliothek! Etwas wie D3.js erlaubt uns, eine Menge von Koordinaten zu übergeben und eine generierte Pfadzeichenfolge zu erhalten. Wir können diese Zeichenfolge mit verschiedenen Kurven, Größen usw. an unsere Bedürfnisse anpassen.

Mit ein wenig Tüftelei können wir eine Funktion erstellen, die einen Pfad basierend auf einem definierten Koordinatensystem skaliert.

Das funktioniert definitiv, ist aber auch nicht ideal, da wir unwahrscheinlich SVG-Pfade anhand von Koordinatensätzen deklarieren werden. Was wir tun wollen, ist, einen Pfad direkt aus einer Vektorgrafikanwendung zu nehmen, ihn zu optimieren und auf eine Seite zu legen. So können wir eine JavaScript-Funktion aufrufen und diese die Hauptarbeit machen lassen.

Das ist also genau das, was wir tun werden.

Zuerst müssen wir einen Pfad erstellen. Dieser wurde in Inkscape schnell zusammengestellt. Andere Vektorgrafikwerkzeuge sind verfügbar.

Ein in Inkscape auf einer 300×300 Leinwand erstellter Pfad.

Als Nächstes optimieren wir das SVG. Nachdem wir die SVG-Datei gespeichert haben, führen wir sie durch Jake Archibalds brillantes SVGOMG-Tool. Das liefert uns etwas in dieser Art.

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 79.375 79.375" height="300" width="300"><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>

Die Teile, an denen wir interessiert sind, sind path und viewBox.

Erweiterung der JavaScript-Lösung

Nun können wir eine JavaScript-Funktion erstellen, um den Rest zu erledigen. Zuvor haben wir eine Funktion erstellt, die einen Satz von Datenpunkten entgegennimmt und sie in einen skalierbaren SVG-Pfad umwandelt. Aber jetzt wollen wir das noch einen Schritt weiter gehen und die Pfadzeichenfolge nehmen und den Datensatz ermitteln. So müssen sich unsere Benutzer nie darum kümmern, ihre Pfade in Datensätze umzuwandeln.

Es gibt eine Einschränkung für unsere Funktion: Neben der Pfadzeichenfolge benötigen wir auch einige Grenzen, gegen die der Pfad skaliert werden soll. Diese Grenzen sind wahrscheinlich die dritten und vierten Werte des viewBox-Attributs in unserem optimierten SVG.

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 y2
const width = 79.375 // equivalent to viewbox x2


const motionPath = new ResponsiveMotionPath({
  height,
  width,
  path,
});

Wir werden diese Funktion nicht Zeile für Zeile durchgehen. Sie können sie sich in der Demo ansehen! Aber wir werden die wichtigen Schritte hervorheben, die dies ermöglichen.

Zuerst wandeln wir eine Pfadzeichenfolge in einen Datensatz um.

Der wichtigste Teil, der dies ermöglicht, ist die Fähigkeit, die Pfadsegmente zu lesen. Das ist dank der SVGGeometryElement API vollständig möglich. Wir beginnen damit, ein SVG-Element mit einem Pfad zu erstellen und die Pfadzeichenfolge seinem d-Attribut zuzuweisen.

// 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 xmlns="http://www.w3.org/2000/svg">
    <path d="${path}" stroke-width="${strokeWidth}"/>
  </svg>`;
const pathElement = svgContainer.querySelector('path');

Dann können wir die SVGGeometryElement API auf diesem Pfadelement verwenden. Alles, was wir tun müssen, ist, über die Gesamtlänge des Pfades zu iterieren und den Punkt bei jeder Länge des Pfades zurückzugeben.

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 xmlns="http://www.w3.org/2000/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;
}

Als Nächstes generieren wir Skalierungsverhältnisse.

Erinnern Sie sich, wie wir sagten, dass wir Grenzen benötigen, die wahrscheinlich durch den viewBox definiert sind? Deshalb. Wir brauchen eine Möglichkeit, ein Verhältnis des Bewegungspfades zu seinem Container zu berechnen. Dieses Verhältnis ist gleich dem des Pfades zum SVG-viewBox. Wir werden diese dann mit D3.js-Skalen verwenden.

Wir haben zwei Funktionen: eine, um die größten x- und y-Werte abzurufen, und eine andere, um die Verhältnisse im Verhältnis zum viewBox zu berechnen.

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]

Jetzt müssen wir den Pfad generieren.

Das letzte Puzzleteil ist die eigentliche Erstellung des Pfades für unser Element. Hier kommt D3.js ins Spiel. Machen Sie sich keine Sorgen, wenn Sie es noch nie benutzt haben, denn wir verwenden nur ein paar Funktionen daraus. Konkret werden wir D3 verwenden, um eine Pfadzeichenfolge mit dem zuvor erstellten Datensatz zu generieren.

Um eine Linie mit unserem Datensatz zu erstellen, machen wir Folgendes.

d3.line()(data); // M10.362000465393066,18.996000289916992L10.107386589050293, etc.

Das Problem ist, dass diese Punkte nicht an unseren Container skaliert sind. Das Coole an D3 ist, dass es die Möglichkeit bietet, Skalen zu erstellen. Diese fungieren als Interpolationsfunktionen. Sehen Sie, worauf das hinausläuft? Wir können einen Satz von Koordinaten schreiben und D3 den Pfad neu berechnen lassen. Das können wir basierend auf unserer Containergröße tun, indem wir die von uns generierten Verhältnisse verwenden.

Zum Beispiel ist hier die Skala für unsere x-Koordinaten.

const xScale = d3
  .scaleLinear()
  .domain([
    0,
    maxWidth,
  ])
  .range([0, width * widthRatio]);

Die Domain reicht von 0 bis zu unserem höchsten x-Wert. Der Bereich geht in den meisten Fällen von 0 bis zur Containerbreite, multipliziert mit unserem Breitenverhältnis.

Es gibt Fälle, in denen unser Bereich abweicht und wir ihn skalieren müssen. Dies geschieht, wenn das Seitenverhältnis unseres Containers nicht mit dem unseres Pfades übereinstimmt. Betrachten Sie zum Beispiel einen Pfad in einem SVG mit einem viewBox von 0 0 100 200. Das ist ein Seitenverhältnis von 1:2. Wenn wir dies dann in einem Container zeichnen, dessen Höhe und Breite 20vmin betragen, beträgt das Seitenverhältnis des Containers 1:1. Wir müssen den Breitenbereich auffüllen, um den Pfad zentriert zu halten und das Seitenverhältnis beizubehalten.

Was wir in diesen Fällen tun können, ist, einen Offset zu berechnen, damit unser Pfad weiterhin in unserem Container zentriert ist. 

const widthRatio = (height - width) / height
const widthOffset = (ratio * containerWidth) / 2
const xScale = d3
  .scaleLinear()
  .domain([0, maxWidth])
  .range([widthOffset, containerWidth * widthRatio - widthOffset])

Sobald wir zwei Skalen haben, können wir unsere Datenpunkte mithilfe der Skalen abbilden und eine neue Linie generieren.

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

Wir können diesen Pfad auf unser Element anwenden, indem wir ihn per Inline-CSS-Eigenschaft übergeben 👍

ELEMENT.style.setProperty('--path', `"${newPath}"`);

Dann liegt es in unserer Verantwortung zu entscheiden, wann wir einen neuen skalierten Pfad generieren und anwenden wollen. Hier ist eine mögliche Lösung.

const setPath = () => {
  const scaledPath = responsivePath.generatePath(
    CONTAINER.offsetWidth,
    CONTAINER.offsetHeight
  )
  ELEMENT.style.setProperty('--path', `"${scaledPath}"`)
}
const SizeObserver = new ResizeObserver(setPath)
SizeObserver.observe(CONTAINER)

Diese Demo (am besten im Vollbildmodus betrachtet) zeigt drei Versionen des Elements mit einem Bewegungspfad. Die Pfade sind vorhanden, um die Skalierung leichter zu erkennen. Die erste Version ist das unskalierte SVG. Die zweite ist ein skalierbarer Container, der veranschaulicht, wie der Pfad nicht skaliert. Die dritte verwendet unsere JavaScript-Lösung, um den Pfad zu skalieren.

Puh, wir haben es geschafft!

Das war eine wirklich coole Herausforderung und ich habe definitiv viel daraus gelernt! Hier sind ein paar Demos, die die Lösung verwenden.

Es sollte als Machbarkeitsnachweis funktionieren und sieht vielversprechend aus! Laden Sie gerne Ihre eigenen optimierten SVG-Dateien in diese Demo, um sie auszuprobieren! – sie sollte die meisten Seitenverhältnisse erfassen.

Ich habe ein Paket namens „Meanderer“ auf GitHub und npm erstellt. Sie können es auch über den unpkg CDN herunterladen, um damit in CodePen zu experimentieren, wenn Sie es ausprobieren möchten.

Ich bin gespannt, wohin das führen könnte und hoffe, dass wir in Zukunft eine native Möglichkeit dafür sehen werden. 🙏