🙌

ReactでFLIPアニメーションをする方法

2023/09/23に公開

始めに

昔Vue.jsはデフォルトでFLIPアニメーションする機能が備わっていて凄いという記事を紹介しました。
https://qiita.com/wintyo/items/7aa0b52c101ec31637b4

こういうのが簡単に設定できる感じです。

しかしReactでもライブラリを使えば比較的簡単にFLIPアニメーションを実現することができますので、その方法について記事にまとめました。

作ったものの比較

Vue.jsとReactでそれぞれ作ったものを先に共有します。Vue.jsは以前の記事でも書いていましたが、だいぶ古くなったのでVue.js 3系のものに作り直しました。Reactはreact-flip-movereact-flip-toolkitのライブラリをそれぞれ作ってみました。どちらも同じ動きを再現できましたが、コードの新しさからreact-flip-toolkitの方が良さそうだなと感じました。詳細は後述します。

Vue.js 3系

※新しいCodeSandboxの形式だと埋め込みが上手くいかなかったのでStackBlitzで作りました。

React

react-flip-moveでFLIPアニメーションする

react-flip-moveはVue.jsのtransition-groupと結構似ており、FlipMoveコンポーネントでラップすると勝手にFLIPアニメーションしてくれます。

react-flip-moveでFLIPアニメーションする
import { FC, useState } from "react";
import { shuffle } from "lodash-es";
import FlipMove from "react-flip-move";

import { List } from "../components/List";
import { ListItem } from "../components/ListItem";

export const ReactFlipMovePage: FC = () => {
  const [itemNums, setItemNums] = useState([1, 2, 3, 4, 5]);

  return (
    <div>
      <div>
        <button
          onClick={() => {
            setItemNums(shuffle(itemNums));
          }}
        >
          シャッフル
        </button>
        <button
          style={{ marginLeft: 5 }}
          onClick={() => {
            const value = itemNums.length > 0 ? Math.max(...itemNums) + 1 : 1;
            const index = Math.floor(itemNums.length * Math.random());
            setItemNums([
              ...itemNums.slice(0, index),
              value,
              ...itemNums.slice(index)
            ]);
          }}
        >
          追加
        </button>
      </div>
      <List>
        <FlipMove
          typeName={null}
        >
          {itemNums.map((num) => (
            <ListItem
              key={num}
              num={num}
              onRemove={() => {
                setItemNums(itemNums.filter((itemNum) => itemNum !== num));
              }}
            />
          ))}
        </FlipMove>
      </List>
    </div>
  );
};

UIコンポーネントは以下のようにしています。ListItemコンポーネントはFlipMove直下のコンポーネントであり、DOMに直接スタイルを当てるのでforwardRefする必要があります。

components/List.tsx
import { FC, ReactNode } from "react";

export type ListProps = {
  children: ReactNode;
};

export const List: FC<ListProps> = ({ children }) => {
  // FLIP移動の起点になるためposition: relativeを設定する必要がある
  // (FlipMoveのtypeNameをnullにしない場合は設定しなくても良い)
  return <ul style={{ position: "relative", padding: 0 }}>{children}</ul>;
};
components/ListItem.tsx
import { FC, forwardRef } from "react";

import styles from "./ListItem.module.scss";

export type ListItemProps = {
  num: number;
  onRemove: () => void;
};

export const ListItem = forwardRef<HTMLLIElement, ListItemProps>(
  (
    {
      num,
      onRemove
    },
    ref
  ) => {
    return (
      <li ref={ref} className={styles.ListItem}>
        <div className={styles.ListItem__text}>{num}</div>
        <div
          className={styles.ListItem__delete}
          onClick={() => {
            onRemove();
          }}
        />
      </li>
    );
  }
);

これで要素の移動ができましたが、追加・削除時にフェードイン/アウトするアニメーションは別途設定する必要があります。これはFlipMoveenterAnimationleaveAnimationfromtoを設定することで実現でき、Vue.jsとかなり似た設定方法になります。

追加・削除時のフェードイン/アウトする
 // 既出のものは省略

 export const ReactFlipMovePage: FC = () => {
   const [itemNums, setItemNums] = useState([1, 2, 3, 4, 5]);

   return (
     <div>
       {/* 既出のものは省略 */}
       <List>
         <FlipMove
           typeName={null}
+          enterAnimation={{
+            from: {
+              transform: "translateY(-30px)",
+              opacity: "0"
+            },
+            to: {
+              transform: "",
+              opacity: "1"
+            }
+          }}
+          leaveAnimation={{
+            from: {
+              transform: "",
+              opacity: "1",
+              pointerEvents: "none"
+            },
+            to: {
+              transform: "translateY(-30px)",
+              opacity: "0",
+              pointerEvents: "none"
+            }
+          }}
         >
           {/* 既出のものは省略 */}
         </FlipMove>
       </List>
     </div>
   );
 };

気になった点

React18だと警告が出てしまう

これはかなり致命的だと思っていて、具体的には以下のような警告が出ました。ざっくり読むとfindDOMNodeがdeprecateしているとのことで、それが未だに解消されていないので、長期的に見ると使わない方が良さそうに思いました。これがなければサクッとFLIPアニメーションしたい時には使っても良いと思ったんですけどね。。

https://github.com/joshwcomeau/react-flip-move/issues/270

まとめ

react-flip-moveの使用感をまとめると以下のようになります。気軽にFLIPアニメーションを実装したいときはかなり良さそうでしたが、改善する見込みがないので長期的に見ると使わない方が良さそうだなと思いました。。

  • Vue.jsと同じくらい気軽に設定できる
  • コードが古いためReact18だと警告が出てしまう

react-flip-toolkitでFLIPアニメーションする

react-flip-toolkitは少し特殊で、FlipperというコンポーネントにあるflipKeyを使ってアニメーションの実行タイミングを制御できます。flipKeyが切り替わった時にアニメーションが実行されるので、例えば配列の数をkeyにして、個数が変わった時だけアニメーションさせるみたいなことができます。今回は追加・削除・並び替えの時にアニメーションして欲しいのでitemNums.join("")としています。また要素ごとにはFlippedコンポーネントでラップする必要があります。

react-flip-toolkitでFLIPアニメーションする
import { FC, useState } from "react";
import { shuffle } from "lodash-es";
import { Flipper, Flipped } from "react-flip-toolkit";

import { List } from "../components/List";
import { ListItem } from "../components/ListItem";

export const ReactFlipToolkitPage: FC = () => {
  const [itemNums, setItemNums] = useState([1, 2, 3, 4, 5]);

  return (
    <div>
      <div>
        <button
          onClick={() => {
            setItemNums(shuffle(itemNums));
          }}
        >
          シャッフル
        </button>
        <button
          style={{ marginLeft: 5 }}
          onClick={() => {
            const value = itemNums.length > 0 ? Math.max(...itemNums) + 1 : 1;
            const index = Math.floor(itemNums.length * Math.random());
            setItemNums([
              ...itemNums.slice(0, index),
              value,
              ...itemNums.slice(index)
            ]);
          }}
        >
          追加
        </button>
      </div>
      <Flipper flipKey={itemNums.join(",")}>
        <List>
          {itemNums.map((num) => (
	    <Flipped key={num} flipId={num}>
	      <ListItem
	        num{num}
		onRemove={() => {
	          setItemNums(itemNums.filter((itemNum) => itemNum !== num));
	        }}
	      />
	    </Flipped>
          ))}
        </List>
      </Flipper>
    </div>
  );
};

Flippedでラップされるコンポーネントは暗黙的にpropsが注入されているので、それをルート要素に入れておく必要があります。どういう内容が入っているのかconsole.logしたところ、以下のようなdata属性が入っていました。また、react-flip-moveではforwardRefが必須でしたが、react-flip-toolkitでは特に不要でした(propsで注入したdata属性をみてDOMを取得している感じなんですかね?)

react-flip-toolkit用のListItem.tsx
 import { forwardRef } from "react";

 import styles from "./ListItem.module.scss";

 export type ListItemProps = {
   num: number;
   onRemove: () => void;
 };

 export const ListItem = forwardRef<HTMLLIElement, ListItemProps>(
   (
     {
       num,
       onRemove,
+      // Flippedコンポーネントから注入されるprops
+      ...flippedProps
     },
     ref
   ) => {
     return (
-      <li ref={ref} className={styles.ListItem}>
+      <li ref={ref} className={styles.ListItem} {...flippedProps}>
         <div className={styles.ListItem__text}>{num}</div>
         <div
           className={styles.ListItem__delete}
           onClick={() => {
             onRemove();
           }}
         />
       </li>
     );
   }
 );

これで一旦FLIPアニメーションはしてくれますが、要素の追加・削除の時のフェードイン/アウトするアニメーションはこちらもreact-flip-moveと同様に別途設定する必要があります。
react-flip-toolkitではonAppearなどのコールバック時にspringというアニメーションメソッドを使ってアニメーションをするのですが、フェードイン/アウトのアニメーション中であってもFLIPアニメーションが発動されるため、その時はアニメーションをキャンセルするかFLIPアニメーションの方を無効にするかの設定をしなければいけません。この辺の管理はコンポーネント内で完結させたいので、新しくFlippedItemという名前で用意すると以下のようになります。

追加・削除時にフェードイン/アウトも行うFlippedコンポーネント
import { FC, useRef } from "react";
import { Flipped, spring } from "react-flip-toolkit";

import { ListItem } from "./ListItem";

export type FlippedItemProps = {
  num: number;
  onRemove: () => void;
};

export const FlippedItem: FC<FlippedItemProps> = ({ num, onRemove }) => {
  const isExitingRef = useRef(false);
  const appearSpringRef = useRef<ReturnType<typeof spring> | null>(null);
  return (
    <Flipped
      flipId={num}
      onAppear={(el) => {
        appearSpringRef.current = spring({
          onUpdate: (val) => {
            if (typeof val !== "number") {
              return;
            }
            el.style.opacity = `${1 * val}`;
            el.style.transform = `translateY(${-30 * (1 - val)}px)`;
          },
          onComplete: () => {
            appearSpringRef.current = null;
          }
        });
      }}
      onStart={() => {
        // 追加アニメーション中に他のアニメーションが発動した場合は追加アニメーションをストップする
        if (appearSpringRef.current) {
          appearSpringRef.current.destroy();
          appearSpringRef.current = null;
        }
      }}
      onExit={(el, index, removeElement) => {
        isExitingRef.current = true;
        el.style.pointerEvents = "none";

        spring({
          onUpdate: (val) => {
            if (typeof val !== "number") {
              return;
            }
            el.style.opacity = `${1 * (1 - val)}`;
            el.style.transform = `translateY(${-30 * val}px)`;
          },
          onComplete: removeElement
        });
      }}
      shouldFlip={() => {
        // 削除アニメーション中はFLIPアニメーションしない
        return !isExitingRef.current;
      }}
    >
      <ListItem num={num} onRemove={onRemove} />
    </Flipped>
  );
};

このコンポーネントに差し替えると以下のようになります。

FlippedItemに差し替える
 import { FC, useState } from "react";
 import { shuffle } from "lodash-es";
-import { Flipper, Flipped } from "react-flip-toolkit";
+import { Flipper } from "react-flip-toolkit";

 import { List } from "../components/List";
-import { ListItem } from "../components/ListItem";
+import { FlippedItem } from "../components/FlippedItem";

 export const ReactFlipToolkitPage: FC = () => {
   const [itemNums, setItemNums] = useState([1, 2, 3, 4, 5]);

   return (
     <div>
       {/* 同じ部分は省略 */}
       <Flipper flipKey={itemNums.join(",")}>
         <List>
           {itemNums.map((num) => (
-            <Flipped key={num} flipId={num}>
-              <ListItem
-                num={num}
-                onRemove={() => {
-                  setItemNums(itemNums.filter((itemNum) => itemNum !== num));
-                }}
-              />
-            </Flipped>
+            <FlippedItem
+	       key={num}
+	       num={num}
+	       onRemove={() => {
+	         setItemNums(itemNums.filter((itemNum) => itemNum !== num));
+              }}
+	     />
           ))}
         </List>
       </Flipper>
     </div>
   );
 };

気になった点

追加・削除アニメーションは自分でキャンセルしないといけない

コードの内容からすれば当然な気もしますが、FLIPアニメーションが始まったらappear時とexit時のアニメーションはキャンセルしてくれたらわざわざキャンセルロジックを書かずに済んだなと思いました。react-flip-moveのようにある条件の時に設定するstyle、みたいになっていれば自動でキャンセルできただろうなぁとは思いました。

springの型周りが少し微妙

実はspringvaluesで各プロパティのfrom, toを指定できますが、どのプロパティが設定されたかonUpdateでは推論されないため結局valueのまま扱った方がまだマシなのかなと思いました。

https://github.com/aholachek/react-flip-toolkit#spring

spring({
  values: {
    opacity: [0, 1],
    transform: [-30, 0]
  },
  // ここで分割代入しようにも、
  // 型が曖昧なため特定のキー名に本当にパラメータが入っているか分からないため、
  // valのまま運用した方がtypeセーフになりそう
  onUpdate: ({ opacity, transform }) => {
    el.style.opacity = `${opacity}`;
    el.style.transform = `translateY(${transform}px)`;
  }
});

durationの設定ができない

イージングの設定はspringの概念で設定するため、durationを調整したい場合はspringの概念であるstiffnessdampingのパラメータをいじって再現する必要があります。なのでデフォルトで良い場合は特に問題ないですが、そこからゆっくり目にしようとか早くしようとかの設定がかなりやりづらいです。react-flip-moveの方はCSSトランジションで表現しているのでdurationが使えるので、ちょっと残念だなと思いました(それともspringで設定するのが一般的なんですかね?)

https://github.com/aholachek/react-flip-toolkit/issues/100#issuecomment-551056183

まとめ

react-flip-toolkitの使用感をまとめると以下のようになります。柔軟性・拡張性を考慮したためか確かに応用しやすそうではありましたが気軽に使おうとするには割とコードを書く必要があるところが少し惜しかったなと思いました。

  • FLIPアニメーションの実行タイミングを指定できる
  • 追加・削除時のアニメーションを設定した時にキャンセルすることも考える必要があるのが手間
    • ライブラリで勝手にキャンセルしてくれたりした方が楽ではあるが、その辺の制御も利用者側で決められるようにという意図な気もするので仕方なさそう
  • 型周りが少し微妙
  • durationが設定できず、stiffnessdampingで表現しないといけない

終わりに

以上がReactでFLIPアニメーションする方法でした。react-flip-moveの方が手軽に扱えそうだなと思いましたが、残念ながらコードが古くてReact18系で警告が出る以上、react-flip-toolsを使うしかないのかなと感じました。確かにこちらの方が柔軟性が高く、色んなことに応用できそうだなとは感じましたが、その分コードを結構書く必要が出たりするので惜しいなと思いました。
ReactでFLIPアニメーションしたい時に参考になれば幸いです。

Discussion