Webhek

使用 CSS 实现缩放动画:变换顺序很重要……有时

前几天我在使用 Discord。我点击放大了一张图片,结果它以一种我之前见过的奇怪方式进行了动画处理。就像这样:

A Scottish wildcat

注意它似乎“俯冲”到野猫的脸上,而不是直接放大?看看猫头右侧如何先出框,然后再回到画面中?

我立刻认出这是因为我在另一个项目中也犯过同样的错误。

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

动画效果会发生变化:

A Scottish wildcat

奇怪吧?你可能不会想到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规范有一个较为复杂的算法来决定如何动画化变换。对于我们的值,它会取fromto值:

@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%);
  }
}

然后,对于每个 fromto 组件对,它将它们转换为使用一个通用的函数,该函数可以表示两种类型的值。在这种情况下:

@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 值是线性插值的,但效果是非线性的:

A Scottish wildcat

在动画开始时,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);
}

就这样!

A Scottish wildcat

尽管 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));
}

当你分别使用 scaletranslate 属性时,translate 总是先应用——这恰好是我们想要的顺序。

大功告成!

但等等,为什么 rotate(0) 能解决问题?

回到文章开头(还记得吗?),我提到可以通过将初始变换设置为 rotate(0) 来“修复”动画:

.demo {
  transition: transform 1s ease;
  transform: rotate(0);
}
.demo.zoom {
  transform: scale(3) translate(-33.1%, 20.2%);
}

尽管 scaletranslate 的顺序错误,我们仍得到了期望的动画效果。这是为什么?其实我不建议使用这个“修复”方法,因为它只是利用了 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%);
  }
}

然后,与之前一样,它尝试将每个分量对转换为一个通用的函数,该函数可以表示两种类型的值。然而,它无法做到。rotatescale 被认为差异太大,无法转换为通用类型。

当这种情况发生时,它通过将这些值以及所有后续值转换为单个矩阵来“恢复”:

@keyframes computed-keyframes {
  from {
    transform: matrix(1, 0, 0, 1, 0, 0);
  }
  to {
    transform: matrix(3, 0, 0, 3, -673.75, 231.904);
  }
}

然后它会像处理两个矩阵一样对它们进行动画处理。scaletranslate 的“错误”顺序被忽略,而 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;

以下是效果:

A Scottish wildcat

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

A Scottish wildcat

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