创建响应式 CSS 运动路径?当然可以!

Avatar of Jhey Tompkins
Jhey Tompkins

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

最近在 Animation at Work Slack 上进行了一次讨论:如何使 CSS 运动路径具有响应性?哪些技术可以奏效?这让我开始思考。

CSS 运动路径允许我们沿着自定义用户定义的路径为元素设置动画。这些路径遵循与 SVG 路径相同的结构。我们使用offset-path为元素定义路径。

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

这些值乍一看是相对的,如果我们使用 SVG,它们确实如此。但是,当在 offset-path 中使用时,**它们的行为类似于 px 单位。**这正是问题所在。像素单位并不是真正响应式的。当包含它的元素变小或变大时,此路径不会随之调整。让我们来解决这个问题。

为了设定场景,offset-distance 属性决定了元素在该路径上的位置。

我们不仅可以定义元素沿路径的距离,还可以使用 offset-rotate 定义元素的旋转。默认值为 auto,这会导致我们的元素跟随路径。查看该属性的 年鉴文章 以了解更多值。

要沿路径为元素设置动画,我们为offset-distance设置动画。

好的,现在我们已经了解了沿路径移动元素的速度。现在我们必须回答…

我们可以创建响应式路径吗?

CSS 运动路径的症结在于其硬编码的本质。它不灵活。我们只能为特定的尺寸和视口大小硬编码路径。无论视口宽度是 300px 还是 3440px,为 600px 的元素设置动画的路径都会为该元素设置 600px 的动画。

这与我们在使用 SVG 路径时所熟悉的有所不同。它们会随着 SVG 视区的尺寸进行缩放。

尝试调整下面下一个演示的大小,您就会看到。

  • SVG 会随着视口大小进行缩放,包含的路径也会随之缩放。
  • offset-path **不会**缩放,并且元素会偏离轨道。

对于简单的路径,这可能还可以。但是,一旦我们的路径变得更复杂,就很难维护。尤其是在我们希望使用在矢量绘图应用程序中创建的路径时。

例如,考虑我们之前使用的路径。

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

要将其缩放到不同的容器大小,我们需要自己计算路径,然后在不同的断点应用该路径。但是,即使对于这条“简单”的路径,是否只需要将所有路径值都乘以一个系数?这能给我们带来正确的缩放效果吗?

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

更复杂的路径(例如在矢量应用程序中绘制的路径)将更难维护。它需要开发人员打开应用程序,重新缩放路径,导出它,并将其与 CSS 集成。这需要针对所有容器大小的变化进行。这不是最糟糕的解决方案,但确实需要我们可能不想承担的维护级别。

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

感觉这里使用 JavaScript 解决方案是有意义的。GreenSock 是我的首选,因为它的 MotionPath 插件可以缩放 SVG 路径。但是,如果我们想在 SVG 外部设置动画怎么办?我们可以编写一个为我们缩放路径的函数吗?我们可以,但这并不简单。

尝试不同的方法

什么工具允许我们以某种方式定义路径而无需过多的思考?图表库!像 D3.js 这样的库允许我们传入一组坐标并接收生成的路径字符串。我们可以根据不同的曲线、大小等调整该字符串以满足我们的需求。

通过一些调整,我们可以创建一个根据定义的坐标系缩放路径的函数。

这绝对有效,但也不理想,因为我们不太可能使用一组坐标来声明 SVG 路径。我们想要做的是直接从矢量绘图应用程序中获取路径,对其进行优化,然后将其放到页面上。这样,我们就可以调用一些 JavaScript 函数,让它完成繁重的工作。

所以,这正是我们要做的。

首先,我们需要创建一个路径。这个路径是在 Inkscape 中快速创建的。还有其他矢量绘图工具可用。

在 300×300 画布上使用 Inkscape 创建的路径

接下来,让我们优化 SVG。保存 SVG 文件后,我们将使用 Jake Archibald 出色的 SVGOMG 工具对其进行处理。这会给我们类似以下内容的东西。

<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>

我们感兴趣的部分是pathviewBox

扩展 JavaScript 解决方案

现在我们可以创建一个 JavaScript 函数来处理剩下的工作。前面,我们创建了一个函数,该函数获取一组数据点并将其转换为可缩放的 SVG 路径。但现在我们想更进一步,获取路径字符串并计算出数据集。这样,我们的用户就不必担心尝试将他们的路径转换为数据集。

我们的函数有一个警告:除了路径字符串之外,我们还需要一些边界来根据这些边界缩放路径。这些边界很可能是我们优化后的 SVG 中 viewBox 属性的第三个和第四个值。

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,
});

我们不会逐行讲解此函数。您可以在演示中查看!但我们将重点介绍使这成为可能的关键步骤。

首先,我们将路径字符串转换为数据集

使这成为可能的最大部分是能够读取路径段。这完全有可能,这要归功于 SVGGeometryElement API。我们首先创建一个带有路径的 SVG 元素,并将路径字符串分配给它的d属性。

// 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');

然后,我们可以在该路径元素上使用 SVGGeometryElement API。我们只需要遍历路径的总长度,并在路径的每个长度处返回点即可。

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

接下来,我们生成缩放比例

还记得我们说过我们需要一些可能由viewBox定义的边界吗?这就是原因。我们需要某种方法来计算运动路径与其容器的比率。该比率将等于路径与 SVGviewBox的比率。然后,我们将使用这些比率与 D3.js 缩放 一起使用。

我们有两个函数:一个用于获取最大的xy值,另一个用于计算相对于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]

现在我们需要生成路径

拼图的最后一块是为我们的元素实际生成路径。这就是 D3.js 实际发挥作用的地方。如果您以前从未使用过它,请不要担心,因为我们只使用了其中的几个函数。具体来说,我们将使用 D3 根据我们之前生成的数据集生成路径字符串。

要使用我们的数据集创建一条线,我们执行以下操作。

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

问题是这些点没有根据我们的容器进行缩放。D3 的妙处在于它提供了创建缩放的功能。它们充当插值函数。看出端倪了吗?我们可以编写一组坐标,然后让 D3 重新计算路径。我们可以使用我们生成的比率根据容器大小来执行此操作。

例如,这是我们的x坐标的缩放。

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

域是从 0 到我们最高的x值。在大多数情况下,范围将从 0 到容器宽度乘以我们的宽度比率。

有时我们的范围可能会有所不同,我们需要对其进行缩放。这是当容器的纵横比与路径的纵横比不匹配时。例如,考虑一个 SVG 中的路径,其viewBox0 0 100 200。这表示纵横比为 1:2。但是,如果我们将其绘制在高度和宽度均为 20vmin 的容器中,则容器的纵横比为 1:1。我们需要填充宽度范围以保持路径居中并保持纵横比。

在这些情况下,我们可以做的是计算一个偏移量,以便我们的路径仍然在我们容器的中心。

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

一旦我们有了两个缩放,我们就可以使用缩放映射我们的数据点并生成一条新线。

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

我们可以通过 CSS 属性内联传递该路径到我们的元素 👍

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

然后,我们需要决定何时生成并应用新的缩放路径。这是一个可能的解决方案

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

此演示(在全屏下查看效果最佳)显示了使用运动路径的元素的三个版本。路径的存在是为了更轻松地查看缩放。第一个版本是未缩放的 SVG。第二个是缩放容器,说明路径如何不缩放。第三个是使用我们的 JavaScript 解决方案来缩放路径。

呼,我们做到了!

这是一个非常酷的挑战,我从中学习了很多!以下是一些使用该解决方案的演示。

它应该作为概念证明,并且看起来很有希望!请随意将您自己的优化 SVG 文件放入此演示中进行尝试! - 它应该捕获大多数纵横比。

我在 GitHubnpm 上创建了一个名为“Meanderer”的包。如果您想尝试一下,您也可以使用 unpkg CDN 下载它并在 CodePen 中使用。

我期待着看到这可能走向何方,并希望将来我们能看到一些本地处理此问题的方法。🙏