想把Gmail里那个Gemini动画搬进自己项目,CSS的shape()函数怎么玩?

4,261字
18–27 分钟
in

无论是Gmail网页端还是APP,右上角那个Google Gemini图标悬停时的动画效果都特别抓眼球,那个小星星先是转一下,然后外面套着的那层彩色“壳”会变着花样地展开、扭动、变色,最后再收回去。这效果看着高级,但要是想在自己项目里复刻出来,是不是得写一大堆复杂的JavaScript或者用上Canvas?其实不用,现在CSS里新出的shape()函数配合上动画,就能把这套“变脸”戏法演得像模像样。这里就完整复盘一下,从头到尾是怎么把这个小按钮从零做出来的,里面会踩到哪些坑,又该怎么绕过去。

目录

形状准备:从矢量图到CSS代码

要搞这个动画,首先得把动画里会出现的所有形状都画出来。仔细盯着那个按钮看,会发现它总共变了五次脸:一个四角星、一个像花朵的东西、一个有点像圆柱的玩意儿、一个圆角六边形,最后再回到圆形。这些形状在CSS里没法直接用简单的border-radius拼出来,得借助shape()这个新函数。

先找个能画矢量图的工具,比如Affinity Designer或者Illustrator,把上面那五个形状一个个画出来。画的时候有个关键点必须得留心,就是所有形状的锚点数量得保持一致。这个锚点就是构成形状的那些控制点,如果星星有八个点,花朵有十二个点,那动画在从星星变到花朵的时候就没法平滑过渡,会直接跳变过去,完全不是想要的效果。这里统一把每个形状的锚点数量定在十二个,因为那个圆角六边形在弯曲的角上会多出几个点,加起来正好是十二个。

形状画好之后,导出成SVG格式。SVG文件本质上是一段路径代码,这段代码就是形状的数学描述。不过直接把这些路径字符串扔进CSS里不太方便,而且可读性很差。可以借助网上现成的转换工具,比如Temani Afif做的那个shape()生成器,把SVG的path数据一键转成CSS shape()能认的语法。

转换的时候会碰到一个隐形大坑。有些形状的起始点位置和其他形状对不上,比如圆形这个形状,它的路径起点可能在最右边,而其他形状的起点都在最上边。当动画运行时,浏览器会强行把第一个形状的起点往第二个形状的起点位置拉,结果就是整个形状在动画过程中会先缩成一团,再疯狂旋转,才能拼到新形状上。解决这问题得回图形软件里,手动旋转那个圆形的锚点位置,反复试错,直到它的起点和其他形状基本对齐为止。

还有个更隐蔽的坑,就是那个圆柱形状。它的路径里有两段是纯直线,用的是line命令。这会导致shape()在动画时无法在这两段直线和其他曲线之间进行插值计算,动画会在进入和离开圆柱形状的时候瞬间跳变,中间过程全没了。解决办法就是回到绘图软件里,把那两段直线稍微加一点点弧度,变成曲线,哪怕肉眼几乎看不出来,但只要路径里用的是curve命令,动画就能正常工作了。

搞定了所有形状的shape()值后,把它们存成CSS自定义属性,这样代码看着清爽,改起来也方便。比如:

--star: shape(...);
--flower: shape(...);
--cylinder: shape(...);
--hexagon: shape(...);
--circle: shape(...);

每个自定义属性后面那一长串shape()里的坐标数据,都是从工具里直接拷出来的,看着像天书也没关系,只要知道它们分别对应哪个形状就行。

HTML结构:两个div的巧妙配合

这个动画需要一个星星在最上面,一个彩色的大壳在星星下面展开。如果只用单个元素,那形状裁剪只能有一个,要么露出星星,要么露出壳,没法让它们叠在一起动。所以得用两个div,一个当底,一个当盖。

外层#geminianimation包裹住内层的一个div。外层负责显示最上面那层星星,内层负责显示底下的彩色形状。HTML结构就这么简单:

<div id="geminianimation">
  <div></div>
</div>

基础样式:先搭个台子

给外层容器定个宽高,让它变成一个正方形,并且相对定位。用::before伪元素来画星星,把之前定义好的--star裁剪路径附上去,背景色先给个深灰色。再给这个伪元素加上transition属性,让它变化的时候有过渡效果,而不是硬切。

内层那个div,宽高都填满父容器,一开始就用--flower这个形状来裁剪。不过现在它被星星盖住了,看不出来。给内层div也加上transition,并且把它的scale初始值设为0,这样它一开始是完全缩小的,看不见。等鼠标悬停的时候,再把scale变回1,就完成了形状“展开”的效果。

颜色方面,用一个线性渐变来填充内层形状,这样颜色会有流动感。为了让颜色能“动”起来,不能直接把渐变放在内层div上,得放在div::after伪元素上,并且把这个伪元素的宽高都放大到400%。这样做的目的是,实际看到的只是渐变的一个小窗口,通过移动这个伪元素的位置,就能让窗口里的颜色发生变化,实现颜色擦除的效果。

#geminianimation {
  width: 200px;
  aspect-ratio: 1/1;
  margin: 50px auto;
  position: relative;

  &::before {
    content: "";
    clip-path: var(--star);
    width: 100%;
    height: 100%;
    position: absolute;
    background-color: #494949;
    transition: 1s ease-in-out;
  }

  div {
    width: 100%;
    height: 100%;
    clip-path: var(--flower);
    scale: 0;
    transition: 1s ease-in-out;
    position: relative;

    &::after {
      content: "";
      background: linear-gradient(135deg, #217bfe, #078efb, #ac87eb, #217bfe);
      width: 400%;
      height: 400%;
      position: absolute;
      top: 0;
      left: 0;
    }
  }
}

第一阶段和第六阶段:星星的转场

这两个阶段是一对镜像动作,鼠标悬停时星星右转180度并变亮,鼠标移开时再转回来变暗。这种有进有出的简单状态变化,用transition最省事。在:hover状态下,直接改变::beforetransformbackground-color就行。

#geminianimation:hover::before {
  transform: rotate(180deg);
  background-color: #FAFBFE;
}

第二阶段和第五阶段:形状的缩放

也是镜像动作,鼠标悬停时内层divscale(0)scale(1)展开,移开时再缩回去。同样用transition搞定。

#geminianimation:hover div {
  scale: 1;
}

第三阶段:形状的变形与旋转

这是最核心也最复杂的部分,需要在一段时间内,让内层div的裁剪形状依次变成花朵、圆柱、六边形、再变回圆形,并且整个过程中还要一直旋转。如果还用transition,那得定义一堆中间状态,代码会臃肿到没法看。所以这里上@keyframes

定义一个名为shapeshift的关键帧动画,在0%时裁剪成圆形,25%时变成花朵,50%时变成圆柱,75%时变成六边形,100%时又回到圆形。同时,让它在0%时旋转0圈,100%时旋转1圈。注意,这里没有在中间帧里写旋转,因为旋转应该在整个动画过程中平滑进行,只要首尾定义了,浏览器会自动插值。

@keyframes shapeshift {
  0% {
    clip-path: var(--circle);
    rotate: 0turn;
  }
  25% {
    clip-path: var(--flower);
  }
  50% {
    clip-path: var(--cylinder);
  }
  75% {
    clip-path: var(--hexagon);
  }
  100% {
    clip-path: var(--circle);
    rotate: 1turn;
  }
}

在鼠标悬停时,给内层div应用这个动画,时长5秒,缓动函数用ease-in-out,让它无限循环,并且动画结束后保持最后的状态。

#geminianimation:hover div {
  animation: shapeshift 5s ease-in-out infinite forwards;
}

这里有个小细节,动画开始和结束都是圆形,圆形无论怎么转都一个样,所以即使动画的缓动函数让旋转在开头和结尾变慢,也完全看不出来,省去了单独处理旋转的麻烦。

第四阶段:颜色的流动

颜色变化也需要单独的关键帧。因为渐变背景放在::after上,而且这个伪元素宽高都是400%,所以可以通过移动它的位置,来改变透过内层div看到的渐变片段。定义一个gradientMove动画,从初始位置translate(0,0)开始,慢慢移动到translate(-75%, -75%)。这样,颜色就会沿着对角线方向扫过整个形状。而且因为内层div本身在旋转,颜色扫过的方向也会跟着变,看起来特别灵动。

@keyframes gradientMove {
  0% {
    translate: 0 0;
  }
  100% {
    translate: -75% -75%;
  }
}

把这个动画也加到鼠标悬停时的内层div上,时长同样5秒,缓动用linear,让它和形状变化动画同时进行。

#geminianimation:hover div {
  animation: 
    shapeshift 5s ease-in-out infinite forwards,
    gradientMove 5s linear infinite forwards;
}

收尾润色:让开始和结束更顺滑

动画跑起来后,会发现在鼠标移出时,内层div会直接从当前形状跳回一开始的花朵形状,然后再缩小消失,这个跳变很突兀。为了让体验更好,可以把内层div默认的裁剪形状从花朵改成圆形。这样,当鼠标移出、动画停止时,它会先从当前正在进行的形状平滑地过渡到圆形,然后再缩小消失,整个过程自然很多。

#geminianimation div {
  clip-path: var(--circle); /* 改成圆形 */
  /* 其他样式不变 */
}

搞定这些,基本就复刻出来了。整个过程里,最难搞的不是写动画,而是在准备形状阶段把锚点数量对齐、起始点对齐、还有曲线直线混用这些问题都摆平。这些细节但凡有一个没注意到,动画就会直接翻车,要么跳帧,要么乱转。