日常开发中的简单动画的实现技巧

569 阅读9分钟

日常中隐藏的动画

在日常的开发中,动画其实充斥在各个角落中,但它们基本都隐藏于我们日常使用的第三方库中,我们也基本不会主动去关注这部分,因为它们通常能够满足所有的日常所需

比如 ant-design 中对于 <Button/> 的波浪效果,<Tree/>的平滑折叠,再比如所有 UI 库几乎都会提供的,折叠组件,手风琴效果等

简单动画"能动起来"的底层实现理论

动画比较常见的种类个人感觉都可以总结成:渐进式变化类, 它们都属于是将某些元素,让其从某个状态,平滑的过度到另一个状态,或者是有多个阶段,然后分别过度过去

在实现上可以分为,CSS/JS 两种实现

对于复杂的特效这部分基本非 Js 莫属,它应该由更专业的第三方提供支持,比如 gasp

对于简单的动画则应该选择 CSS,因为它的性能更佳,而 CSS 的动画大致就是用各种属性结合 transition,animate 属性做文章

虽然这里我将它叫做 CSS 动画,这不意味着就全都是通过 CSS 实现,而是说,实现这种动画的核心应该是围绕着 CSS 展开的

动画上的挑战 与 拆解

动画往往不仅仅是将某个盒子,做做平移,缩放下的事,这种复杂度只能算基础,它比简单还简单

实际上的所谓简单动画是

  1. 将各种这些基础的技巧进行不同的搭配组合
  2. 应用的对象大概率是一坨子,这一坨子还有层级,比如对多层级的 div 应用不同的动画
  3. 动画之间存在衔接和接替,比如前一个结束了才能开始下一个

为了更好的理解这里面之间的关系,和解决这些概念,我们可以把它们抽象提取成以下问题

  1. 对单个对象做乱七八糟的动画
  2. 对2个对象做乱七八糟的动画
  3. 对列表做乱七八糟的动画

对于不同数量的对象做动画,要思考的点和面临的问题绝大多数是不一样的

而对于没提到的怎么处理动画之间的衔接和搭配,这在单个对象上也能做。比如写两个 animate 动画,想办法让它们按照某种顺序和规则执行

2个对象的动画

单个对象的和这类动画应该是最最常见的,2个对象的动画往往伴随着切换的行为

比如 1 个数字变成了另一个,那么旧的要做渐隐,新的要做渐入

比如让某个元素跟随鼠标移动,需要做到一种拖尾现象,这里和上面的几乎一样,旧的要做渐隐,新的要做渐入,然后渐变的过程持续时间长点,过程中要连续触发下就会形成视觉上的拖尾

这类动画做的越高级,视觉感受上就会越像是列表动画,往往会感觉到有超过2个以上的元素在动一样,原则上是要想明白,对于动画的切换为主的运用

对于 2 个对象的动画个人感觉难点更多的不是代码怎么写,而是需要巧思,即使代码能力再强,但如果想不到那就怎样的都写不出,从难点上来说,个人感觉它是简单动画中最最复杂的,除了大量积累相关的经验没什么好办法,也提炼不出来什么特别通用的技巧

列表动画

列表动画在数量上比 2 个对象的动画多了很多,但复杂度上往往要小很多,因为实际上我们对于列表动画的需求,都可以分为以下三类来作文章

  1. 新增元素
  2. 删除元素
  3. 移动元素

列表上如果引用了复杂的动画很容易让页面的行为变得非常奇怪,所以在实现上不会追求复杂,而是在追求简单达到效果,最最常见就是引用各种,可见度opacity 和 各种平移 的变化。而这是我们学习动画最开始就会接触的效果,所以思考的重心就变成了,应该怎样让不同子元素在动画的同时,能够看起来很协调

如果我们使用了框架,比如 Vue/React 的话,有些人会认为纯 js 操作各种属性不太适合,那么如果依赖框架的行为做动画,这里还得思考怎么排除掉框架更新带来的不可控因素

在做列表动画时就不得不提一个很通用的实现技巧,FLIP,这个词我第一次知道是在 Vue 官网上看到的,用中文解释的大概意思就是

将动画反方向播放

比如我们想要将一个盒子,位置坐标是 (0,0),向右边平移 100px,意味着需要在盒子的当前位置上,加上向右平移的距离,最终位置就是 (100, 0)。而反方向播放是指,先将盒子放到最终位置 (100, 0),然后通过样式让它回到开始的位置 (0, 0),在让它回到(100, 0)

这样做的好处是,动画不害怕被打断,因为动画永远是从正确的,最终的位置开始计算。过程中可能会因为动画被打断变得乱七八糟,但结果一定是正确的。这对于经常写动画的人来说应该是深有体会的,因为大家应该都体会过,在使用框架 React/Vue 时,动画的过程中发生了响应式更新,元素不是没了就飞了的效果

FLIP 应用于列表的实现技巧

在实现套路上,比较求稳的方式是,我们能将动画不同的时间拆分成以下阶段

  • enter-from 进入前
  • enter-from-active 正在进入的过程中
  • enter-to 进入后
  • leave-from 离开前
  • leave-from-active 正在离开的过程中
  • leave-to 离开后
  • move 正在移动中

每个阶段都是一个 class,过程中主要是给加上过度效果 transition,然后一前一后就字面意思,这个过程涵盖了列表动画的所有的生命周期阶段,当所有阶段结束后记得把动画加的临时属性去掉即可

写成代码这里有个简陋的 demo,可以结合更下方的代码行为理解

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <style>
      .list-item-enter-active,
      .list-item-leave-active,
      .list-item-move {
        transition: all 0.5s ease;
      }
      .list-item-enter-from,
      .list-item-leave-from {
        opacity: 1;
        transform: translateX(0px);
      }
      .list-item-enter-from {
        opacity: 0;
        transform: translateX(-30px);
      }
      .list-item-leave-to {
        opacity: 0;
        transform: translateX(30px);
      }
    </style>

    <div>
      <!-- 通过 addItem 把输入的内容添加到下面 -->
      <input type="text" id="newItemInput" autocomplete="off" />
      <button id="addItem">Add</button>

      <!-- 洗牌操作,只有交换 -->
      <button id="shuffle">shuffle</button>

      <!-- 混合洗牌,同时存在 新增,移动,删除 -->
      <button id="mixinShuffle">mixinShuffle</button>
    </div>
    
    <ol id="itemList"></ol>

    <script type="module" src="./index.js"></script>
  </body>
</html>
class AnimatedList {
  constructor() {
    this.element = document.querySelector("#itemList")
    this.items = Array.from(document.querySelectorAll(".list-item"))
    this.init()
  }

  init() {
    // 绑定一些基础属性
    document.getElementById("addItem").addEventListener("click", () => {
      const value = document.getElementById("newItemInput").value.trim()
      if (value) {
        this.addItem(value)
        document.getElementById("newItemInput").value = ""
      }
    })

    document.querySelector("#shuffle").onclick = () => {
      this.shuffle()
    }

    document.querySelector("#mixinShuffle").onclick = () => {
      // 使用纯 css 实现,繁琐
      // this.mixinShuffle();

      // 使用 浏览器API,简洁
      this.mixinShuffle1()
    }
  }

  addItem(value) {
    // 创建子元素
    const itemElement = document.createElement("li")
    itemElement.textContent = value
    itemElement.classList.add("list-item", "list-item-enter-from")
    itemElement.style.cursor = "pointer"

    // 先添加进入,变成进入前的样式
    this.element.appendChild(itemElement)

    // 在下一帧,让元素进入到过渡中的状态
    // 并设置 enter-to 让其开始过度
    requestAnimationFrame(() => {
      itemElement.classList.add("list-item-enter-active")
      itemElement.classList.add("list-item-enter-to")
      itemElement.classList.remove("list-item-enter-from")
    })
    // 过度结束后清理没用的属性
    itemElement.addEventListener(
      "transitionend",
      () => {
        itemElement.classList.remove(
          "list-item-enter-to",
          "list-item-enter-active"
        )
      },
      { once: true }
    )

    // 添加一个点了能删除自身的回调
    itemElement.addEventListener("click", e => {
      this.removeItem(e.currentTarget)
    })
  }

  removeItem(itemElement) {
    // 设置删除前的样式
    itemElement.classList.add("list-item-leave-from", "list-item-leave-active")
    // 在下一帧,开始过度
    requestAnimationFrame(() => {
      itemElement.classList.remove("list-item-leave-from")
      itemElement.classList.add("list-item-leave-to")
    })
    // 过度完成后删除自身
    itemElement.addEventListener(
      "transitionend",
      () => {
        itemElement.remove()
      },
      { once: true }
    )
  }

  shuffle() {
    const children = Array.from(this.element.children)
    const prePosition = {}

    // 先记录移动前的位置
    // 设置个临时属性,用来做后边查找变化前的元素是哪个
    children.forEach((ch, i) => {
      ch.setAttribute("data-key", i)
      prePosition[i] = ch.getBoundingClientRect()
    })

    // 给元素随便排个顺序
    children.forEach((e, i) => {
      const to = Math.floor(Math.random() * (children.length - 1))
      if (to !== i) {
        this.element.insertBefore(e, children[to])
      }
    })

    children.forEach(ch => {
      // 通过上边的自定义属性能够拿到之前的位置
      const pRect = prePosition[ch.dataset.key]
      // 获取当前的位置
      const rect = ch.getBoundingClientRect()

      // 拿到(如果需要)移动的插值
      // 因为此时界面上已经是最终的位置,这里的插值是在算,从最终位置还原到初始为止的距离,所以的 前-后
      const diffX = pRect.x - rect.x
      const diffY = pRect.y - rect.y

      // 如果不存在移动就跳过不操作
      if (diffX === 0 && diffY === 0) {
        return
      }

      // 确保还原到原始为止时,这个效果是立马完成,不能存在过度效果
      ch.classList.remove("list-item-move")
      ch.style.transition = null

      // 让元素立马回到原始为止
      ch.style.transform = `translate(${diffX}px, ${diffY}px)`

      // 下一帧时,开始过度
      requestAnimationFrame(() => {
        ch.classList.add("list-item-move")
        ch.style.transform = `translate(0px, 0px)`
      })

      ch.addEventListener(
        "transitionend",
        () => {
          ch.classList.remove("list-item-move")
          ch.style.transform = null
        },
        { once: true }
      )
    })
  }

  mixinShuffle() {
    const children = Array.from(this.element.children)

    // 删除
    const delTargetIndex = Math.floor(Math.random() * (children.length - 1))
    const delTarget = children[delTargetIndex]
    delTarget.setAttribute("data-index", "-1")
    delTarget.style.transition = null
    delTarget.classList.add("list-item-leave-from")
    requestAnimationFrame(() => {
      delTarget.classList.remove("list-item-leave-from")
      delTarget.classList.add("list-item-leave-active", "list-item-leave-to")
    })
    delTarget.addEventListener(
      "transitionend",
      () => {
        this.element.removeChild(delTarget)
      },
      { once: true }
    )

    // 移动
    const prePosition = {}
    children.forEach((ch, i) => {
      if (ch.dataset.index === "-1") {
        return
      }
      ch.setAttribute("data-key", i)
      prePosition[i] = ch.getBoundingClientRect()
    })
    children.forEach((e, i) => {
      if (e.dataset.index === "-1") {
        return
      }
      const to = Math.floor(Math.random() * (children.length - 1))
      if (to !== i) {
        this.element.insertBefore(e, children[to])
      }
    })
    children.forEach(ch => {
      if (ch.dataset.index === "-1") {
        return
      }

      const pRect = prePosition[ch.dataset.key]
      const rect = ch.getBoundingClientRect()

      const diffX = pRect.x - rect.x
      const diffY = pRect.y - rect.y
      if (diffX === 0 && diffY === 0) {
        return
      }

      ch.classList.remove("list-item-move")
      ch.style.transition = null
      ch.style.transform = `translate(${diffX}px, ${diffY}px)`
      requestAnimationFrame(() => {
        ch.classList.add("list-item-move")
        ch.style.transform = `translate(0px, 0px)`
      })

      ch.addEventListener(
        "transitionend",
        () => {
          ch.classList.remove("list-item-move")
          ch.style.transform = null
        },
        { once: true }
      )
    })

    // 新增
    const itemElement = document.createElement("li")
    itemElement.textContent = Math.random()
    itemElement.classList.add("list-item", "list-item-enter-from")
    this.element.insertBefore(itemElement, this.element.children[0])
    itemElement.classList.add("list-item-enter-active")
    requestAnimationFrame(() => {
      itemElement.classList.remove("list-item-enter-from")
      itemElement.classList.add("list-item-enter-to")
    })
    itemElement.addEventListener(
      "transitionend",
      () => {
        itemElement.classList.remove(
          "list-item-enter-to",
          "list-item-enter-active"
        )
      },
      { once: true }
    )
  }

  mixinShuffle1() {
    const children = Array.from(this.element.children)

    // 删除
    const delTargetIndex = Math.floor(Math.random() * (children.length - 1))
    const delTarget = children[delTargetIndex]
    delTarget
      .animate(
        [
          { transform: "translateX(0px)", opacity: 1 },
          { transform: "translateX(30px)", opacity: 0 }
        ],
        { duration: 500, easing: "ease", fill: "forwards" }
      )
      .finished.then(() => {
        delTarget.remove()
      })

    // 移动
    const prePosition = {}
    children.forEach((ch, i) => {
      if (ch.dataset.index === "-1") {
        return
      }
      ch.setAttribute("data-key", i)
      prePosition[i] = ch.getBoundingClientRect()
    })
    children.forEach((e, i) => {
      if (e.dataset.index === "-1") {
        return
      }
      const to = Math.floor(Math.random() * (children.length - 1))
      if (to !== i) {
        this.element.insertBefore(e, children[to])
      }
    })
    children.forEach(ch => {
      if (ch.dataset.index === "-1") {
        return
      }

      const pRect = prePosition[ch.dataset.key]
      const rect = ch.getBoundingClientRect()

      const diffX = pRect.x - rect.x
      const diffY = pRect.y - rect.y
      if (diffX === 0 && diffY === 0) {
        return
      }

      ch.animate(
        [
          { transform: `translateY(${diffY}px)` },
          { transform: `translateY(0px)` }
        ],
        { duration: 500, easing: "ease" }
      )
    })

    // 新增
    const itemElement = document.createElement("li")
    itemElement.textContent = Math.random()
    this.element.insertBefore(itemElement, this.element.children[0])
    itemElement.animate(
      [
        { transform: "translateX(-30px)", opacity: 0 },
        { transform: "translateX(0px)", opacity: 1 }
      ],
      { duration: 500, easing: "ease", fill: "forwards" }
    )
  }
}

new AnimatedList()

代码行为解释

  • 为了能以更好的性能快速开始动画,所以可以多使用 requestAnimationFrame。浏览器会对不同帧进行动画过度,帧是浏览器的 fps,通常是一分钟 60帧,requestAnimationFrame 可以将代码放到下一帧绘制前执行
  • 浏览器提供了 transitionend 事件的监听,在这里可以很方便的知道什么时候结束了,然后第一时间清理掉无用的临时属性
  • 使用 js 控制过程会很繁琐,使用 animate() 函数可以让过程控制的更轻松,常用的属性,大部分浏览器也都支持了