前端时钟翻页效果,一看就会,一写就fei

5,558 阅读4分钟

最近阅读了不少实现翻页效果的文章,受益匪浅,在此写个学习笔记。

22.gif

一、元素拆解

动画拆解.png

从侧面来观察这个翻页过程,能看到承载文字内容的主要有三个面板:一个静止在上半部分的面板,显示旧文字的上半部分;一个静止在下半部分的面板,显示新文字的下半部分;第三个是旋转面板,一面显示旧文字的下半部分,另一面显示新文字的上半部分。翻转的动画,我们考虑采用FLIP的思想:

  1. 先实现【动画结束帧】的样式;
  2. 再从【动画开始帧】播放。

二、实现结束帧样式

准备工作:用vue脚手架创建一个模板项目,并添加一个div容器:

image.png

<!-- App.vue -->
<template>
  <div id="app">
    <test-comp/>
  </div>
</template>

<!-- Test.vue -->
<template>
  <div class="card"></div>
</template>

<style lang="less" scoped>
.card {
  position: relative;
  border: solid 4px black;
  width: 400px;
  height: 400px;
  perspective: 1000px;
}
</style>

2.1 实现静止的上半面板

image.png

<template>
  <div class="card">
    <div class="half-card top-half"></div>
    <!-- <div class="half-card bottom-half">财</div> -->
  </div>
</template>

<style lang="less" scoped>
/* ... */
.half-card {
  position: absolute;
  width: 100%;
  height: 50%;
  overflow: hidden;
  background-color: #2c292c;
  color: white;
  font-size: 320px;
}
.top-half {
  line-height: 400px;
}
</style>

我们知道line-height配合font-size可以控制文字在垂直方向的位置,大多数情况下,文字顶部与容器顶部的距离公式为(line-height - font-size) / 2。

记容器高度h,文字大小f,容器只显示文字上半部分的情况下,上述距离的值为h - f / 2,即(line-height - f) / 2 = h - f / 2,所以line-height为2h(400px)。

2.2 实现静止的下半面板

image.png

<template>
  <div class="card">
    <!-- <div class="half-card top-half">发</div> -->
    <div class="half-card bottom-half"></div>
  </div>
</template>

<style lang="less" scoped>
/* ... */
.bottom-half {
  top: 50%;
  line-height: 0;
}
</style>

在容器只显示文字下半部分的情况下,完整的文字顶部距离容器顶部的距离是-f / 2,那么就有(line-height - f) / 2 = - f / 2,即line-height = 0;

2.3 实现旋转面板

2.3.1 旋转面板的正面————新文字的上半部分

image.png

<template>
  <div class="card">
    <!-- <div class="half-card top-half">发</div> -->
    <!-- <div class="half-card bottom-half">财</div> -->
    <div class="rotating-half">
      <div class="half-card front-side"></div>
      <!-- <div class="half-card back-side">发</div> -->
    </div>
  </div>
</template>

<style lang="less" scoped>
/* ... */
.rotating-half {
  position: absolute;
  width: 100%;
  height: 50%;
  .half-card {
    height: 100%;
  }
}
.front-side {
  line-height: 400px;
}
</style>

2.3.2 旋转面板的背面————旧文字的下半部分

怎么让一个div背对我们?只要让它绕着自己的腰部横线翻转180度即可(翻跟斗)。

image.png image.png

<template>
  <div class="card">
    <!-- <div class="half-card top-half">发</div> -->
    <!-- <div class="half-card bottom-half">财</div> -->
    <div class="rotating-half">
      <!-- <div class="half-card front-side">财</div> -->
      <div class="half-card back-side"></div>
    </div>
  </div>
</template>

<style lang="less" scoped>
/* ... */
.back-side {
  line-height: 0;
  transform: rotateX(180deg); // !!!!!!!!!!!
}
</style>

现在,如果把正面也加上,会发现这样一个问题:两个面的位置是重叠的,在模板中后声明的背面元素(即使它是背对着我们)会覆盖正面元素。我们想让这两个面在背对我们的状态下都不显示,这就需要到如下的css属性:backface-visibility: hidden。

此外,现在一个旋转面板中带有两个“面”,我们想要这两个面随着父元素面板的3d旋转一起旋转,也就是保持相对静止,这就需要设置旋转面板【将子元素纳入自己的3d变换空间】:transform-style: preserve-3d。

加上css后,让旋转面板简单地旋转一下,看看效果(效果图有点慢):

<style lang="less" scoped>
/* ... */
.rotating-half {
  /* ... */
  transform-style: preserve-3d;
  .half-card {
    /* ... */
    backface-visibility: hidden;
  }
  /* to delete */
  transition: transform 1s;
  &:hover { transform: rotateX(-180deg); }
}
/* ... */
</style>

2.gif

至此,三个面板静态效果已经完成:

image.png

三、播放动画

在第二节已经得到了动画结束时的状态。接下来需要从动画开始的状态进行播放。

3.1 设置好旋转轴

在目标动画中,旋转面板应该是绕着底边进行旋转的。把【变换原点】设置为底边的中点,这样,经过这个点的X轴就和底边所在的直线重合,绕X轴旋转就等价于绕底边旋转:

<style lang="less" scoped>
/* ... */
.rotating-half {
  /* ... */
  transform-origin: center bottom;
}
/* ... */
</style>

3.2 找到动画开始帧,使用animate播放动画

动画开始时,旋转面板在主面板的下半区域。要从上半区域(无变换状态)到达下半区域,需要绕着底边逆时针旋转180度,因此开始帧所处于的变换状态就是rotateX(-180deg),从而得到动画的关键帧:

【transform: rotateX(-180deg)】->【transform: none】。

我们给旋转面板加上ref,然后在组件挂载完毕时播放即可:

<script>
export default{
  mounted() {
    this.$refs.rotate?.animate?.(
      [
        { offset: 0, transform: 'rotateX(-180deg)' },
        // { offset: 1, transform: 'none' },
      ],
      {
        duration: 1000,
        easing: 'ease-in-out',
      },
    );
  },
};
</script>

2.gif

四、应用

这样的UI组件可能会用于记录时间、比赛分数变化啥的,自然是不能把值写死。考虑如下的应用场景:

<!-- App.vue -->
<template>
  <div id="app" class="flex-row">
    <test-comp :value="scoreLGD"/>
    <h1>VS</h1>
    <test-comp :value="scoreLiquid"/>
  </div>
</template>

<script>
import TestComp from './Test';
export default {
  components: { TestComp },
  data() { return {
      scoreLGD : 15,
      scoreLiquid: 13,
    };
  },
  mounted() {
    setInterval(() => {
      this.scoreLGD = this.randomInt(99);
      this.scoreLiquid = this.randomInt(99);
    }, 5000);
  },
  /* ... */
};

在该场景下,翻页组件需要在更新时而不是挂载时执行动画(因为没有上一个值)。因此我们在组件内部维护一个记录上一个值的状态,然后把动画从挂载阶段移动到更新阶段:

<template>
  <div class="card">
    <!-- 旧文字上 -->
    <div
      v-if="staleValue !== undefined"
      class="half-card top-half">
      {{ staleValue }}
    </div>
    <!-- 新文字下 -->
    <div class="half-card bottom-half">{{ value }}</div>
    <!-- 旋转面板 -->
    <div ref="rotate" class="rotating-half">
      <!-- 新文字上 -->
      <div class="half-card front-side">{{ value }}</div>
      <!-- 旧文字下 -->
      <div
        v-if="staleValue !== undefined"
        class="half-card back-side">
        {{ staleValue }}
      </div>
    </div>
  </div>
</template>

<script>
export default {
  props: ['value'],
  data() { return { staleValue: undefined }; },
  watch: {
    value(_, old) { this.staleValue = old; },
  },
  updated() {
    this.$refs.rotate?.animate?.(
      [{ offset: 0, transform: 'rotateX(-180deg)' }],
      { duration: 1000, easing: 'ease-in-out' },
    );
  },
};
</script>

基本完成:

22.gif

总结一下

实现翻页效果 = 实现两块静态面板 + 实现一块双面旋转面板 + 播放旋转动画。
这里用vue写了demo, react应该也差不多,将updated换成layoutEffect等等。
另外,动画也可以用类名加css实现,当元素不在视口可以不播放,一些样式可以改成props配置。总之应该有不少地方还可以迭代优化下。

参考文章如下,分析思路基本一致,代码实现上有差异:
【1】优雅的时钟翻页效果,让你的网页时钟与众不同!
【2】原生JS实现一个翻页时钟