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><buttonid="btn">Click</button><script>document.querySelector("#btn").addEventListener("click",()=>{constdiv=document.createElement("div");div.style.height="50px";div.style.width="50px";div.style.backgroundColor="black";document.body.appendChild(div);leti=0;while(true){i+=1;}});</script></body></html>
ボタンをクリックすると、<div>
が作成されて、それを<body>
の入れ子にしたのに、実はいつまで経ってもその<div>
が現れない(描画されない)。なぜかというと、メインスレッドがずっと無限ループで忙しいから。
その事実を利用することができる!
無限ループはもちろん書かない。
- まずは、並べ替える前の、すべての子要素の元位置を覚える。位置がわかるために、
getBoundingClientRect()
という関数を使う (First) - そして、子要素を並べ替えた直後に、描画される前でも、子要素の「新しい」DOMの位置がわかるので、
getBoundingClientRect()
関数を使って、新しい位置も覚える (Last) - 次に、LastとFirstの差を計算し、子要素が元の位置にまだあるかのように見せるために、CSSの
transform
を使って、LastとFirstの差だけtranslate
する (Invert) - 好きな
transition
の値を設定して、transform
の値をnone
に戻すと、子要素が元の位置から、新しい位置にスムーズに移動するアニメーションができる (Play)
1-3はすべて同期処理で、スタイルも含めて、描画される前に実行される!一方で、4は、requestAnimationFrame
の中で実行しないと、ブラウザーからすれば、transform
がもともとあって変更されたということにはならないので、注意。なので、ここだけrequestAnimationFrame
を使うように気を付けよう。
以下のような結果になるはず:
ちなみに、"Vanilla"コードにすると、少し長いかもしれないが、こんな感じになる:
<!DOCTYPE html><html><head><style>/* 省略 */</style></head><body><buttonid="btn">Click</button><divclass="container"><divid="first">1</div><divid="second">2</div></div><script>document.querySelector("#btn").addEventListener("click",()=>{constcontainer=document.querySelector(".container");constchildren=container.children;constprevPos={};Array.from(children).forEach(child=>{prevPos[child.id]=child.getBoundingClientRect().left;});container.insertBefore(children[1],children[0]);//ここでDOMの位置がかわるが、描画されないfor(letchildofchildren){constnewPos=child.getBoundingClientRect().left;constdeltaX=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のソリューションを使うとかなり手間が省ける。
react-easy-flip
https://github.com/jlkiri/react-easy-flip
僕が作ったライブラリー。OSSの中でHooksを使っているのはこのライブラリーだけで、もっとも軽い (807B)。現状では、並べ替えなどposition
が変わるケースに特化していて、将来opacity
やscale
の対応も実装予定
DEMO: https://flip.jlkiri.now.sh/react-flip-toolkit
https://github.com/aholachek/react-flip-toolkit
Vanilla向けとReact向けのパッケージがある。対応するCSSのアニメーションが多い。比較的にサイズが重い (7.0KB)react-flip-move
https://github.com/joshwcomeau/react-flip-move