💬

MUIで使われるreact-transition-groupについて調べた

2023/10/21に公開

始めに

MUIのトランジションコンポーネントはreact-transition-groupというライブラリをベースにした作りをしています。

https://mui.com/material-ui/transitions/#transitiongroup

したがってMUIでオリジナルのTransitionコンポーネントを作る場合はそもそもreact-transition-groupについて知る必要がありましたので、それについてまとめました。

Transitionコンポーネント

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にenteringexitingなどのステータスが受け取れるので、それに応じたスタイルを当ててトランジションアニメーションを表現します。
次からいくつか例を挙げていきます。

フェードトランジション

まずはフェードトランジションするコンポーネントですが、これを簡単に作ると以下のようになります。

フェードトランジションするコンポーネント
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を受け付けているため、使用することができます。

自作のTransitionFadeを使った例
<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からも除外する処理をしているようです。

詳細コード

したがってトランジションコンポーネントにはonExitedも発火するように作る必要がありました。unmountOnExitをつけていたことでDOM自体は表示されていなかったので気づきませんでしたが、React Developer Toolsで確認すると、中途半端にコンポーネントが残っていました。

これがちゃんと消えるようにするのにはonExitedを提供します。

TransitionGroupを使う際の正しい設定
 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で提供されているFadeGrowコンポーネントの中身はもっと複雑で、色々な考慮がされたものになっていましたので、それについては以下の記事でまとめましたので興味がある方はこちらもご参照ください。

https://zenn.dev/numa_san/articles/e213d29e4cc227

GitHubで編集を提案

Discussion