0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MUIで、サブヘッダーもヘッダーみたいに固定したい

Last updated at Posted at 2024-05-21

1. はじめに

MUIのTabを用いてサブヘッダーをつくろうと思い、nav要素用のAppbarをこのサブヘッダーにも使おうとしたところ、うまくいきませんでした。

position="fixed"にすると下の要素がサブヘッダーの裏側に回り込んでしまったり、それを避けようとposition="sticky"にするとどうしても要素がスクロールに追従しなかったり、<Toolbar /><Appbar ></Appbar >の後ろに挿入したらサブヘッダーとヘッダーが重なってしまったりしました。これはおそらく、MUIのAppbarは二つも使われることを想定していなかったのではないかと思います。

メインヘッダーのように、スクロールに追従するサブヘッダーを実装しようと頑張ったので、忘備録も兼ねてその過程を書こうと思います。

目次

2. ヘッダー

今回のプロジェクトにおけるヘッダー部分はほとんど公式のコンポーネントを流用しています。

実際のコードは以下のようにしています。

Header.tsx
"use client";

import MenuIcon from "@mui/icons-material/Menu";
import {
  AppBar,
  Avatar,
  Box,
  Button,
  CssBaseline,
  Divider,
  Drawer,
  IconButton,
  List,
  ListItem,
  ListItemButton,
  ListItemText,
  Toolbar,
  Typography,
} from "@mui/material";
import Link from "next/link";
import { hoverUnderline } from "./Component/Props";
import { useEffect, useRef, useState } from "react";

const drawerWidth = 200;
const navItems = ["About", "Staff", "Partners", "Tournament", "Contact"];

export default function Header() {
  const [mobileOpen, setMobileOpen] = useState(false);

  const handleDrawerToggle = () => {
    setMobileOpen((prevState) => !prevState);
  };

  const drawer = (
    <Box onClick={handleDrawerToggle} sx={{ textAlign: "center" }}>
      <Link href="/">
        <Typography variant="h6" sx={{ my: 2 }}>
          QDO
        </Typography>
      </Link>
      <Divider />
      <List>
        {navItems.map((item) => (
          <Link key={item} href={`/${item.toLowerCase()}`}>
            <ListItem disablePadding>
              <ListItemButton sx={{ textAlign: "center" }}>
                <ListItemText primary={item} />
              </ListItemButton>
            </ListItem>
          </Link>
        ))}
      </List>
    </Box>
  );

  return (
    <Box>
      <CssBaseline />
      <AppBar style={{ backgroundColor: "steelblue" }} component="nav">
        <Toolbar>
          <IconButton
            color="inherit"
            aria-label="open drawer"
            edge="start"
            onClick={handleDrawerToggle}
            sx={{ mr: 2, display: { md: "none" } }}
          >
            <MenuIcon />
          </IconButton>

          <Link href="/">
            <Avatar src="/logo/qdo-logo.jpeg" alt="qdo" sx={{ width: 24, height: 24, opacity: "0.9" }} />
          </Link>
          <Box sx={{ flexGrow: 1, marginX: 1, display: { xs: "none", md: "block" } }}>
            <Typography variant="h6" component="div">
              <Link href="/">QDO</Link>
            </Typography>
          </Box>

          <Box sx={{ display: { xs: "none", md: "block" } }}>
            {navItems.map((item) => (
              <Link key={item} href={`/${item.toLowerCase()}`}>
                  <Button sx={{ ...hoverUnderline, color: "#fff" }}>{item}</Button>
              </Link>
            ))}
          </Box>
        </Toolbar>
      </AppBar>
      <nav>
        <Drawer
          variant="temporary"
          open={mobileOpen}
          onClose={handleDrawerToggle}
          ModalProps={{
            keepMounted: true, 
          }}
          sx={{
            display: { xs: "block", md: "none" },
            "& .MuiDrawer-paper": { boxSizing: "border-box", width: drawerWidth },
          }}
        >
          {drawer}
        </Drawer>
      </nav>
    </Box>
  );
}

実は、上のコードはこのままでは想定通りの挙動をしてくれません。
MUIのAppbarにおいて、Appbarを上部に固定しつつ、その高さ分だけ自動的に高さを確保させるためには、二通りほどやり方があります。


1.Appbarを親要素にして、positionに"sticky"を指定する

上のコードの基本形は以下のようになっています。

function Header(){
  return(
    <Box>
      <Appbar >
        {/* content */}
      </Appbar>
      <nav>
        {/* content */}
      </nav>
    </Box>
  );
}

AppbarはMUIのBoxコンポーネントでWrapされていますが、React.Fragmentコンポーネントを使えば、Appbarが親要素になります。このとき、Appbarのpositionにstickyを指定してあげれば、上部に固定されてスクロールに追従しつつも、下の要素がヘッダーの裏に回り込むのを防ぐことができます。

function Header(){
  return(
-    <Box>
+    <Fragment>
      <Appbar position="sticky" >
        {/* content */}
      </Appbar>
      <nav>
        {/* content */}
      </nav>
+    </Fragment>
-    </Box
  );
}

参考
MUIのAppBarでfixedを使用すると要素が重なり、stickyだと固定されないときに確認すること。

<Fragment></Fragment>はあまり見かけなくても、ショートハンド記法の<></>は見かけたことがあると思います。
これは、要素をグルーピングしつつも、レンダリング結果として実際のDOM要素を返すことはありません。このため、事実上<Fragment></Fragment>で囲まれている要素は何も囲まれていないことと同義になります。


2.二つ目のをAppbar直下に挿入する

これは単純に、Appbarコンポーネントの終了タグより後に入れれば、期待通りに動いてくれます。

function Header(){
  return(
    <Fragment>
      <Appbar>
        <Toolbar>
          {/* content */}
        </Toolbar>
      </Appbar>
+     <Toolbar />
      <nav>
        {/* content */}
      </nav>
    </Fragment>
  );
}

この方法は、公式ページにも書いてありますが、そうなる仕組みを探しても見当たりませんでした。

ただ、結果からわかる挙動としては、二つ目のToolbarは、Appbarコンポーネントの高さ分だけ自動的に高さを確保し、且つ固定してくれるということです。
Appbarのデフォルトのpositionがfixedなので、fixedの要素の高さを自動検出しているのかもしれません。

詳しい方がいらっしゃったら教えてほしいです。

3. サブヘッダー

さて、ヘッダーが以上のようなコーディングで仕上がったのはよいのですが、サブヘッダーは同じようにはできません。
まず、二つ目のToolbarを用いようとするとサブヘッダーがヘッダーに重なってしまいます。また、Appbarのpositionにstickyを指定すると、初期位置ではサブヘッダーがヘッダーに重なることはありませんが、スクロールすると覆いかぶさってしまって期待通りの挙動をしません。

このため、AppbarやToolbarを利用せず、Box(div)要素にposition: "sticky"をあてる通常のスタイリングを採用しました。
サブナビゲーションとしては、MUIのTabs及びTabコンポーネントを用いて、リッチな画面遷移ナビゲーションを実装しました。

3-1. 基本形

基本的なコードは以下の通りです。

SubNav.tsx
"use client";

import { Box, Tab, Tabs } from "@mui/material";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { SyntheticEvent, useEffect, useState } from "react";

const aboutPages: [string, string][] = [
  ["QDO", ""],
  ["Kyushu Uni", "kyushu-university"],
  ["Fukuoka", "fukuoka"],
];

const tournamentPages: [string, string][] = ["Registration", "Visit", "Schedule"].map((page) => [
  page,
  page.toLowerCase(),
]);

export default function SubNav() {
  const [value, setValue] = useState(0);

  const pathname = usePathname();
  const flagAboutPage = pathname.includes("about");
  const flagTournamentPage = pathname.includes("tournament");
  const pages = flagAboutPage ? aboutPages : flagTournamentPage ? tournamentPages : undefined;

  if (pages == undefined) return null;

  function handleChange(e: SyntheticEvent, newValue: number) {
    setValue(newValue);
  }

  return (
    <Box
      sx={{
        position: "sticky",
        width: "100%",
        zIndex: 10,
        display: "flex",
        gap: 2,
        justifyContent: "center",
        backgroundColor: "lightsteelblue",
      }}
    >
      <Tabs value={value} onChange={handleChange} indicatorColor="primary" textColor="primary">
        {pages.map((page, index) => (
          <Tab
            key={index}
            value={index}
            label={page[0]}
            component={Link}
            href={`/${flagAboutPage ? "about" : "tournament"}/${page[1]}`}
          />
        ))}
      </Tabs>
    </Box>
  );
}

Boxにたいしてposition="sticky"を指定してやれば、Appbarではいまいちだったサブヘッダーのスタイリングを実現できます。
まず、初期位置でヘッダーと重なることもないですし、スクロール時にヘッダーに覆いかぶさることもありません。
しかし、上部で固定されることはなく、スクロールしたらヘッダーに隠れてしまいます。
position="fixed"であれば、上部に固定されますが、下の要素がサブヘッダーの裏に回り込んでしまいます。

この問題を解決するためには、positionとしてstickeyを当てた後、ヘッダーの高さの分だけ、サブヘッダーのtopの位置を下におろさなければなりません。
もっとも簡単なのは検証モードでヘッダーの高さを調べて、そのピクセル分topの値として指定することです。
しかし、ヘッダーの高さというものはAppbar内の要素によって変動します。要素の高さやpaddingとmarginの値が変わるたびに検証モードで確認して、topの値を変えて……というのは、ちょっと面倒です。

そこで、ヘッダーの高さを取得し、サブヘッダーコンポーネントに値を渡すことを考えます。

3-2. ヘッダーの高さ取得

ヘッダーの高さを取得した後、サブヘッダーにPropsとして渡すことも考えられましたが、今回は練習のためにグローバルステートを利用しました。

Header.tsx
"use client";

import { Dispatch, ReactNode, SetStateAction, createContext, useContext, useState } from "react";

const HeightContext = createContext<{
  navHeight: number;
  setNavHeight: Dispatch<SetStateAction<number>>;
}>({
  navHeight: 0,
  setNavHeight: () => {},
});

export function ContextProvider({ children }: { children: ReactNode }) {
  const [navHeight, setNavHeight] = useState(0);

  return <HeightContext.Provider value={{ navHeight, setNavHeight }}>{children}</HeightContext.Provider>;
}

export const useNavHeight = () => useContext(HeightContext);

ReactのuseRefを用いて、ヘッダーコンポーネント内のAppbarの高さを取得します。

useRefは、再レンダリングをトリガーせず、要素への参照を渡すHooksであり、currentプロパティからoffsetHeightという要素の高さを取得できます。

具体的には、以下の通りです。

Header.tsx
"use client";

import MenuIcon from "@mui/icons-material/Menu";
import {
  AppBar,
  Avatar,
  Box,
  Button,
  CssBaseline,
  Divider,
  Drawer,
  IconButton,
  List,
  ListItem,
  ListItemButton,
  ListItemText,
  Toolbar,
  Typography,
  useMediaQuery,
} from "@mui/material";
import Link from "next/link";
import { hoverUnderline } from "./Component/Props";
import { useNavHeight } from "./ContextProvider";
import { Fragment, useEffect, useRef, useState } from "react";

const drawerWidth = 200;
const navItems = ["About", "Staff", "Partners", "Tournament", "Contact"];

export default function Header() {
+  const matches = useMediaQuery("(max-width:600px)");
+  const ref = useRef<HTMLHeadElement>(null);
+  const { setNavHeight } = useNavHeight();

+  useEffect(() => {
+    const height = ref.current?.offsetHeight;
+    setNavHeight((prev) => height || prev);
+  }, [matches]);

  const [mobileOpen, setMobileOpen] = useState(false);

  const handleDrawerToggle = () => {
    setMobileOpen((prevState) => !prevState);
  };

  const drawer = (
     {/* content */}
  );

  return (
    <Fragment>
      <CssBaseline />
      <AppBar position="fixed" style={{ backgroundColor: "steelblue" }} component="nav" ref={ref}>
        <Toolbar>
          {/* content */}
        </Toolbar>
      </AppBar>
      <Toolbar />
      <nav>
        {/* content */}
      </nav>
    </Fragment>
  );
}

MUI Appbarは横の長さが600pxを境にして高さが変動します。
このため、useMediaQueryを用いて600px前後で値が変動する変数を取得し、これをuseEffectの依存配列に加えます。
今回はconst matches = useMediaQuery("(max-width:600px)");としていますが、クエリー部分をmin-widthとしても同じです。なぜならば、600pxの前後でtrueになるかfalseになるかには興味がなく、ただ値が変化することを確認できればいいからです。

ここで取得したnavHeight(グローバルステート)を、SubNavコンポーネントに渡すことになります。

3-3. サブヘッダーの位置調整

SubNav.tsx
"use client";

import { Box, Tab, Tabs } from "@mui/material";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useNavHeight } from "./ContextProvider";
import { SyntheticEvent, useEffect, useState } from "react";

const aboutPages: [string, string][] = [
  ["QDO", ""],
  ["Kyushu Uni", "kyushu-university"],
  ["Fukuoka", "fukuoka"],
];

const tournamentPages: [string, string][] = ["Registration", "Visit", "Schedule"].map((page) => [
  page,
  page.toLowerCase(),
]);

export default function SubNav() {
  const [value, setValue] = useState(0);
+  const { navHeight } = useNavHeight();

  const pathname = usePathname();
  const flagAboutPage = pathname.includes("about");
  const flagTournamentPage = pathname.includes("tournament");
  const pages = flagAboutPage ? aboutPages : flagTournamentPage ? tournamentPages : undefined;

  if (pages == undefined) return null;

  function handleChange(e: SyntheticEvent, newValue: number) {
    setValue(newValue);
  }

  return (
    <Box
      sx={{
        position: "sticky",
+        top: `${navHeight}px`,
        width: "100%",
        zIndex: 10,
        display: "flex",
        gap: 2,
        justifyContent: "center",
        backgroundColor: "lightsteelblue",
      }}
    >
      <Tabs value={value} onChange={handleChange} indicatorColor="primary" textColor="primary">
        {pages.map((page, index) => (
          <Tab
            key={index}
            value={index}
            label={page[0]}
            component={Link}
            href={`/${flagAboutPage ? "about" : "tournament"}/${page[1]}`}
          />
        ))}
      </Tabs>
    </Box>
  );
}

SubNavコンポーネントにおいては、Headerコンポーネントで取得した高さをtopの値として渡すだけなので、さほどの違いはありません。

これで、当初の目的であった、サブヘッダーをページ上部に固定しつつ、要素がサブヘッダーの裏側に回り込まないような実装ができました。

4. Tabの初期表示

サブヘッダーにおいては先の章までのコーディングで大丈夫なのですが、今回のSubNavコンポーネントの実装上、少々問題が残ります。

このサブヘッダーの出し分けは、「サブページを持つパスならば表示して、そうでないならばnullを返す」となっています。
そして、このSubNavコンポーネントはRootLayoutで読み込んでいます。

即ちこのままでは、一度表示されるとページが変更されてもアンマウントされず、コンポーネントのvalueステートが維持されたままになってしまいます。
例えば、他のページからTournamentやAboutのページに戻ってきた時、閲覧中のページではないTabがアクティブになってしまっているのです。
この問題を防ぎ、挙動を正確に制御するために、副作用を用いることにします。

具体的なコードは以下の通りです。

SubNav.tsx
"use client";

import { Box, Tab, Tabs } from "@mui/material";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useNavHeight } from "./ContextProvider";
import { SyntheticEvent, useEffect, useState } from "react";

const aboutPages: [string, string][] = [
  ["QDO", ""],
  ["Kyushu Uni", "kyushu-university"],
  ["Fukuoka", "fukuoka"],
];

const tournamentPages: [string, string][] = ["Registration", "Visit", "Schedule"].map((page) => [
  page,
  page.toLowerCase(),
]);

export default function SubNav() {
  const [value, setValue] = useState<number | boolean>(false);

  const { navHeight } = useNavHeight();
  const pathname = usePathname();
  const flagAboutPage = pathname.includes("about");
+  const isAboutQDO = pathname.endsWith("about");
  const flagTournamentPage = pathname.includes("tournament");
  const pages = flagAboutPage ? aboutPages : flagTournamentPage ? tournamentPages : undefined;
+  const currentPath = flagAboutPage ? "about" : flagTournamentPage ? "tournament" : undefined;

+  useEffect(() => {
+    isAboutQDO && setValue(0);
+    return () => {
+      setValue(false);
+    };
+  }, [currentPath]);

  if (pages == undefined) return null;

  function handleChange(e: SyntheticEvent, newValue: number) {
    setValue(newValue);
  }

  return (
    <Box
      sx={{
        position: "sticky",
        top: `${navHeight}px`,
        width: "100%",
        zIndex: 10,
        display: "flex",
        gap: 2,
        justifyContent: "center",
        backgroundColor: "lightsteelblue",
      }}
    >
      <Tabs value={value} onChange={handleChange} indicatorColor="primary" textColor="primary">
        {pages.map((page, index) => (
          <Tab
            key={index}
            value={index}
            label={page[0]}
            component={Link}
            href={`/${flagAboutPage ? "about" : "tournament"}/${page[1]}`}
          />
        ))}
      </Tabs>
    </Box>
  );
}

useEffectの依存配列には配列ではなくプリミティブ値としてのcurrentPathを渡しており、この値が変化する時、即ちサブページをもつページに入ったり抜けたりした時に再レンダーをトリガーするようにしています。

useEffect内では、クリーンアップ関数が呼ばれています。
クリーンアップ関数とは、再レンダーがトリガーされたときやコンポ―エントが破棄されるとき(アンマウント)に、前回のエフェクトを破棄する関数のことです。
今回の例で言えば、サブページをもつページから抜けた時に再レンダーがトリガーされてクリーンアップ関数が呼び出されるため、setValue(false)とが実行されます。これにより、再びサブページをもつページに入ってきた時にTabの初期値が0もしくはfalseになりました。

ただし、ページリロードした時、コンポーネント再マウントされるため、どう頑張ってもリロード前のstateが捨てられてしまいます。

これに対処するためには、グローバルステートで状態管理するか、sessionStorageを用いるしかなさそうです。

追記

useEffectのなかで条件分岐をすることで、ページをリロードしたときや、サブヘッダー経由ではなく直接URLを打ち込んでサブページにアクセスしたときでもTabが正しくアクティブになるようにしました。

  useEffect(() => {
    const splitPathname = pathname.split("/");
    const lastRoute = splitPathname[splitPathname.length - 1];

    isAboutQDO
      ? setValue(0)
      : aboutPages.map(page => page[1]).includes(lastRoute)
        ? aboutPages.forEach((page, index) => pathname.endsWith(page[1]) && setValue(index))
        // setValue(aboutPages.map(page => page[1]).indexOf(lastRoute)) などでも同義。
        : tournamentPages.map(page => page[1]).includes(lastRoute)
          ? tournamentPages.forEach((page, index) => pathname.endsWith(page[1]) && setValue(index))
          : setValue(false);
  }, [isAboutQDO, pathname]);

5. おわりに

この記事を書きながら、CSSのoffsetやReactのuseEffect、そしてそのクリーンアップ関数についてより深く理解できました。

まだまだ理解が甘いところがあると感じるので、機会があれば公式チュートリアルも読み込んで基本を積みなおしたいと思いました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?