💨

ReactでFLIPアニメーションを自作する

2023/10/29に公開

始めに

以前ReactでFLIPアニメーションする場合に使えそうなライブラリを紹介しました。

https://zenn.dev/wintyo/articles/0d0bed193e6f80

ここでの検証によってreact-flip-toolkitを使うのが良さそうでしたが、FLIPがネストされた形になると孫要素も含めてFLIPアニメーションされてしまう問題がありました。

そもそもアニメーションの時間設定でdurationを指定できなかったり使いづらさがあったので、自前でFLIPアニメーションを実装することできないか色々調べて、それっぽいものが作れましたので備忘録としてまとめました。

FLIPアニメーションの仕組み

まずはFLIPアニメーションの仕組みを理解したいと思います。実装も含めてこの記事がとても参考になりましたので、この記事をベースとした説明をします。

https://css-tricks.com/everything-you-need-to-know-about-flip-animations-in-react/

基本実装

FLIPとはFirst, Last, Invert, Playの頭文字を取ったもので、Lastの位置からFirstの場所までCSS transformを使ってInvertして、元の場所に戻るようにCSS transitionをPlayして滑らかな遷移をする手法です。
Reactでは前回の位置をrefで持っておき、useLayoutEffectでこれから描画するべき位置を取得してInvert設定してアニメーションを実行することで実現できます。

これをコードに起こすと以下のようになります。アニメーションはWeb Animations APIを使うと簡単にアニメーションを実行できるためこちらを使用しました。

FLIPアニメーションを自作する簡易コード
import { FC, useState, useRef, useEffect, useLayoutEffect } from "react";

export const FlipContainer: FC = () => {
  const [ids, setIds] = useState(["square-1", "square-2"]);
  const elRootRef = useRef<HTMLDivElement | null>(null);
  const rectMapRef = useRef(new Map<string, DOMRect>());

  useEffect(() => {
    const elRoot = elRootRef.current;
    if (elRoot == null) {
      return;
    }
    const squares = elRoot.querySelectorAll(".square");

    squares.forEach((square) => {
      rectMapRef.current.set(square.id, square.getBoundingClientRect());
    });
  }, []);

  useLayoutEffect(() => {
    const elRoot = elRootRef.current;
    if (elRoot == null) {
      return;
    }
    const squares = elRoot.querySelectorAll(".square");
    squares.forEach((square) => {
      const cachedRect = rectMapRef.current.get(square.id);
      if (cachedRect == null) {
        return;
      }
      const nextRect = square.getBoundingClientRect();

      rectMapRef.current.set(square.id, nextRect);

      // Invert
      const translateX = cachedRect.x - nextRect.x;

      square.animate(
        [
          { transform: `translate(${translateX}px, 0px)` },
          { transform: "translate(0px, 0px)" }
        ],
        500
      );
    });
  });

  return (
    <div ref={elRootRef}>
      <div style={{ marginBottom: 5 }}>
        <button
          onClick={() => {
            setIds(([a, b]) => [b, a]);
          }}
        >
          swap
        </button>
      </div>
      <div
        style={{
          display: "flex",
          justifyContent: "space-between"
        }}
      >
        {ids.map((id) => (
          <div
            key={id}
            id={id}
            className="square"
            style={{
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
              width: "100px",
              height: "100px",
              color: "white",
              backgroundColor: "black"
            }}
          >
            {id}
          </div>
        ))}
      </div>
    </div>
  );
};

これでひとまずFLIPアニメーションができましたが、以下のような問題があるため、その辺を調整していきます。

  • アニメーション中に追加のFLIPアニメーションが起きると位置が変になる
  • useEffectのタイミングでしか座標を更新していないため、その後に画面のリサイズなどで位置がずれてしまうとアニメーションが変になる

アニメーション中に追加のFLIPアニメーションできるようにする

この問題は次の予測された位置がtransform込みで算出されるためです。なので現在の位置と次目指したい位置それぞれに対してtransform分を足し引きして調整してから次のInvert値を求める必要があります。

これをコードに落とすと以下のようになります。ちなみにアニメーションを上書き設定した場合は自動でキャンセルされているように見えたので、特にキャンセル設定は書いていません。

アニメーション中に追加のFLIPアニメーションをできるようにする
+/**
+ * transformで移動中の場合はその値を取得する
+ * @param el - HTML Element
+ */
+const getTranslatingValue = (el: Element) => {
+  const translating = {
+    x: 0,
+    y: 0
+  };
+  const transformStyle = el.computedStyleMap().get("transform");
+  if (transformStyle instanceof CSSTransformValue) {
+    const getTranslateValue = (val: CSSNumericValue) => {
+      if (val instanceof CSSUnitValue) {
+        return val.value;
+      }
+      return 0;
+    };
+    transformStyle.forEach((translate) => {
+      if (translate instanceof CSSTranslate) {
+        translating.x = getTranslateValue(translate.x);
+        translating.y = getTranslateValue(translate.y);
+      }
+    });
+  }
+  return translating;
+};

 export const FlipContainer: FC = () => {
   // 既出のものは省略
  
   useLayoutEffect(() => {
     if (!shouldFlipRef.current) {
       return;
     }
     const elRoot = elRootRef.current;
     if (elRoot == null) {
       return;
     }
     const squares = elRoot.querySelectorAll(".square");
     squares.forEach((square) => {
       const cachedRect = rectMapRef.current.get(square.id);
       if (cachedRect == null) {
         return;
       }
       const nextRect = square.getBoundingClientRect();
+      const translating = getTranslatingValue(square);

+      // transformがなかった時の座標に調整する
+      // (値を調整してしまっているのでDOMRectを直接使わない方が良いかも)
+      const adjustedNextRect = {
+        ...nextRect,
+        x: nextRect.x - translating.x,
+        y: nextRect.y - translating.y
+      };

-      rectMapRef.current.set(square.id, nextRect);
+      rectMapRef.current.set(square.id, adjustedNextRect);

       // Invert
-      const translateX = cachedRect.x - nextRect.x;
+      const translateX = cachedRect.x + translating.x - adjustedNextRect.x;

       square.animate(
         [
           { transform: `translate(${translateX}px, 0px)` },
           { transform: "translate(0px, 0px)" }
         ],
         500
       );
     });
   });
  
   // render部分に変更はないため省略
 }

過去の位置情報をrender直前に再計算する

簡易実装ではuseEffectで位置情報を取得しましたが、この後に画面のリサイズなどで位置が変わってしまうと本来移動するべき位置がずれてしまいます。なので位置情報のキャッシュはrender直前の方が好ましいのでそちらに書き換えます。
しかしFunction Componentでは残念ながらrender直前にフックする方法が存在しません。理屈としてはrender直前ということはReactElementをreturnする前に計算することなので、パフォーマンスの懸念が少しありますが今回はそこで実行したいと思います。ついでにFLIPアニメーションする必要のないケースはuseLayoutEffectの処理をスキップできるようにフラグを持たせるようにしました。

renderサイクル時にFLIPアニメーションが必要な時は位置情報を更新するように変更
 export const FlipContainer: FC = () => {
   const [ids, setIds] = useState(["square-1", "square-2"]);
   const elRootRef = useRef<HTMLDivElement | null>(null);
   const rectMapRef = useRef(new Map<string, DOMRect>());

-  useEffect(() => {
-    const elRoot = elRootRef.current;
-    if (elRoot == null) {
-      return;
-    }
-    const squares = elRoot.querySelectorAll(".square");
-
-    squares.forEach((square) => {
-      rectMapRef.current.set(square.id, square.getBoundingClientRect());
-    });
-  }, []);

+  const prevIdsRef = useRef<string[] | undefined>();
+  const shouldFlipRef = useRef(false);

+  // idsに変更がある時だけ位置情報の再計算して、FLIPアニメーション実行フラグを立てる
+  if (prevIdsRef.current !== ids) {
+    const elRoot = elRootRef.current;
+    if (elRoot != null) {
+      prevIdsRef.current = ids;
+      shouldFlipRef.current = true;
+
+      const squares = elRoot.querySelectorAll(".square");
+      squares.forEach((square) => {
+        const clientRect = square.getBoundingClientRect();
+        const translating = getTranslatingValue(square);
+        // transformがなかった時の座標に調整する
+        // (値を調整してしまっているのでDOMRectを直接使わない方が良いかも)
+        const adjustedClientRect = {
+          ...clientRect,
+          x: clientRect.x - translating.x,
+          y: clientRect.y - translating.y
+        };
+        rectMapRef.current.set(square.id, adjustedClientRect);
+      });
+    }
+  }

   useLayoutEffect(() => {
+    if (!shouldFlipRef.current) {
+      return;
+    }

     // useLayoutEffectの中身は変更がないため省略

+    shouldFlipRef.current = false;
   })
  
   // render部分に変更はないため省略
 }
余談: class Componentだと`getSnapshotBeforeUpdate`が使える

class Componentにはrender直前にフックできるメソッドがあり、getSnapshotBeforeUpdateが使えます。これはreact-flip-toolkitでも使われています。

https://github.com/aholachek/react-flip-toolkit/blob/v7.1.0/packages/react-flip-toolkit/src/Flipper/index.tsx#L25-L36

しかしFunction Componentではこれと同等の機能は存在せず今回は諦めてrenderサイクルで直接再計算処理を実行しましたが、頑張ればgetSnapshotBeforeUpdateと同等な処理をhooksでも作れるようです。
https://blog.logrocket.com/how-is-getsnapshotbeforeupdate-implemented-with-hooks/

検証コード

以上のコードを以下のCodeSandboxで試しましたので、動作確認や詳細のコードを確認したい方はこちらをご参照ください。

FLIPアニメーションできるコンポーネントを作る

前のセクションでFLIPアニメーションの仕組みを理解しました。前のセクションでは動作の理解ということでHTML要素をquerySelectorで直接取得したり、表示要素も2つだけで固定という単純なものでした。
ここでは実際にライブラリでも使われるようなFLIPアニメーションをするためのコンポーネントをラップするだけで動くようなコンポーネントを作っていきたいと思います。

renderされているHTML要素を取得できるようにする

まずはquerySelectorでHTML要素を取得せずに済むような設計を考えたいと思います。ReactはrefでHTML要素を取得でき、要素が消える際はnullも渡されるため、ここでenter,leave判定も兼ねたいと思います。コンポーネント化しておくことでついでにReact要素にキチンとkeyを指定しているかの判定もやりやすいです。

HTML要素の取得とenter,leaveを判定するコンポーネント
import {
  FC,
  ReactElement,
  cloneElement,
  useCallback,
  useRef,
  Key
} from "react";

export type OriginalFlipItemProps = {
  /**
   * 要素が表示された時
   * @param key - child.key
   * @param el - HTML要素
   */
  onEnter: (key: Key, el: HTMLElement) => void;
  /**
   * 要素が削除されるとき
   * @param key - child.key
   * @param finalElement - 削除直前のReact要素
   */
  onLeave: (key: Key, finalElement: ReactElement) => void;
  children: ReactElement;
};

export const OriginalFlipItem: FC<OriginalFlipItemProps> = ({
  onEnter,
  onLeave,
  children
}) => {
  const key = children.key;

  if (key == null) {
    throw new Error("keyを設定してください。");
  }

  const handleEnter = useCallback(
    (el: HTMLElement) => {
      onEnter(key, el);
    },
    [key, onEnter]
  );

  // 描画するchildをキャッシュしておく
  const cachedChild = useRef<ReactElement>(children);
  cachedChild.current = children;
  const handleLeave = useCallback(() => {
    onLeave(key, cachedChild.current);
  }, [key, onLeave]);

  const handleRef = useCallback(
    (el: HTMLElement | null) => {
      if (el != null) {
        handleEnter(el);
      } else {
        handleLeave();
      }
    },
    [handleEnter, handleLeave]
  );

  return cloneElement(children, {
    ref: handleRef
  });
};

FLIPアニメーションを管理するhooksを用意する

keyとHTML要素を取得できたら、そのデータをもとにFLIPアニメーションできるようにそれを管理するhooksを用意します。このhooksを通じてFLIPアニメーションする項目の追加・削除を行い、追加されたものがFLIPアニメーションされるようにします。また位置情報をルート要素からの相対座標にしましたが、これは後述するleaveする要素にアニメーションする際に必要になるのでそのような計算をしています。

次で紹介するhooksで使用される小さなコード
RelativePosType.ts
/** ルート要素からの相対座標 */
export type RelativePos = {
  x: number;
  y: number;
};
utils.ts
import { RelativePos } from "./RelativePosType";

/**
 * transformで移動中の場合はその値を取得する
 * @param el - HTML要素
 */
export const getTranslatingValue = (el: HTMLElement) => {
  const translating = {
    x: 0,
    y: 0
  };
  const transformStyle = el.computedStyleMap().get("transform");
  if (transformStyle instanceof CSSTransformValue) {
    const getTranslateValue = (val: CSSNumericValue) => {
      if (val instanceof CSSUnitValue) {
        return val.value;
      }
      return 0;
    };
    transformStyle.forEach((translate) => {
      if (translate instanceof CSSTranslate) {
        translating.x = getTranslateValue(translate.x);
        translating.y = getTranslateValue(translate.y);
      }
    });
  }
  return translating;
};

/**
 * ルート要素を基点にした相対座標を取得する
 * @param rootClientRect - ルート要素のClientRect
 * @param el - HTML要素
 */
export const getRelativePos = (
  rootClientRect: DOMRect,
  el: HTMLElement
): RelativePos => {
  const clientRect = el.getBoundingClientRect();
  const translating = getTranslatingValue(el);
  return {
    x: clientRect.x - rootClientRect.x - translating.x,
    y: clientRect.y - rootClientRect.y - translating.y
  };
};
FLIPアニメーションを管理するhooks
import {
  useRef,
  useEffect,
  useLayoutEffect,
  useCallback,
  MutableRefObject,
  Key
} from "react";

import { RelativePos } from "./RelativePosType";
import { getTranslatingValue, getRelativePos } from "./utils";

export type UseFlipAnimationOption = {
  /** FLIPアニメーションの時間 */
  duration: number;
  /** ルート要素 */
  elRootRef: MutableRefObject<HTMLElement | null>;
  /** FLIPアニメーションを実行するキー(キーが変わるとアニメーションが実行される) */
  flipKey: string;
};

/** FLIP項目 */
type FlipItem = {
  /** HTML要素 */
  el: HTMLElement;
  /** ルート要素からの相対座標 */
  relativePos?: RelativePos;
};

export type ReturnUseFlipAnimation = {
  /**
   * FLIP項目を取得する
   * @param key - child.key
   */
  getFlipItem: (key: Key) => FlipItem | undefined;
  /**
   * FLIP項目に追加する
   * @param key - child.key
   * @param el - HTML要素
   */
  enterFlipItem: (key: Key, el: HTMLElement) => void;
  /**
   * FLIP項目から外す
   * @param key - child.key
   */
  leaveFlipItem: (key: Key) => void;
};

/**
 * FLIPアニメーションを実行するhooks
 */
export const useFlipAnimation = ({
  duration,
  elRootRef,
  flipKey
}: UseFlipAnimationOption): ReturnUseFlipAnimation => {
  const isMountedRef = useRef(false);

  /** FLIPアニメーションする項目を管理するマップ */
  const flipItemMapRef = useRef(new Map<Key, FlipItem>());

  const getFlipItem: ReturnUseFlipAnimation["getFlipItem"] = useCallback(
    (key) => {
      return flipItemMapRef.current.get(key);
    },
    []
  );

  const enterFlipItem: ReturnUseFlipAnimation["enterFlipItem"] = useCallback(
    (key: Key, el: HTMLElement) => {
      // マッピングデータに要素だけ登録する
      // 相対座標の計算はuseLayoutEffectで行う
      flipItemMapRef.current.set(key, {
        el
      });
    },
    []
  );

  const leaveFlipItem: ReturnUseFlipAnimation["leaveFlipItem"] = useCallback(
    (key: Key) => {
      flipItemMapRef.current.delete(key);
    },
    []
  );

  // FLIPアニメーションをするか判定する情報
  const prevFlipKeyRef = useRef<string | undefined>();
  const shouldFlipRef = useRef(false);

  useEffect(() => {
    isMountedRef.current = true;
  }, []);

  // FLIPアニメーションをするか判定する
  if (prevFlipKeyRef.current !== flipKey) {
    const elRoot = elRootRef.current;
    if (elRoot != null) {
      prevFlipKeyRef.current = flipKey;
      shouldFlipRef.current = true;

      // 位置情報を更新する
      const rootClientRect = elRoot.getBoundingClientRect();
      flipItemMapRef.current.forEach((childInfo, key) => {
        const { el } = childInfo;
        flipItemMapRef.current.set(key, {
          el,
          relativePos: getRelativePos(rootClientRect, el)
        });
      });
    }
  }

  useLayoutEffect(() => {
    if (!shouldFlipRef.current) {
      return;
    }

    const elRoot = elRootRef.current;
    if (elRoot == null) {
      return;
    }
    const rootClientRect = elRoot.getBoundingClientRect();
    flipItemMapRef.current.forEach((childInfo, key) => {
      const { el, relativePos } = childInfo;
      const nextRelativePos = getRelativePos(rootClientRect, el);
      flipItemMapRef.current.set(key, {
        el,
        relativePos: nextRelativePos
      });

      // まだ初回renderで相対座標が記録されていない場合
      if (relativePos == null) {
        return;
      }

      const translating = getTranslatingValue(el);

      // Invert
      const translateX = relativePos.x + translating.x - nextRelativePos.x;
      const translateY = relativePos.y + translating.y - nextRelativePos.y;

      el.animate(
        [
          { transform: `translate(${translateX}px, ${translateY}px)` },
          { transform: "translate(0px, 0px)" }
        ],
        duration
      );
    });
    shouldFlipRef.current = false;
  });

  return {
    getFlipItem,
    enterFlipItem,
    leaveFlipItem
  };
};

2つのコードを組み合わせたFLIPアニメーションするコンポーネントの土台を作る

上記の2つのコードを組み合わせるとFLIPアニメーションをするコンポーネントの土台ができるので、それを書くと以下のようになります。

FLIPアニメーションするコンポーネントの土台
import { FC, useRef, useCallback, ReactElement, Key, Children } from "react";

import { OriginalFlipItem } from "./OriginalFlipItem";
import { useFlipAnimation } from "./useFlipAnimation";

export type OriginalFlipperProps = {
  /** FLIPアニメーションの時間 */
  duration?: number;
  children: ReactElement[];
};

export const OriginalFlipper: FC<OriginalFlipperProps> = ({
  duration = 400,
  children
}) => {
  const elRootRef = useRef<HTMLDivElement | null>(null);

  // FLIPアニメーションをするか判定する情報
  const flipKey = children.map((child) => child.key).join(",");
  const { getFlipItem, enterFlipItem, leaveFlipItem } = useFlipAnimation({
    duration,
    elRootRef,
    flipKey
  });

  const handleEnter = useCallback(
    (key: Key, el: HTMLElement) => {
      enterFlipItem(key, el);
    },
    [enterFlipItem]
  );

  const handleLeave = useCallback(
    (key: Key, finalChild: ReactElement) => {
      const flipItem = getFlipItem(key);
      if (flipItem == null) {
        return;
      }

      leaveFlipItem(key);
    },
    [getFlipItem, leaveFlipItem]
  );

  return (
    <div ref={elRootRef} style={{ position: "relative" }}>
      {Children.map(children, (child) => {
        return (
          <OriginalFlipItem
            key={child.key}
            onEnter={handleEnter}
            onLeave={handleLeave}
          >
            {child}
          </OriginalFlipItem>
        );
      })}
    </div>
  );
};

enter,leave時のHTML要素にアニメーションを設定できるようにする

以上の内容でFLIPアニメーション自体はできるようになりましたが、react-flip-movereact-flip-toolkitにあるようにenter時やleave時のHTML要素にアニメーションを設定できるようにしたいと思います。
今回はWeb Animations APIを使うことを想定するので、以下のようなインターフェースにして、ついでにデフォルトのアニメーションも定義しておきます。アニメーション終了後に要素を削除する処理が必要になるのでPromiseで返すようにします。

enter,leaveアニメーションのインターフェースとデフォルトの定義
/**
 * 追加/削除アニメーションを設定するハンドラー
 * @param element - DOM要素
 */
export type AnimationHandler = (element: HTMLElement) => Promise<void>;

/**
 * デフォルトの追加アニメーションハンドラー
 */
export const defaultEnterAnimationHandler: AnimationHandler = (el) => {
  return new Promise<void>((resolve) => {
    const player = el.animate(
      [
        { opacity: 0, transform: "translate(0px, -30px)" },
        { opacity: 1, transform: "translate(0px, 0px)" }
      ],
      400
    );
    player.addEventListener("finish", () => {
      resolve();
    });
  });
};

/**
 * デフォルトの削除アニメーションハンドラー
 * @param el - DOM要素
 */
export const defaultLeaveAnimationHandler: AnimationHandler = (el) => {
  return new Promise<void>((resolve) => {
    const player = el.animate(
      [
        { opacity: 1, transform: "ranslateY(0)" },
        { opacity: 0, transform: "translateY(-30px)" }
      ],
      400
    );
    player.addEventListener("finish", () => {
      resolve();
    });
  });
};

enter時の設定

enter時の設定はuseLayoutEffect時にまだ座標が計算されていない場合に実行するとよさそうです。

enter時にアニメーションを実行できるようにする
 // 変更がないコードは省略

 export type UseFlipAnimationOption = {
   /** FLIPアニメーションの時間 */
   duration: number;
+  /** 要素が追加される時のアニメーション設定 */
+  enterAnimationHandler: AnimationHandler;
   /** ルート要素 */
   elRootRef: MutableRefObject<HTMLElement | null>;
   /** FLIPアニメーションを実行するキー(キーが変わるとアニメーションが実行される) */
   flipKey: string;
 };

 /**
  * FLIPアニメーションを実行するhooks
  */
 export const useFlipAnimation = ({
   duration,
+  enterAnimationHandler,
   elRootRef,
   flipKey
 }: UseFlipAnimationOption): ReturnUseFlipAnimation => {
   // 変更のないコードは省略

   useLayoutEffect(() => {
     // 変更のないコードは省略
     flipItemMapRef.current.forEach((childInfo, key) => {
       const { el, relativePos } = childInfo;
       const nextRelativePos = getRelativePos(rootClientRect, el);
       flipItemMapRef.current.set(key, {
         el,
         relativePos: nextRelativePos
       });

       // まだ初回renderで相対座標が記録されていない場合
       if (relativePos == null) {
+        // mount済みの場合はenterアニメーションを実行する
+        if (isMountedRef.current) {
+          enterAnimationHandler(el);
+        }
         return;
       }

       const translating = getTranslatingValue(el);

       // Invert
       const translateX = relativePos.x + translating.x - nextRelativePos.x;
       const translateY = relativePos.y + translating.y - nextRelativePos.y;

       el.animate(
         [
           { transform: `translate(${translateX}px, ${translateY}px)` },
           { transform: "translate(0px, 0px)" }
         ],
         duration
       );
     });
     shouldFlipRef.current = false;
   });

   return {
     getFlipItem,
     enterFlipItem,
     leaveFlipItem
   };
 };

leave時の設定

leave時の設定は結構難しいです。というのも、そもそもrenderから除外されているため、それ用に改めてローカルステートを用意して別途render処理をする必要があるからです。OriginalFlipItemでは事前にその辺を考慮してonLeavefinalElementという削除直前のReactElementを取得できるようにしています。
これが貰える前提で、まずは削除中に描画するコンポーネントを用意します。

削除中に描画するコンポーネント
import { FC, ReactElement, cloneElement, useCallback, Key } from "react";

import { RelativePos } from "../RelativePosType";
import { AnimationHandler } from "../animationHandler";

export type RemovingItemProps = {
  removingKey: Key;
  /** 削除された要素の相対座標 */
  relativePos: RelativePos;
  /** DOMサイズ */
  domSize: {
    width: number;
    height: number;
  };
  /** 削除時のアニメーション設定 */
  leaveAnimationHandler: AnimationHandler;
  /** アニメーション終了時 */
  onFinishAnimation: (removingKey: Key) => void;
  children: ReactElement;
};

export const RemovingItem: FC<RemovingItemProps> = ({
  removingKey,
  relativePos,
  domSize,
  leaveAnimationHandler,
  onFinishAnimation,
  children
}) => {
  const handleRef = useCallback(
    (el: HTMLElement | null) => {
      if (el == null) {
        return;
      }
      el.style.position = "absolute";
      el.style.top = `${relativePos.y}px`;
      el.style.left = `${relativePos.x}px`;
      el.style.width = `${domSize.width}px`;
      el.style.height = `${domSize.height}px`;
      el.style.pointerEvents = "none";

      leaveAnimationHandler(el).then(() => {
        onFinishAnimation(removingKey);
      });
    },
    [
      relativePos,
      domSize,
      removingKey,
      leaveAnimationHandler,
      onFinishAnimation
    ]
  );

  return cloneElement(children, {
    ref: handleRef
  });
};

このコンポーネントを削除中の要素を管理するhooksと組み合わせると以下のようになります。

削除中の項目を管理するhooks
import { Key, ReactElement, useState, useCallback } from "react";

import { RelativePos } from "../RelativePosType";
import { RemovingItem, RemovingItemProps } from "./RemovingItem";

export type TRemovingItem = {
  key: Key;
  /** 削除された要素の相対座標 */
  relativePos: RelativePos;
  /** DOMサイズ */
  domSize: {
    width: number;
    height: number;
  };
  /** 削除直前まで使われていたreact element */
  finalChild: ReactElement;
};

export type UseFlipRemovingManagerOption = {} & Pick<
  RemovingItemProps,
  "leaveAnimationHandler"
>;

export type ReturnUseFlipRemovingManager = {
  /**
   * 削除中を描画するReact要素
   */
  removingElement: ReactElement[];
  /**
   * 削除される項目を登録する
   * @param removingItem - 削除される項目
   */
  addRemovingItem: (removingItem: TRemovingItem) => void;
};

/**
 * FLIPアニメーションから除外される項目を管理するhooks
 */
export const useFlipRemovingManager = ({
  leaveAnimationHandler
}: UseFlipRemovingManagerOption): ReturnUseFlipRemovingManager => {
  const [removingItems, setRemovingItems] = useState<TRemovingItem[]>([]);

  const addRemovingItem: ReturnUseFlipRemovingManager["addRemovingItem"] = useCallback(
    (removingItem: TRemovingItem) => {
      setRemovingItems((prevRemovingItems) => [
        ...prevRemovingItems,
        removingItem
      ]);
    },
    []
  );

  const handleFinishRemovingAnimation = useCallback((removingKey: Key) => {
    setRemovingItems((prevRemovingItems) =>
      prevRemovingItems.filter((item) => item.key !== removingKey)
    );
  }, []);

  const removingElement = removingItems.map((item) => {
    const { key, relativePos, domSize, finalChild } = item;
    return (
      <RemovingItem
        key={`removing-${key}`}
        removingKey={key}
        relativePos={relativePos}
        domSize={domSize}
        leaveAnimationHandler={leaveAnimationHandler}
        onFinishAnimation={handleFinishRemovingAnimation}
      >
        {finalChild}
      </RemovingItem>
    );
  });

  return {
    removingElement,
    addRemovingItem
  };
};

あとはこのhooksを親コンポーネントで呼び出せば完成です。

削除中の項目を管理するhooksを使用する
 import { FC, useRef, useCallback, ReactElement, Key, Children } from "react";

 import { OriginalFlipItem } from "./OriginalFlipItem";
+import { useFlipRemovingManager } from "./removing/useFlipRemovingManager";
 import {
   AnimationHandler,
   defaultEnterAnimationHandler,
+  defaultLeaveAnimationHandler
 } from "./animationHandler";
 import { useFlipAnimation } from "./useFlipAnimation";

 export type OriginalFlipperProps = {
   /** FLIPアニメーションの時間 */
   duration?: number;
   /** 要素が追加される時のアニメーション設定 */
   enterAnimationHandler?: AnimationHandler;
+  /** 要素が削除されるときのアニメーション設定 */
+  leaveAnimationHandler?: AnimationHandler;
   children: ReactElement[];
 };

 export const OriginalFlipper: FC<OriginalFlipperProps> = ({
   duration = 400,
   enterAnimationHandler = defaultEnterAnimationHandler,
+  leaveAnimationHandler = defaultLeaveAnimationHandler,
   children
 }) => {
   const elRootRef = useRef<HTMLDivElement | null>(null);

   // FLIPアニメーションをするか判定する情報
   const flipKey = children.map((child) => child.key).join(",");
   const { getFlipItem, enterFlipItem, leaveFlipItem } = useFlipAnimation({
     duration,
     enterAnimationHandler,
     elRootRef,
     flipKey
   });
+  const { removingElement, addRemovingItem } = useFlipRemovingManager({
+    leaveAnimationHandler
+  });

   const handleEnter = useCallback(
     (key: Key, el: HTMLElement) => {
       enterFlipItem(key, el);
     },
     [enterFlipItem]
   );

   const handleLeave = useCallback(
     (key: Key, finalChild: ReactElement) => {
       const flipItem = getFlipItem(key);
       if (flipItem == null) {
         return;
       }
+      const { el, relativePos } = flipItem;
+      if (relativePos == null) {
+        return;
+      }
+
+      addRemovingItem({
+        key,
+        relativePos,
+        domSize: {
+          width: el.clientWidth,
+          height: el.clientHeight
+        },
+        finalChild
+      });
       leaveFlipItem(key);
     },
-    [getFlipItem, leaveFlipItem]
+    [getFlipItem, leaveFlipItem, addRemovingItem]
   );

   return (
     <div ref={elRootRef} style={{ position: "relative" }}>
       {Children.map(children, (child) => {
         return (
           <OriginalFlipItem
             key={child.key}
             onEnter={handleEnter}
             onLeave={handleLeave}
           >
             {child}
           </OriginalFlipItem>
         );
       })}
+      {removingElement}
     </div>
   );
 };

検証コード

以上のコードを以下のCodeSandboxに書きましたので、動きや詳細のコードを確認したい方はご参照ください。react-flip-toolkitで実装したものもありますので、そちらと比較するのも良いと思います。本当はネストされたFlipperだと期待した動きにならないことを比較するために用意したものでしたが、問題が解決できてしまったのであんまり比較にならないかもです(汗)。

終わりに

以上がReactでFLIPアニメーションを自作する方法でした。元々の課題であったネストされたFlipperがreact-flip-toolkitでも解決することができたので自作する意味のほとんどがなくなってしまいましたが、FLIPアニメーションの仕組みを知りたい人の参考になれれば幸いです。

Discussion