FLIPで並べ替え&入れ替えアニメーションを作成する

CSS, JavaScript

はじめに

Vue.js の transition には、-move クラスというのがある。
これを使って、要素の並べ替えをアニメーションさせることが可能になっている。
例えば、Sudoku のサンプルなど。

このスムーズに入れ替わるアニメーションは、FLIP と呼ばれるテクニックを使用している。

参考
公式ドキュメント
Vue.js で使われている FLIP アニメーションの仕組み - Qiita

FLIPとは

FLIP はPaul Lewis 考案のテクニックで、First、Last、Invert、Play の頭文字からなる造語。
その手順は下記のようになる。

  1. First: 要素の初期状態を記録する
  2. Last: 要素の最終的な情報を記録する
  3. Invert: First, Last の情報を反転させる
  4. Play: アニメーションさせる

よくわからないので、CSS と JavaScript を使って実際に書いてみる。

下図の青いブロックを、500px 右に移動させるのを目的とする。

青いブロックが左側にある。ここから右に500px移動させるのをイメージした。

(薄い水色のブロックは、移動先のイメージ)

html

<div id="elm" class="block"></div>

css

.block {
  background-color: #236db9;
  position: relative;
  width: 200px;
  height: 200px;
}

First. 初期状態を記憶

目的はブロックを移動させることにあるので、まず初期の座標を記憶する必要がある。
あらかじめ getBoundingClientRect() などで記憶しておく。

javascript

// First
const first = elm.getBoundingClientRect();

first.left には 0 が入る。

Last. 最終的な情報を記憶

次は、要素の最終的な情報を記憶する。
今回の手順は、ブロックをあらかじめ移動させ、その座標を getBoundingClientRect() で取得しておくというもの。

要素のアクティブ的なクラスを作って、移動先を指定すれば OK。

css

.block.is-active {
  left: 500px;
}

そして JavaScript 側で、そのクラスを付与する。
スタイルが当たって 500px 移動するので、移動先の座標を getBoundingClientRect(); で記憶する。

javascript

// Last
elm.classList.add('is-active');
const last = elm.getBoundingClientRect();

こうすることで、last.left には 500 が入る。
また、is-active が付くので、ブロックは 500px 移動した状態になる。

青いブロックが右に500px移動した。

Invert: 1と2の差を出す

Last の時点でブロックはすでに移動しているので、次はそれを元の位置にあるように見せかける必要がある。
その手順が Invert になる。

まず、移動元と移動先の差を出す。

javascript

// invert
const invert = first.left - last.left;

今回は x の初期位置 0 から移動先の 500 を引くので、結果は-500 になる。

この差分で出した-500 を、ブロックの translateX に指定する。

javascript

elm.style.transform = `translateX(${invert}px)`;

こうすることで、右に 500px 移動しているけど、translateX で-500px 移動した状態ができる。
つまり、見た目では要素の位置が移動前と同じになる。

右に500px移動した青いブロックを、translateXで左に500px移動させた。視覚的には左500pxにあるかのように見えるが、実際は右500pxの位置にある

Play: 再生する

あとは、Invert で設定した translateX を、0 に戻すアニメーションを行う。

animate() を使用した場合…。

javascript

// play
const play = elm.animate([
  { transform: `translateX(${invert}px)` },
  { transform: 'translateX(0)' }
], {
  duration: 500,
  easing: 'ease-out',
});

とすることで対応可能。
サンプルはこちら。
https://codepen.io/dkrk/pen/RdaLaR

animate でなくても、アニメーションライブラリなどで代替できる。(polyfill

または、Vue の v-move のような transition 用クラスを付与して、CSS で対応することも可能。

.block.is-move {
  transition: all 0.5s ease-out;
}

この場合は、requestAnimationFrame 内で is-move クラスを付与したり、transitionEnd イベントでアクティブ要素を消したり一手間かかる。

いいこと

レイアウトの更新を最小限の手順で行いつつ、負荷の少ないアニメーションが作成できる。

"負荷の少ない" と言うのは、アニメーションに transformopacity を使用する場合。
topleft などのプロパティなどは、レイアウトそのものの情報を再計算するため、更新処理に負担がかかる。
transformopacity は、上位のレイヤー(Composite Layer)で更新され、最小限の変更で済むため滑らかにできる、というような理由がある。

(この辺りについては、下記を参考にしてください)
Aerotwist - Pixels are expensive
Animating Layouts with the FLIP Technique | CSS-Tricks
DevTools の Timeline パネルを見ながら、レンダリングの仕組みを理解する - Qiita

要素の入れ替え

次は、下図の 1 番目と 9 番目のアイテムを入れ替えるアニメーションを作成してみる。

9つのパネルがあり、縦に3、横に3マス並んでいる。これのパネル同士を入れ替えることをイメージしている。

html

<button id="button">トリガー用のボタン</button>

<ul id="itemList" class="list">
  <li id="item01" class="item">1</li>
  <li id="item02" class="item">2</li>
  <li id="item03" class="item">3</li>
  <li id="item04" class="item">4</li>
  <li id="item05" class="item">5</li>
  <li id="item06" class="item">6</li>
  <li id="item07" class="item">7</li>
  <li id="item08" class="item">8</li>
  <li id="item09" class="item">9</li>
</ul>

item01 を elm、item09 を target とする。

javascript

const elm = item01;
const target = item09;

まず first。
今回は elm と target それぞれの first, last, invert の値を記憶していく。

javascript

// fist
const elmFirst = elm.getBoundingClientRect();
const targetFirst = target.getBoundingClientRect();

次に last。
last の時点で、要素の入れ替えを完了させることがポイント。

今回は「お互いの要素があった場所に要素を挿入する」処理をしている。
そして、最後の 2 行でそれぞれの最終座標を取得している。

javascript

// last

// ここは要素の位置変更
const elmPrev = elm.previousElementSibling;
const targetPrev = target.previousElementSibling;
itemList.insertBefore(
  elm,
  targetPrev ? targetPrev.nextElementSibling : itemList.firstElementChild
);
itemList.insertBefore(
  target,
  elmPrev ? elmPrev.nextElementSibling : itemList.firstElementChild
);

// ここで最終位置を記憶
const elmLast = elm.getBoundingClientRect();
const targetLast = target.getBoundingClientRect();

elm と target の位置情報の差を、deltaX、deltaY という変数に代入する。

javascript

// invert
const elmDeltaX = elmFirst.left - elmLast.left;
const elmDeltaY = elmFirst.top - elmLast.top;

const targetDeltX = targetFirst.left - targetLast.left;
const targetDeltaY = targetFirst.top - targetLast.top;

// transformを適用して、元の位置にあるよう見せかける
elm.style.transform = `translate(${elmDeltaX}px, ${elmDeltaY}px)`;
target.style.transform = `translate(${targetDeltX}px, ${targetDeltaY}px)`;

あとは、それぞれ animate()でアニメーションさせる。

javascript

const playElm = elm.animate(
  [
    { transform: `translate(${elmDeltaX}px, ${elmDeltaY}px)` },
    { transform: `translate(0, 0)` },
  ],
  {
    duration: 300,
    easing: 'ease-out',
    fill: 'forwards',
  }
);

// アニメーション終了後にelmのtransformを消す
playElm.addEventListener('finish', () => {
  elm.style.transform = '';
});

// targetも同じなので省略

オプションにある fill: forwards を入れておくと、アニメーション後の状態をそのまま反映させることができる。
その場合も、invert でいじった transform の値が残ってしまうので、finish イベントで初期化する。

これで、1 と 9 の位置が入れ替わるようなアニメーションをする。
サンプルはこちら。
https://codepen.io/dkrk/pen/gErEEq

リスト全体の置き換え

ソート、フィルター、シャッフルなどでリスト全体を置き換える場合もやり方は同じ。
foreach などを使って、各要素に FLIP を適用していく。

再構成した際に要素の座標も変わってしまうので、元の座標を記憶させるとやりやすい。
今回は id をキーとして、アクセスできるようにした。
それらの値を first として使用すれば、簡単に実装できる。

javascript

button.addEventListener('click', () => flipFunc());

function flipFunc(elm, target) {
  const children = [...itemList.children];
  const shuffle = _.shuffle(children);
  const keys = {};

  // アイテム要素のidをkeyとして格納
  children.forEach(elm => {
    keys[elm.id] = elm.getBoundingClientRect();
  });

  // 一旦シャッフル
  shuffle.forEach((elm, index) => {
    itemList.appendChild(elm);
  });

  // シャッフル後の配置でFLIPを実施
  [...itemList.children].forEach((elm, index) => {

    // first
    // 初期状態は、入れ替え前のキーを参照する
    const first = keys[elm.id];

    // last
    const last = elm.getBoundingClientRect();

    // invert
    const delta = {
      x: first.left - last.left,
      y: first.top - last.top,
    };

    elm.style.transform = `translate(${delta.x}px, ${delta.y}px)`;

    // play
    const play = elm.animate(
      [
        { transform: `translate(${delta.x}px, ${delta.y}px)` },
        { transform: `translate(0, 0)` },
      ],
      {
        duration: 300,
        easing: 'ease-out',
        fill: 'forwards',
      }
    );
    play.addEventListener('finish', () => {
      elm.style.transform = '';
    });
  });
}

サンプルはこちら。
https://codepen.io/dkrk/pen/XGKreG

別の要素でFLIP

移動させる要素が別の要素でも問題ない。
下のサンプルでは、サムネイルのワンちゃんと、サムネイルをクリックして拡大した時のワンちゃんが別の要素になっている。
https://codepen.io/dkrk/pen/moEmBr

今回は変形を行っているので、動かす要素に transform-origin を設定している。
これがないと移動開始時の位置(というか原点)がずれる。

img {
  transform-origin: top left;
}

まとめ

  • FLIP を使って、レイアウトの更新を行ってから負荷の少ないアニメーションができる
  • アニメーションには transform など、レイアウト更新処理のないプロパティを用いる
  • 移動させる要素が、移動前の要素と別でも問題ない

これを活かせば並べ替え系はもちろん、クリック場所からスムーズに開閉するドロワーができたり、クローン要素をクローン元の要素であるかのように移動させたりできる。

この手のアニメーションはライブラリが出回っていて、自分で作ることはあまりなさそうだけど、ちょっとした要素の移動を行う時に、役に立つかもしれない。