MUIでVuetify2系のナビゲーションドロワーを再現する
始めに
Vue.js 2系が今年(2023年)の12月でサポートが終了します。思い切ってReactの方に移行としようと思い、一番有名なMUIを使った際に同じ機能を再現することは可能なのか気になり色々試していました。その際ナビゲーションドロワーについては大分苦労したので、その辺の実装方法について備忘録としてまとめました。
今回再現したナビゲーションドロワー
今回再現したナビゲーションドロワーは以下のようなものになります。
SPビュー
- オーバーレイ表示
- 一階層目のアコーディオンは1つだけ開くことができ、他のものを開く場合は選択したもの以外を閉じる
- リンクをクリックしたらメニューを閉じる
PCビュー
- 横にずっと表示されており、メニューアイコンクリックでフルサイズとミニサイズに切り替えられる
- ミニサイズの場合はhoverで一時的に開くことができる
- ミニサイズの時はアコーディオンは閉じている
サンプルコード
Vuetify2で実装したものはこちらで確認できます。余談ですが、この辺の機能がVuetify標準というのも衝撃ですよね。。
MUIでVuetify2系のナビゲーションの再現
画面レイアウトの作成
まずは画面全体のレイアウトを用意します。図で表すと以下のような構成になっています。一番上のアプリケーションバーはposition: fixed
で配置します。ドロワー部分と右側のレイアウトはflexで配置し、それぞれの要素のトップに同じ高さ分の空要素(Toolbar)を詰めます。
これはアプリケーションバーを削除するとイメージが湧くと思います。fixedで配置することで通常の要素がその場所に余白ができてしまうので、同じ高さをもつコンポーネントを用意して調整します。
まとめると以下のようなコードになります。
import { FC, useEffect } from "react";
import { Outlet, useLocation } from "react-router-dom";
import {
Box,
AppBar,
Toolbar as MuiToolbar,
IconButton,
Typography
} from "@mui/material";
import { styled } from "@mui/material/styles";
import { Menu as MenuIcon } from "@mui/icons-material";
const Toolbar = styled(MuiToolbar)(({ theme }) => ({
padding: theme.spacing(0, 2.5),
[theme.breakpoints.up("sm")]: {
padding: theme.spacing(0, 2.5)
}
}));
export const Layout: FC = () => {
return (
<Box sx={{ display: "flex", minHeight: "100vh" }}>
{/* アプリケーションバー */}
<AppBar
position="fixed"
>
<Toolbar>
<IconButton
color="inherit"
edge="start"
>
<MenuIcon />
</IconButton>
<Typography>MUIアプリケーション</Typography>
</Toolbar>
</AppBar>
{/* NavigationDrawer。後で実装 */}
<Box>
{/* 高さ確保用 */}
<Toolbar />
<div>ドロワー</div>
</Box>
{/* 右側レイアウト */}
<Box sx={{ flex: "1 1 0", display: "flex", flexDirection: "column" }}>
{/* 高さ確保用 */}
<Toolbar />
<Box sx={{ flex: "1 1 auto", padding: 2, overflow: "hidden" }}>
<Outlet />
</Box>
<Box
sx={{
display: "flex",
justifyContent: "flex-end",
padding: (theme) => theme.spacing(1, 2),
backgroundColor: "#eee"
}}
>
フッター
</Box>
</Box>
</Box>
);
};
NavigationDrawerの実装
次にドロワー部分を実装します。
SPビュー
MUIには標準でvariant: temporary
でオーバーレイ表示になるのでそれを使います。
import { Box, Drawer as MuiDrawer } from "@mui/material";
import { FC } from "react";
export type SPNavigationDrawerProps = {
/** 開閉フラグ */
isOpen: boolean;
/** ドロワーの幅 */
width?: number;
/** 閉じる時 */
onClose: DrawerProps["onClose"];
/** ドロワーコンテンツ */
children: ReactNode;
};
export const SPNavigationDrawer: FC<SPNavigationDrawerProps> = ({
isOpen,
width = 250,
onClose,
children
}) => {
return (
<MuiDrawer
open={isOpen}
width={width}
anchor="left"
keepMounted
variant="temporary"
onClose={onClose}
>
<Box sx={{ width, overflowY: "auto" }}>
{children}
</Box>
</MuiDrawer>
)
}
PCビュー
PCビューでミニサイズの調整はMUI標準にはないため、styled
でカスタマイズします。
- hover時はドロワーだけ幅を広げる
- isOpen時は全体の幅を広げる
また、以下の観点も考慮します。
- Toolbar分の高さを確保する
import { Box, Drawer as MuiDrawer, DrawerProps } from "@mui/material";
import { CSSObject, styled } from "@mui/material/styles";
import { FC, ReactNode, useState } from "react";
const MiniVariantDrawer = styled(MuiDrawer, {
shouldForwardProp: (prop) =>
prop !== "open" &&
prop !== "isHover" &&
prop !== "width" &&
prop !== "keepMounted"
})<DrawerProps & { width: number; isHover?: boolean }>(
({ theme, open, width, isHover }) => {
const drawerWidth = open
? width
: // border分加える
`calc(${theme.spacing(7)} + 1px)`;
const drawerAndPaperStyles: CSSObject = {
width: drawerWidth,
transition: theme.transitions.create("width", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen
}),
overflowX: "hidden"
};
return {
flexShrink: 0,
whiteSpace: "nowrap",
...drawerAndPaperStyles,
"& .MuiDrawer-paper": {
...drawerAndPaperStyles,
width: isHover ? width : drawerWidth
}
};
}
);
export type PCNavigationDrawerProps = {
/** 開閉フラグ */
isOpen: boolean;
/** ドロワーの幅 */
width?: number;
/** トップの高さを調整するためだけに表示するコンポーネント(Toolbar) */
PaddingComponent: FC<any>;
/** 閉じる時 */
onClose: DrawerProps["onClose"];
/** ドロワーコンテンツ */
children: ReactNode;
}
export const PCNavigationDrawer: FC<PCNavigationDrawerProps> = ({
isOpen,
width = 250,
PaddingComponent,
onClose,
children
}) => {
const [isHover, setIsHover] = useState(false);
return (
<MiniVariantDrawer
open={isOpen}
width={width}
anchor="left"
variant="permanent"
isHover={isHover}
onMouseEnter={() => {
setIsHover(true);
}}
onMouseLeave={() => {
setIsHover(false);
}}
onClose={onClose}
>
<PaddingComponent />
<Box sx={{ width, overflowX: "hidden", overflowY: "auto" }}>
{children}
</Box>
</MiniVariantDrawer>
);
}
まとめ
SPとPCをまとめたNavigationDrawerは以下のようになります。
// importやMiniVariantDrawerは既出なため省略
export type NavigationDrawerProps = {
/** 開閉フラグ */
isOpen: boolean;
/** ドロワーの幅 */
width?: number;
/** バリアント */
variant: "temporary" | "permanent";
/** トップの高さを調整するためだけに表示するコンポーネント(Toolbar) */
PaddingComponent: FC<any>;
/** 閉じる時 */
onClose: DrawerProps["onClose"];
/** ドロワーコンテンツ */
children: ReactNode;
};
export const NavigationDrawer: FC<NavigationDrawerProps> = ({
isOpen,
width = 250,
variant,
PaddingComponent,
onClose,
children
}) => {
const [isHover, setIsHover] = useState(false);
const Drawer = variant === "temporary" ? MuiDrawer : MiniVariantDrawer;
const additionalProps =
variant === "permanent"
? {
isHover
}
: {};
return (
<Drawer
open={isOpen}
width={width}
anchor="left"
variant={variant}
keepMounted
{...additionalProps}
onMouseEnter={() => {
setIsHover(true);
}}
onMouseLeave={() => {
setIsHover(false);
}}
onClose={onClose}
>
{variant === "permanent" && <PaddingComponent />}
<Box sx={{ width, overflowX: "hidden", overflowY: "auto" }}>
{children}
</Box>
</Drawer>
);
};
これをLayoutで呼ぶと以下のようになります。
import {
Box,
AppBar,
Toolbar as MuiToolbar,
IconButton,
Typography,
+ Theme,
+ useMediaQuery
} from "@mui/material";
// 既出のコードは一部省略
export const Layout: FC = () => {
+ const [isOpen, setIsOpen] = useState(false);
+ const isPc = useMediaQuery<Theme>((theme) => theme.breakpoints.up("sm"));
return (
<Box sx={{ display: "flex", minHeight: "100vh" }}>
{/* アプリケーションバー */}
<AppBar
position="fixed"
+ sx={{
+ // PC表示のpermanentでAppBarよりもzIndexが大きかったので+1して上に上げる
+ zIndex: (theme) => (isPc ? theme.zIndex.drawer + 1 : undefined)
+ }}
>
<Toolbar>
<IconButton
color="inherit"
edge="start"
+ onClick={() => {
+ setIsOpen(!isOpen);
+ }}
>
<MenuIcon />
</IconButton>
<Typography>MUIアプリケーション</Typography>
</Toolbar>
</AppBar>
{/* NavigationDrawer */}
+ <NavigationDrawer
+ isOpen={isOpen}
+ variant={isPc ? "permanent" : "temporary"}
+ PaddingComponent={Toolbar}
+ onClose={() => {
+ setIsOpen(false);
+ }}
+ >
+ {/* 中身は後で実装 */}
+ <div>ドロワー</div>
+ </NavigationDrawer>
{/* 右側レイアウトは省略 */}
</Box>
);
}
NavigationListの実装
次にナビゲーションドロワーの中身を作っていきます。
各構成要素の実装
アコーディオンコンポーネントやアコーディオンの開閉フラグの管理などを考慮して、構成イメージは以下のようになります。
- 各要素の番地を配列にして階層の深さと階層ごとの番地が分かるようにする
- アコーディオンはNavGroupでラップし、階層が1つ深くなる
- NavGroupは再帰的に配置できて、それで階層を表現する
- NavItem, NavGroupHeaderは階層の深さに応じて見た目を変える
NavItemなどの要素が上の図だとごちゃごちゃしているので更に細かく分解すると以下のようになります。
これを元にまずは型やコンポーネントは以下のようになります。コードが多くて最初は折りたたんでいるので、詳細みたい方は開いて見てください。
NavigationItemType
import { FC } from "react";
/** 単一ナビゲーション */
export type NavigationSingleItem = {
/** アイコン */
icon?: FC;
/** タイトル */
title: string;
/** 遷移先 */
href: string;
};
/** グループナビゲーション */
export type NavigationGroupItem = {
/** アイコン */
icon?: FC;
/** タイトル */
title: string;
/** サブメニュー */
subs: NavigationItem[];
};
/** ナビゲーション項目 */
export type NavigationItem = NavigationSingleItem | NavigationGroupItem;
NavItem
import {
Link,
ListItemButton,
ListItemIcon,
ListItemText
} from "@mui/material";
import { FC } from "react";
import { Link as RouterLink, useLocation } from "react-router-dom";
import { NavigationSingleItem } from "../NavigationItemType";
export type NavItemProps = {
/** 単一項目 */
item: NavigationSingleItem;
/** ナビゲーションの番地 */
navIndexes: number[];
};
export const NavItem: FC<NavItemProps> = ({ item, navIndexes }) => {
const Icon = item.icon;
const depth = navIndexes.length - 1;
const { pathname } = useLocation();
return (
<Link
component={RouterLink}
to={item.href}
underline="none"
color="inherit"
>
<ListItemButton
sx={{
pl: Math.max(2 * depth, 2),
minHeight: depth === 0 ? "56px" : "48px"
}}
selected={item.href === pathname}
>
{Icon && (
<ListItemIcon>
<Icon />
</ListItemIcon>
)}
<ListItemText primary={item.title} inset={Icon == null} />
</ListItemButton>
</Link>
);
};
NavGroupHeader
import { ListItemButton, ListItemIcon, ListItemText } from "@mui/material";
import {
ArrowDropDown as ArrowDropDownIcon,
ExpandMore as ExpandMoreIcon
} from "@mui/icons-material";
import { FC } from "react";
import { NavigationGroupItem } from "../NavigationItemType";
export type NavGroupHeaderProps = {
/** 開閉フラグ */
isOpen: boolean;
/** グループ項目 */
item: NavigationGroupItem;
/** ナビゲーションの番地 */
navIndexes: number[];
/** 開閉フラグをトグルする */
onToggle: () => void;
};
export const NavGroupHeader: FC<NavGroupHeaderProps> = ({
isOpen,
item,
navIndexes,
onToggle
}) => {
const Icon = item.icon;
const depth = navIndexes.length - 1;
return (
<ListItemButton
color="primary"
sx={{ minHeight: "56px" }}
onClick={() => {
onToggle();
}}
>
{Icon && (
<ListItemIcon>
<Icon />
</ListItemIcon>
)}
{/* depthに応じた判断は別に切り出していた方が良いかも */}
{depth === 1 && (
<ListItemIcon
sx={{
justifyContent: "center"
}}
>
<ArrowDropDownIcon
sx={{
transform: `rotate(${isOpen ? 180 : 0}deg)`,
transition: "transform 0.3s"
}}
/>
</ListItemIcon>
)}
<ListItemText primary={item.title} inset={depth < 1 && Icon == null} />
{depth === 0 && (
<ExpandMoreIcon
sx={{
// ListItemIconでデフォルトで設定されている色をセットする
color: "rgba(0, 0, 0, 0.54)",
transform: `rotate(${isOpen ? 180 : 0}deg)`,
transition: "transform 0.3s"
}}
/>
)}
</ListItemButton>
);
};
NavItem, NavGroupHeaderを使ってNavGroupコンポーネント作ります。開閉フラグは別ロジックに切り出していた方が良いため、AccordionManager
というオブジェクトに任せます。詳細の実装は次のセクションで説明します。
NavGroup
import { Collapse, List } from "@mui/material";
import { FC } from "react";
import { AccordionManager } from "../hooks/useAccordionManager";
import { NavigationGroupItem } from "../NavigationItemType";
import { NavGroupHeader } from "./NavGroupHeader";
import { NavItem } from "./NavItem";
export type NavGroupProps = {
/** グループ項目 */
item: NavigationGroupItem;
/** ナビゲーションの番地 */
navIndexes: number[];
/** アコーディオン情報を管理するインスタンス(後で説明) */
accordionManager: AccordionManager;
};
export const NavGroup: FC<NavGroupProps> = ({
item,
navIndexes,
accordionManager
}) => {
const isOpen = accordionManager.isOpen(navIndexes);
return (
<>
<NavGroupHeader
isOpen={isOpen}
item={item}
navIndexes={navIndexes}
onToggle={() => {
accordionManager.toggle(navIndexes);
}}
/>
<Collapse in={isOpen} timeout="auto">
<List disablePadding>
{item.subs.map((subItem, index) =>
"subs" in subItem ? (
<NavGroup
key={index}
item={subItem}
navIndexes={[...navIndexes, index]}
accordionManager={accordionManager}
/>
) : (
<NavItem
key={index}
item={subItem}
navIndexes={[...navIndexes, index]}
/>
)
)}
</List>
</Collapse>
</>
);
};
アコーディオン開閉フラグを管理するhooksを作成
Vuetifyのナビゲーションアコーディオンの開閉フラグの仕様はなかなか複雑です。以下のような仕様をコンポーネントの中に含めるとロジックがごちゃついてしまいます。そこで開閉フラグのロジックだけhooksに切り出します。
- 第一階層は最大1つだけ開くことができ、それ以外は閉じられる
- 第二階層は開く個数に制限はないが、第一階層を閉じた際は一緒に閉じる
- (ドロワーがミニサイズになる外部要因で)一時的に全てが閉じた状態になる場合がある
利用者側にとっては以下の機能だけあれば良いので、このインターフェースを提供するように実装します。
type AccordionManager = {
/** アコーディオンをトグルする */
toggle: (navIndexes: NavIndexes) => void;
/** アコーディオンの開閉フラグの取得 */
isOpen: (navIndexes: NavIndexes) => boolean;
};
useAccoridonManager
import { useState } from "react";
type AccordionState = {
/** トップ階層で開いているindex番号 */
depth0: number[];
/** 二階層目で開いているindex番号 */
depth1: {
[depth0Index: number]: number[];
};
};
/** ナビゲーションの番地 */
// 本当はタプル型で厳密にしたいが、再帰コードが書きづらくなるのでとりあえずnumber[]にする
// export type NavIndexes = [number] | [number, number]
type NavIndexes = number[];
export type AccordionManager = {
toggle: (navIndexes: NavIndexes) => void;
isOpen: (navIndexes: NavIndexes) => boolean;
};
export type UseAccordionManagerOptions = {
/** 一時的に全て閉じている状態にするか */
isTemporaryAllClose?: boolean;
};
/**
* 開閉フラグを管理するhooks
*/
export const useAccordionManager = ({
isTemporaryAllClose
}: UseAccordionManagerOptions = {}): AccordionManager => {
const [accordionState, setAccordionState] = useState<AccordionState>({
depth0: [],
depth1: {}
});
return {
toggle: (navIndexes) => {
const [depth0Index, depth1Index] = navIndexes;
// トップ階層のtoggleの場合
if (depth1Index == null) {
if (accordionState.depth0.includes(depth0Index)) {
setAccordionState({
...accordionState,
depth0: accordionState.depth0.filter(
(openIndex) => openIndex !== depth0Index
),
depth1: {}
});
} else {
setAccordionState({
...accordionState,
depth0: [depth0Index],
depth1: {}
});
}
return;
}
// 二階層目の場合
const openIndexes = accordionState.depth1[depth0Index] || [];
if (openIndexes.includes(depth1Index)) {
setAccordionState({
...accordionState,
depth1: {
...accordionState.depth1,
[depth0Index]: openIndexes.filter(
(openIndex) => openIndex !== depth1Index
)
}
});
} else {
setAccordionState({
...accordionState,
depth1: {
...accordionState.depth1,
[depth0Index]: openIndexes.concat(depth1Index)
}
});
}
},
isOpen: (navIndexes) => {
if (isTemporaryAllClose) {
return false;
}
const [depth0Index, depth1Index] = navIndexes;
// トップ階層の場合
if (depth1Index == null) {
return accordionState.depth0.includes(depth0Index);
}
// 二階層目の場合
return (accordionState.depth1[depth0Index] || []).includes(depth1Index);
}
};
};
NavigationDrawerと連携する
全てのアコーディオンを一時的に閉じるケースはNavigationListをラップするドロワー側のステータスに依存します。なのでその辺の情報をPropsで渡せるに調整します。Propsを渡す必要があるのでchildrenはReactNodeではなくFunctionComponentに変えますが、受け取る必要がないケースでもFunctionComponentにするのはちょっと変なのでReactNodeも引き続き受け付けれるようにします。
+export type NavigationDrawerChildProps = {
+ /** ドロワー部分hover時 */
+ isHover: boolean;
+ /** ドロワーオープン時 */
+ isOpen: boolean;
+ /** ドロワーのバリアント状態 */
+ variant: NavigationDrawerProps["variant"];
+};
export type NavigationDrawerProps = {
// 既出のものは省略
/** ドロワーコンテンツ */
- children: ReactNode;
+ children: FC<NavigationDrawerChildProps> | ReactNode;
};
export const NavigationDrawer: FC<NavigationDrawerProps> = ({
isOpen,
width = 250,
variant,
PaddingComponent,
onClose,
- children
+ children: Child
}) => {
const [isHover, setIsHover] = useState(false);
const Drawer = variant === "temporary" ? MuiDrawer : MiniVariantDrawer;
+ const childContent = useMemo(() => {
+ return typeof Child === "function" ? (
+ <Child isHover={isHover} isOpen={isOpen} variant={variant} />
+ ) : (
+ Child
+ );
+ }, [Child, isHover, isOpen, variant]);
const additionalProps =
variant === "permanent"
? {
isHover
}
: {};
return (
<Drawer
// 変更がないため省略
>
{variant === "permanent" && <PaddingComponent />}
<Box sx={{ width, overflowX: "hidden", overflowY: "auto" }}>
- {children}
+ {childContent}
</Box>
</Drawer>
);
};
NavigationDrawerChildProps
を受け取るコンポーネントとNavigationListは別々になっていた方が柔軟性があるので、最終的には以下のようになりました。
import { FC } from "react";
import { NAVIGATION_ITEMS } from "./NavigationItems";
import { NavigationList } from "../NavigationList";
import { NavigationDrawerChildProps } from "../NavigationDrawer";
export const AppNavigationList: FC<NavigationDrawerChildProps> = ({
isHover,
isOpen,
variant
}) => {
return (
<NavigationList
items={NAVIGATION_ITEMS}
forceCollapse={variant === "permanent" && !isHover && !isOpen}
/>
);
};
import { List } from "@mui/material";
import { FC } from "react";
import { useAccordionManager } from "./hooks/useAccordionManager";
import { NavigationItem } from "./NavigationItemType";
import { NavGroup } from "./subComponents/NavGroup";
import { NavItem } from "./subComponents/NavItem";
export type NavigationMenuProps = {
/** ナビゲーション項目リスト */
items: NavigationItem[];
/** 強制的に閉じるか */
forceCollapse?: boolean;
};
export const NavigationList: FC<NavigationMenuProps> = ({
items,
forceCollapse
}) => {
const accordionManager = useAccordionManager({
isTemporaryAllClose: forceCollapse
});
return (
<List>
{items.map((item, index) =>
"subs" in item ? (
<NavGroup
key={index}
item={item}
accordionManager={accordionManager}
navIndexes={[index]}
/>
) : (
<NavItem key={index} item={item} navIndexes={[index]} />
)
)}
</List>
);
};
最後にこれをLayout.tsxで呼んだら完成です。childrenはFunctionComponentなので<AppNavigationList />
ではなく{AppNavigationList}
と書くのに注意してください。
export const Layout: FC = () => {
return (
<Box sx={{ display: "flex", minHeight: "100vh" }}>
{/* アプリケーションバー */}
{/* NavigationDrawer */}
<NavigationDrawer
// 内容が同じなので省略
>
- {/* 中身は後で実装 */}
- <div>ドロワー</div>
+ {AppNavigationList}
</NavigationDrawer>
{/* 右側レイアウトは省略 */}
</Box>
);
}
SPで画面遷移時はドロワーを閉じる
最後に細かい機能を実装します。SPではURLが変わったらドロワーは閉じてほしいので、それをLayoutに書きます。
import { Outlet, useLocation } from "react-router-dom";
export const Layout: FC = () => {
const [isOpen, setIsOpen] = useState(false);
const isPc = useMediaQuery<Theme>((theme) => theme.breakpoints.up("sm"));
+ // URLが変わった時に、SPの場合は閉じる
+ const { pathname } = useLocation();
+ useEffect(
+ () => {
+ if (!isPc && isOpen) {
+ setIsOpen(false);
+ }
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [pathname]
+ );
// 他は省略
}
成果物
最終的に出来上がったものはCodeSandboxに置きました。動作やコード全体を見たい方はご参照ください。
終わりに
以上がMUIでVuetifyのナビゲーションドロワーを再現する方法でした。ReactのUIライブラリは基本自分達で組み合わせる必要があるので結構苦労するなと感じました。ただVuetifyのように標準で色々やってしまうとバージョンが変わった際に仕様もそれにひきづられてしまうので一長一短だなと思いました。実際Vuetify3系になったらGroupHeader部分のUIが微妙に変わってましたし・・・。
MUIでVuetifyのようなナビゲーションドロワーを実装したい人の参考になれたら幸いです。
Discussion