最近在 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 中快速创建的。还有其他矢量绘图工具可用。

接下来,让我们优化 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>
我们感兴趣的部分是path
和viewBox
。
扩展 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 缩放 一起使用。
我们有两个函数:一个用于获取最大的x
和y
值,另一个用于计算相对于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 中的路径,其viewBox
为0 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 文件放入此演示中进行尝试! - 它应该捕获大多数纵横比。
我在 GitHub 和 npm 上创建了一个名为“Meanderer”的包。如果您想尝试一下,您也可以使用 unpkg CDN 下载它并在 CodePen 中使用。
我期待着看到这可能走向何方,并希望将来我们能看到一些本地处理此问题的方法。🙏
很棒的文章!
我也对路径动画着迷,并且
创建了一个小程序(很多年前),它可以自动在路径上移动
https://github.com/yairEO/pathAnimator
谢谢 vsync!
哦,查看了一下,这很酷。这是一个很有趣的主题。非常有兴趣看看它在 CSS 中的发展方向。
如果将来会有一些相对定位支持,或者其他东西。
我想知道我们是否会永远在
path()
函数中获得%
单位,就像我们在可以与clip-path
之类的东西一起使用的polygon()
函数中获得的那样?这使得clip-path
非常灵活,并且在这里拥有相同的功能将使这变得更加简单。完全正确!如果我们这样做,它将是一个非常酷的功能。
clip-path: polygon()
唯一的问题是曲线支持。任何形式的相对语法都需要某种定义曲线控制点的方法。我认为这可能是有点棘手的地方。手动创建曲线语法并不容易哈哈。如果我们不需要曲线,大多数事情都可以通过链接的translate
来处理。您如何预见手动编写曲线?或者可能有一种方法可以定义某种“开箱即用”的曲线,就像使用
animation-timing-function
一样。例如,这是我的路径
--path: path(0 0, 50% 0, 100% 50%)
。现在我想让它使用基本曲线,offset-path-curve: normal || none || 其他选项
。我最后一次评论 这里 为什么我认为这可能会破坏解析器。
将路径重新采样为折线是针对小问题的重大解决方案,并且效果充其量只能算次优。生成的运动路径会丢失信息,因为它只近似于原始路径,并且同时会大大增加路径数据的尺寸:在您的示例中,路径字符串从 9 个顶点的 137 个字符变为 155 个顶点的 5718 个字符。
一种更简单(就所需的资源而言)且更好(就无损转换而言)的方法是使用一个库来处理重写任意转换的路径数据,以便每个路径命令在结构上都得以保留,而只有控制点数字会发生变化。
我自己在未发布的项目中编写了一个这样的库,并且周围还有其他一些库。例如,快速搜索发现了 svgpath,它完全能够处理您的所有需求。
至于找到将路径拟合到容器所需的变换,您的解决方案看起来有点即兴创作。正确的算法在 规范 中有描述。这是一个 实现,您只需将源 SVG 拥有的所有属性字符串都扔进去,即可获得正确的缩放和转换变换。
可能是这样,但是,它**是**一个解决方案。
它是一个概念证明。从未声明它是完美的或最佳的。它正在探索可能性。它并没有被提倡作为要在您的生产网站上使用的内容。
缩放 SVG 不是问题。这已经在浏览器中完成了。缩放路径字符串是问题所在。我们没有使用 SVG。但是
offset-path
使用 SVG 路径语法。缩放是一种解决方案,但是如果我们不想缩放正在移动的元素会怎样。无论如何,我期待着看到您的解决方案。
它在这里:https://github.com/ccprog/pathfit(我将进一步增强它并在某个时候发布到 npm。)
对于我关于 SVG 缩放的评论不清楚,我表示歉意。正如您所说,SVG 具有一个调整大小算法来使其响应式。为了模拟这种响应性以用于 CSS
path()
,重新创建此算法以从源 SVG 的视区(或固有大小)(路径数据来自)到您应用运动路径的容器元素的宽度和高度是有意义的。此外,如果您坚持使用规范中的算法,则可以通过使用
<svg>
元素preserveAspectRatio
属性的语法来获得一种机制来描述在原始 SVG 源和容器的纵横比不匹配时在何处以及如何放置运动路径。这太棒了!感谢分享。
我完全赞成找到一个更好的解决方案。正如我之前所说,这仅仅是一个概念证明,以探索可能性并开始像这样的对话。我的目的是找到一个人们可以使用的好的解决方案,同时将路径生成部分外包到其他地方。
在查看时,我找不到一个浏览器就绪的包来执行接近我想要执行的操作。
svgpath
可以执行我们想要的操作。但我没有成功捆绑它。理想情况下,一个执行此操作并支持树形抖动的包将非常棒。这样,它将是一个简单的import { scale } from 'svg-path-transformer-package'
案例。这是唯一需要的那一部分。微库可以处理计算这些比例或是否需要转换。在不介意移动元素是否缩放的情况下,我们可以相应地缩放整个容器。哇哦,Jhey - 这正是我在完成您所做的事情时一直在寻找的研发流程,您为我节省了许多工作时间。非常感谢您详细的撰写 - 我学到了很多,并感谢您分享您在如何进行响应式路径跟踪方面的经历。干杯,伙计!