MUIで使われるreact-transition-groupについて調べた
始めに
MUIのトランジションコンポーネントはreact-transition-groupというライブラリをベースにした作りをしています。
したがってMUIでオリジナルのTransitionコンポーネントを作る場合はそもそもreact-transition-group
について知る必要がありましたので、それについてまとめました。
Transitionコンポーネント
Transitionという名前ではありますが、厳密にはステータスを管理しているだけになります。
import { FC, useState, useRef } from "react";
import { Transition } from "react-transition-group";
export const TransitionStatusCheckContainer: FC = () => {
const [isShow, setIsShow] = useState(false);
const nodeRef = useRef<HTMLDivElement | null>(null);
return (
<div>
<div>ステータスチェック</div>
<button
onClick={() => {
setIsShow(!isShow);
}}
>
{isShow ? "非表示" : "表示"}
</button>
<Transition nodeRef={nodeRef} in={isShow} timeout={500}>
{(state) => {
// stateには「'entering' | 'entered' | 'exiting' | 'exited'」が入っている
return <div ref={nodeRef}>{state}</div>;
}}
</Transition>
</div>
);
};
Transition
コンポーネントのchildrenにentering
やexiting
などのステータスが受け取れるので、それに応じたスタイルを当ててトランジションアニメーションを表現します。
次からいくつか例を挙げていきます。
フェードトランジション
まずはフェードトランジションするコンポーネントですが、これを簡単に作ると以下のようになります。
import { FC, useRef, ReactNode, CSSProperties } from "react";
import { Transition, TransitionStatus } from "react-transition-group";
// トランジションステータスに応じたスタイル
const TRANSITION_STYLES: Partial<Record<TransitionStatus, CSSProperties>> = {
entering: { opacity: 1 },
entered: { opacity: 1 },
exiting: { opacity: 0 },
exited: { opacity: 0 }
};
export type TransitionFadeProps = {
in?: boolean;
duration?: number;
children: ReactNode;
};
export const TransitionFade: FC<TransitionFadeProps> = ({
in: inProp,
duration = 500,
children
}) => {
const nodeRef = useRef<HTMLDivElement | null>(null);
return (
<Transition nodeRef={nodeRef} in={inProp} timeout={duration} unmountOnExit>
{(state) => {
return (
<div
ref={nodeRef}
style={{
transition: `opacity ${duration}ms`,
opacity: 0,
...TRANSITION_STYLES[state]
}}
>
{children}
</div>
);
}}
</Transition>
);
};
Transition
コンポーネントにunmountOnExit
というpropsを足していますが、これを入れないとexitedしてもDOM要素が残ったままになります。
unmountOnExitなし | unmountOnExitあり |
---|---|
アコーディオントランジション
同じ要領でアコーディオントランジションするコンポーネントを作ると以下のようになります。アコーディオンは閉じる際に現在の高さから0までトランジションする必要があるので、onExit
のタイミングで一度現在の高さをセットしてからrender時に0がセットされるようにしています。
import { FC, ReactNode, useRef, CSSProperties } from "react";
import { Transition, TransitionStatus } from "react-transition-group";
const TRANSITION_STYLES: Partial<Record<TransitionStatus, CSSProperties>> = {
entering: { overflow: "hidden" },
entered: { height: "" },
exiting: { overflow: "hidden", height: 0 },
exited: { overflow: "hidden", height: 0 }
};
export type TransitionAccordionProps = {
in?: boolean;
duration?: number;
children: ReactNode;
};
export const TransitionAccordion: FC<TransitionAccordionProps> = ({
in: inProp,
duration = 500,
children
}) => {
const nodeRef = useRef<HTMLDivElement | null>(null);
const elContentRef = useRef<HTMLDivElement | null>(null);
return (
<Transition
nodeRef={nodeRef}
in={inProp}
timeout={duration}
onExit={() => {
if (nodeRef.current == null) {
return;
}
const node = nodeRef.current;
const contentHeight = elContentRef.current?.clientHeight;
if (contentHeight) {
node.style.height = `${contentHeight}px`;
}
}}
unmountOnExit
>
{(state) => {
const contentHeight = elContentRef.current?.clientHeight ?? 0;
return (
<div
ref={nodeRef}
style={{
transition: `height ${duration}ms`,
height: `${contentHeight}px`,
...TRANSITION_STYLES[state]
}}
>
<div ref={elContentRef}>{children}</div>
</div>
);
}}
</Transition>
);
};
アコーディオンenter時の流れ
アコーディオンleave時の流れ
動作イメージ
スライド&フェードトランジション
最後にフェードしながらスライドインするトランジションを作りたいと思います。これはopacityとtranslateYをいじることで実現できますが、フェードの時に高さが急に確保してガタッとコンテンツが下に移動されていたのが気になったので、アコーディオントランジションを参考にコンテンツ全体も滑らかに移動されるようにしました。
import { FC, useRef, ReactNode, CSSProperties } from "react";
import { Transition, TransitionStatus } from "react-transition-group";
const TRANSITION_STYLES: Partial<Record<TransitionStatus, CSSProperties>> = {
entering: { pointerEvents: "none", opacity: 1, transform: "translateY(0)" },
entered: { opacity: 1, transform: "translateY(0)", height: "" },
exiting: {
pointerEvents: "none",
opacity: 0,
transform: "translateY(-30px)",
height: 0
},
exited: { opacity: 0, transform: "translateY(-30px)", height: 0 }
};
export type TransitionSlideFadeProps = {
in?: boolean;
duration?: number;
children: ReactNode;
};
export const TransitionSlideFade: FC<TransitionSlideFadeProps> = ({
in: inProp,
duration = 500,
children
}) => {
const nodeRef = useRef<HTMLDivElement | null>(null);
const elContentRef = useRef<HTMLDivElement | null>(null);
return (
<Transition
nodeRef={nodeRef}
in={inProp}
timeout={duration}
onExit={() => {
if (nodeRef.current == null) {
return;
}
const node = nodeRef.current;
const contentHeight = elContentRef.current?.clientHeight;
if (contentHeight) {
node.style.height = `${contentHeight}px`;
}
}}
unmountOnExit
>
{(state) => {
const contentHeight = elContentRef.current?.clientHeight ?? 0;
return (
<div
ref={nodeRef}
style={{
transition: `opacity ${duration}ms, transform ${duration}ms, height ${duration}ms`,
height: `${contentHeight}px`,
...TRANSITION_STYLES[state]
}}
>
<div ref={elContentRef}>{children}</div>
</div>
);
}}
</Transition>
);
};
動作イメージ
TransitionGroupコンポーネント
TransitionGroupはTransitionコンポーネントをラップする形で使用され、主にリスト内の各要素のenter、leave時のアニメーションを行いやすくするために使われます。
<TransitionGroup>
{ids.map((id) => (
// トランジションコンポーネントを含めると、
// in propsが差し込まれて、enterやleaveアニメーションを起動できる
<Transition key={id}>{/* コンテンツ */}</Transition>
))}
</TransitionGroup>
前セクションで作成したTransitionFade
などもin
propsを受け付けているため、使用することができます。
<TransitionGroup key={transitionType}>
{ids.map((id) => (
<TransitionFade key={id}>
<Block
onRemove={() => {
setIds(ids.filter((_id) => _id !== id));
}}
>
{id}
</Block>
</TransitionFade>
))}
</TransitionGroup>
各トランジションコンポーネントを使用した結果
それぞれのトランジションコンポーネントを使用した動きは以下のようになります。
フェードトランジション
アコーディオントランジション
スライド&フェードトランジション
余談: leave時のchildren保持
TransitionGroup
でleaveする時はchildrenから除外することになりますが、アニメーション中はrenderしないと表示できません。どうやってこの問題を解消しているのかコードを見てみましたが、どうやらchildrenはstateで持っていて、leaveアニメーションを完全に終了した時(onExited時)にstateからも除外する処理をしているようです。
詳細コード
stateのchildrenを使ってrenderしている
childrenに対してonExited
などを渡しており、このタイミングでstateから除外している
したがってトランジションコンポーネントにはonExited
も発火するように作る必要がありました。unmountOnExit
をつけていたことでDOM自体は表示されていなかったので気づきませんでしたが、React Developer Toolsで確認すると、中途半端にコンポーネントが残っていました。
これがちゃんと消えるようにするのにはonExited
を提供します。
export type TransitionFadeProps = {
in?: boolean;
duration?: number;
+ // TransitionGroupでちゃんとコンポーネントを破棄させるにはonExitedを受け取る必要がある
+ onExited?: TransitionProps<HTMLDivElement>["onExited"];
children: ReactNode;
};
export const TransitionFade: FC<TransitionFadeProps> = ({
in: inProp,
duration = 500,
+ onExited,
children
}) => {
const nodeRef = useRef<HTMLDivElement | null>(null);
return (
<Transition
nodeRef={nodeRef}
in={inProp}
timeout={duration}
+ onExited={onExited}
unmountOnExit
>
{/* 省略 */}
</Transition>
);
};
検証コード
今回検証したコードを以下に貼りますので、詳細を見たい方はご参照ください。
終わりに
以上がMUIで使われているreact-transition-groupの内容でした。大体は使い方が分かりましたが、MUIで提供されているFade
やGrow
コンポーネントの中身はもっと複雑で、色々な考慮がされたものになっていましたので、それについては以下の記事でまとめましたので興味がある方はこちらもご参照ください。
Discussion