ReactでFLIPアニメーションをする方法
始めに
昔Vue.jsはデフォルトでFLIPアニメーションする機能が備わっていて凄いという記事を紹介しました。
こういうのが簡単に設定できる感じです。
しかしReactでもライブラリを使えば比較的簡単にFLIPアニメーションを実現することができますので、その方法について記事にまとめました。
作ったものの比較
Vue.jsとReactでそれぞれ作ったものを先に共有します。Vue.jsは以前の記事でも書いていましたが、だいぶ古くなったのでVue.js 3系のものに作り直しました。Reactはreact-flip-moveとreact-flip-toolkitのライブラリをそれぞれ作ってみました。どちらも同じ動きを再現できましたが、コードの新しさからreact-flip-toolkitの方が良さそうだなと感じました。詳細は後述します。
Vue.js 3系
※新しいCodeSandboxの形式だと埋め込みが上手くいかなかったのでStackBlitzで作りました。
React
react-flip-moveでFLIPアニメーションする
react-flip-move
はVue.jsのtransition-groupと結構似ており、FlipMove
コンポーネントでラップすると勝手に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する必要があります。
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>;
};
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>
);
}
);
これで要素の移動ができましたが、追加・削除時にフェードイン/アウトするアニメーションは別途設定する必要があります。これはFlipMove
のenterAnimation
、leaveAnimation
にfrom
とto
を設定することで実現でき、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アニメーションしたい時には使っても良いと思ったんですけどね。。
まとめ
react-flip-moveの使用感をまとめると以下のようになります。気軽にFLIPアニメーションを実装したいときはかなり良さそうでしたが、改善する見込みがないので長期的に見ると使わない方が良さそうだなと思いました。。
- Vue.jsと同じくらい気軽に設定できる
- コードが古いためReact18だと警告が出てしまう
react-flip-toolkitでFLIPアニメーションする
react-flip-toolkit
は少し特殊で、Flipper
というコンポーネントにあるflipKey
を使ってアニメーションの実行タイミングを制御できます。flipKey
が切り替わった時にアニメーションが実行されるので、例えば配列の数をkeyにして、個数が変わった時だけアニメーションさせるみたいなことができます。今回は追加・削除・並び替えの時にアニメーションして欲しいのでitemNums.join("")
としています。また要素ごとにはFlipped
コンポーネントでラップする必要があります。
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を取得している感じなんですかね?)
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
という名前で用意すると以下のようになります。
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>
);
};
このコンポーネントに差し替えると以下のようになります。
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の型周りが少し微妙
実はspring
のvalues
で各プロパティのfrom, toを指定できますが、どのプロパティが設定されたかonUpdate
では推論されないため結局valueのまま扱った方がまだマシなのかなと思いました。
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の概念であるstiffness
とdamping
のパラメータをいじって再現する必要があります。なのでデフォルトで良い場合は特に問題ないですが、そこからゆっくり目にしようとか早くしようとかの設定がかなりやりづらいです。react-flip-moveの方はCSSトランジションで表現しているのでdurationが使えるので、ちょっと残念だなと思いました(それともspringで設定するのが一般的なんですかね?)
まとめ
react-flip-toolkitの使用感をまとめると以下のようになります。柔軟性・拡張性を考慮したためか確かに応用しやすそうではありましたが気軽に使おうとするには割とコードを書く必要があるところが少し惜しかったなと思いました。
- FLIPアニメーションの実行タイミングを指定できる
- 追加・削除時のアニメーションを設定した時にキャンセルすることも考える必要があるのが手間
- ライブラリで勝手にキャンセルしてくれたりした方が楽ではあるが、その辺の制御も利用者側で決められるようにという意図な気もするので仕方なさそう
- 型周りが少し微妙
- durationが設定できず、
stiffness
とdamping
で表現しないといけない
終わりに
以上がReactでFLIPアニメーションする方法でした。react-flip-move
の方が手軽に扱えそうだなと思いましたが、残念ながらコードが古くてReact18系で警告が出る以上、react-flip-tools
を使うしかないのかなと感じました。確かにこちらの方が柔軟性が高く、色んなことに応用できそうだなとは感じましたが、その分コードを結構書く必要が出たりするので惜しいなと思いました。
ReactでFLIPアニメーションしたい時に参考になれば幸いです。
Discussion