使用 CSS 实现缩放动画:变换顺序很重要……有时
前几天我在使用 Discord。我点击放大了一张图片,结果它以一种我之前见过的奇怪方式进行了动画处理。就像这样:


注意它似乎“俯冲”到野猫的脸上,而不是直接放大?看看猫头右侧如何先出框,然后再回到画面中?
我立刻认出这是因为我在另一个项目中也犯过同样的错误。
CSS代码非常简单:
.demo {
transition: transform 1s ease;
}
.demo.zoom {
transform: scale(3) translate(-33.1%, 20.2%);
}
但看看这个……如果我将起始变换从默认值(none
)改为rotate(0)
:
.demo {
transition: transform 1s ease;
transform: rotate(0);
}
.demo.zoom {
transform: scale(3) translate(-33.1%, 20.2%);
}
动画效果会发生变化:


奇怪吧?你可能不会想到rotate(0)
(与none
等效)会完全改变动画的运作方式,但这正是CSS规范中设计好的效果。
让我们深入探讨原因。
是什么导致了这个奇怪的动画?
现在我们先移除 rotate(0)
,回到原始代码:
.demo {
transition: transform 1s ease;
}
.demo.zoom {
transform: scale(3) translate(-33.1%, 20.2%);
}
在放大元素的一部分时,scale(n) translate(x, y)
似乎是最简单的方法。你使用 translate
将主体移至中心,然后调整 scale
值进行缩放。在开发者工具中调整这些值很简单,在代码中计算这些值也同样容易。
然而,尽管这种值的顺序易于编写,但它并不能产生最自然的动画效果。
变换如何被动画化
CSS规范有一个较为复杂的算法来决定如何动画化变换。对于我们的值,它会取from
和to
值:
@keyframes computed-keyframes {
from {
transform: none;
}
to {
transform: scale(3) translate(-33.1%, 20.2%);
}
}
…并首先对它们进行填充,使其具有相同数量的成分:
@keyframes computed-keyframes {
from {
transform: none none;
}
to {
transform: scale(3) translate(-33.1%, 20.2%);
}
}
然后,对于每个 from
和 to
组件对,它将它们转换为使用一个通用的函数,该函数可以表示两种类型的值。在这种情况下:
@keyframes computed-keyframes {
from {
transform: scale(1) translate(0, 0);
}
to {
transform: scale(3) translate(-33.1%, 20.2%);
}
}
现在变换已处于类似格式,它会生成一个动画,对每个组件进行单独的线性插值。这意味着 scale
组件会从 1
线性动画到 3
,而 translate
组件会从 0, 0
线性动画到 -33.1%, 20.2%
。
动画本身并非线性,因为应用了缓动效果,但线性插值被用作起点。
问题是,当 scale
紧跟 translate
时,scale
会作为 translate
值的乘数。因此,随着 scale
增加,尽管 translate
值是线性插值的,但效果是非线性的:


在动画开始时,translate
值的 1 像素变化会在屏幕上产生约 1 像素的变化,因为 scale
值约为 1。但到了动画结束时,translate
值的 1 像素变化会在屏幕上产生约 3 像素的变化,因为 scale
值约为 3。动画结束时,位置变化速度似乎加快,从而产生“俯冲”效果。
如何修复
要修复此问题,我们需要避免 scale
作为 translate
的乘数,实现方法是将 translate
放在前面。
我们不能简单地交换顺序,因为我们依赖于scale
的乘法效果来使translate
正确工作。为了实现相同的效果,我们需要手动将translate
值乘以scale
值:
.demo.zoom {
transform: translate(-99.3%, 60.6%) scale(3);
}
就这样!


尽管 translate
值仍然被乘以,但它们被乘以一个常数 3,即最终的 scale
值,而不是一个变化的 scale
值。结果是稳步向目标移动。translate
值每移动 1px,屏幕上就会移动 1px。
遗憾的是,这种格式在开发者工具中更难调整,但你可以用一点 calc
来解决!
.demo.zoom {
--scale: 3;
--x: -33.1%;
--y: 20.2%;
transform: translate(
calc(var(--x) * var(--scale)),
calc(var(--y) * var(--scale))
)
scale(var(--scale));
}
或者,将 translate
分为两个独立的属性:
.demo.zoom {
--scale: 3;
--x: -33.1%;
--y: 20.2%;
scale: var(--scale);
translate: calc(var(--x) * var(--scale)) calc(var(--y) * var(--scale));
}
当你分别使用 scale
和 translate
属性时,translate
总是先应用——这恰好是我们想要的顺序。
大功告成!
但等等,为什么 rotate(0) 能解决问题?
回到文章开头(还记得吗?),我提到可以通过将初始变换设置为 rotate(0)
来“修复”动画:
.demo {
transition: transform 1s ease;
transform: rotate(0);
}
.demo.zoom {
transform: scale(3) translate(-33.1%, 20.2%);
}
尽管 scale
和 translate
的顺序错误,我们仍得到了期望的动画效果。这是为什么?其实我不建议使用这个“修复”方法,因为它只是利用了 CSS 规范中的一个边界案例才“生效”。
让我们再次回顾算法,这次使用 rotate(0)
变换:
@keyframes computed-keyframes {
from {
transform: rotate(0);
}
to {
transform: scale(3) translate(-33.1%, 20.2%);
}
}
与之前一样,它用 none
填充值,使它们具有相同数量的分量:
@keyframes computed-keyframes {
from {
transform: rotate(0) none;
}
to {
transform: scale(3) translate(-33.1%, 20.2%);
}
}
然后,与之前一样,它尝试将每个分量对转换为一个通用的函数,该函数可以表示两种类型的值。然而,它无法做到。rotate
和 scale
被认为差异太大,无法转换为通用类型。
当这种情况发生时,它通过将这些值以及所有后续值转换为单个矩阵来“恢复”:
@keyframes computed-keyframes {
from {
transform: matrix(1, 0, 0, 1, 0, 0);
}
to {
transform: matrix(3, 0, 0, 3, -673.75, 231.904);
}
}
然后它会像处理两个矩阵一样对它们进行动画处理。scale
和 translate
的“错误”顺序被忽略,而 translate
已经预先乘以了 scale
。巧合的是,它正好以我们希望的方式进行动画处理。
额外内容:缩放与3D平移
在这篇文章中,我们通过动画化scale
来实现“放大”的效果。但根据你想要的效果,你也可以使用3D平移。
当你动画化 scale
时,目标的宽度和高度会在整个动画过程中线性变化(尽管,如前所述,可以应用缓动效果)。这感觉类似于相机缩放效果。
然而,你可能希望实现一种效果,即物体朝相机移动,或相机朝物体移动。为了实现这一点,你不希望物体的视觉大小线性变化。
这是因为,当一个远处的物体移动 1 米时,它在你的视野中所占的空间变化不大。但当一个近处的物体移动 1 米时,它在你的视野中所占的空间变化很大。这就是透视效果。
因此,我们不使用scale
属性,而是使用perspective
属性并结合3D变换:
.container {
perspective: 1000px;
}
.demo.zoom {
translate: -33.1% 20.2% 666.666px;
}
这个看似诡异的translate-z
值是通过将scale
属性转换为translate-z
值计算得出的,计算公式如下:
const scaleToTranslateZ = (scale, perspective) =>
(perspective * (scale - 1)) / scale;
以下是效果:


效果较为微妙。以下是scale
版本的对比示例:


在动画的缩小部分,差异最为明显。3D版本的起始速度明显快于scale
版本。个人认为scale
版本的体验更好,因为其设计意图更偏向于“缩放”而非“移动”。但了解两者的差异有助于您根据需求选择合适的方案。