ReactでFLIPアニメーションを自作する
始めに
以前ReactでFLIPアニメーションする場合に使えそうなライブラリを紹介しました。
ここでの検証によってreact-flip-toolkitを使うのが良さそうでしたが、FLIPがネストされた形になると孫要素も含めてFLIPアニメーションされてしまう問題がありました。
そもそもアニメーションの時間設定でdurationを指定できなかったり使いづらさがあったので、自前でFLIPアニメーションを実装することできないか色々調べて、それっぽいものが作れましたので備忘録としてまとめました。
FLIPアニメーションの仕組み
まずはFLIPアニメーションの仕組みを理解したいと思います。実装も含めてこの記事がとても参考になりましたので、この記事をベースとした説明をします。
基本実装
FLIPとはFirst, Last, Invert, Playの頭文字を取ったもので、Lastの位置からFirstの場所までCSS transformを使ってInvertして、元の場所に戻るようにCSS transitionをPlayして滑らかな遷移をする手法です。
Reactでは前回の位置をrefで持っておき、useLayoutEffect
でこれから描画するべき位置を取得してInvert設定してアニメーションを実行することで実現できます。
これをコードに起こすと以下のようになります。アニメーションはWeb Animations APIを使うと簡単にアニメーションを実行できるためこちらを使用しました。
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値を求める必要があります。
これをコードに落とすと以下のようになります。ちなみにアニメーションを上書き設定した場合は自動でキャンセルされているように見えたので、特にキャンセル設定は書いていません。
+/**
+ * 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
の処理をスキップできるようにフラグを持たせるようにしました。
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
でも使われています。
しかしFunction Componentではこれと同等の機能は存在せず今回は諦めてrenderサイクルで直接再計算処理を実行しましたが、頑張ればgetSnapshotBeforeUpdate
と同等な処理をhooksでも作れるようです。
検証コード
以上のコードを以下のCodeSandboxで試しましたので、動作確認や詳細のコードを確認したい方はこちらをご参照ください。
FLIPアニメーションできるコンポーネントを作る
前のセクションでFLIPアニメーションの仕組みを理解しました。前のセクションでは動作の理解ということでHTML要素をquerySelectorで直接取得したり、表示要素も2つだけで固定という単純なものでした。
ここでは実際にライブラリでも使われるようなFLIPアニメーションをするためのコンポーネントをラップするだけで動くようなコンポーネントを作っていきたいと思います。
renderされているHTML要素を取得できるようにする
まずはquerySelectorでHTML要素を取得せずに済むような設計を考えたいと思います。Reactはref
でHTML要素を取得でき、要素が消える際はnullも渡されるため、ここでenter,leave判定も兼ねたいと思います。コンポーネント化しておくことでついでにReact要素にキチンとkey
を指定しているかの判定もやりやすいです。
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で使用される小さなコード
/** ルート要素からの相対座標 */
export type RelativePos = {
x: number;
y: number;
};
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
};
};
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アニメーションをするコンポーネントの土台ができるので、それを書くと以下のようになります。
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-move
やreact-flip-toolkit
にあるようにenter時やleave時のHTML要素にアニメーションを設定できるようにしたいと思います。
今回はWeb Animations APIを使うことを想定するので、以下のようなインターフェースにして、ついでにデフォルトのアニメーションも定義しておきます。アニメーション終了後に要素を削除する処理が必要になるのでPromiseで返すようにします。
/**
* 追加/削除アニメーションを設定するハンドラー
* @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
時にまだ座標が計算されていない場合に実行するとよさそうです。
// 変更がないコードは省略
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
では事前にその辺を考慮してonLeave
でfinalElement
という削除直前の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と組み合わせると以下のようになります。
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を親コンポーネントで呼び出せば完成です。
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