# 你可能不知道的 Animation 动画技巧与细节

# 引言

在 web 应用中,前端开发在实现动画效果时往往常用的几种方案:

  • CSS3 transition / animation —— 实现过渡动画
  • setInterval / setTimmeout —— 通过设置一个间隔时间来不断的改变图像的位置
  • requestAnimationFrame —— 通过一个回调函数来改变图像位置,有系统来决定这个回调函数的执行时机,必定是修改的性能更好,不存在失帧现象。

在大多数需求中,css3 的 transition / animation 都能满足我们的需求,并且相对于 js 实现,可以大大提升我们的开发效率,降低开发成本。

本篇文章将着重对 animation 的使用做个总结,如果你的工作中动画需求较多,相信本篇文章能够让你有所收获:

  • Animation 常用动画属性
  • Animation 实现不间断播报
  • Animation 实现回弹效果
  • Animation 实现直播点赞效果
  • Animation 与 Svg 又会擦出怎样的火花呢?
    • Loading 组件
    • 进度条组件
  • Animation steps() 运用
    • 实现打字效果
    • 绘制帧动画

# Animation 常用动画属性

介绍完 animation 常用属性,为了将这些属性更好地理解与运用,下面将手把手实现一些 DEMO 具体讲述。

# Animation 实现不间断播报

img

通过修改内容在父元素的 y 轴位置来实现广播效果

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>不间断播报</title>
    <style>
      @keyframes scroll {
        0% {
          transform: translate(0, 0);
        }
        100% {
          transform: translate(0, -160px);
        }
      }
      .container {
        width: 220px;
        height: 40px;
        background: #0066ff;
        overflow: hidden;
        border-radius: 2em;
      }
      .ul {
        animation-name: scroll;
        animation-duration: 5s;
        animation-timing-function: linear;
        animation-iteration-count: infinite;
      }
      .li {
        line-height: 40px;
        vertical-align: bottom;
        color: #fff;
        text-align: center;
      }
    </style>
  </head>
  <body>
    <div class="container">
      <div class="ul">
        <div class="li">小刘同学加入了凹凸实验室</div>
        <div class="li">小邓同学加入了凹凸实验室</div>
        <div class="li">小李同学加入了凹凸实验室</div>
        <div class="li">小王同学加入了凹凸实验室</div>
        <!--   插入用于填充的数据数据 -->
        <div class="li">小刘同学加入了凹凸实验室</div>
      </div>
    </div>
  </body>
</html>

此处为了保存广播效果连贯性,防止滚动到最后一帧时没有内容, 需要多添加一条重复数据进行填充。

# Animation 实现回弹效果

通过将过渡动画吃啊分为多个阶段,每个阶段的 top 属性停留在不同位置来实现。

img

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>回弹效果</title>
    <style>
      @keyframes animate {
        0% {
          top: -100%;
          opacity: 0;
        }
        25% {
          top: 60%;
          opacity: 1;
        }
        50% {
          top: 48%;
          opacity: 1;
        }
        75% {
          top: 52%;
          opacity: 1;
        }
        100% {
          top: 50%;
          opacity: 1;
        }
      }
      button {
        padding: 6px 20px;
        font-size: 14px;
        position: absolute;
        left: 50%;
        top: 50%;
        transform: translate(-50%, -50%);
        background: #0066ff;
        color: white;
        border: 0;
        border-radius: 2em;
        outline: none;
      }
      button:hover {
        opacity: 0.8;
        cursor: pointer;
      }

      .popup {
        position: fixed;
        top: -50%;
        width: 300px;
        line-height: 200px;
        text-align: center;
        background: #0066ff;
        color: #fff;
        border-radius: 0.25em;
        box-shadow: 0 0 10px #ccc;
        left: 50%;
        transform: translate(-50%, -50%);
      }

      .popup.active {
        animation-name: animate;
        animation-duration: 0.5s;
        animation-timing-function: cubic-bezier(0.21, 0.85, 1, 1);
        animation-iteration-count: 1;
        animation-fill-mode: forwards;
      }

      .close {
        width: 40px;
        line-height: 40px;
        height: 40px;
        border-radius: 50%;
        background: #fff;
        color: #000;
        font-size: 20px;
        top: 100%;
        box-shadow: 0 0 10px #888484;
        cursor: pointer;
        position: absolute;
        left: 50%;
        transform: translate(-50%, -50%);
      }

      .close:hover {
        filter: brightness(0.9);
      }
    </style>
  </head>
  <body>
    <button>唤起弹窗</button>
    <div class="popup">
      我是弹窗
      <div class="close">x</div>
    </div>
  </body>
  <script>
    const show = document.getElementsByTagName("button")[0];
    const close = document.getElementsByClassName("close")[0];
    const popup = document.getElementsByClassName("popup")[0];

    show.onclick = function () {
      popup.className += " active";
    };

    close.onclick = function () {
      popup.className = "popup";
    };
  </script>
</html>

为了让过渡效果更自然,这里通过 cubic-bezier() 函数定义一个被塞尔曲线来控制动画播放速度。

过渡动画执行完成后,为了让元素应用动画最后一帧的属性值,我们需要使用 animation-fill-mode: forwards

# Animation 实现点赞效果

img

为了让气泡可以向上偏移,我们需要先实现一个 y 轴方向向上移动的动画。

为了让气泡向上偏移时显得不那么单调,我们再实现一个 x 轴方向上移动的动画。

这里是我的理解:

  • 虽然是通过修改 margin 来改变 x 轴偏移距离,但实际上与修改 transform 没有太大的性能;

  • 因为通过 @keyframes animation-y 中的 transform 已经新建了一个渲染层;

  • animation 属性可以让该渲染层提升至合成层拥有单独的图形层,即开启了硬件加速,不会影响其他渲染层的 paint、layout;

  • 对于合成层不是很了解的同学,可以阅读一下这篇文章从浏览器渲染层面解析 css3 动效优化原理 (opens new window)

  • 如下图所示

    img

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>直播点赞动画</title>
    <style>
      @keyframes animation-y {
        0% {
          transform: translate(-50%, 100px) scale(0);
        }
        50% {
          transform: translate(-50%, -100px) scale(1.5);
        }
        100% {
          transform: translate(-50%, -300px) scale(1.5);
        }
      }

      @keyframes animation-x {
        0% {
          margin-left: 0px;
        }
        25% {
          margin-left: 25px;
        }
        75% {
          margin-left: -25px;
        }
        100% {
          margin-left: 0px;
        }
      }

      .btn {
        top: 80%;
        user-select: none;
        width: 50px;
        line-height: 50px;
        background: #0066ff;
        color: #fff;
        text-align: center;
        border-radius: 50%;
        box-shadow: 0 0 10px #999;
        cursor: pointer;
        position: absolute;
        left: 50%;
        transform: translate(-50%, -50%);
      }
      .btn:hover {
        opacity: 0.8;
      }
      .btn:active {
        opacity: 1;
      }

      .like {
        position: absolute;
        left: 50%;
        top: 50%;
        transform: translate(-50%, -50%);
        width: 25px;
        height: 23px;
        pointer-events: none;
        background-size: cover;
        background-position: center;
        background-repeat: no-repeat;
        background-image: url(https://upload.wikimedia.org/wikipedia/commons/thumb/4/44/Coraz%C3%B3n.svg/150px-Coraz%C3%B3n.svg.png);
        animation: animation-x 3s 0s linear infinite, animation-y 4s 0s linear 1;
      }
      .like.second {
        background-image: url(https://upload.wikimedia.org/wikipedia/commons/thumb/4/44/Coraz%C3%B3n.svg/150px-Coraz%C3%B3n.svg.png);
        animation: animation-x 3s -2s linear infinite,
          animation-y 4s 0s linear 1;
      }
    </style>
  </head>
  <body>
    <div class="btn" onclick="like()">点赞</div>
  </body>
  <script>
    var count = 0;
    function like() {
      var dom = document.createElement("div");
      count += 1;
      dom.className = count % 2 ? "like second" : "like";
      dom.style.willChange = "margin-top";
      document.body.appendChild(dom);
      setTimeout(function () {
        document.body.removeChild(dom);
      }, 2000);
    }
  </script>
</html>

# Animation 与 Svg 绘制 loading / 进度条 组件

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Loading / 进度条</title>
    <style>
      :root {
        --color: #0079f5;
      }
      div {
        color: var(--color);
        position: absolute;
        left: 50%;
        top: 50%;
        transform: translate(-50%, -50%);
      }

      div label {
        display: flex;
        align-items: center;
      }

      @keyframes loading-active {
        0% {
          stroke-dashoffset: 0;
        }
        100% {
          stroke-dashoffset: -207;
        }
      }

      .loading svg {
        transform: rotate(-150deg);
      }

      .loading circle {
        animation: loading-active 1s 0s ease-out infinite;
      }

      .progress circle {
        stroke-dasharray: 157 157;
        stroke-dashoffset: 0;
        stroke-linecap: round;
        transition: stroke-dashoffset 0.8s cubic-bezier(0.29, 0.6, 0.42, 0.99);
      }

      .progress .trail {
        stroke-dashoffset: 0;
      }

      .progress span {
        left: 118px;
        top: 150px;
        position: absolute;
        transform: translate(-50%, -50%);
      }
      .progress button {
        margin-right: 5px;
        border: 0;
        color: #fff;
        padding: 4px 10px;
        background: var(--color);
        border-radius: 0.25em;
        outline: none;
      }
      .progress button:hover {
        opacity: 0.8;
      }
    </style>
  </head>
  <body>
    <div>
      <label class="loading">
        Loading:
        <svg with="100" height="100" viewBox="0 0 60 60">
          <circle
            cx="30"
            cy="30"
            r="25"
            fill="transparent"
            stroke-width="4"
            stroke="#0079f5"
            stroke-dasharray="50 157"
            stroke-linecap="round"
          ></circle>
        </svg>
      </label>
      <label class="progress">
        进度条:
        <svg with="100" height="100" viewBox="0 0 60 60">
          <defs>
            <linearGradient id="gradient" x1="100%" y1="0%" x2="0%" y2="0%">
              <stop offset="0%" stop-color="#0079f5"></stop>
              <stop offset="100%" stop-color="#6149f6"></stop>
            </linearGradient>
          </defs>
          <circle
            class="trail"
            cx="30"
            cy="30"
            r="25"
            fill="transparent"
            stroke-width="4"
            stroke="#eee"
          ></circle>
          <circle
            id="progress-bar"
            class="path"
            cx="30"
            cy="30"
            r="25"
            fill="transparent"
            stroke-width="4"
            stroke="url(#gradient)"
            style="stroke-dashoffset: 141.3"
          ></circle>
        </svg>
        <span id="progress-detail">20%</span>
        <button onclick="reduce()">减少</button>
        <button onclick="add()">增加</button>
      </label>
    </div>
  </body>

  <script>
    const bar = document.getElementById("progress-bar");
    const detail = document.getElementById("progress-detail");
    const total = 157; // 圆周长
    const per = total / 100; //一个百分比进度代表的周长
    let progress = 20; // 当前百分比进度

    function add() {
      if (progress >= 100) {
        return;
      }
      progress += 20;
      update();
    }

    function reduce() {
      if (progress <= 0) {
        return;
      }
      progress -= 20;
      update();
    }

    function update() {
      bar.style.strokeDashoffset = total - per * progress;
      detail.innerHTML = `${progress}%`;
    }
  </script>
</html>