FLIP手法によるスムーズなアニメーションとVanillaとReactでの実現


FLIP手法って何?

60fpsでスムーズなアニメーションを達成することは簡単ではない。ピュアなCSSでできることも多いが、DOMの変更とJavascriptがかかわるアニメーションは、メインスレッドが忙しいと、その影響を受けるので遅延することがある。
たとえば、setIntervalを使った、positionを変える「移動」のアニメーションは、設定したインターバルの値にもかかわらず、他に処理しないといけないことの多さによって実行が遅くなることがある。60fpsをはるかに下回ると、アニメーションがカクカクしてしまうのでユーザーにとって悪い体験になる。

が、Paul Lewis氏が考えたFLIP手法によって、複雑なアニメーションでもスムーズに実行することができる!

FLIPとは:
1. First : 最初(のDOM位置などの状態)と
2. Last : 最後(のDOM位置などの状態)を
3. Invert : 反転させて
4. Play : 再生する

YoutubeにあるPaul Lewis氏の発表を見てみると、使いどころがたくさんあることがわかるが、今回はとてもシンプルな例を挙げる。

リスト並べ替えのアニメーションの例 (Vanilla編)

TODOリストとか商品のリストなどを並べ替えるアクションがあるとしよう。普段なら、DOMの位置が瞬時にかわるだけで、ユーザーには並べ替えた結果しか見えない。リストの子要素が前の位置から、新しい位置に移動する過程が見えないわけだ。残念ながらCSSだけでは、このようなアニメーションを定義することができない。

しかし、ブラウザーがどう描画を進めるか思い出すと、次のようなトリックができる。

あくまでコード的に、並べ替えた直後、それとも新しい子要素を追加した直後に、結果が描画されるわけではない。並べ替えたあとでも、同期処理がある場合、それらが実行されるまで描画が遅延される。次の場合を見てみよう:

<!DOCTYPE html>
<html>
  <head></head>
  <body>
    <button id="btn">Click</button>
    <script>
      document.querySelector("#btn").addEventListener("click", () => {
        const div = document.createElement("div");
        div.style.height = "50px";
        div.style.width = "50px";
        div.style.backgroundColor = "black";
        document.body.appendChild(div);
        let i = 0;
        while (true) {
          i += 1;
        }
      });
    </script>
  </body>
</html>

ボタンをクリックすると、<div>が作成されて、それを<body>の入れ子にしたのに、実はいつまで経ってもその<div>が現れない(描画されない)。なぜかというと、メインスレッドがずっと無限ループで忙しいから。

その事実を利用することができる!

無限ループはもちろん書かない。

  1. まずは、並べ替える前の、すべての子要素の元位置を覚える。位置がわかるために、getBoundingClientRect()という関数を使う (First)
  2. そして、子要素を並べ替えた直後に、描画される前でも、子要素の「新しい」DOMの位置がわかるので、getBoundingClientRect()関数を使って、新しい位置も覚える (Last)
  3. 次に、LastとFirstの差を計算し、子要素が元の位置にまだあるかのように見せるために、CSSのtransformを使って、LastとFirstの差だけtranslateする (Invert)
  4. 好きなtransitionの値を設定して、transformの値をnoneに戻すと、子要素が元の位置から、新しい位置にスムーズに移動するアニメーションができる (Play)

1-3はすべて同期処理で、スタイルも含めて、描画される前に実行される!一方で、4は、requestAnimationFrameの中で実行しないと、ブラウザーからすれば、transformがもともとあって変更されたということにはならないので、注意。なので、ここだけrequestAnimationFrameを使うように気を付けよう。

以下のような結果になるはず:

ちなみに、"Vanilla"コードにすると、少し長いかもしれないが、こんな感じになる:

<!DOCTYPE html>
<html>
  <head>
    <style>
      /* 省略 */
    </style>
  </head>
  <body>
    <button id="btn">Click</button>
    <div class="container">
      <div id="first">1</div>
      <div id="second">2</div>
    </div>
    <script>
      document.querySelector("#btn").addEventListener("click", () => {
        const container = document.querySelector(".container");
        const children = container.children;

        const prevPos = {};

        Array.from(children).forEach(child => {
          prevPos[child.id] = child.getBoundingClientRect().left;
        });

        container.insertBefore(children[1], children[0]); //ここでDOMの位置がかわるが、描画されない

        for (let child of children) {
          const newPos = child.getBoundingClientRect().left;
          const deltaX = prevPos[child.id] - newPos;
          child.style.transition = "";
          child.style.transform = `translateX(${deltaX}px)`;

          requestAnimationFrame(() => {
            child.style.transition = "500ms";
            child.style.transform = ``;
          });
        }
      });
    </script>
  </body>
</html>

React編

では、Reactでは同じことが簡単にできるか?答えはYES。
YESだが、Reactがどういうふうに、どの段階でDOMの更新をするか、把握しないといけない。前のバージョンだと、それがcomponentDidUpdateになるようだ。16.8.0以降では、Hooksもできたので、Hooksを使う場合は、useLayoutEffectのコールバックが最適のようだ。useLayoutEffectのコールバックが、DOM更新が終わったあとに、同期的に実行される。

Reactでやるとめんどくさいところ

Vanillaと違って、まず親と子要素のDOMの参照ができるようにするためには、それらのrefを保持しないといけない。さらに、元の位置は、レンダー後のsetStateなどで保管してもいいのだが、余計なレンダーを起こすので、最適とは言えない。なので、そこでuseRefを使うと、単純なオブジェクトをステートとして利用することができる。その中身を変えても、再レンダーが起こらないから。

ほかにも、レンダーが頻繁だと、子要素がまだアニメーション中で、それの新しい位置が、正確ではなくアニメーションが崩れることがある。ひとつの対策として、onTransitionEndなどのリスナーで、アニメーションが終わるのを待ってから、位置を保存することができる。

おすすめライブラリー

以上のめんどくさいところがたくさんあるということで、OSSのソリューションを使うとかなり手間が省ける。

  1. react-easy-flip
    https://github.com/jlkiri/react-easy-flip
    僕が作ったライブラリー。OSSの中でHooksを使っているのはこのライブラリーだけで、もっとも軽い (807B)。現状では、並べ替えなどpositionが変わるケースに特化していて、将来opacityscaleの対応も実装予定
    DEMO: https://react-easy-flip-demo.now.sh/
  2. react-flip-toolkit
    https://github.com/aholachek/react-flip-toolkit
    Vanilla向けとReact向けのパッケージがある。対応するCSSのアニメーションが多い。比較的にサイズが重い (7.0KB)
  3. react-flip-move
    https://github.com/joshwcomeau/react-flip-move