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

はじめに

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右に移動させるのを目的とする。

flip anim01

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

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移動した状態になる。

flip anim02 fix

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移動した状態ができる。
つまり、見た目では要素の位置が移動前と同じになる。

flip anim03

Play: 再生する

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

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

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

とすることで対応可能。
サンプルはこちら。

animateでなくても、アニメーションライブラリなどで代替できる。
(animateは非対応ブラウザが多いです。一応polyfillがあります)

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

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

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

何がいいのか

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

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

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

要素の入れ替え

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

flip anim04

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の位置が入れ替わるようなアニメーションをする。
サンプルはこちら。

リスト全体の置き換え

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

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

javascript(lodash使用)
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 = '';
    });
  });
}

サンプルはこちら。

別の要素でFLIP

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

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

img {
  transform-origin: top left;
}

まとめ

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

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

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