一个酷炫星球旋转动画的前端实现

314 阅读5分钟

最终实现效果如视频所示。

需求描述

若干个星球如图定时公转,支持手势左右滑动(本次demo懒得做)

应用于手机H5、微信公众号、小程序端

前端动效技术分析

  • css3 transition
    • 性能优秀,只关心关键帧,有且只有两个关键帧
  • css3 animation
    • 性能一般,只关心关键帧,存在多个关键帧
  • js - canvas
    • 性能优秀,一切都可控制,但是需要代码逻辑控制所有帧
  • media gif | video
    • 性能优秀,需要预加载,基本上不支持交互

需求分析

  • 存在交互需求,淘汰media方案
  • 有比较明确的动画路线,而且并不复杂,淘汰js - canvas方案
  • 遵循如果transition能做,就不用animation的原则,采用transition来实现

实现方案分析

  • 需求动效为一个3D效果(近大远小,透视),transition可以使用transition-3d来模拟实现,也可使用scale只模拟做出近大远小的效果。DEMO中采用transition-3d实现所有动效。
  • 动效为星球旋转,首先考虑的就是rotate属性来实现,但是实际上实现过程中存在太多问题,不在本次DEMO之中,有兴趣的可自行尝试。既然决定使用transition-3d实现,完全可以直接在立体坐标系中通过三轴定位每个星球,然后使用transition动画切换位置,即可实现需求。有一个弊端:星球公转走的是点到点的直线,而不是围绕公转轨道弧线移动。
  • 涉及到交互效果,所以我们使用className来控制每个星球的位置,通过切换className来触发旋转动效。

具体实现方案

  • 先在XY平面上平均分布若干个球,组成环形。
  • step1 将XY平面沿Z轴做一个初始旋转@rotateZ
  • step2 在上一步基础上,将YZ平面沿X轴做一个旋转@rotateX
  • step3 在上一步基础上,再次将XY平面沿Z轴做一个初始旋转@rotateZ2

变量定义

// 星星个数
@starLength: 6;
// 景深
@perspective: 1000px;
// 初始角
@rotateZ: 12;
@rotateX: 65;
@rotateZ2: -10;

// 圆半径
@rSize: 200;
// 星球图大小
@imgSize: 250px;
// 选中放大倍率
@scaleSize: 1.2;
// 动画速度
@duration: 0.5s;

  • 将所有的球,绝对定位到同一个点,我们定位到视窗正中心
  • 这里最外层使用flex布局,每个球使用绝对定位,即可保证每个球重叠并且中心点在视窗正中心
.star-box{
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}
.star{
  height: @imgSize;
  width: @imgSize;
  position: absolute;
}
  • step1 将所有的球,在视窗平面,以中心点为正圆圆心,使用transition3D分散到圆边上排列,并将所有的球分散到XY平面,并将XY平面沿Z轴做一个初始旋转@rotateZ,分别计算出每个球的XYZ(Z坐标为0)。

image.png

@rotateZItem: (@rotateZ + ((@i - 1) * 360 / @starLength));
@transformX: sin(@rotateZItem * 1deg) * @rSize;
@transformY: cos(@rotateZItem * 1deg) * @rSize;
@transformZ: 0;
  • step2 在上一步基础上,将YZ平面沿X轴做一个旋转@rotateX,分别计算出所有球的YZ(X坐标不会变)。

image.png

@step1Y: cos(@rotateZItem * 1deg) * @rSize;
@transformY: cos(@rotateX * 1deg) * @step1Y;
@transformZ: sin(@rotateX * 1deg) * @step1Y;
  • step3 在上一步基础上,再次将XY平面沿Z轴做一个初始旋转@rotateZ2,我们取任意一个点在XY平面做计算:

image.png

x = sin(m + n) * r 
  = (sin(m) * cos(n) + sin(n) * cos(m)) * r
  = ((a/r) * cos(n) + (b/r) * sin(n)) * r
  = a * con(n) + b * sin(n)

y = cos(m + n) * r
  = (cos(m) * cos(n) - sin(n) * sin(m)) * r
  = ((b/r) * cos(n) - (a/r) * sin(n)) * r
  = b * con(n) - a * sin(n)
@step1X: sin(@rotateZItem * 1deg) * @rSize;
@step2Y: cos(@rotateX * 1deg) * @step1Y;
@transformY: @step2Y * cos(@rotateZ2 * 1deg) + @step1X * sin(@rotateZ2 * 1deg);
@transformX: @step1X * cos(@rotateZ2 * 1deg) - @step2Y * sin(@rotateZ2 * 1deg);
  • 至此所有球点位都计算出来了,剩下的就是分配classname,通过state动态修改classname,使用transition补全关键帧。就做完了,具体可看demo代码。
transform: translate3D( @transformX * 1px, @transformY * 1px, @transformZ * 1px );

核心less代码:

.star-box {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}

.star {
  height: @imgSize;
  width: @imgSize;
  position: absolute;

  &-content {
    height: 100%;
    width: 100%;
    transition: all @duration;
    .bgContain();
    background-size: 0;
    background-image: url('./assets/img/star-shadow.png');
  }

  &-position-01 {
    scale: @scaleSize;

    &.star-content {
      background-size: 100%;

      &::before {
        opacity: 1;
        transition: all @duration;
        transition-delay: @duration;
      }
    }
  }

  .loopStar(@i) when (@i <=@starLength) {
    // 初始角度 加上Z轴初始角度 rotateZ
    @rotateZItem: (@rotateZ + ((@i - 1) * 360 / @starLength));
    @transformX: sin(@rotateZItem * 1deg) * @rSize;
    @transformY: cos(@rotateZItem * 1deg) * @rSize;
    @transformZ: 0;
    // X轴旋转 rotateX transformX不会变
    @step1Y: cos(@rotateZItem * 1deg) * @rSize;
    @transformZ: sin(@rotateX * 1deg) * @step1Y;
    @transformY: cos(@rotateX * 1deg) * @step1Y;
    // Z轴旋转 rotateZ2
    @step1X: sin(@rotateZItem * 1deg) * @rSize;
    @step2Y: cos(@rotateX * 1deg) * @step1Y;
    @transformY: @step2Y * cos(@rotateZ2 * 1deg)+@step1X * sin(@rotateZ2 * 1deg);
    @transformX: @step1X * cos(@rotateZ2 * 1deg) - @step2Y * sin(@rotateZ2 * 1deg);

    &-0@{i} {
      &::after {
        content: "";
        display: block;
        width: 100%;
        height: 100%;
        position: absolute;
        background-image: url('./assets/img/star0@{i}.png');
        .bgContain();
      }

      &::before {
        content: "";
        display: block;
        width: 100%;
        height: 100%;
        position: absolute;
        bottom: 50%;
        left: -5%;
        background-image: url('./assets/img/light.png');
        background-size: 100% 100%;
        transform: rotate(4deg);
        opacity: 0;
      }
    }

    &-position-0@{i} {
      transform: translate3D(@transformX * 1px, @transformY * 1px, @transformZ * 1px);
    }

    .loopStar(@i+1);
  }

  .loopStar(1);
}
function Index() {
  // 选中 第一个
  const [selectIndex, setSelectIndex] = useState(0)
  // 定时滚动
  const clearInterval = useInterval(() => { 
    setSelectIndex(flatIndex(selectIndex + 1))
  }, intervalTime)
  useEffect(() => { 
    return () => { 
      clearInterval()
    }
  }, [])

  return (
    <div className={styles.page}>
      <div className='star-box'>
        {Array.from(new Array(starLength)).map((_, index) => {
          const imgClass = `star-0${index + 1}`
          const positionIndex = flatIndex(selectIndex - index + starLength)
          const positionClass = `star-position-0${positionIndex + 1}`
          return (
            <div key={index} className={'star'}>
              <div className={ `star-content ${imgClass} ${positionClass}`}></div>
            </div>
          )
        })}
      </div>
    </div>
  )
}