用FlIP思想来操纵你的动画

836 阅读4分钟

1. 写在前面

Vue官网上过渡与动画一章有一个非常炫酷的洗牌动画,这里也是用的FLIP思想来做的过渡动画,Vue过渡与动画文档GIF.gif

2. 什么是FLIP?

  • F:代表fisrt,表示所有过渡元素的初始状态
  • L:代表last,表示所有过渡元素的结束状态
  • I:代表Invert,反转,这是FlIP思想的核心。表示把过渡元素的状态(top、left、width、height、opacity等)回归到之前的状态,具体怎样回归呢?举个例子:某元素从初始位置0px到结束向右偏移了100px,这个时候进行反转设置,给这个元素设置transform: translateX(0-100px),让这个元素看起来好像还停留在初始的位置。
  • P:代表play,播放。就是删除上面Invert的设置,并且为其css设置tansition: all 1s过渡,这时元素就会从起点到终点做路径动画。

3. 基于Vue实现洗牌动画

Vue中要获取元素最终的状态要在$nextTick中执行,才能确保元素最终的状态。Vue源码详解之nextTick:MutationObserver只是浮云,microtask才是核心!

  • html
<div id="root">
  <button @click="handleShuffle">shuffle</button>
  <div class="container">
    <div class="item" v-for="item in arr" :key="item" ref="box">
      {{item}}
    </div>
  </div>
</div>
  • css
.container {
  margin100px auto 0;
  width450px;
  height450px;
  display: flex;
  flex-wrap: wrap;
}
.item {
  width50px;
  height50px;
  border1px solid #333;
  display: flex;
  justify-content: center;
  align-items: center;
  box-sizing: border-box;
}
  • js
new Vue({
  el: '#root',
  data() {
    return {
      arr: [...Array(81).keys()]
    }
  },
  methods: {
    handleShuffle() {
      // 第一步:First
      [...this.$refs.box].forEach(item => {
        const { left, top } = item.getBoundingClientRect();
        item.dataset.left = left;
        item.dataset.top = top;
        item.style.transition = 'unset';
      });
      
      // 数组乱序操作
      this.arr.sort((a, b) => Math.random() - 0.5);
      
      // 在nextTick中获取元素的最终位置
      this.$nextTick(() => {
        [...this.$refs.box].forEach(item => {
          // 第二步:Last
          const { left: currentLeft, top: currentTop } = item.getBoundingClientRect();
          const { left: oldLeft, top: oldTop } = item.dataset;
          // 第三步:Invert
          item.style.transform = `translate3d(${oldLeft - currentLeft}px, ${oldTop - currentTop}px, 0)`;
        })
      });

      // 第四步:Play
      requestAnimationFrame(() => {
        [...this.$refs.box].forEach(item => {
          item.style.transform = 'translate3d(0, 0, 0)';
          item.style.transition = 'all 1s';
        })
      });
    }
  }
})

4. 为什么是在requestAnimationFrame中执行play?

先看这段代码:

for (var i = 0; i < 100; i++) {
  document.body.style.background = 'red';
  document.body.style.background = 'blue';
}
  • 这里面有一个重要的浏览器渲染知识,并不是每执行一次代码,浏览器就执行一次渲染。通过浏览器Performance面板里看到: Dingtalk_20210202152325.jpg
  • 浏览器并没有根据我们常规思维去执行渲染,而是执行了最后一次状态位的渲染。浏览器会将js的操作推入到一个渲染队列里,在js执行完后会将渲染队列任务取出然后统一进行渲染
  • 而requestAnimationFrame会在页面重新渲染前调用,从渲染前调用到下一帧的开始渲染,大概有100ms的空闲时间,我们利用这100ms,用Vue.$nextTick获取到了元素的最终位置,并且对元素做了Invert回归操作。等到渲染的时候,让元素的位置又变回去,即transform: translate3d(0, 0, 0),并且给元素添加了tansition: all 1s过渡,这样元素就按着位置改变进行动画,这里强烈推荐大佬的深入解析你不知道的 EventLoop 和浏览器渲染、帧动画、空闲回调(动图演示)这篇文章,里面详细写了requestAnimationFrame requestIdleCallback
  • 另外这里也可以用web animation来进行动画,做如下修改:
this.$nextTick(() => {
  [...this.$refs.box].forEach(el => {
    const { left: currentLeft, top: currentTop } = el.getBoundingClientRect();
    const { left: oldLeft, top: oldTop } = el.dataset;
    var player = el.animate([
      { transform: `translate3d(${oldLeft - currentLeft}px, ${oldTop - currentTop}px, 0)` },
      { transform'`translate3d(0, 0, 0)' }
    ], {
      duration500,
      easing'cubic-bezier(0,0,0.32,1)',
    });
  })
});

对于web animation兼容性: web animation.jpg 我们可以用官方提供的polyfill

5. 使用React Hooks实现洗牌动画

css还是用上面的,这里要注意useEffect和useLayoutEffect的区别useLayoutEffect里面的callback函数会在DOM更新完成后立即执行,但会在浏览器进行任何绘制之前运行完成,我们在这里面获取到元素的最终状态,类似于Vue里的$nextTick。

import { useState, useRef, useLayoutEffect, useEffect } from "react";
import './App.css';

// 打乱数组顺序-洗牌算法
// 每次从未处理的数组中随机取一个元素,然后把该元素放到数组的尾部
// 即数组的尾部放的就是已经处理过的元素,以此循环处理
// https://github.com/ccforward/cc/issues/44
function shuffleArr(arr) {
  let len = arr.length;
  while (len) {
    const random = Math.floor(Math.random() * len);
    len--;
    // const lastNum = arr[len];
    // const randomNum = arr[random];

    // arr[len] = randomNum;
    // arr[random] = lastNum;

    // es6变量互换
    [arr[len], arr[random]] = [arr[random], arr[len]];
  }
  return arr;
}

function App() {
  const [arr, setArr] = useState([...Array(81).keys()]);
  const containerRef = useRef(null);

  const handleClick = () => {
    const itemList = containerRef.current.querySelectorAll(".item");
    // 第一步:First
    [...itemList].forEach((item) => {
      const { left, top } = item.getBoundingClientRect();
      item.dataset.left = left;
      item.dataset.top = top;
      item.style.transition = "unset";
    });

    // 对数组进行乱序
    const newArr = shuffleArr(arr);
    setArr([...newArr]);
  }

  useLayoutEffect(() => {
    const itemList = containerRef.current.querySelectorAll(".item");

    [...itemList].forEach((item) => {
      // 第二步:Last
      const { left: currentLeft, top: currentTop } = item.getBoundingClientRect();
      const { left: oldLeft, top: oldTop } = item.dataset;
      // 第三步:Invert
      item.style.transform = `translate3d(${oldLeft - currentLeft}px, ${oldTop - currentTop}px, 0)`;
    });
  }, [arr]);

  useEffect(() => {
    const itemList = containerRef.current.querySelectorAll(".item");

    // 第四步:Play
    requestAnimationFrame(() => {
      [...itemList].forEach((item) => {
        item.style.transform = `translate3d(000)`;
        item.style.transition = "all 1s";
      });
    });
  }, [arr]);

  return (
    <div>
      <button onClick={handleClick}>btn</button>
      <div className="container" ref={containerRef}>
        {arr.map((item) => (
          <div className="item" key={item}>
            {item}
          </div>
        ))}
      </div>
    </div>
  );
}

export default App;

6. 参考资料

  1. 深入解析你不知道的 EventLoop 和浏览器渲染、帧动画、空闲回调(动图演示)
  2. 前端动画必知必会:React 和 Vue 都在用的 FLIP 思想实现小姐姐流畅移动
  3. FLIP Your Animations
  4. Web_Animations_API
  5. Vue官网Shuffle动画解析
  6. Vue 源码详解之 nextTick:MutationObserver 只是浮云,microtask 才是核心!