🐷

MUIのトランジションコンポーネントを自作して使う

2023/10/22に公開

始めに

MUIはいくつかのトランジションコンポーネントが提供されており、このコンポーネントを使ったりTransitionComponentというPropsに渡すことでトランジションを切り替えることができます。

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

もし提供されているトランジション以外のものを使いたい場合は既存のトランジションコンポーネントの実装を参考に自作することで実現できますが、色々なコードが書かれており、初見ではサッパリでした(汗)

https://github.com/mui/material-ui/blob/v5.14.14/packages/mui-material/src/Fade/Fade.js

しかしベースとなっているreact-transition-groupについて調べたり、色々検証していたことで大体は理解できて自作のトランジションコンポーネントを作ることができましたので、備忘録としてまとめたいと思います。

react-transition-group自体についてはこの記事でまとめており、この内容を元にMUIでどう使われているかという話をしますので、事前に読んでおくことを推奨します。

https://zenn.dev/numa_san/articles/219a3e815f6211

今回作ったもの

今回カスタムトランジションで作ったものは以下のような下からフェードインするようなものになります。これをSnackbarのTransitionComponentに渡して表示するところまでやりました。

また今回作ったものは以下のCodeSandboxにありますので、動きや詳細のコードを確認したい方はこちらをご参照ください。

MUI向けのカスタムトランジションコンポーネントの作成

まずは以下の記事でも作ったような、最低限のコードを書きたいと思います。

https://zenn.dev/numa_san/articles/219a3e815f6211

CustomTransitionコンポーネントの土台を作る
import { TransitionProps } from "@mui/material/transitions";
import {
  useRef,
  CSSProperties,
  ReactElement
} from "react";
import { Transition, TransitionStatus } from "react-transition-group";

export type CustomTransitionProps = Omit<TransitionProps, "children"> & {
  children: ReactElement<any, any>;
};

const styles: Partial<Record<TransitionStatus, CSSProperties>> = {
  entering: {
    opacity: 1,
    transform: "none"
  },
  entered: {
    opacity: 1,
    transform: "none"
  },
  exiting: {
    transform: "translateY(-30px)"
  }
};

export const CustomTransition: FC<CustomTransitionProps> =
  (props) => {
    const {
      appear = true,
      children,
      in: inProp,
      timeout,
      ...other
    } = props;

    const nodeRef = useRef<HTMLElement | null>(null);

    return (
      <Transition
        appear={appear}
        in={inProp}
        nodeRef={nodeRef}
        timeout={timeout}
        {...other}
      >
        {(state) => {
          return (
            <div
              ref={nodeRef}
              style={{
                opacity: 0,
                transform: "translateY(30px)",
                // timeoutの設定が難しいので一旦ハードコードする
                transition: "opacity 250ms, transform 250ms",
                visibility: state === "exited" && !inProp ? "hidden" : undefined,
                ...styles[state],
              }}
            >
              {children}
            </div>
          )
        }}
      </Transition>
    );
  }
;

これでも最低限の動きができますが、ここからMUIで実装されているトランジションコンポーネントに合わせていきます。

timeoutをthemeから参照する

timeoutはMUIのthemeにデフォルト値があるため、props未指定の場合はそちらを参照する必要があります。またデフォルト値はenterとexitでは値が異なっているため、transitionを設定する際にこれを考慮する必要があります。

themeに含まれているデフォルト値
const defaultTimeout = {
  enter: theme.transitions.duration.enteringScreen,
  exit: theme.transitions.duration.leavingScreen
}
// { enter: 225, exit: 195 }

これを直接指定するのは大変なため、MUIでもgetTransitionProps, theme.transitions.createを使ってトランジション設定をしています。

timeoutをthemeから参照する
+import { useTheme } from "@mui/material";
 import { TransitionProps } from "@mui/material/transitions";
+import { getTransitionProps } from "@mui/material/transitions/utils";
 import {
   useRef,
   CSSProperties,
   ReactElement
 } from "react";
 import { Transition, TransitionStatus } from "react-transition-group";

export const CustomTransition: FC<CustomTransitionProps> =
  (props) => {
+    const theme = useTheme();
+    const defaultTimeout = {
+      enter: theme.transitions.duration.enteringScreen,
+      exit: theme.transitions.duration.leavingScreen
+    };

     const {
       appear = true,
       children,
+      easing,
       in: inProp,
+      style,
-      timeout,
+      timeout = defaultTimeout,
       ...other
     } = props;

     const nodeRef = useRef<HTMLElement | null>(null);

     return (
       <Transition
         appear={appear}
         in={inProp}
         nodeRef={nodeRef}
         timeout={timeout}
         {...other}
       >
         {(state) => {
+          const transitionProps = getTransitionProps(
+            { style, timeout, easing },
+            {
+              // TODO: enterとexitをstateに応じて切り替える
+              mode: "enter"
+            }
+          );
+
+          const transition = theme.transitions.create(
+            ["opacity", "transform"],
+            transitionProps
+          );
           return (
             <div
               ref={nodeRef}
               style={{
                 opacity: 0,
                 transform: "translateY(30px)",
-                // timeoutの設定が難しいので一旦ハードコードする
-                transition: "opacity 250ms, transform 250ms",
+                transition,
                 visibility: state === "exited" && !inProp ? "hidden" : undefined,
                 ...styles[state],
               }}
             >
               {children}
             </div>
           )
         }}
       </Transition>
     );
   }
 ;

https://github.com/mui/material-ui/blob/v5.14.14/packages/mui-material/src/transitions/utils.ts#L21-L33

コールバック関数をpropsで受け取れるようにする

TransitionコンポーネントにはonEnteronEnteredなどのコールバック関数がありますが、自作のコンポーネントもこのコールバックは受け取れるようにするのが適切で、MUIもこの対応がされています。
しかしコールバックの定義は思っていたよりも手間で、コンポーネント内でハンドリングする場合もあり、その時にpropsから渡されたコールバック関数も実行されるようにケアする必要があります(この部分が一番コード量が多い)。これをMUIではnormalizedTransitionCallbackというメソッドを作って、そこからhandle~というメソッドを作ってTransitionのpropsに渡しています。
transitionstyleの設定もコールバック側に移動することでenterexitの切り替えが容易になるのでそちらに移動します。また、webkitを考慮してかstyle.webkitTransitionにも設定されていたので同じように設定しました。

コールバック関数をpropsで受け取れるようにする
 import { useTheme } from "@mui/material";
 import { TransitionProps } from "@mui/material/transitions";
 import { getTransitionProps, reflow } from "@mui/material/transitions/utils";
 import {
   useRef,
   CSSProperties,
   ReactElement
 } from "react";
 import { Transition, TransitionStatus } from "react-transition-group";

 export const CustomTransition =
   (props) => {
     const theme = useTheme();
     const defaultTimeout = {
       enter: theme.transitions.duration.enteringScreen,
       exit: theme.transitions.duration.leavingScreen
     };

     const {
+      addEndListener,
       appear = true,
       children,
       easing,
       in: inProp,
+      onEnter,
+      onEntered,
+      onEntering,
+      onExit,
+      onExited,
+      onExiting,
       style,
       timeout = defaultTimeout,
       ...other
     } = props;

     const nodeRef = useRef<HTMLElement | null>(null);

+    const normalizedTransitionCallback = (
+      callback?:
+        | ((node: HTMLElement) => void)
+        | ((node: HTMLElement, isAppearing: boolean) => void)
+    ) => (maybeIsAppearing?: boolean) => {
+      const node = nodeRef.current;
+      if (node == null || callback == null) {
+        return;
+      }
+
+      // onEnterXxx and onExitXxx callbacks have a different arguments. length value.
+      if (maybeIsAppearing === undefined) {
+        // 型エラー解消のため、参照されない第二引数ととりあえず入れておく
+        callback(node, false);
+      } else {
+        callback(node, maybeIsAppearing);
+      }
+    };

+    const handleEntering = normalizedTransitionCallback(onEntering);

+    const handleEnter = normalizedTransitionCallback((node, isAppearing) => {
+      reflow(node); // So the animation always start from the start.
+
+      const transitionProps = getTransitionProps(
+        { style, timeout, easing },
+        {
+          mode: "enter"
+        }
+      );
+
+      node.style.webkitTransition = theme.transitions.create(
+        ["opacity", "transform"],
+        transitionProps
+      );
+      node.style.transition = theme.transitions.create(
+        ["opacity", "transform"],
+        transitionProps
+      );
+
+      if (onEnter) {
+        onEnter(node, isAppearing);
+      }
+    });

+    const handleEntered = normalizedTransitionCallback(onEntered);

+    const handleExiting = normalizedTransitionCallback(onExiting);

+    const handleExit = normalizedTransitionCallback((node: HTMLElement) => {
+      const transitionProps = getTransitionProps(
+        { style, timeout, easing },
+        {
+          mode: "exit"
+        }
+      );
+
+      node.style.webkitTransition = theme.transitions.create(
+        ["opacity", "transform"],
+        transitionProps
+      );
+      node.style.transition = theme.transitions.create(
+        ["opacity", "transform"],
+        transitionProps
+      );
+
+      if (onExit) {
+        onExit(node);
+      }
+    });

+    const handleExited = normalizedTransitionCallback(onExited);

+    const handleAddEndListener = (next: () => void) => {
+      if (addEndListener && nodeRef.current != null) {
+        // Old call signature before `react-transition-group` implemented `nodeRef`
+        addEndListener(nodeRef.current, next);
+      }
+    };

     return (
       <Transition
         appear={appear}
         in={inProp}
         nodeRef={nodeRef}
+        onEnter={handleEnter}
+        onEntered={handleEntered}
+        onEntering={handleEntering}
+        onExit={handleExit}
+        onExited={handleExited}
+        onExiting={handleExiting}
+        addEndListener={handleAddEndListener}
         timeout={timeout}
         {...other}
       >
         {(state) => {
-          // handleEnter, handleExitでtransition styleを設定するのでここは削除
-          const transitionProps = getTransitionProps(
-            { style, timeout, easing },
-            {
-              // TODO: enterとexitをstateに応じて切り替える
-              mode: "enter"
-            }
-          );
-
-          const transition = theme.transitions.create(
-            ["opacity", "transform"],
-            transitionProps
-          );
           return (
             <div
               ref={nodeRef}
               style={{
                 opacity: 0,
                 transform: "translateY(30px)",
-                transition,
                 visibility: state === "exited" && !inProp ? "hidden" : undefined,
                 ...styles[state],
               }}
             >
               {children}
             </div>
           )
        }}
      </Transition>
    );
  }
;
reflowについて

handleEnterには最初にreflowというメソッドが呼ばれています。

const handleEnter = normalizedTransitionCallback((node, isAppearing) => {
  reflow(node); // So the animation always start from the start.

  const transitionProps = getTransitionProps(
    { style, timeout, easing },
    {
      mode: "enter",
    }
  );

  node.style.webkitTransition = theme.transitions.create(
    ["opacity", "transform"],
    transitionProps
  );
  node.style.transition = theme.transitions.create(
    ["opacity", "transform"],
    transitionProps
  );

  if (onEnter) {
    onEnter(node, isAppearing);
  }
});

MUIのコードを見ると以下のコードが実行されています。

https://github.com/mui/material-ui/blob/v5.14.14/packages/mui-material/src/transitions/utils.ts#L3

Reflowというのは簡単に言うとJSの実行中にDOMの再計算を行うもので、通常は避けた方が良いものです。しかしあえて再計算させることで、それ以降のDOM設定が最新の状態になるので意図してやる場合があります。
正直本当に必要かは分かりませんでしたが、MUIでは書かれていたので同じように書きました。

ラップしているdiv要素をなくす

Transitionコンポーネントでrenderする際にstyleやrefを渡すようにdiv要素をラップしていましたが、MUIではラップせずにcloseElementで直接childrenに入っているReactElementに対してpropsを流し込みます。

div要素をなくしてcloneElementでpropsを流し込む
 import { useTheme } from "@mui/material";
+import { useForkRef } from "@mui/material";
 import { TransitionProps } from "@mui/material/transitions";
 import { getTransitionProps, reflow } from "@mui/material/transitions/utils";
 import {
+  cloneElement,
   useRef,
   CSSProperties,
   ReactElement
 } from "react";
 import { Transition, TransitionStatus } from "react-transition-group";

 export const CustomTransition: FC<CustomTransitionProps> =
   (props) => {
     // 一部省略

     const nodeRef = useRef<HTMLElement | null>(null);
+    const handleRef = useForkRef(
+      nodeRef,
+      // 呼び出し側でchildrenにrefを指定している場合にきちんとrefが渡されるように設定
+      // @ts-ignore childrenにrefの型がなかったのでとりあえずignoreする
+      children.ref
+    );

     // 一部省略

     return (
       <Transition
         // 変更がないため省略
       >
+        {(state, childProps) => {
-        {(state) => {
-          return (
-            <div
-              ref={nodeRef}
-              style={{
-                opacity: 0,
-                transform: "translateY(30px)",
-                transition,
-                visibility: state === "exited" && !inProp ? "hidden" : undefined,
-                ...styles[state],
-              }}
-            >
-              {children}
-            </div>
-          )
+          return cloneElement(children, {
+            style: {
+              opacity: 0,
+              transform: "translateY(30px)",
+              visibility: state === "exited" && !inProp ? "hidden" : undefined,
+              ...styles[state],
+              ...style,
+              ...children.props.style
+            },
+            ref: handleRef,
+            ...childProps
+          });
         }}
       </Transition>
     );
   }
 ;

childrenの内容を直接使用するため、refstyleなどの設定がバッティングする可能性があります。styleについてはchildren.props.styleで両方設定することができますが(opacityなどトランジションで使っているstyleを設定してしまうと上書きされてしまいますが。。)、refの方は一工夫する必要があります。
MUIでは同じものにrefを設定できるようにuseForkRefhooksを用意しているため、それを呼ぶことで両方にrefが設定されるようにできます。

https://github.com/mui/material-ui/blob/v5.14.14/packages/mui-utils/src/useForkRef/useForkRef.ts

childrenメソッドにあるchildPropsについて

Transitionコンポーネントにあるchildrenメソッドはドキュメントには書かれていませんが実は第二引数があるようで、MUIではそれを考慮した書き方をしていたので合わせました。

https://reactcommunity.org/react-transition-group/transition#Transition-prop-children

<Transition>
  {(state, childProps) => {
    //
  }}
</Transition>

childPropsはどういう時に渡せるかコードを見たところ、どうやらTransitionコンポーネントで余分にpropsを渡した時に子供に中継するようです。この書き方はtypeエラーになると思うので基本的には使わないと思われます。

https://github.com/reactjs/react-transition-group/blob/v4.4.5/src/Transition.js#L377-L384

トランジションコンポーネントにrefを渡せるようにする

最後にトランジションコンポーネント自身もforwardRefを使ってrefを渡せるようにします。正直使う機会が今一分かりませんが、他のMUIトランジションコンポーネントがそういう作りをしていたので合わせました。MUIではchildrenのrefに入るようにhandleRefに含めていました。

トランジションコンポーネントもforwardRefを使ってrefを渡せるようにする
 import { useForkRef, useTheme } from "@mui/material";
 import { TransitionProps } from "@mui/material/transitions";
 import { getTransitionProps, reflow } from "@mui/material/transitions/utils";
 import {
   cloneElement,
+  forwardRef,
   useRef,
   CSSProperties,
   ReactElement
 } from "react";
 import { Transition, TransitionStatus } from "react-transition-group";

-export const CustomTransition: FC<CustomTransitionProps> =
-  (props) => {
+export const CustomTransition = forwardRef<HTMLElement, CustomTransitionProps>(
+  (props, ref) => {
     // 一部省略

     const nodeRef = useRef<HTMLElement | null>(null);
     const handleRef = useForkRef(
       nodeRef,
       // 呼び出し側でchildrenにrefを指定している場合にきちんとrefが渡されるように設定
       // @ts-ignore childrenにrefの型がなかったのでとりあえずignoreする
       children.ref,
+      ref
     );

     // 一部省略
   }
+);

終わりに

以上がMUIのトランジションコンポーネントを自作する方法でした。MUIでは色々なケースを考慮して作られていたのでそれに合わせるのはかなり大変でしたが、useForkRefとかnormalizedTransitionCallbackとかMUIの実装の深いところを知れて良かったなと思いました。
MUIでトランジションコンポーネントを自作したい時の参考になれれば幸いです。

GitHubで編集を提案

Discussion