MUIのトランジションコンポーネントを自作して使う
始めに
MUIはいくつかのトランジションコンポーネントが提供されており、このコンポーネントを使ったりTransitionComponent
というPropsに渡すことでトランジションを切り替えることができます。
もし提供されているトランジション以外のものを使いたい場合は既存のトランジションコンポーネントの実装を参考に自作することで実現できますが、色々なコードが書かれており、初見ではサッパリでした(汗)
しかしベースとなっているreact-transition-groupについて調べたり、色々検証していたことで大体は理解できて自作のトランジションコンポーネントを作ることができましたので、備忘録としてまとめたいと思います。
react-transition-group
自体についてはこの記事でまとめており、この内容を元にMUIでどう使われているかという話をしますので、事前に読んでおくことを推奨します。
今回作ったもの
今回カスタムトランジションで作ったものは以下のような下からフェードインするようなものになります。これをSnackbarのTransitionComponent
に渡して表示するところまでやりました。
また今回作ったものは以下のCodeSandboxにありますので、動きや詳細のコードを確認したい方はこちらをご参照ください。
MUI向けのカスタムトランジションコンポーネントの作成
まずは以下の記事でも作ったような、最低限のコードを書きたいと思います。
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を設定する際にこれを考慮する必要があります。
const defaultTimeout = {
enter: theme.transitions.duration.enteringScreen,
exit: theme.transitions.duration.leavingScreen
}
// { enter: 225, exit: 195 }
これを直接指定するのは大変なため、MUIでもgetTransitionProps
, theme.transitions.create
を使ってトランジション設定をしています。
+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>
);
}
;
コールバック関数をpropsで受け取れるようにする
Transition
コンポーネントにはonEnter
やonEntered
などのコールバック関数がありますが、自作のコンポーネントもこのコールバックは受け取れるようにするのが適切で、MUIもこの対応がされています。
しかしコールバックの定義は思っていたよりも手間で、コンポーネント内でハンドリングする場合もあり、その時にpropsから渡されたコールバック関数も実行されるようにケアする必要があります(この部分が一番コード量が多い)。これをMUIではnormalizedTransitionCallback
というメソッドを作って、そこからhandle~
というメソッドを作ってTransition
のpropsに渡しています。
transition
styleの設定もコールバック側に移動することでenter
とexit
の切り替えが容易になるのでそちらに移動します。また、webkit
を考慮してかstyle.webkitTransition
にも設定されていたので同じように設定しました。
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のコードを見ると以下のコードが実行されています。
Reflowというのは簡単に言うとJSの実行中にDOMの再計算を行うもので、通常は避けた方が良いものです。しかしあえて再計算させることで、それ以降のDOM設定が最新の状態になるので意図してやる場合があります。
正直本当に必要かは分かりませんでしたが、MUIでは書かれていたので同じように書きました。
ラップしているdiv要素をなくす
Transition
コンポーネントでrenderする際にstyleやrefを渡すようにdiv要素をラップしていましたが、MUIではラップせずにcloseElement
で直接childrenに入っているReactElementに対して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
の内容を直接使用するため、ref
やstyle
などの設定がバッティングする可能性があります。style
についてはchildren.props.style
で両方設定することができますが(opacity
などトランジションで使っているstyleを設定してしまうと上書きされてしまいますが。。)、ref
の方は一工夫する必要があります。
MUIでは同じものにref
を設定できるようにuseForkRef
hooksを用意しているため、それを呼ぶことで両方にrefが設定されるようにできます。
childrenメソッドにあるchildPropsについて
Transition
コンポーネントにあるchildrenメソッドはドキュメントには書かれていませんが実は第二引数があるようで、MUIではそれを考慮した書き方をしていたので合わせました。
<Transition>
{(state, childProps) => {
//
}}
</Transition>
childProps
はどういう時に渡せるかコードを見たところ、どうやらTransition
コンポーネントで余分にpropsを渡した時に子供に中継するようです。この書き方はtypeエラーになると思うので基本的には使わないと思われます。
トランジションコンポーネントにrefを渡せるようにする
最後にトランジションコンポーネント自身もforwardRef
を使ってrefを渡せるようにします。正直使う機会が今一分かりませんが、他のMUIトランジションコンポーネントがそういう作りをしていたので合わせました。MUIではchildrenのrefに入るようにhandleRef
に含めていました。
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でトランジションコンポーネントを自作したい時の参考になれれば幸いです。
Discussion