きみはねこみたいなにゃんにゃんなまほう

ねこもスクリプトをかくなり

Material UI の AppBar と Drawer を自分好みに組み合わせる

半年ほどかけてようやく Material UI にも慣れてきました。AppBar と Drawer の組み合わせは昨今のアプリでは定番となっていますが、レスポンシブに Drawer の表示を切り替えようとすると、AppBar との色使いの兼ね合いで迷います。

  • モバイルなどの狭いスクリーン
    • Drawer は AppBar のボタンをクリックした時に表示
  • デスクトップなどの広いスクリーン
    • Drawer は常にページ横などに表示

というのが割とよくあるパターンですが、この時両方にテーマカラーなどの濃い色をつけてしまうと、広いスクリーンの場合に上と横の2辺に濃い色が来てしまいます。

f:id:lightbulbcat:20190820041626p:plain

個人的な好みですが、この 2辺以上の濃い色のバーに囲まれたコンテンツ からは窮屈さを感じてしまい、あまり好きではありません。

今回作成するのはスクリーン幅によって AppBar の色が変わるようにして Drawer と組み合わせたものです。

f:id:lightbulbcat:20190820041754g:plain

ついでに公式に useScrollTrigger のデモがあったので、それも利用してなんかいい感じに表示されてくれる AppBar をカスタムしていきます。

動作するコードは github に置いてあります。

ドロワー用のダークテーマを用意する

ドロワー用のテーマをサクッと定義しておきます。今回は単純なコンテンツなので部分的に palette を定義するにと止めています。手抜きです。

import { createMuiTheme } from '@material-ui/core'
import { cyan } from '@material-ui/core/colors'
import { darken, lighten } from '@material-ui/core/styles/colorManipulator'

export default createMuiTheme({ palette: {
  primary: {
    light: lighten(cyan[700], 0.5),
    main: darken(cyan[700], 0.3),
    dark: darken(cyan[700], 0.5),
    contrastText: '#fff',
  },
  background: {
    default: darken(cyan[700], 0.5),
    paper: darken(cyan[700], 0.5),
  },
  type: 'dark',
} })

重要なのは type: 'dark' です。これが指定されていると文字色が白くなったりするなど、各コンポーネントがダークテーマを前提とした色使いになります。

今回はこのドロワー用のダークテーマと、コンテンツ領域用の Material UI デフォルトのテーマを利用します。ルートの App コンポーネントはこんな感じです。MyAppBar が今回作成するメインのコンポーネントです。

const theme = createMuiTheme()
const App: React.FC = () => {
  return (
    <div className="App">
      <CssBaseline />
      <MuiThemeProvider theme={theme}>
        <MyAppBar />
      </MuiThemeProvider>
    </div>
  )
}

MyAppBar

実際に組んだ AppBar が以下のようなものです。 MyContentMyDrawerContent は適当な内容を定義しているだけなので適当に読み飛ばしてください。

HideOnScrolluseScrollTrigger を利用した例として 公式のサンプル として書かれているものです。ありがたいですね。同じ公式のサンプルからスクロール時に AppBarbox-shadow を適用する効果も付け加えてみました。

import React from 'react'
import {
  Drawer,
  AppBar,
  Toolbar,
  IconButton,
  Typography,
  makeStyles,
  useMediaQuery,
  useScrollTrigger,
  Theme,
} from '@material-ui/core'
import { ThemeProvider } from '@material-ui/styles'
import * as Icons from '@material-ui/icons'

import drawerTheme from '../themes/drawerTheme'
import { useBoolean } from '../utils'
import MyContents from './MyContents'
import MyDrawerContent from './MyDrawerContent'
import HideOnScroll from './HideOnScroll'

const useStyles = makeStyles(
  ({ palette, spacing: sp, breakpoints: bp, mixins }) => ({
    root: {
      backgroundColor: palette.background.default,
      minHeight: '100vh',
    },
    appBar: {
      [bp.up('md')]: {
        width: `calc(100vw - 240px)`,
      },
      transition: 'background .2s, color .2s',
    },
    drawerPaper: {
      width: 240,
    },
    menuButton: {
      marginRight: sp(2),
    },
    contentWrapper: {
      [bp.up('md')]: {
        paddingLeft: 240,
      },
    },
    appBarSpacer: mixins.toolbar,
    content: {
      padding: sp(3),
    },
  }),
)

export default function MyAppBar() {
  const cls = useStyles()
  const [mobileOpen, openDrawer, closeDrawer] = useBoolean() // useState の boolean 版

  // `useMediaQuery` でモバイルかどうか判定 (*˘꒳˘*) らくちん
  const isMobile = useMediaQuery<Theme>(theme => theme.breakpoints.down('sm'))

  // おまけ: `useScrollTrigger` でスクロール時に `AppBar` に `box-shadow` を適用する
  // おしゃれ (*˘꒳˘*) 
  const elevationTrigger = useScrollTrigger({
    disableHysteresis: true,
    threshold: 0,
  })

  // ドロワーの中身を適当に
  const drawerContent = <MyDrawerContent sets={3} length={12} />

  return (
    <div className={cls.root}>
      <ThemeProvider theme={drawerTheme}>
        <nav>
          {isMobile ? (
            <Drawer
              open={mobileOpen}
              color="primary"
              onClose={closeDrawer}
              classes={{ paper: cls.drawerPaper }}
              variant="temporary"
              ModalProps={{ keepMounted: true }}
              children={drawerContent}
            />
          ) : (
            <Drawer
              open
              color="primary"
              classes={{ paper: cls.drawerPaper }}
              variant="permanent"
              children={drawerContent}
            />
          )}
        </nav>
      </ThemeProvider>

      <ThemeProvider
        theme={originalTheme => (isMobile ? drawerTheme : originalTheme)}
      >
        <HideOnScroll>
          <AppBar
            className={cls.appBar}
            color={isMobile ? 'primary' : 'default'}
            elevation={elevationTrigger ? 4 : 0}
          >
            <Toolbar>
              {isMobile && (
                <IconButton
                  className={cls.menuButton}
                  edge="start"
                  color="inherit"
                  onClick={openDrawer}
                >
                  <Icons.Menu />
                </IconButton>
              )}
              <Typography variant="h6">AppBar</Typography>
            </Toolbar>
          </AppBar>
        </HideOnScroll>
      </ThemeProvider>

      <div className={cls.contentWrapper}>
        <div className={cls.appBarSpacer} />
        <div className={cls.content}>
          <MyContents length={12} />
        </div>
      </div>
    </div>
  )
}

今回の肝としては isMobiletheme をスイッチしている部分です。

<ThemeProvider theme={originalTheme => (isMobile ? drawerTheme : originalTheme)}>

といっても特に特別なことをやっているわけではないですね。 こんな感じかな...で実装して実際に動いてよく動くもんだな、と感心したというそんな感じの実験的コードでした。

余談ですが地味に 100vh がとても便利でした。今までスクリーンの高さを覆う要素を定義するには height: 100% を親子間でリレーしていかなければならなかったので、入れ込んだ要素でそれを行うのが面倒だったのですが、いいプロパティが増えたものですね。create-react-app の初期状態のコードを見ていて気づきました。いいコードを入れてくれているものですね。ありがたいです。