Flip

149 阅读2分钟

简介

FLIP 是一种用于实现结构变化动画的方案,核心流程首字母组成FLIP:

  • First 计算初始位置
  • Last 计算变换之后的位置[页面无变化]
  • Invert 反转[页面无变化]
  • Play 播放动画[页面变化]

使用场景

  • 拖拽排序
  • 洗牌

Flip使用的知识点

  • 浏览器的渲染操作不在事件循环的队列中,而是在额外的渲染队列中;
  • 渲染队列中会存在多条渲染相关代码,对于相同的样式属性,会将其计算后合并执行,不会逐个执行;
  • 渲染队列会在主线程微小空闲期间批量执行【本次任务已结束,下个任务未开始|队列空】;

利用 渲染操作非逐行执行的特性
在 Last 步骤移动元素并计算移动的距离
在 Invert 步骤增加 transform 使元素还原到 First 步骤时位置
在 Play 步骤中设置 transition 过滤和删除 Invert 步骤中的 transform 属性
实现 First ---> Last --transform-> First --removeTransform-> Last

Flip 简单核心案例

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      body {
        width: 100vw;
        height: 100vh;
        display: flex;
        flex-direction: column;
        align-items: flex-start;
      }
      .list {
        border-radius: 10px;
        border: 1px solid #ccc;
        padding: 15px 30px;
        list-style: none;
        margin-top: 10px;
        margin-left: 30px;
      }
      .list-item {
        background: #7ebf50;
        border: 2px solid #4d782e;
        color: #fff;
        padding: 0 30px;
        border-radius: 25px;
        margin: 10px 0;
        height: 50px;
        line-height: 50px;
        padding-right: 60px;
        letter-spacing: 3px;
        user-select: none;
        position: relative;
      }
      button {
        border: none;
        outline: none;
        background: #409eff;
        padding: 12px 20px;
        color: #fff;
        border-radius: 7px;
        font-size: 16px;
        margin-top: 30px;
        margin-left: 30px;
        cursor: pointer;
      }

      button:hover {
        background: #66b1ff;
      }
      button:active {
        background: #3a8ee6;
      }
    </style>
  </head>
  <body>
    <button>改变第一个元素的位置</button>
    <ul class="list">
      <li class="list-item" style="background: #e75723; border-color: #a12d02">
        HTML + CSS
      </li>
      <li class="list-item">JavaScript</li>
      <li class="list-item">网络</li>
      <li class="list-item">工程化</li>
      <li class="list-item">框架</li>
      <li class="list-item">移动端</li>
      <li class="list-item">NodeJS</li>
    </ul>
    <script>
      const btn = document.querySelector("button");
      const list = document.querySelector(".list");
      const firstItem = document.querySelector(".list-item:first-child");
      const lastItem = document.querySelector(".list-item:last-child");

      function getLocation() {
        const rect = firstItem.getBoundingClientRect();
        return rect.top;
      }

      const start = getLocation();
      console.log("First:", start);

      btn.onclick = () => {
        list.insertBefore(firstItem, null);
        const end = getLocation();
        console.log("Last:", end);

        const dis = start - end;
        firstItem.style.transform = `translateY(${dis}px)`;
        console.log("Invert:", dis);

        raf(() => {
          firstItem.style.transition = "transform 1s";
          firstItem.style.removeProperty("transform");
          console.log("play");
        });
      };

      function raf(callback) {
        requestAnimationFrame(() => {
          requestAnimationFrame(callback);
        });
      }

      function delay(duration = 1000) {
        const start = Date.now();
        while (Date.now() - start < duration) {}
      }
    </script>
  </body>
</html>

Flip 列表中的使用封装

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <link rel="stylesheet" href="./font/iconfont.css" />
    <link rel="stylesheet" href="./css/index.css" />
  </head>
  <body>
    <button>排序</button>
    <ul class="list">
      <li class="list-item">HTML + CSS</li>
      <li class="list-item">JavaScript</li>
      <li class="list-item">网络</li>
      <li class="list-item">工程化</li>
      <li class="list-item">框架</li>
      <li class="list-item">移动端</li>
      <li class="list-item">NodeJS</li>
    </ul>

    <script src="./js/flip.js"></script>
    <script src="./js/index.js"></script>
  </body>
</html>
//index.js
var listContainer = document.querySelector(".list");
var btn = document.querySelector("button");
btn.onclick = function () {
  var f = new Flip(listContainer.children);
  function shuffle(listContainer) {
    var children = Array.from(listContainer.children); // 将子元素转换为数组
    var len = children.length;

    for (var i = len - 1; i > 0; i--) {
      // 生成一个随机索引,范围从 0 到 i
      var j = Math.floor(Math.random() * (i + 1));
      // 交换 children[i] 和 children[j]
      var temp = children[i];
      children[i] = children[j];
      children[j] = temp;
    }

    // 将乱序后的子元素重新插入到容器中
    listContainer.innerHTML = ""; // 清空容器
    children.forEach(function (child) {
      listContainer.appendChild(child);
    });
  }

  // 调用函数
  shuffle(listContainer);
  f.play();
};

上面,我们实现了一个一键洗牌的列表,下面给列表添加 shuffle 的动画

const Flip = (function () {
  class FlipDom {
    constructor(dom, duration = 0.5) {
      this.dom = dom;
      this.transition =
        typeof duration === "number" ? `${duration}s` : duration;
      this.firstPosition = {
        x: null,
        y: null,
      };
    }
    getDomPosition() {
      const rect = this.dom.getBoundingClientRect();
      return {
        x: rect.left,
        y: rect.top,
      };
    }
    recordFirst(firstPosition) {
      if (!firstPosition) {
        firstPosition = this.getDomPosition();
      }
      this.firstPosition.x = firstPosition.x;
      this.firstPosition.y = firstPosition.y;
    }
    play() {
      this.dom.style.transition = "none";
      const lastPosition = this.getDomPosition();
      const dis = {
        x: lastPosition.x - this.firstPosition.x,
        y: lastPosition.y - this.firstPosition.y,
      };
      if (!dis.x && !dis.y) {
        console.log("No displacement, no animation needed.");
        return;
      }
      this.dom.style.transform = `translate(${-dis.x}px, ${-dis.y}px)`;
      raf(() => {
        this.dom.style.transition = `transform ${this.transition}`;
        this.dom.style.transform = `none`;
      });
    }
  }
  function raf(callback) {
    requestAnimationFrame(() => {
      requestAnimationFrame(callback);
    });
  }
  class Flip {
    constructor(doms, duration = 0.5) {
      this.flipDoms = [...doms].map((it) => new FlipDom(it, duration));
      this.duration = duration;
      this.flipDoms.forEach((it) => it.recordFirst());
    }
    play() {
      this.flipDoms.forEach((it) => it.play());
    }
  }
  return Flip;
})();

对上面代码使用生成器函数进行优化

//flip.js
const Flip = (function () {
  class FlipDom {
    constructor(dom, duration = 0.5) {
      this.dom = dom;
      this.transition =
        typeof duration === "number" ? `${duration}s` : duration;
      this.firstPosition = {
        x: null,
        y: null,
      };
      this.isPlaying = false;
      this.transitionEndHandler = () => {
        this.isPlaying = false;
        this.recordFirst();
      };
    }

    getDomPosition() {
      const rect = this.dom.getBoundingClientRect();
      return {
        x: rect.left,
        y: rect.top,
      };
    }

    recordFirst(firstPosition) {
      if (!firstPosition) {
        firstPosition = this.getDomPosition();
      }
      this.firstPosition.x = firstPosition.x;
      this.firstPosition.y = firstPosition.y;
    }

    *play() {
      if (!this.isPlaying) {
        this.dom.style.transition = "none";
        const lastPosition = this.getDomPosition();
        const dis = {
          x: lastPosition.x - this.firstPosition.x,
          y: lastPosition.y - this.firstPosition.y,
        };
        if (!dis.x && !dis.y) {
          return;
        }
        this.dom.style.transform = `translate(${-dis.x}px, ${-dis.y}px)`;
        yield "moveToFirst";
        this.isPlaying = true;
      }

      this.dom.style.transition = this.transition;
      this.dom.style.transform = `none`;
      this.dom.removeEventListener("transitionend", this.transitionEndHandler);
      this.dom.addEventListener("transitionend", this.transitionEndHandler);
    }
  }

  class Flip {
    constructor(doms, duration = 0.5) {
      this.flipDoms = [...doms].map((it) => new FlipDom(it, duration));
      this.flipDoms = new Set(this.flipDoms);
      this.duration = duration;
      this.flipDoms.forEach((it) => it.recordFirst());
    }

    addDom(dom, firstPosition) {
      const flipDom = new FlipDom(dom, this.duration);
      this.flipDoms.add(flipDom);
      flipDom.recordFirst(firstPosition);
    }

    play() {
      let gs = [...this.flipDoms]
        .map((it) => {
          const generator = it.play();
          return {
            generator,
            iteratorResult: generator.next(),
          };
        })
        .filter((g) => !g.iteratorResult.done);

      while (gs.length > 0) {
        document.body.clientWidth;
        gs = gs
          .map((g) => {
            g.iteratorResult = g.generator.next();
            return g;
          })
          .filter((g) => !g.iteratorResult.done);
      }
    }
  }

  return Flip;
})();